Sorting collections of custom types using higher order functions in Swift
This post is a cheat sheet for sorting sequences of custom objects using Swift's sort operators. While sorting simple types (String, Int) are covered well in Apple's documentation on the subject and in many other places, I've focused below on sorting custom object types, which is more relevant to my daily work.
The Object
I'll be sorting a collection of the following model object in this cheat sheet. Note the struct conforms to Comparable in order to enable default sorting behavior and to allow code to compare the order of two Book objects.
struct Book: Comparable {
let title: String
let price: Double
let pageCount: Int
static func < (lhs: Book, rhs: Book) -> Bool {
return lhs.title < rhs.title
}
}
The Book Object
The examples in this post will operate on the following collection of Book objects.
The Collection
let books:[Book] = [
Book(title: "Don Quixote", price: 9.99, pageCount: 992),
Book(title: "The Great Gatsby", price: 14.99, pageCount: 108),
Book(title: "Moby Dick", price: 13.45, pageCount: 378),
Book(title: "War and Peach", price: 10.89, pageCount: 1152),
Book(title: "Hamlet", price: 11.61, pageCount: 244),
Book(title: "The Odyssey", price: 14.99, pageCount: 206),
Book(title: "The Abbot", price: 8.49, pageCount: 164),
Book(title: "Catch-22", price: 20.06, pageCount: 544),
Book(title: "To Kill a Mockingbird", price: 7.19, pageCount: 336),
Book(title: "Gulliver's Travels", price: 13.99, pageCount: 312)
]
The Book Collection
Default Sort via Comparable Conformance
sorted() Returns a copy of a sequence of Comparable items as an Array. Most base Swift types are Comparable. For a custom object like Book, adding conformance is required, but also trivial (see the Book declaration above).
To sort by the built-in sort order of a Type, just call the .sorted() function on the collection. Simple!
let sortedBooks = books.sorted()
Sort using Comparable conformance
Since the Book type has a built-in "<" operator that sorts by title, sorted() can be called without parameters to return an array in the following order.
Catch-22 20.06 544
Don Quixote 9.99 992
Gulliver's Travels 13.99 312
Hamlet 11.61 244
Moby Dick 13.45 378
The Abbot 8.49 164
The Great Gatsby 14.99 108
The Odyssey 14.99 206
To Kill a Mockingbird 7.19 336
War and Peach 10.89 1152
Sort using Comparable conformance result
Sort by Alternate Property
We often would want to sort in a different order than that provided by than the built-in Comparable conformance operator--or would like to sort a collection that has no Comparable conformance at all.
Use the following syntax to sort by page count in descending order:
let sortedByPageCount = books.sorted { (lhs, rhs) -> Bool in
return lhs.pageCount > rhs.pageCount
}
Sort by alternate property
This sort orders the collection as follows. Note the use of ">" in the closure inverted the sort to create an array sorted by page count in reverse numerical order.
War and Peach 10.89 1152
Don Quixote 9.99 992
Catch-22 20.06 544
Moby Dick 13.45 378
To Kill a Mockingbird 7.19 336
Gulliver's Travels 13.99 312
Hamlet 11.61 244
The Odyssey 14.99 206
The Abbot 8.49 164
The Great Gatsby 14.99 108
Sort by alternate property result
Sort with Custom Property Calculation
In the first example (using the build-in Comparable conformance), the output was according to the "<" operator's result, which was to sort by the title property.
This is accurate according to the English alphabet, however in English when a title starts with "The", we sort by the next word, as if the "The" didn't appear at the beginning of the title.
We'll fix this using two approaches. In the first, I'll implement the "The" rule by adding an extension to the String object that adds a function to strip a leading "The" word, and then sort by the return of that extension function.
Note that I only want to apply this rule to English, and have queries the system Locale to skip this process in any other language.
Here's the extension:
extension String {
func theToEnd() -> String {
let prefix = "The"
guard Locale.current.languageCode?.starts(with: "en") == true,
self.lowercased().hasPrefix(prefix.lowercased()) else
{ return self }
return String(self.dropFirst(prefix.count)
.trimmingCharacters(in: .whitespaces))
}
}
String extension
And then using that function within the .Sort { ... } expression:
let sortedWithoutThe = books.sorted { (lhs, rhs) -> Bool in
return lhs.title.theToEnd() < rhs.title.theToEnd()
}
Sort using String extension
This returns a new array with the following contents:
The Abbot 8.49 164
Catch-22 20.06 544
Don Quixote 9.99 992
The Great Gatsby 14.99 108
Gulliver's Travels 13.99 312
Hamlet 11.61 244
Moby Dick 13.45 378
The Odyssey 14.99 206
To Kill a Mockingbird 7.19 336
War and Peach 10.89 1152
Sort using String extension result
Bonus: Moving the custom Title sort into the Comparable operator
Since (in English) we would don't sort titles by the word "The", you might wonder, wouldn't it have been better to move this rule into the Book model's Comparable conformance? Yes, it almost certainly would. So let's do it!
Move the titleWithoutThe() function from the String extension to the the Book model:
struct Book: Comparable {
let title: String
let price: Double
let pageCount: Int
static func < (lhs: Book, rhs: Book) -> Bool {
return lhs.titleWithoutThe() < rhs.titleWithoutThe()
}
private func titleWithoutThe() -> String {
let prefix = "The"
guard Locale.current.languageCode?.starts(with: "en") == true,
title.lowercased().hasPrefix(prefix.lowercased()) else
{ return title }
return String(title.dropFirst(prefix.count)
.trimmingCharacters(in: .whitespaces))
}
}
Modify Comparable conformance with custom title rule
Since this logic only applies to Book titles, it makes more sense as part of the Model class, doesn't it?
Now we can use the original .sorted() function to achieve the same result while avoiding the String extension and customized .sort { } syntax.
let sortedBooks = books.sorted()
Custom title rule sorting
The resulting array will be ordered as follows:
The Abbot 8.49 164
Catch-22 20.06 544
Don Quixote 9.99 992
The Great Gatsby 14.99 108
Gulliver's Travels 13.99 312
Hamlet 11.61 244
Moby Dick 13.45 378
The Odyssey 14.99 206
To Kill a Mockingbird 7.19 336
War and Peach 10.89 1152
Custom title rule sorting result
Levels of Conciseness in Sort Closures
In the following code block, sorted1, sorted2, sorted3 and sorted4 all return the same output array, and illustrate how it's possible to remove syntax from sort closures.
// Original array (var to enable .sort() in place sorting)
var strArray = ["zebra", "antelope", "horse", "fox"]
print(strArray)
// Sort into new arrays
let sorted1 = strArray.sorted( by: { a, b in a < b} )
let sorted2 = strArray.sorted( by: { $0 < $1} )
let sorted3 = strArray.sorted( by: < )
print(sorted1)
print(sorted2)
print(sorted3)
// use a function for the logic
func customSortFunc(_ a: String, _ b: String) -> Bool {
return a.lowercased() < b.lowercased()
}
let sorted4 = strArray.sorted( by: customSortFunc )
print(sorted4)
// Sort the original array in place
strArray.sort(by: <)
print(strArray) // original array is now sorted version
// Output:
["zebra", "antelope", "horse", "fox"]
["antelope", "fox", "horse", "zebra"]
["antelope", "fox", "horse", "zebra"]
["antelope", "fox", "horse", "zebra"]
["antelope", "fox", "horse", "zebra"]
["antelope", "fox", "horse", "zebra"]