Entire code is available in the gist.
The problem
Let’s have an array of people.
struct Person {
let lastName: String
let firstName: String
let rank: Int?
}
let people: [Person] = [
.init(firstName: "Avon", lastName: "Barksdale", rank: 9),
.init(firstName: "Russel", lastName: "Bell", rank: 9),
.init(firstName: "D'Angelo", lastName: "Barksdale", rank: 5),
.init(firstName: "Dennis", lastName: "Wise", rank: nil),
]
Say, we want it sorted by rank (optional), then last and then first name. Doing it with Sequence
sorted(by:)
method would end up with nested conditions, switches and be pretty complex in general. And that’s just for a fixed set of rules. What if we wanted to reorder sorting rules, filter or add new ones dynamically? That’s impossible! At least without a composable sorting abstraction built on top of it.
So let’s do just that! Let’s build a scalable, composable and maintanable solution to support sorting based on dynamically changing set of rules.
SortDescriptor
We will start with a new type representing a single sorting rule. SortDescriptor
embodies comparison logic between two instances of the same Value
type.
struct SortDescriptor<Value> {
let compare: (Value, Value) -> ComparisonResult
}
Now it’s pretty straightforward to make Collection
sortable with an array of sort descriptors.
extension Collection {
func sorted(by sortDescriptors: [SortDescriptor<Element>]) -> [Element] {
sorted { a, b in
for sortDescriptor in sortDescriptors {
switch sortDescriptor.compare(a, b) {
case .orderedAscending:
return true
case .orderedDescending:
return false
case .orderedSame:
break
}
}
return false
}
}
}
See how that helps organising sorting logic. Sorting rules are now nicely separated!
let firstNameSortDescriptor = SortDescriptor<Person> { a, b in
a.firstName.compare(b.firstName)
}
let lastNameSortDescriptor = SortDescriptor<Person> { a, b in
a.lastName.compare(b.lastName)
}
let sortedByName = people.sorted(by: [
lastNameSortDescriptor,
firstNameSortDescriptor
])
// Barksdale, Avon
// Barksdale, D'Angelo
// Bell, Russel
// Wise, Dennis
So far, so good. More improvements to come, keep reading!
Sorting by attributes
Noticed how firstNameSortDescriptor and lastNameSortDescriptor logic is duplicated, just concerning different properties? That’s not ideal.
Now we will separate sorting logic from pointing to the specific type’s attribute. This will enable reusing logic for different attributes.
extension SortDescriptor {
static func attribute<Attribute>(_ attribute: @escaping (Value) -> Attribute, sortDescriptor: SortDescriptor<Attribute>) -> Self {
self.init { a, b in
sortDescriptor.compare(attribute(a), attribute(b))
}
}
}
Now we will use it to create a common alphabetical sort descriptor.
let alphabeticalSortDescriptor = SortDescriptor<String>{ a, b in
a.compare(b)
}
let sortedByName = people.sorted(by: [
.attribute(\.lastName, sortDescriptor: alphabeticalSortDescriptor),
.attribute(\.firstName, sortDescriptor: alphabeticalSortDescriptor),
])
There! Sorting logic is now reused for different attributes!
Sorting based on external factors
Note that I deliberately call it an attribute and not a property since it can be anything an object can be mapped to.
Imagine we want to sort people based on a record file name available from an external source. One way to go would be to map Person
to a new type PersonWithRecord
. Another, to link the record on the go, just for the sorting. See below:
class RecordStore {
func record(for person: Person) -> String { ... }
}
let recordStore = RecordStore()
let sortedByRecord = people.sorted(by: [
.attribute(recordStore.record(for:), alphabeticalSortDescriptor)
])
We were able to sort people based on an attributes linked from external source without intermidiate types. Very convenient!
Reversing order
Once we have a defined sort descriptor it is very handy to be able to sort with a reversed order. We will make it easy to build a reversed version of a sort descriptor.
extension SortDescriptor {
func reversed() -> Self {
SortDescriptor { a, b in
compare(a, b).opposite
}
}
}
extension ComparisonResult {
var opposite: ComparisonResult {
switch self {
case .orderedSame:
return .orderedSame
case .orderedAscending:
return .orderedDescending
case .orderedDescending:
return .orderedAscending
}
}
}
Now let’s sort people with reversed alphabetical order.
let reversedAlphabeticalSortDescriptor = alphabeticalSortDescriptor.reversed()
let sortedByNameReversed = people.sorted(by: [
.attribute(\.lastName, sortDescriptor: reversedAlphabeticalSortDescriptor),
.attribute(\.firstName, sortDescriptor: reversedAlphabeticalSortDescriptor)
])
// Wise, Dennis
// Bell, Russel
// Barksdale, D'Angelo
// Barksdale, Avon
Effortless!
Handling optionals
Having a sort descriptor for a Value
it should be easy to build a sort descriptor on top of it that is able to handle Value?
. The only question is about treating nil
values, should they be put in the front or at the back.
extension SortDescriptor {
func handlingOptionals(nilValuesAtTheEnd: Bool) -> SortDescriptor<Value?> {
SortDescriptor<Value?> { a, b in
switch (a, b) {
case (.none, .none):
return .orderedSame
case (.none, .some):
return nilValuesAtTheEnd ? .orderedDescending : .orderedAscending
case (.some, .none):
return nilValuesAtTheEnd ? .orderedAscending : .orderedDescending
case let (.some(a), .some(b)):
return compare(a, b)
}
}
}
}
Let’s see how that helps us sorting people by the optional rank
.
let rankSortDescriptor = SortDescriptor<Int> { a, b in
a > b
? .orderedAscending
: a < b
? .orderedDescending
: .orderedSame
}
let optionalRankSortDescriptor = rankSortDescriptor
.handlingOptionals(nilValuesAtTheEnd: true)
let sortedByRank = people.sorted(by: [
.attribute(\.rank, sortDescriptor: optionalRankSortDescriptor),
])
That’s super handy! It was also pretty easy to accomplish. Great testament to SortDescriptor
expandability.
Utilizing Comparable types
Until now we have been creating sort descriptors based on custom logic. Especially rankSortDescriptor utilises Int
operators: <
and >
. These operators are implementing requirements of Comparable
protocol to which Int
(and String
, and many more) conforms.
Implementation of the Comparable
protocol is, in fact, a ready-made sorting logic. Let’s leverage that by making it easy to create a SortDescriptor
based on a type conforming to Comparable
.
extension SortDescriptor where Value: Comparable {
init(ascending: Bool) {
self.init { a, b in
ascending
? a.compare(b)
: a.compare(b).opposite
}
}
}
extension Comparable {
func compare(_ other: Self) -> ComparisonResult {
if self < other {
return .orderedAscending
}
if self > other {
return .orderedDescending
}
return .orderedSame
}
}
Now we will add convenience builder methods on SortDescriptor
bringing their ascending/descending nature to the forefront.
eextension SortDescriptor {
static func comparableAttribute<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute,
ascending: Bool
) -> Self {
.attribute(
comparableAttribute,
sortDescriptor: .init(ascending: ascending)
)
}
static func comparableAttribute<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute?,
ascending: Bool,
nilValuesAtTheEnd: Bool
) -> Self {
.attribute(
comparableAttribute,
sortDescriptor: SortDescriptor<ComparableAttribute>(ascending: ascending)
.handlingOptionals(nilValuesAtTheEnd: nilValuesAtTheEnd)
)
}
}
Based on them let’s add more convenient builders bringing their ascending/descending nature to the forefront:
extension SortDescriptor {
static func ascending<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute
) -> Self {
.comparableAttribute(comparableAttribute, ascending: true)
}
static func descending<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute
) -> Self {
.comparableAttribute(comparableAttribute, ascending: false)
}
static func ascending<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute?,
nilValuesAtTheEnd: Bool
) -> Self {
.comparableAttribute(comparableAttribute, ascending: true, nilValuesAtTheEnd: nilValuesAtTheEnd)
}
static func descending<ComparableAttribute: Comparable>(
_ comparableAttribute: @escaping (Value) -> ComparableAttribute?,
nilValuesAtTheEnd: Bool
) -> Self {
.comparableAttribute(comparableAttribute, ascending: false, nilValuesAtTheEnd: nilValuesAtTheEnd)
}
}
See how expresive and easy to follow sorting can be:
people.sorted(by: [
.descending(\.rank, nilValuesAtTheEnd: true),
.ascending(\.lastName),
.ascending(\.firstName)
])
// (9) Barksdale, Avon
// (9) Bell, Russel
// (5) Barksdale, D'Angelo
// (–) Wise, Dennis
Conclusion
We got a sorting abstraction that enables dynamic sorting logic creation. Sort descriptors are composable and extendible. The callsite is expresive, explicit yet clear and consise.
We made it!