Swift high-order map usage with custom types

What is Map?

Map is a term that has roots in other technologies (such as Hadoop big data processing, which is where I've used it before 🙂).  Map means to iterate over a sequence of objects, and perform some operation on each one, returning a new sequence of objects as the final output.  The operation performed is arbitrary, and in Swift is essentially a closure (block) of code that is called on each element in the sequence.

The Object

In this article I'll be working with an Array of custom object types (rather than simple types), since this is more representative of my daily workflow.

This is the model object I'll work with:

    struct Book {
        let title: String
        let price: Double
        let pageCount: Int
    }

The Collection

For most of the code samples, I'll start with an unordered array of Book objects:

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

Updating the array with Map

Let's say we'd like to increase the price of all books by some percentage.  We can use Map to do this easily:

    let newPriceList = books.map { 
        Book (title: $0.title, 
              price: $0.price * 1.10, 
              pageCount: $0.pageCount)
    }

This code iterates over the original array books, and for each one creates a new Book object with a price raised by 10%. The resulting array newPriceList is a new Array with the updated list of books:

    Don Quixote               10.989 992   
    The Great Gatsby          16.489 108   
    Moby Dick                 14.795 378   
    War and Peach             11.979 1152  
    Hamlet                    12.771 244   
    The Odyssey               16.489 206   
    The Abbot                 9.339  164   
    Catch-22                  22.066 544   
    To Kill a Mockingbird     7.9090 336   
    Gulliver's Travels        15.389 312 

Transforming the original array

The previous example returned a replacement for the original array with updated members. However, it isn't a requirement that the resulting array has the same element type as the input sequence. The result can be almost anything that makes sense for the application.

Let's say we'd just like a sorted array of book titles, i.e. [String], as the result of the Map operation:

    let bookTitles = books.map { $0.title }

The result of this map is:

    [
     "Don Quixote", "The Great Gatsby", "Moby Dick", 
     "War and Peach", "Hamlet", "The Odyssey", 
     "The Abbot", "Catch-22", "To Kill a Mockingbird", 
     "Gulliver\'s Travels"
    ]

Removing nil values from the Map result

It may happen that during a Map transformation, some output array elements result in a nil value. Often, when analyzing data, nil (missing) values aren't desirable. It would be possible to use .filter { } after .map { } to remove the *nil *values from the final result.  But there is an easier and more efficient technique.

compactMap { } works the same as map {}, but automatically performs the step of excluding nil values from the result.

Let's say we want to return a list of Book with only books having more than one word. The problem when using .map in this scenario is that for books with one word, the map function needs to return something, and so we return nil because "nothing" is the right answer for books with < 2 words in the title.

Without any filtering, the Map would look like so:

    let transformedBooks = books.map { (book) -> Book? in
        let titleWords = book.title.split(separator: " ")
        if titleWords.count > 1 {
            return Book(title: book.title, price: book.price, pageCount: book.pageCount)
        } else {
            return nil
        }
    }

The returned array has the following contents. Note there are two nil entries, which create a gap in the resulting array.

    Don Quixote               9.99   992   
    The Great Gatsby          14.99  108   
    Moby Dick                 13.45  378   
    War and Peach             10.89  1152  
    
    The Odyssey               14.99  206   
    The Abbot                 8.49   164   
    
    To Kill a Mockingbird     7.19   336   
    Gulliver's Travels        13.99  312   

By using .compactMap { }, the two books having one word in the title can be excluded from the result right from the start:

    let transformedBooks = books.compactMap { (book) -> Book? in
        let titleWords = book.title.split(separator: " ")
        if titleWords.count > 1 {
            return Book(title: book.title, price: book.price, pageCount: book.pageCount)
        } else {
            return nil
        }
    }

Now the result is as desired:

    Don Quixote               9.99   992   
    The Great Gatsby          14.99  108   
    Moby Dick                 13.45  378   
    War and Peach             10.89  1152  
    The Odyssey               14.99  206   
    The Abbot                 8.49   164   
    To Kill a Mockingbird     7.19   336   
    Gulliver's Travels        13.99  312 

This result could also have been achieved through a map + filter like so:

    let transformedBooks = books.map { (book) -> Book? in
        let titleWords = book.title.split(separator: " ")
        if titleWords.count > 1 {
            return Book(title: book.title, price: book.price, pageCount: book.pageCount)
        } else {
            return nil
        }
    }.filter { $0 != nil }

However using compactMap is the better solution. Why?

compactMap allows Swift the opportunity to optimize the building of the Array, whereas chaining .filter after .map will create a new array, then use that intermediate array as an input to .filter, which willcreate another new array. This may be fine on small data sets, but data sets can grow, so using the more efficient approach is the better path.

Using flatMap to combine nested collections

flatMap is a powerful function that is most used to transform nested arrays of objects into a single object array.  It's most powerful when combined with .filter, but let's just look at .flatMap on its own first.

Let's say we've separated the books into sections by price. This is a common thing to do--let's say we have a store that shows books in price tiers, so the app API returns books in groups by default.

The data may look like the following, where books less than $10 are in the first group, books between $10 and $20 are int he second, and books over $20 are in the third.

    let booksByPriceRange =
    [
      [
        Book(title: "Don Quixote", price: 9.99, pageCount: 992),
        Book(title: "The Abbot", price: 8.49, pageCount: 164),
        Book(title: "To Kill a Mockingbird", price: 7.19, pageCount: 336),
      ],
      [
        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: "Gulliver's Travels", price: 13.99, pageCount: 312)
      ],
      [
        Book(title: "Catch-22", price: 20.06, pageCount: 544),
      ]
    ]

To collapse the three sections into a single array, we can use flatMap:

    let allBooks = booksByPriceRange.flatMap({ $0 })

The output of this syntax is as follows.

    Don Quixote               9.99   992   
    The Abbot                 8.49   164   
    To Kill a Mockingbird     7.19   336   
    The Great Gatsby          14.99  108   
    Moby Dick                 13.45  378   
    War and Peach             10.89  1152  
    Hamlet                    11.61  244   
    The Odyssey               14.99  206   
    Gulliver's Travels        13.99  312   
    Catch-22                  20.06  544

This by itself could be useful. Having a flattened product list, we could then use .filter on it if the user was trying to search for something specific.

But could we combine the search and flatten steps into a single one?  Yes--by combining flatMap and filter, we can do exactly that.

The use of .filter is covered in a related post. If you're not familiar with .filter, please review this post.

For example, we might want to pull out all the books that have more than 300 pages, no matter what the price of the book.

    let longBooks = booksByPriceRange
                     .flatMap({ $0.filter({ $0.pageCount > 300}) })

This code snippet builds on the previous one by filtering each price bracket array to only those with > 300 pages as they're added to the flattened array result.

The final result is:

    Don Quixote               9.99   992   
    To Kill a Mockingbird     7.19   336   
    Moby Dick                 13.45  378   
    War and Peach             10.89  1152  
    Gulliver's Travels        13.99  312   
    Catch-22                  20.06  544 

As with the previous example (.map + filter), we could instead use .flatMap and then .filter the output of .flatMap in a second step:

    let longBooks = booksByPriceRange
                        .flatMap({ $0 })
                        .filter { $0.pageCount > 300 }

But again, this two-step process potentially uses more memory and CPU time to complete. It's better to give Swift the opportunity to optimize the process internally than to chain operations at the application level.