This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We build a flexible sort descriptor abstraction on top of Swift's native sort methods, which is dynamic and type safe.

00:06 Today we'll talk about how we can use functions in Swift to implement some of the dynamic features you'd use the runtime for in Objective-C. As an example of this, we'll look at sort descriptors.

NSSortDescriptor and the Runtime

00:21 NSSortDescriptor is this cool API in Foundation that lets you specify complex sorting criteria in an easy, declarative way.

00:39 To demonstrate how sort descriptors work, we've defined a Person class that inherits from NSObject (which is necessary to work with NSSortDescriptor), along with an array of Person instances we're going to work on:

final class Person: NSObject {
    var first: String
    var last: String
    var yearOfBirth: Int
    init(first: String, last: String, yearOfBirth: Int) {
        self.first = first
        self.last = last
        self.yearOfBirth = yearOfBirth
    }
}

var people = [
    Person(first: "Jo", last: "Smith", yearOfBirth: 1970),
    Person(first: "Joanne", last: "Williams", yearOfBirth: 1985),
    Person(first: "Annie", last: "Williams", yearOfBirth: 1985),
    Person(first: "Robert", last: "Jones", yearOfBirth: 1990),
]

00:58 We can easily sort this people array by last name. For this, we create a sort descriptor with the key "last" and use the NSString.localizedCaseInsensitiveCompare(_:) selector for comparing two last names:

let lastDescriptor = NSSortDescriptor(key: "last", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))

(people as NSArray).sortedArray(using: [lastDescriptor])

/*
{NSObject, first "Robert", last "Jones", yearOfBirth 1990},
{NSObject, first "Jo", last "Smith", yearOfBirth 1970},
{NSObject, first "Joanne", last "Williams", yearOfBirth 1985},
{NSObject, first "Annie", last "Williams", yearOfBirth 1985}
*/

02:24 Currently, we only apply one sort descriptor, but since the sortedArray(using:) API takes an array of sort descriptors, it's very easy to, for example, sort by first name as the second criterium:

let firstDescriptor = NSSortDescriptor(key: "first", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))

(people as NSArray).sortedArray(using: [lastDescriptor, firstDescriptor])

/*
{NSObject, first "Robert", last "Jones", yearOfBirth 1990},
{NSObject, first "Jo", last "Smith", yearOfBirth 1970},
{NSObject, first "Annie", last "Williams", yearOfBirth 1985},
{NSObject, first "Joanne", last "Williams", yearOfBirth 1985}
*/

02:48 Using both sort descriptors results in the following logic: if two last names compare equal, the first names will be used to decide the order. If the last names aren't equal, the sort descriptor for the first name no longer needs to be checked.

02:58 The NSSortDescriptor API is very powerful. It lets you specify complex sorting criteria with very little code, and it's easy to read. On the flip side, it only works with subclasses of NSObject. So if we want to sort a collection of plain Swift objects or even structs, we're out of luck.

03:13 The fact that it relies on the runtime makes it pretty easy for us to make mistakes: for example, we could've mistyped the keys specifying the last and first properties, and we'd only discover this at runtime. Similarly, the selector used to compare to elements is also just a string under the hood, and it gets resolved at runtime.

03:49 However, we really like how flexible this API is. For example, you could create sort descriptors on the fly at runtime in response to user input.

Sorting in Swift

04:07 If we want to sort the people array in pure Swift, we don't have this kind of declarative API available. Instead, the Swift standard library offers us two methods: sort(by:) and sorted(by:). The former sorts an array in place, while the latter returns a new sorted array. Both methods take a function as argument, which receives two elements as input and has to return a boolean value (true if the elements are already ordered in an ascending way).

04:56 We can use the sorted(by:) API to sort the people array by last name:

people.sorted { person1, person2 in
    person1.last.localizedCaseInsensitiveCompare(person2.last) == .orderedAscending
}

05:36 For a simple case like ordering by last name, this is easy enough to write and to read. However, we've hardcoded this sorting criterium, and adding more criteria gets complicated very quickly.

05:53 As a first step, we can pull the compare function out into its own variable:

