00:06 Today we are joined by Wouter Swierstra, Assistant Professor at
Utrecht University and co-author of Functional
Swift. One of the areas he works
in is functional programming, and he's excited to see that some ideas from this
field, in which people have been working for a really long time, are coming to a
mainstream language like Swift.
00:44 Together we'll dive down the rabbit hole of functional programming
during a few episodes. More specifically, we'll be looking at reduce. To
remind ourselves what reduce does, we'll first write out some examples.
Examples of reduce
01:14 We create an array that holds the numbers from one to ten, and we
call reduce on it to find the largest number in the array. The function takes
two arguments: an initial result value, and a function that combines a single
array element with the result. We pass in the smallest possible Int as the
initial value, and we use max for the combining function:
let numbers = Array(1...10)
numbers.reduce(Int.min, max)
01:53 We can also use reduce to calculate the sum of all elements by
passing in zero and the + operator:
numbers.reduce(0, +)
02:23 Let's take a closer look at the function signature of reduce on
Array:
func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Self.Element) throws -> Result) rethrows -> Result
02:42 The function is generic over its Result type. In the above two
examples, the result type and the type of the array's elements are both Int,
but these types don't have to match up. For example, we can also use reduce to
find out if an array contains an element. The result of this reduce call is a
Bool:
extension Sequence where Element: Equatable {
func contains1(_ el: Element) -> Bool {
return reduce(false) { result, x in
return x == el || result
}
}
}
numbers.contains1(3) numbers.contains1(13)
03:33 We call reduce with an initial result of false, because this
has to be the result if the array is empty. In the combining function, we check
if the passed-in element is equal to the element we're looking for or if the
result thus far is true.
05:36 This version of contains is not the most performant because it
does more work than it needs to. Nevertheless, it's an interesting exercise to
find an implementation that uses reduce.
List
05:55 But where does reduce come from? We can explore its origins by
defining a singly linked list and finding a reduce operation on it.
In Swift, we define a linked list as an enum with a case for an empty list and a
case for a non-empty list. The non-empty case is traditionally called cons,
and its associated values are a single list element and a tail. The tail is
another list, which makes the case recursive, so we have to mark it as indirect:
enum List<Element> {
case empty
indirect case cons(Element, List)
}
07:26 We can create a list of integers, like so:
let list: List<Int> = .cons(1, .cons(2, .const(3, .empty)))
08:08 Then we define a function called fold, which looks a lot like
reduce, but it's slightly different:
extension List {
func fold<Result>(_ emptyCase: Result, _ consCase: (Element, Result) -> Result) -> Result {
}
}
09:15 It's no accident that the two arguments of fold match up with
the two cases of List. Within the function's implementation, we use each of
these arguments with its corresponding case:
extension List {
func fold<Result>(_ emptyCase: Result, _ consCase: (Element, Result) -> Result) -> Result {
switch self {
case .empty:
return emptyCase
case let .cons(x, xs):
return consCase(x, xs.fold(emptyCase, consCase))
}
}
}
11:07 Now we can fold the list to calculate the sum of its elements:
list.fold(0, +)
11:45 We can also use fold to find the length of the list:
list.fold(0, { _, result in result + 1 })
12:20 There's correspondence between the arguments of fold and the
declaration of List.
We can think of the enum cases of List as the two ways to construct a list:
one to construct an empty list, and one to construct a non-empty list.
And fold takes two arguments: one for the .empty case, and one for the
.cons case — exactly the information we need in order to compute a result from
each of the cases.
13:18 If we think of the emptyCase parameter not as a value of type
Result, but rather as a function, () -> Result, then the correspondence with
the .empty constructor becomes even clearer.
fold vs. reduce
13:44 The fold function is nearly identical to reduce, but there's a
small distinction. The difference between the two can be demonstrated by calling
both functions and comparing the results.
14:12 First we call fold, passing in the constructors of the two cases
of List as the arguments:
dump(list.fold(List.empty, List.cons))
14:27 We see that the result is exactly the same as the original list.
In other words, calling fold with the two case constructors is a complicated
way of writing the identity function: nothing changes.
15:09 Then we reduce an array, passing in the same constructors of the
List cases — except we have to swap the order of the arguments of the cons
case, because reduce passes in the accumulating result first and the current
element second:
dump(Array(1...3).reduce(List.empty, { .cons($1, $0) }))
16:03 When we inspect the result from this reduce call, we see that
it's a List containing the array elements in reverse order, because reduce
traverses the array and processes each element into the result as it goes. This
is different from what fold does, because it goes from left to right through
the linked list and only uses the emptyCase value when it hits the very end of
the list.
16:39 There are plenty of operations, like calculating a sum or a
length, for which reduce and fold give the same result. But through
operations in which the order matters, we start to see the difference in the
behavior of the two functions.
List.reduce
17:03 We've implemented fold, and we've used Swift's Array.reduce,
but it would also be interesting to look at the implementation of List.reduce.
We write the function in an extension, and we give it the same arguments as
fold:
extension List {
func reduce<Result>(_ emptyCase: Result, _ consCase: (Element, Result) -> Result) -> Result {
}
}
17:59 To implement the function, we assign the emptyCase parameter to
an initial result, and then we switch over the list to see whether or not it's
empty. If it's empty, we can return the result right away. If the list is
non-empty, we add the x element to the result we've seen so far using the
consCase function, and we recursively call reduce on the tail, passing on
the accumulated result:
extension List {
func reduce<Result>(_ emptyCase: Result, _ consCase: (Element, Result) -> Result) -> Result {
let result = emptyCase
switch self {
case .empty:
return result
case let .cons(x, xs):
return xs.reduce(consCase(x, result), consCase)
}
}
}
Tail Recursion
20:15 Here we can see that reduce is tail-recursive: it either
returns a result or it makes a recursive call immediately. fold is not
tail-recursive because it calls the consCase function, and the recursion is
more or less hidden and used to construct the second argument of that function.
20:50 This difference is what leads to the different results, which we
can see even more clearly now by comparing the two methods on List:
let list: List<Int> = .cons(1, .cons(2, .const(3, .empty)))
list.fold(List.empty, List.cons) list.reduce(List.empty, List.cons)
21:28 An operation using tail recursion can very easily be rewritten
with a loop:
extension List {
func reduce1<Result>(_ emptyCase: Result, _ consCase: (Element, Result) -> Result) -> Result {
var result = emptyCase
var copy = self
while case let .cons(x, xs) = copy {
result = consCase(x, result)
copy = xs
}
return result
}
}
22:59 This version, reduce1, produces the same result as reduce:
list.reduce1(List.empty, List.cons)
Coming Up
23:18 reduce is just one example of a folding operation, and we can
actually define these operations on many other structures as well, as we'll see
next time.