Recently, I came across a couple of real-world examples that illustrate functional programming usage in Swift. All those examples have a single thing in common - they have been initially written in an imperative manner, which made them harder to read and extend.
The article provides an in-depth description of the use cases, shows the process of transitioning from an imperative to a functional paradigm and highlights the benefits of using functional programming tools.
Human readable strings
Building human readable strings is a perfect real-life use case for functional programming. This is also one of the most painful to overlook. Suppose we have a User
model defined as:
struct User {
let name: String?
let surname: String?
let nickname: String?
}
Our goal is to build a human-readable string in a Name Surname, "Nickname"
format. The example output would be Jakub Turek, "Brogrammer"
. It seems to be trivial at first, but there are a couple of challenges:
- Each of the components can possibly be
nil
. - Each of the components can possibly be a blank string.
- Nickname must be wrapped inside double quotes.
There are 8 different formatting permutations in total. It is easy to miss a couple of them when implementing a formatter by hand:
extension User {
var readableName: String {
if let name = name, let surname = surname, let nickname = nickname, !name.isEmpty, !surname.isEmpty, !nickname.isEmpty {
return "\(name) \(surname), \"\(nickname)\""
} else if let name = name, let surname = surname, !name.isEmpty, !surname.isEmpty {
return "\(name) \(surname)"
} else if let name = name, let nickname = nickname, !name.isEmpty, !nickname.isEmpty {
return "\(name), \"\(nickname)\""
} else if let name = name, !name.isEmpty {
return name
} else if let surname = surname, let nickname = nickname, !surname.isEmpty, !nickname.isEmpty {
return "\(surname), \"\(nickname)\""
} else if let surname = surname, !surname.isEmpty {
return surname
} else if let nickname = nickname, !nickname.isEmpty {
return "\"\(nickname)\""
} else {
return ""
}
}
}
Fortunately, there is a better, functional approach to the problem. First, let's build a full name property for the User
that concatenates name and surname. In order to do that we will:
- Build an array consisting of two elements to join (name, surname). This is an array of optional strings:
Array<String?>
. - Remove
nil
values from the array. - Remove blank values from the array.
- Join the components with spaces.
Here is the computed property that behaves as described:
extension User {
var fullName: String {
return [name, surname]
.flatMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
}
}
To make it easier to understand, we will examine all of the functional calls separately:
- The
flatMap
method invokes a closure on every element of the array. The closure transforms a value to another (possibly nil) value. The result is the array containing transformed, non-nil values. In the example above no transformation is applied, which means that theflatMap
will:- Filter out all the
nil
values. - Promote the array type from
Array<String?>
(input) toArray<String>
(output).
- Filter out all the
- The
filter
method invokes a closure on every element of the array. The closure takes an element and returns a boolean value indicating whether the element should be a part of a resulting list or not. In the example abovefilter { !$0.isEmpty }
will drop empty strings from the array. - The
joined
method creates a string out of the array, joining all of the elements with given separator string.
User(name: "Jakub", surname: "Turek", nickname: nil).fullName == "Jakub Turek"
User(name: "Jakub", surname: "", nickname: nil).fullName == "Jakub"
Since we are going to use the same pattern to join full name with nickname, let's extract it to a sequence method:
extension Sequence where Iterator.Element == Optional<String> {
func joinedNonBlanks(separator: String) -> String {
return self
.flatMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: separator)
}
}
We can now refactor fullName
variable to a one liner:
extension User {
var fullName: String {
return [name, surname].joinedNonBlanks(separator: " ")
}
}
Let's move on to a nickname part. Since we want to wrap it in double quotes if it is present, we will implement a computed property which does exactly that.
extension User {
var formattedNickname: String? {
return nickname.flatMap { !$0.isEmpty ? "\"\($0)\"" : nil }
}
}
In this case, we are using a lesser-known version of flatMap
function for optionals, not arrays:
- If optional has a value, it is transformed by the
flatMap
closure and returned. - If optional is nil, the
flatMap
returns nil.
According to the spec above, the formattedNickname
property:
- Returns
nil
when nickname is nil. Transformation is not applied. - Returns
nil
when nickname is a blank string. The transformation is applied, the!$0.isEmpty
check evaluates to false and nil is returned from the ternary operator. - Returns nickname wrapped in double quotes otherwise. The transformation is applied, the
!$0.isEmpty
check evaluates to true and the formatted value is returned from the ternary operator.
Having formattedNickname
in place, we can now print out a full, formatted name:
extension User {
var presentedFullName: String {
return [fullName, formattedNickname].joinedNonBlanks(separator: ", ")
}
}
Example outputs:
User(name: nil, surname: "", nickname: "Brogrammer").presentedFullName == "\"Brogrammer\""
User(name: "Jakub", surname: "", nickname: "Brogrammer").presentedFullName == "Jakub, \"Brogrammer\""
Comparing a naive implementation using if-else
statements with the functional approach:
- Functional approach is less error-prone. It is harder to omit a formatting case.
- Functional approach is way more extensible. Imagine adding an optional middle name field to the user structure. To get the
Jakub Jan Turek, "Brogrammer"
output one would have to:- Add 8 additional if statements for the naive approach.
- Add a new element to an array in
fullName
property for the functional approach.
- Functional approach requires knowledge about a couple of built-in functions: two versions of
flatMap
,filter
and alsojoined
. It is not obvious, nor natural for functional programming beginners.
Processing data
Functional programming is great for processing data contained in structures meant for presentation and/or storage. Recently, I have been reviewing a piece of code that extracts a bounding box from a sequence of MKPolygon
s:
struct CoordinatesRange {
let minLatitude: CLLocationDegrees
let maxLatitude: CLLocationDegrees
let minLongitude: CLLocationDegrees
let maxLongitude: CLLocationDegrees
}
extension MKPolygon {
var coordinates: [CLLocationCoordinate2D] {
var coords = [CLLocationCoordinate2D](repeating: kCLLocationCoordinate2DInvalid, count: pointCount)
getCoordinates(&coords, range: NSRange(location: 0, length: pointCount))
return coords
}
}
extension Sequence where Iterator.Element == MKPolygon {
var coordinatesRange: CoordinatesRange? {
var minLatitude: CLLocationDegrees?
var maxLatitude: CLLocationDegrees?
var minLongitude: CLLocationDegrees?
var maxLongitude: CLLocationDegrees?
forEach { polygon in
polygon.coordinates.forEach { coordinate in
minLatitude = Swift.min(minLatitude ?? coordinate.latitude, coordinate.latitude)
maxLatitude = Swift.max(maxLatitude ?? coordinate.latitude, coordinate.latitude)
minLongitude = Swift.min(minLongitude ?? coordinate.longitude, coordinate.longitude)
maxLongitude = Swift.max(maxLongitude ?? coordinate.longitude, coordinate.longitude)
}
}
if let minLatitude = minLatitude, let maxLatitude = maxLatitude,
let minLongitude = minLongitude, let maxLongitude = maxLongitude {
return CoordinatesRange(
minLatitude: minLatitude,
maxLatitude: maxLatitude,
minLongitude: minLongitude,
maxLongitude: maxLongitude)
}
return nil
}
}
Upon closer examination, it is clear what coordinatesRange
method does. However, the classic imperative approach was not in my taste:
- There are four optional variables defined:
minLatitude
,maxLatitude
,minLongitude
,maxLongitude
. The code does not express they are tightly coupled to the sameCoordinatesRange
domain model instance. - If one of the four variables is
nil
while the others have values assigned, this is a developers mistake. The code should crash immediately. Such condition is allowed, but not asserted. - There is a lot of juggling with the optionals. Especially the quadruple
if let
assignment is not pleasant to read. - The are two exit paths to test (separate return statements) for the property.
Can we do any better? The code iterates over a two-dimensional array and calculates a single value based on that. There is a functional programming method called reduce
that does exactly that.
reduce
takes a closure that is applied to every element of the array. The closure accumulates partial result and the element into a new partial result. It might sound complicated, so let's see how one can add up a couple of integers using reduce:
let sum = [1, 2, 3, 4].reduce(0) { sum, element in sum + element }
sum == 10
The initial value passed to reduce
method is 0. The reduce
method takes the initial value and the first element of the array and adds them up. Then, it takes the result and adds it to the second element. And so on...
Back to the polygons example, we have identified that we reduce an array to a single value. But what is getting reduced? Our goal is to calculate the bounding box for the coordinates contained inside polygons. For a single coordinate we instantly know its bounding box:
extension CoordinatesRange {
init(coordinate: CLLocationCoordinate2D) {
self.init(minLatitude: coordinate.latitude,
maxLatitude: coordinate.latitude,
minLongitude: coordinate.longitude,
maxLongitude: coordinate.longitude)
}
}
We also know how to calculate a bounding box given two different ranges:
extension CoordinatesRange {
func boundingBox(range: CoordinatesRange) -> CoordinatesRange {
return CoordinatesRange(
minLatitude: min(minLatitude, range.minLatitude),
maxLatitude: max(maxLatitude, range.maxLatitude),
minLongitude: min(minLongitude, range.minLongitude),
maxLongitude: max(maxLongitude, range.maxLongitude))
}
}
Given the above, we can now transform every coordinate to the CoordinatesRange
structure and use boundingBox
method for reduction:
extension Sequence where Iterator.Element == MKPolygon {
var coordinatesRange: CoordinatesRange? {
let ranges: [CoordinatesRange] = flatMap { polygon in
polygon.coordinates.map(CoordinatesRange.init(coordinate:))
}
return ranges.reduce(ranges.first, { result, range in
result?.boundingBox(range: range)
})
}
}
What is interesting about this approach is how a nested array of coordinates is transformed into a single-dimensional array of CoordinatesRange
:
- For every polygon, the
coordinates
property is mapped withCoordinatesRange
initializer that takes aCLLocationCoordinate2D
as an argument. This returns[CoordinatesRange]
array for a single polygon. - The
flatMap
method is called on a whole sequence of polygons. This is yet another implementation offlatMap
method. It is accessible for sequences only and it accepts a closure that takes a single element and transforms it into a sequence. All of the sequences that are returned by transformations are merged into a single resulting sequence. You may also know this operation by the name of flattening an array.
Example of using flatMap
for flattening arrays:
let results = [[1, 2, 3], [4, 5], [6, 7]].flatMap { $0 }
results == [1, 2, 3, 4, 5, 6, 7]
Comparing the functional coordinatesRange
to the imperative one:
- We operate on a domain model only. There is no way to build incorrect partial outputs by mistake and silently ignore it.
- There are almost no optionals left. There is a single quotation mark and no
if let
unwrapping, compared to four quotation marks, four conditionallet
statements and four??
optional variable substitutions. What is more, although the result variable inside closure is optional, it is guaranteed to have a value if the closure gets called. - There is only a single exit point from the method.
Grouping
Another use case for reduce
method is grouping. Let's suppose we are building an index for a table view. For the sake of example, we will use User
structure previously defined in the Human readable strings section. Let's assume that we need to group users by the first character of a surname.
First, let's define a computed variable for the User
structure that returns a group name. The grouping key should be either a first, uppercased letter of the surname (if the surname begins with a letter) or #
.
extension User {
var groupingKey: String {
let firstScalar = surname?.uppercased().unicodeScalars.first
let groupingKey: String? = firstScalar.flatMap { scalar in
guard CharacterSet.letters.contains(scalar) else { return nil }
return String(scalar)
}
return groupingKey ?? "#"
}
}
The grouping operation will output a dictionary. Since are going to implement the grouping using reduce
method, we need to have a function capable of returning updated, immutable dictionary:
extension Dictionary {
func updating(key: Key, with value: Value) -> Dictionary<Key, Value> {
var copy = self
copy[key] = value
return copy
}
}
Now, let's implement the grouping function. It will receive a single argument - partitioning method - that takes a sequence element and returns generic partition code that conforms to Hashable
protocol. The grouping function will return a dictionary where a key is the output of partitioning method and a value is the list of objects with the same partition key.
extension Sequence {
func partition<T: Hashable>(by partitioner: @escaping (Iterator.Element) -> T)
-> [T: [Iterator.Element]] {
return reduce([:]) { result, element in
let partition = partitioner(element)
let partitioned: [Iterator.Element] = result[partition] ?? []
return result.updating(key: partition, with: partitioned + [element])
}
}
}
The partition
method uses reduce
to convert a sequence into a grouped dictionary. For every element of a sequence, it computes a partition key, fetches elements for that key and appends current item. Let's see partition
method in action:
let users = [
User(name: "John", surname: "Doe", nickname: nil),
User(name: "Joe", surname: "Smith", nickname: nil),
User(name: "Jane", surname: "Doe", nickname: nil),
User(name: "James", surname: "007", nickname: nil)
]
let partitions = users
.partition { $0.groupingKey }
.map { partition, users in
let surnames = users.map { $0.fullName }.joined(separator: ", ")
return "\(partition): \(surnames)"
}.joined(separator: "\n")
print(partitions)
// D: John Doe, Jane Doe
// S: Joe Smith
// #: James 007
Traversing view hierarchy
This might be the shortest use case ever, yet still useful. Recursive reduce
call makes it super easy to capture all of the views in the hierarchy:
extension UIView {
var allSubviews: [UIView] {
return subviews.reduce([self], { result, view in
return result + view.allSubviews
})
}
}
When combined with flatMap
, you can easily extract strongly-typed references to concrete type instances:
extension UIView {
func allSubviews<T>(of type: T.Type) -> [T] {
return allSubviews.flatMap { $0 as? T }
}
}
Summary
The article presented a few use cases of functional programming in Swift. It proves how useful functional programming is for everyday development. It also shows that for certain use cases functional programming can greatly improve code readability and quality.
I hope that one day we can see a much wider application of functional paradigm pattern for iOS development.