let lastName: (Person, Person) -> Bool = { person1, person2 in
    person1.last.localizedCaseInsensitiveCompare(person2.last) == .orderedAscending
}

people.sorted(by: lastName)

06:16 Sorting only by first name is easy now. We copy the declaration of lastName, call it firstName, and change all occurrences of last to first:

let firstName: (Person, Person) -> Bool = { person1, person2 in
    person1.first.localizedCaseInsensitiveCompare(person2.first) == .orderedAscending
}

people.sorted(by: firstName)

06:42 However, we can't easily sort according to both criteria, lastName and firstName. Before we look into combining multiple sort criteria, we'll first improve the code we already have.

Building a Sort Descriptor Abstraction

06:56 The first problem we notice is that we've created a lot of repetitive code by copy-pasting the lastName function. To improve on this, we introduce a type alias to get a more descriptive type name:

typealias SortDescriptor = (Person, Person) -> Bool

07:30 Of course, the SortDescriptor type alias doesn't have to be Person specific, and it's easy to make it generic by replacing Person with a generic parameter, A. Once we do that, we have to specify the type of this generic parameter — in our case, Person — when we use the type alias:

typealias SortDescriptor<A> = (A, A) -> Bool

let lastName: SortDescriptor<Person> = { person1, person2 in
    person1.last.localizedCaseInsensitiveCompare(person2.last) == .orderedAscending
}
let firstName: SortDescriptor<Person> = { person1, person2 in
    person1.first.localizedCaseInsensitiveCompare(person2.first) == .orderedAscending
}

08:00 While we've improved the type of our sort descriptors, lastName and firstName still share a lot of the same copy-pasted code. We'll tackle this next by introducing a function that creates a SortDescriptor. This function needs to have an argument that lets us specify which property should be used for sorting. Since we don't have the runtime available, and we want to write this in a type-safe way, we use a function argument where NSSortDescriptor uses a string key:

func sortDescriptor<Value>(property: @escaping (Value) -> String) -> SortDescriptor<Value> {
    return { value1, value2 in
        property(value1).localizedCaseInsensitiveCompare(property(value2)) == .orderedAscending
    }
}

Where we previously wrote person1.last or person1.first, we now use the property function to extract the value that should be used for sorting. Currently though, the sortDescriptor function only works for string properties, since the return type of property is hardcoded as String.

10:41 Using the property function already solves a potential source of bugs we had before: we could've easily made a copy-paste error and compared the last property with the first property. Since both values are now extracted using property, we're guaranteed to compare the same properties with each other.

10:48 Another noteworthy detail in the declaration of the sortDescriptor function is the use of @escaping. The function we pass in will be used at a later point in time — for sure after sortDescriptor itself has returned. Therefore, we explicitly have to specify @escaping in Swift 3.

11:13 Using the sortDescriptor function, we can write our lastName and firstName descriptors in a much shorter and more descriptive way:

let lastName: SortDescriptor<Person> = sortDescriptor { $0.last }
let firstName: SortDescriptor<Person> = sortDescriptor { $0.first }

11:48 Next, we'll make the sortDescriptor function more flexible. Currently, it only works for string properties, and the comparison method, localizedCaseInsensitiveCompare, is hardcoded.

12:27 The first step is to add a parameter that lets us specify the comparison method. If we look at the type of localizedCaseInsensitiveCompare, we see that it's a curried function from String to String to ComparisonResult. So basically, it's a function that takes two strings and returns a ComparisonResult, but it's written in its curried form. That means it doesn't take the two string parameters at once, but rather in two consecutive function calls.

13:15 So let's add a parameter to sortDescriptor, comparator, that has the same type as localizedCaseInsensitiveCompare:

func sortDescriptor<Value>(property: @escaping (Value) -> String, comparator: @escaping (String) -> (String) -> ComparisonResult) -> SortDescriptor<Value>

13:33 Now we can use the comparator parameter to perform the actual comparison:

