Similarly different: join(), reduce() and flatMap() in Swift 2


This is the first ever piece that I've written first as a playground and second as a blogpost. This is something that I'd like to make the norm wherever possible and will endeavour to do so. It covers a selection of higher order functions in Swift and draws attention to their similarities and differences. Enjoy!

Similarly different: join(), reduce() and flatMap() in Swift 2

reduce(), flatMap() and join() can produce the same results
let nestedArray = [[1,2,3,4],[6,7,8,9]]

let joined = [].join(nestedArray)
let flattened = nestedArray.flatMap{$0}
let reduced = nestedArray.reduce([], combine: {$0 + $1})

joined // [1, 2, 3, 4, 6, 7, 8, 9]
flattened // [1, 2, 3, 4, 6, 7, 8, 9]
reduced // [1, 2, 3, 4, 6, 7, 8, 9]
The differences only real become clear when we want to add elements to the array we're flattening
let joinedPlus = [5].join(nestedArray)
let flattenedPlus = nestedArray.flatMap{$0 + [5]}
let reducedPlus = nestedArray.reduce([], combine: {$0 + [5] + $1})

joinedPlus // [1, 2, 3, 4, 5, 6, 7, 8, 9]
flattenedPlus // [1, 2, 3, 4, 5, 6, 7, 8, 9, 5]
reducedPlus // [5, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Look carefully at the position of the 5 in each case.

join()

join() begins with a joining element that is the same type as the sequence being joined. This is best explained with an array of strings transforming into a single string.
", ".join(["one","two","three"])  // "one, two, three"
Notice that with join() the joining element is inserted after each item in the array but not at the beginning or end. This is the limit of join. It has a joining element and it has the items to be joined. Whether this be an array of arrays or an array of strings. It is to be noted, however, that it is not a way of combining numbers and is reserved for arrays (and collections) of sequences, strings and character views.

reduce()

Let's look now to reduce. It takes an initial value, which becomes $0 in the following. The first time the combine closure is iterated over the first value is added to the initial string (in this case), this addition then becomes represented the second time it is iterated over so $0 = "" + "one" (i.e. "one") and $1 = "two", and the following and final time it is iterated over $0 = "" + "one" + "two" (i.e. "onetwo"), and $1 = "three".
["one","two","three"].reduce("",combine:{$0 + $1}) // "onetwothree"
If we want to add or change anything then this must happen within the closure. For instance if we want to reverse the order of the strings:
["one","two","three"].reduce("",combine:{$1 + $0}) // "threetwoone"
Or if we wanted to add a character between the Strings:
["one","two","three"].reduce("",combine:{$1 + "-" + $0}) // "three-two-one-"
But perhaps we want something that isn't always the same, perhaps we want to emulate join()
let strArr = ["one","two","three"]

strArr.enumerate().reduce("",combine:{$0 + $1.element + ($1.index < strArr.endIndex-1 ? "-" : "") }) // "one-two-three"
Or we might choose to become more sophisticated than join()
strArr.enumerate().reduce("",combine:{$0 + $1.element + ($1.index < strArr.endIndex-1 ? ", " : ".") }) // "one, two, three."
The point is that reduce() while appearing at first similar to join() actually has some more fine-grained abilities if we're willing to accept its greater complexity.

Nested arrays

Returning to nested arrays, let's look at the way our initial and joining values are interjected
["four"].join([["one","two","three"],["five","six","seven"]]) // ["one", "two", "three", "four", "five", "six", "seven"]
With join() this happens between array items.
[["one","two","three"],["five","six","seven"]].reduce(["four"],combine:{$0 + $1}) // ["four", "one", "two", "three", "five", "six", "seven"]
With reduce() our interjected value is placed before the other values and recurrs only once (hence it is called an initial value), whereas if we had three or more values in the join() array our joining value would be placed between each set of values in each array.
["four"].join([["one","two","three"],["five","six","seven"],["eight","nine","ten"]]) // ["one", "two", "three", "four", "five", "six", "seven", "four", "eight", "nine", "ten"]

flatMap()

