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.