Last week we concluded our 3-part introductory series to the zip
function. In the second episode of that series we saw that many types support zip
-like operations, even though we don’t typically think of those types in that way. For example, two Result
values can be zipped up, and even two functions with the same input can be zipped. At the end of the episode we provided some exercises to help viewers dive a little deeper into what zip
had to offer, and this week we solve most of those problems!
Exercise 1 Can you make the
zip2
function on ourParallel
type thread safe?
You can use GCD’s DispatchGroup
to precisely coordinate two units of work so that we are notified when they are both finished, and we avoid the racing problems we mentioned in the episode. Here’s how we can use it:
func zip2<A, B>(
_ pa: Parallel<A>, _ pb: Parallel<B>
) -> Parallel<(A, B)> {
return .init { callback in
let group = DispatchGroup()
var a: A!
var b: B!
group.enter()
pa.run { a = $0; group.leave() }
group.enter()
pb.run { b = $0; group.leave() }
group.notify(queue: .main) {
callback((a, b))
}
}
}
Here we have chosen to notify the group’s completion on the main thread, but a more robust Parallel
implementation might make that customizable. It’s interesting to think of DispatchGroup
‘s as just GCD’s version of a zip
operation. We feel that zip
is a little more expressive and composable than DispatchGroup
’s are.
Exercise 2 Generalize the
Parallel
type to a type that allows returning values other thanVoid
:struct F4<A, R> { let run: (@escaping (A) -> R) -> R }
Define
zip2
andzip2(with:)
on theA
type parameter.
Although this F4
type is closely related to Parallel
, its zip
implementation is a bit different. If we try to repeat what we did for Parallel
above we will quickly run into the problem that we must return an R
value from each of the fa.run
and fb.run
functions, and we don’t have any such value. Instead, we can take the approach we first took in episode two when trying to define zip
on Parallel
, and we will nest the run
blocks:
func zip2<A, B, R>(
_ fa: F4<A, R>, _ fb: F4<B, R>
) -> F4<(A, B), R> {
return .init { callback in
fa.run { a in
fb.run { b in
callback((a, b))
}
}
}
}
Exercise 3 Find a function in the Swift standard library that resembles the function above. How could you use
zip2
on it?
Have you ever looked at the method signature of withUnsafeBytes
? I mean, really looked at it? Here it is:
func withUnsafeBytes<Result>(
_ body: (UnsafeRawBufferPointer) throws -> Result
) rethrows -> Result
There’s a lot of noise in this function signature, so let’s clear it up a bit by removing all the throws
stuff and shortening the Result
generic name:
func withUnsafeBytes<R>(
_ body: (UnsafeRawBufferPointer) -> R
) -> R
OK interesting. If we focus on just the shape of this, we see it’s a function of the form: ((UnsafeRawBufferPointer) -> R) -> R
. That is basically F4<UnsafeRawBufferPointer, R>
as defined in exercise 2!
Unfortunately, we cannot just wrap these functions up in an F4
value, and then start zipping them. The throws
and rethrows
annotations make these function signatures distinct from that of F4
. Instead of littering our F4
type with throws
annotations, let’s just define a specialized zip2
for functions of the form ((A) throws R) throws R
:
func zip2<A, B, R>(
_ f: @escaping ((A) throws -> R) throws -> R,
_ g: @escaping ((B) throws -> R) throws -> R
) -> (@escaping (A, B) throws -> R) throws -> R {
return { callback in
try f { a in
try g { b in
try callback(a, b)
}
}
}
}
OK, now that we have zip
defined, how can we use it? Well, imagine we had some C function that was imported that operates on UnsafeRawBufferPointer
values. Then we could zip
up the withUnsafeBytes
of two arrays and invoke that C function:
var xs = [1, 2, 3]
var ys = [4, 5, 6]
func someCFunction(
_ x: UnsafeRawBufferPointer,
_ y: UnsafeRawBufferPointer
) -> Int {
// Do something with the pointers here...
return 1
}
try (zip2(xs.withUnsafeBytes, ys.withUnsafeBytes)) { x, y in
someCFunction(x, y)
}
This allows you to clearly express that you want to grab the underlying bytes of the array storage and invoke a C function with those contents. The alternative way is to nest multiple calls to withUnsafeBytes
, which leads to highly indented code and “callback hell”:
try xs.withUnsafeBytes { x in
try ys.withUnsafeBytes { y in
someCFunction(x, y)
}
}
Notice that we had to nest two layers deep, and if we were to need more unsafe bytes the indentation would continue to grow. The zip
method for handling unsafe bytes is shorter and more expressive.
This exercise consists of multiple parts, and aims to explore what happens when you nest two types that each support a zip
operation.
Exercise 4.1 Consider the type
[A]? = Optional<Array<A>>
. The outer layerOptional
haszip2
defined, but also the inner layerArray
has azip2
. Can we define azip2
on[A]?
that makes use of both of these zip structures? Write the signature of such a function and implement it.
We accidentally put this exercise in both part 1 and 2 episodes, so we already solved it before!
Exercise 4.2 Consider the type
[Validated<A, E>]
. We again have have a nesting of types, each of which have their ownzip2
operation. Can you define azip2
on this type that makes use of bothzip
structures? Write the signature of such a function and implement it.
Let’s start by writing the signature of the function we want to implement:
func zip2<A, B, E>(
_ a: [Validated<A, E>], _ b: [Validated<B, E>]
) -> [Validated<(A, B), E>] {
fatalError()
}
If we perform a `zip` on the arrays, then we will get an array of tuples, where each component of
the tuple is a validated value. We can then `map` into _that_ array, and apply `zip` on the
validated values:
```swift
func zip2<A, B, E>(
_ a: [Validated<A, E>], _ b: [Validated<B, E>]
) -> [Validated<(A, B), E>] {
return zip2(a, b).map(zip2)
}
This allows us to express the idea of taking two lists of validated values, and combining them into one list, where if there are any errors they combine, and otherwise we get a tuple of the valid values.
Exercise 4.3 Consider the type
Func<R, [A]>
. Again we have a nesting of types, each of which have their ownzip2
operation. Can you define azip2
on this type that makes use of both structures? Write the signature of such a function and implement it.
Let’s start by writing the signature of the function we want to implement:
func zip2<A, B, R>(
_ a: Func<R, [A]>, _ b: Func<R, [B]>
) -> Func<R, [(A, B)]> {
fatalError()
}
We know how to perform zip
on Func
values, so we could start with that:
func zip2<A, B, R>(
_ a: Func<R, [A]>, _ b: Func<R, [B]>
) -> Func<R, [(A, B)]> {
zip2(a, b) // Func<R, ([A], [B])>
fatalError()
}
Also, map
on Func
operates on the second type parameter, in this case ([A], [B])
, and that’s precisely the shape we like for zip
, so sounds like we can do those two operations together:
func zip2<A, B, R>(
_ a: Func<R, [A]>,
_ b: Func<R, [B]>
) -> Func<R, (A, B)> {
return zip2(a, b).map(zip2)
}
Exercise 4.4 Finally, consider the type
Parallel<Validated<A, E>>
. Yet again we have a nesting of types, each of which have their ownzip2
operation. Can you define azip2
on this type that makes use of both structures? Write the signature of such a function and implement it.
Well, now I think you are probably seeing the pattern, but let’s go through the steps anyway. Let’s start by writing out the signature:
func zip2<A, B, E>(
_ a: Parallel<Validated<A, E>>,
_ b: Parallel<Validated<B, E>>
) -> Parallel<Validated<(A, B), E>> {
fatalError()
}
If we zip
both of these parallel values together, we will arrive at another parallel value that holds two validated values. So, we can map
into that new parallel value, and then zip
the two validated values inside it. This implements the function:
func zip2<A, B, E>(
_ a: Parallel<Validated<A, E>>,
_ b: Parallel<Validated<B, E>>
) -> Parallel<Validated<(A, B), E>> {
zip2(a, b).map(zip2)
}
This allows us to simultaneously perform two parallel tasks and two validations at the same time, bringing the results into one single value. Very powerful!
Exercise 5 Do you see anything common in the implementation of all of the functions in the previous exercise? What this is showing is that nested zippable containers are also zippable containers because
zip
on the nesting can be defined in terms of zip on each of the containers.
Every implementation of zip
on nested containers looked identical. We first zip
the outer containers, then map
on that container with the zip
on the inner containers. What we are seeing here is that nested zippable containers are always zippable themselves. Unfortunately Swift does not have the type level features that allows us to express this algorithm generically, and so we are forced to write the zip2(lhs, rhs).map(zip2)
boilerplate every time we want to nest two zippable containers. Maybe someday this will be better!
And that’s the solutions to the second part of our 3 part introductory series to zip
! If you thought those were too easy, be sure to check out the exercises to part 3 too. Until next time!