on
Swift - Higher order functions
Swift, as a multi paradigm programming language, provides a wide range of convenient functions allowing us to write declarative and self-explanatory code in a concise way that makes our code easier to read and understand.
This is referred as functional programming.
map()
, filter()
, reduce()
are some of these functions.
Under the hood
Functions, in Swift, are first-class citizens. They can be treated as any other object that is be assigned to variables or passed around as function arguments.
— The map case
Let’s take a look at the actual implementation of map()
in the Sequence.swift
file of the standard library.
@inlinable
public func map<T>(
_ transform: (Element) throws -> T
) rethrows -> [T] {
let initialCapacity = underestimatedCount
var result = ContiguousArray<T>()
result.reserveCapacity(initialCapacity)
var iterator = self.makeIterator()
// Add elements up to the initial capacity without checking for
regrowth.
for _ in 0..<initialCapacity {
result.append(try transform(iterator.next()!))
}
// Add remaining elements, if any.
while let element = iterator.next() {
result.append(try transform(element))
}
return Array(result)
}
map()
takes a throwing function with a generic type Element
as a parameter and returns some other generic type T
.
The first generic type Element
refers to the Sequence
elements type (map
being part of the Sequence
extension) that is your initial object without transformation.
The second generic type T
refers to the new type after your object has been transformed.
A throwing function passed in as an argument must somehow be handled by the function using it, hence the rethrows
keyword.
Rethrows
is used when one of the function argument throws as well.
A do, try, catch
block won’t be needed as long as the function argument doesn’t throw. It will be assessed at compile time.
map()
will return an array of the transformed objects.
Within the function’s body, a ContiguousArray
is used to store the Sequence elements consecutively in memory. It optimizes storage for class or @objc
protocol type.
A reserved capacity with an underestimated count is set on the array.
Some computation can be expensive and the size of the array may depend upon that. This provides some guarantees that everything below this count will be in the sequence.
An iterator is provided (using the next()
method from the IteratorProtocol
) to go through each element from the current sequence in order for the transformation to be applied to each element. The result, if the transformation succeed, is appended to the contiguous array.
An array of transformed objects will at last be returned.
Usage in code
map()
[1, 2, 3, 4].map { $0 * 2 }
// output: [2, 4, 6, 8]
If you try to apply a failable transformation, you might end up with an array of optional objects.
["1", "2", "Hello world"].map { Int($0) }
// output: [Optional(1), Optional(2), nil]
This is pretty much useless in our code right now, fortunately there is a map variant called compactMap()
. This will create an array with non optional objects.
compactMap()
["1", "2", "Hello world"].compactMap { Int($0) }
// output: [1, 2]
compactMap()
becomes also very handy when it comes to objects casting providing some safety that you array object will only contain the casted values, if the cast succeed.
filter()
[1, 2, 3, 4, 6].filter { $0.isMultiple(of: 2) }
// output: [2, 4, 6]
reduce()
[1, 2, 3, 4, 6].reduce(0, +)
// output: 16
The beauty of functional programming is that one could chain all of these operations to a specific object while keeping a neat and concise code.
["1", "2", "3", "4", "Hello world"]
.compactMap { Int($0) }
.filter { $0 <= 3 }
.reduce(0, +)
// output: 6
The importance of being lazy.
A sequence containing the same elements as this sequence, but on which some operations, such as map and filter, are implemented lazily.
let evenNumbers = [1, 2, 3, 4, 5]
.map { $0 * 2 }
.filter { $0.isMultiple(of: 2 }
let firstEvenNumber = evenNumbers[0]
// output: 2
The current implementation of evenNumbers
induces a loop through each value from the array for each operation.
While firstEvenNumber
only cares about accessing and storing the first value from evenNumbers
, the latter had to apply the operations to all the stored values from the array which definitely leads to some unnecessary extra work.
This is where lazy
kicks in. Its underlying type is a LazySequence
.
No upfront work will be done until it is actually needed, in other words when trying to retrieve the first value of evenNumbers
, it will only map and filter the first element from the array, no more.
let evenNumbers = [1, 2, 3, 4, 5]
.lazy
.map { $0 * 2 }
.filter { $0.isMultiple(of: 2) }
let firstNumber = evenNumbers[0]
This is a very powerful keyword, performance wise, when dealing with large arrays.
Conclusion
Using higher order functions is pretty straightforward and turns out to be very convenient when dealing with consecutive operations on objects.
This declarative approach improves code readability while providing some quick understanding regarding the developer intents.
Dealing with large arrays, on which some expensive computation may be necessary, can lead to performance issues, the lazy
keyword allows some optimization avoiding all of the unnecessary work.