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"]