Sort descriptors

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!

Inspirations

Chris Eidhof, Swift by Sundell