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])
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])
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.