func sortDescriptor<Value>(property: @escaping (Value) -> String, comparator: @escaping (String) -> (String) -> ComparisonResult) -> SortDescriptor<Value> {
    return { value1, value2 in
        comparator(property(value1))(property(value2)) == .orderedAscending
    }
}

13:56 And now we can write our sort descriptors like this:

let lastName: SortDescriptor<Person> = sortDescriptor(property: { $0.last }, comparator: String.localizedCaseInsensitiveCompare)
let firstName: SortDescriptor<Person> = sortDescriptor(property: { $0.first }, comparator: String.localizedCaseInsensitiveCompare)

14:15 The code to write those sort descriptors has gotten a little bit longer, but the API is way more flexible now, since we can specify any comparison method we want.

14:29 Another improvement we'll make is to let the sortDescriptor function work for non-string properties. For this, we don't have to change any of our implementation; we just introduce a generic parameter, Property, and replace all occurrences of String with Property:

func sortDescriptor<Value, Property>(property: @escaping (Value) -> Property, comparator: @escaping (Property) -> (Property) -> ComparisonResult) -> SortDescriptor<Value>

Combining Sort Descriptors

15:08 Next, let's take a look at how we can sort not just by one, but by multiple sort descriptors. There are two approaches we could take: either we'd overload Swift's sort and sorted methods to accept an array of sort descriptors (similar to NSArray's sortedArray method), or we'd write a function that combines multiple sort descriptors into one. We'll take the latter approach here.

16:03 We write a function, combine, that takes an array of sort descriptors and returns a new sort descriptor:

func combine<A>(sortDescriptors: [SortDescriptor<A>]) -> SortDescriptor<A>

16:35 In the implementation, we'll loop over the array of sort descriptors and immediately return true or false if a sort descriptor results in an unambiguous order. If two elements compare equal with the current descriptor, we simply continue in the next iteration with the next sort descriptor. Lastly, we return a default value in case all sort descriptors didn't yield an unambiguous result:

func combine<A>(sortDescriptors: [SortDescriptor<A>]) -> SortDescriptor<A> {
    return { value1, value2 in
        for descriptor in sortDescriptors {
            if descriptor(value1, value2) { return true }
            if descriptor(value2, value1) { return false }
        }
        return false
    }
}

18:22 Now it's easy to replicate the sorting criteria we implemented using NSSortDescriptor in the beginning, namely to sort by last name first, and by first name second:

people.sorted(by: combine(sortDescriptors: [lastName, firstName]))

Leveraging the Comparable Protocol

19:17 Let's create one more variant of the sortDescriptor function that makes it very easy to create sort descriptors for values that already conform to the Comparable protocol. For this, we constrain the generic parameter Property to types that conform to Comparable, which allows us to get rid of the comparator parameter. We can simply use the < operator to compare two elements:

func sortDescriptor<Value, Property>(property: @escaping (Value) -> Property) -> SortDescriptor<Value> where Property: Comparable {
    return { value1, value2 in property(value1) < property(value2) }
}

20:37 Using this function, we can create a sort descriptor for a person's yearOfBirth property, which is an integer:

let yearOfBirth: SortDescriptor<Person> = sortDescriptor { $0.yearOfBirth }

21:25 Of course, we can easily combine the yearOfBirth descriptor with the other descriptors we've created before:

people.sorted(by: combine(sortDescriptors: [lastName, firstName, yearOfBirth]))

21:41 We've ended up with an API that's very flexible, and we can easily create sort descriptors on the fly, just as we could with NSSortDescriptor. Additionally, it's also very safe. For example, we can't make mistakes — like using a comparison method that doesn't work with the type of the property we're trying to compare — since everything is statically type checked.

22:07 There's one limitation in our current implementation: at the moment, we can't handle optionals, which is easy to do with NSSortDescriptor. Technically it's possible, but it's just a bit more work to make this API feature complete.

22:32 Within this entire example, we used two main features of Swift to our advantage: generics help us make everything type safe, and first-class functions allow us to implement dynamic features.

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    New subscriber-only episodes every two weeks

  • Invite Your Team

    Sign up additional team members at 30% discount

  • Support Us

    Ensure the continuous production of new episodes