Using flatMap() there is no initial value, it always returns an array of whichever type is generated, it also doesn't allow access to the cumulating initial value (represented as $0 in the reduce() function).
let a = [["one","two","three"],["five","six","seven"],["eight","nine","ten"]].flatMap{$0 + ["four"]}
a // ["one", "two", "three", "four", "five", "six", "seven", "four", "eight", "nine", "ten", "four"]
It is very easy for reduce to emulate flatMap().
let b = [["one","two","three"],["five","six","seven"],["eight","nine","ten"]].reduce([],combine:{$0 + $1 + ["four"]})
b // ["one", "two", "three", "four", "five", "six", "seven", "four", "eight", "nine", "ten", "four"]
But the complexity of attempting to make flatMap() mimic reduce() using enumerate() has in my experience caused the compiler to complain.

Arrays of optionals

Transforming an array of optionals into an array of non-optionals is something of favourite when it comes to flatMap:
let arr:[Int?] = [1,nil,2,nil,3]

arr.flatMap{$0} // [1,2,3]
arr.filter({$0 != nil}) // [1,2,3]
arr.reduce([Int](), combine: {
    guard let n = $1 else { return $0 }
    return $0 + [n]
}
) // [1,2,3]
As you'll see here filter and reduce can perform the same trick but it becomes increasingly laboured for those higher order functions that aren't as refined for the task.

Conclusions

Where you have a nested array that simply needs joining with an additional array of values placed between each use join(). If you have an array that needs to be flattened and needs additional values appended as a suffix or prefix to each nested array use flatMap(). If you require a one off initial value and possibly to append values before or after nested values use reduce().

Afterword

Following the completion of the playground that accompanies this post I was encouraged by @Al_Skipp to place additional emphasis on the power of reduce() by illustrating how it can be used to reverse, map, filter and join. This task can be achieved like so:
"!tfiwS olleH".characters.reduce("", combine: {String($1) + $0})  // reverse
[1,2,3,4,5,6].reduce([String](), combine: {$0 + [String($1)]})  // map
"Heallo Swift!".characters.reduce("", combine: {$0 + ($1 == "a" ? "" : String($1))}) // filter
"Hello Swift!".characters.enumerate().reduce("",combine:{$0 + String($1.element) + ($1.index < "Hello Swift!".characters.count-1 ? "_" : "") }) // join
All of which goes to show how similarly different the higher order functions available in Swift are.

Breaking into Dictionaries

The focus of this post has been arrays, but actually there are things that can be done with dictionaries as well. I've seen the question of whether it's possible to combine two dictionaries using higher order functions appears on StackOverflow a number of times and always the answer has been to loop through the entries to be added in the dictionary. But then this was tweeted in response to a question from Natasha the Robot:
The code is at once clever and simple,
["a": "b"].reduce(["c": "d"]) { (var dict, pair) in
    dict[pair.0] = pair.1
    return dict
}
and opens the door to work with reduce in the context of dictionaries. Not only this but making the initial value a variable within the closure (rather than let it default to a constant) makes for all sorts of new craziness to happen. To begin the ball rolling: 
let b = [1,4].reduce([Int]()){
    (var arr, b) in arr.append(b)
    arr = arr.map{$0*2}
    return arr
}
b // [4,8]
Every time the reduce function adds a new value here, every value in the array is multiplied by 2 using map() for no particular reason.


Comments

  1. let strArr = ["one","two","three"]

    strArr.enumerate().reduce("",combine:{$0 + $1.element + ($1.index < strArr.endIndex-1 ? "-" : "") }) // "three-two-one"

    Seems here the result should be one-two-three...
    Isn't it?

    ReplyDelete
  2. arr.filter({$0 != nil}) // [1,2,3]

    The result of this is not equivalent to the result given by flatMap, because this will not return an array of non-optional type. You would need to add a .map { $0! } for that, whereas flatMap will give you an [Int] immediately.

    ReplyDelete
  3. In your second code block, could you flip the order of the last two output lines? They don't mirror the code that sets them up and I was confused for a second.

    flattenedPlus // [1, 2, 3, 4, 5, 6, 7, 8, 9, 5]
    reducedPlus // [5, 1, 2, 3, 4, 5, 6, 7, 8, 9]

    ReplyDelete

Post a Comment