Swift high-order reduce usage with custom types

What is Reduce?

Map is a general term that has roots in other technologies (such as Hadoop big data processing, which is where I've used it before ūüôā). ¬†Reduce means to iterate over a sequence of objects, and perform some operation on each one, returning some summary value computed from the entire set. ¬† Map and Reduce are often used together, as they will be in the last example of this post.

If you're not familiar with the purpose and syntax of .map, see this related post for more details.

The canonical example of reduce is adding up an array of integers to calculate a sum. The most concise example of this is:

let pageCounts = [992, 108, 378, 
                  1152, 244, 206, 
                  164, 544, 336, 312]
                  
let totalPages = pageCounts.reduce(0, +)

print("Total Pages: \(totalPages)")

This results in the output:

Total Pages: 4436

This code essential instructs the computer to:

  1. Initialize a temporary variable to zero
  2. Iterate over the pageCounts array
  3. For each item int he input array, use the + operator to add each element to the temporary variable's running total.
  4. After all members are iterated over, assign the result to the totalPages constant.

The operator doesn't have to be +. For example:

let pageCounts = [992, 108, 378, 1152, 244, 206, 164, 544, 336, 312]
let totalPages = pageCounts.reduce(0, -)

print("Total Pages: \(totalPages)")

Output:

Total Pages: -4436

This much brevity is possible only when reducing types that have a unary operator that can be applied to each member. Integers have a + operator, so this works.  With custom types, which is the focus of this post, it usually takes more code to get the job done.

Let's look at some other examples.

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 for most examples:

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

The Collection

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

Calculate the total pages from within the custom model object array

Using the books array declared above, the following would calculate the same page count. However since the Book model struct doesn't have a unary operator, so the .reduce function needs more specific information about what property is being added to the initial accumulator.

let totalPages = books.reduce(0) { (current, next) -> Int in
    return (current + next.pageCount)
}

print("Total Pages: \(totalPages)")

Output:

Total Pages: 4436

The only difference is that the Reduce is initialized, but then a closure is used to add the values.

Note that there's a bit of syntax that, in this case is optional:

-> Int 

This part of the closure syntax tells .reduce what will be returned from the closure.  Logically, the return type specified here should match the initial accumulator value (0).  Normally this bit of syntax is optional, since Swift can infer the return type of the closure. However in some cases where there's ambiguity it may be needed (the compiler will flag this scenario).  In further examples below I'll exclude it where it's not necessary.

Calculate the average price of all books in the inventory

Here's a similar .reduce example to calculate the average price of the books in the inventory array.  Note that .reduce returns only the total, so we have to be a bit clever about calculating the average entirely on the fly...rather than dividing by the number of items in the end.  

let averagePrice = books.reduce(0.0) { (current, next) in
    return current + (next.price / Double(books.count))
}

print("Average Price: \(averagePrice)")

Output:

Average Price: 12.565000000000001

Using Reduce for String Processing

Is .reduce is only useful for doing math or statistical analysis? Not at all!  Reduce can be used to perform all kinds of data processing against collection objects.  

For example, let's say we'd like to reverse the words in the book titled "The Great Gatsby".  You can probably think of various ways to do this. For example, you could split the words into an array of String, reverse the array, then iterate over the array to combine the (reversed) words into a new string.

This is a reasonable approach, and .reduce can be used to elegantly execute the iteration and result build into a single function call. Take a look at this code:

let titleWords = "The Great Gatsby".split(separator: " ")

let reversedWords = titleWords.reversed().reduce("") { 
    (result, next) -> String in
        return "\(result)\(result.count > 0 ? " " : "")\(next)"
    }

print(reversedWords)

Output:

Gatsby Great The

Let's take it one step further: combine Map and Reduce into a single expression.

Bonus: Combine Map and Reduce

Let's extend on the previous example of reversing title words to perform an operation on the entire inventory.

In this example, we'll create a result set that:

  1. Returns all books with titles of 2 or more words as an Array of Book.
  2. For each member in the result set, reverse the title (i.e. title words are in reverse order).
  3. Exclude books from the result where the book title has only one word.

To accomplish all the objectives, we'll actually combine .compactMap and .reduce.

  • .compactMap will be used to transform the input from all books to book that have more than one word in the title.
  • .reduce will be used to reverse the words in the title in the output array.
.compactMap is really just a version of .map that omits nil results in its output. If you're not familiar with the purpose and syntax of .map or .compactMap, see this related post for more details.

Here's the code:

let newBooksArray = books.compactMap { (book) -> Book? in

    let titleWords = book.title.split(separator: " ")
    
    if titleWords.count > 1 {
        return Book(
            title: titleWords.reversed()
                             .reduce("") { (result, next) -> String in
                return "\(result) \(next)"
                       .trimmingCharacters(in: .whitespaces)
            }
            , price: book.price, pageCount: book.pageCount)
    } else {
        return nil
    }
}

The result of this code is the following array:

Quixote Don               9.99   992   
Gatsby Great The          14.99  108   
Dick Moby                 13.45  378   
Peach and War             10.89  1152  
Odyssey The               14.99  206   
Abbot The                 8.49   164   
Mockingbird a Kill To     7.19   336   
Travels Gulliver's        13.99  312
  • All titles present have been reversed
  • The two titles (Hamlet and Catch-22) that had only one word were skipped

Some notes about this solution:

  • If the number ¬†of words is < 2 then the new Book object isn't built at all‚ÄďcompactMap is provided a nil value. As we learned in this related post, compactMap excludes nil members from its results.
  • .reduce is used to concatenate the reversed words into the output string. ¬†This is actually not very different than adding integers or doubles, but illustrates the flexibility that .reduce provides.
Rob Kerr
App development for iOS, creating applications for my own development studio (Cuvenx Inc.), and consulting with awesome clients to build their mobile applications.
Ann Arbor, Michigan, USA

Copyright © 2021 Rob Kerr