🎉 Black Friday Sale! Save 30% when you subscribe today.

The Many Faces of Zip: Part 3

Episode #25 • Aug 6, 2018 • Free Episode

The third, and final, part of our introductory series to zip finally answers the question: “What’s the point?”

Previous episode
The Many Faces of Zip: Part 3
Next episode
FreeThis episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

We’re now in the 3rd and final part of our introduction to the zip function. Let’s do a quick recap of the first two episodes.

In the first episode we showed that the Swift standard library has a zip function for combining sequences, and that it can be pretty useful. But then we zoomed out a bit so that we could concentrate on just its shape, and saw that it did something very peculiar: it allowed us to transform a tuple of arrays into an array of tuples. It flipped those containers around. We then zoomed out even more and showed that zip in fact generalizes the notion of map on arrays. And finally that empowered us to define zip on optionals, which previously probably would have seemed weird, but was in fact a very natural thing to do.

Then in episode 2 we continued the theme of defining zip on types that we don’t normally think of as having a zip operation. We started with the Result type, which actually had two zips, but neither seemed like the “right” one. Then we defined the Validated type, a close relative of the Result type, and it had a zip operation that was very useful and clearly the “right” one.

We kept going and defined zip on functions that have the same input type. It seemed strange, but we applied it to zipping lazy values and we were able to write some code that really crammed a lot of powerful ideas in a small package.

And then we ended the episode with yet another type that has a zip operation, it was the strange F3 type from our map episode that we have vaguely alluded to being related to callbacks that we see in UIView animations and URLSession blocks. We showed that this type had two zip operations, but one didn’t feel quite right and the other did.

In this episode we are finally going to answer “what’s the point?” for the past two episodes. Because although we’ve shown some cool uses of zip, is it enough for us to bring in more zips into our codebases at the end of the day?

What’s the point?

And we say yes!

In the episode on map one of the things I thought was cool was showing that map was not just some handy thing that was given to us by the designers of the Swift standard library. It was a universal concept just waiting to be discovered. This empowered us to define map on our own types, which allows us to create really expressive code.

Understanding this for zip allows us to do the same, but the results are maybe even a little more impressive.

Before diving in, let’s take a quick look at all the code we’ve written so far in this series.

First, we have a whole bunch of zip functions. First, from our first episode, zip on arrays:

func zip2<A, B>(_ xs: [A], _ ys: [B]) -> [(A, B)] {
  var result: [(A, B)] = []
  (0..<min(xs.count, ys.count)).forEach { idx in
    result.append((xs[idx], ys[idx]))
  }
  return result
}

func zip3<A, B, C>(_ xs: [A], _ ys: [B], _ zs: [C]) -> [(A, B, C)] {
  return zip2(xs, zip2(ys, zs))  // [(A, (B, C))]
    .map { a, bc in (a, bc.0, bc.1) }
}

func zip2<A, B, C>(
  with f: @escaping (A, B) -> C
  ) -> ([A], [B]) -> [C] {

  return { zip2($0, $1).map(f) }
}

func zip3<A, B, C, D>(
  with f: @escaping (A, B, C) -> D
  ) -> ([A], [B], [C]) -> [D] {

  return { zip3($0, $1, $2).map(f) }
}

And from the same episode, zip on optionals:

func zip2<A, B>(_ a: A?, _ b: B?) -> (A, B)? {
  guard let a = a, let b = b else { return nil }
  return (a, b)
}

func zip3<A, B, C>(_ a: A?, _ b: B?, _ c: C?) -> (A, B, C)? {
  return zip2(a, zip2(b, c))
    .map { a, bc in (a, bc.0, bc.1) }
}

func zip2<A, B, C>(
  with f: @escaping (A, B) -> C
) -> (A?, B?) -> C? {

  return { zip2($0, $1).map(f) }
}

func zip3<A, B, C, D>(
  with f: @escaping (A, B, C) -> D
) -> (A?, B?, C?) -> D? {

  return { zip3($0, $1, $2).map(f) }
}

Then, in the 2nd episode, we introduced the Result type, reminded ourselves what its map operation looked like, and asked: what would a zip operation look like?

enum Result<A, E> {
  case success(A)
  case failure(E)
}

func map<A, B, E>(
  _ f: @escaping (A) -> B
) -> (Result<A, E>) -> Result<B, E> {
  return { result in
    switch result {
    case let .success(a):
      return .success(f(a))
    case let .failure(e):
      return .failure(e)
    }
  }
}

func zip2<A, B, E>(
  _ a: Result<A, E>, _ b: Result<B, E>
) -> Result<(A, B), E> {

  switch (a, b) {
  case let (.success(a), .success(b)):
    return .success((a, b))
  case let (.success, .failure(e)):
    return .failure(e)
  case let (.failure(e), .success):
    return .failure(e)
  case let (.failure(e1), .failure(e2)):
    // return .failure(e1)
    return .failure(e2)
  }
}

What we saw was that in the case where we have two failures, we have to make a choice: we either take the first failure, or the second failure, and either way we’re discarding information.

Then what we showed was that if we imported our NonEmpty library and defined the Validated type, a relative of Result, its map operation looks the same, but its zip operation lets us concatenate errors together, which means we’re no longer discarding information.

import NonEmpty

enum Validated<A, E> {
  case valid(A)
  case invalid(NonEmptyArray<E>)
}

func map<A, B, E>(
  _ f: @escaping (A) -> B
) -> (Validated<A, E>) -> Validated<B, E> {
  return { result in
    switch result {
    case let .valid(a):
      return .valid(f(a))
    case let .invalid(e):
      return .invalid(e)
    }
  }
}

func zip2<A, B, E>(
  _ a: Validated<A, E>, _ b: Validated<B, E>
) -> Validated<(A, B), E> {

  switch (a, b) {
  case let (.valid(a), .valid(b)):
    return .valid((a, b))
  case let (.valid, .invalid(e)):
    return .invalid(e)
  case let (.invalid(e), .valid):
    return .invalid(e)
  case let (.invalid(e1), .invalid(e2)):
    return .invalid(e1 + e2)
  }
}

And with zip2 defined, we can define the other versions of zip very easily.

func zip2<A, B, C, E>(
  with f: @escaping (A, B) -> C
) -> (Validated<A, E>, Validated<B, E>) -> Validated<C, E> {

  return { zip2($0, $1) |> map(f) }
}

func zip3<A, B, C, E>(
  _ a: Validated<A, E>, _ b: Validated<B, E>, _ c: Validated<C, E>
) -> Validated<(A, B, C), E> {
  return zip2(a, zip2(b, c))
    |> map { a, bc in (a, bc.0, bc.1) }
}

func zip3<A, B, C, D, E>(
  with f: @escaping (A, B, C) -> D
) -> (Validated<A, E>, Validated<B, E>, Validated<C, E>)
  -> Validated<D, E>
{
  return { zip3($0, $1, $2) |> map(f) }
}

Then we looked at our Func type, which is just a wrapper around a function, and which also has a map operation.

struct Func<R, A> {
  let apply: (R) -> A
}

func map<A, B, R>(
  _ f: @escaping (A) -> B
) -> (Func<R, A>) -> Func<R, B> {
  return { r2a in
    return Func { r in
      f(r2a.apply(r))
    }
  }
}

Then we defined zip2:

func zip2<A, B, R>(
  _ r2a: Func<R, A>, _ r2b: Func<R, B>
) -> Func<R, (A, B)> {
  return Func<R, (A, B)> { r in
    (r2a.apply(r), r2b.apply(r))
  }
}

And the other zip functions come easily enough:

func zip3<A, B, C, R>(
  _ r2a: Func<R, A>,
  _ r2b: Func<R, B>,
  _ r2c: Func<R, C>
) -> Func<R, (A, B, C)> {

  return zip2(r2a, zip2(r2b, r2c)) |> map { ($0, $1.0, $1.1) }
}

func zip2<A, B, C, R>(
  with f: @escaping (A, B) -> C
) -> (Func<R, A>, Func<R, B>) -> Func<R, C> {

  return { zip2($0, $1) |> map(f) }
}

func zip3<A, B, C, D, R>(
  with f: @escaping (A, B, C) -> D
) -> (Func<R, A>, Func<R, B>, Func<R, C>) -> Func<R, D> {

  return { zip3($0, $1, $2) |> map(f) }
}

Finally we came to the F3 type, which wraps a function that takes a function as input that returns Void.

struct F3<A> {
  let run: (@escaping (A) -> Void) -> Void
}

We pasted in its map function, which we previously defined.

func map<A, B>(_ f: @escaping (A) -> B) -> (F3<A>) -> F3<B> {
  return { f3 in
    return F3 { callback in
      f3.run { callback(f($0)) }
    }
  }
}

And then we defined zip2.

func zip2<A, B>(
  _ fa: Parallel<A>, _ fb: Parallel<B>
) -> Parallel<(A, B)> {
  return Parallel<(A, B)> { callback in
    var a: A?
    var b: B?
    fa.run {
      a = $0
      if let b = b { callback(($0, b)) }
    }
    fb.run {
      b = $0
      if let a = a { callback((a, $0)) }
    }
  }
}

We ended up with an implementation that ran each value indendently, saving the results in a mutable variable so that once both callbacks completed, we can invoke the main callback with both values.

And with that defined we get our other zips.

func zip2<A, B, C>(
  with f: @escaping (A, B) -> C
) -> (Parallel<A>, Parallel<B>) -> Parallel<C> {

  return { zip2($0, $1) |> map(f) }
}

func zip3<A, B, C>(
  _ fa: Parallel<A>, _ fb: Parallel<B>, _ fc: Parallel<C>
) -> Parallel<(A, B, C)> {

  return zip2(fa, zip2(fb, fc)) |> map { ($0, $1.0, $1.1) }
}

func zip3<A, B, C, D>(
  with f: @escaping (A, B, C) -> D
) -> (Parallel<A>, Parallel<B>, Parallel<C>) -> Parallel<D> {

  return { zip3($0, $1, $2) |> map(f) }
}

So that’s the big overview. Let’s get to some new code.

We can start by defining a simple data struct to play with:

struct User {
  let email: String
  let id: Int
  let name: String
}

Array

Let’s say we want to initialize a value of this type, but we don’t have the data for the fields immediately as plain strings and ints, they are instead wrapped up in some context. For example, we could load arrays of values for each of the fields, perhaps from a CSV or database:

let emails = [
  "blob@pointfree.co",
  "blob.jr@pointfree.co",
  "blob.sr@pointfree.co"
]
let ids = [1, 2, 3]
let names = ["Blob", "Blob Junior", "Blob Senior"]

We can’t immediately construct a User from these values, but zip3(with:) precisely allows us to create an array of users from this data:

let users = zip3(with: User.init)(
  emails,
  ids,
  names
)
// [
//   {email "blob@pointfree.co", id 1, name "Blob"},
//   {email "blob.jr@pointfree.co", id 2, name "Blob Junior"},
//   {email "blob.sr@pointfree.co", id 3, name "Blob Senior"}
// ]

Remember that zip bakes in safety if any of these arrays is shorter than the others: it always zips up to the point of the shortest one.

Adding an id doesn’t change the result.

let emails = [
  "blob@pointfree.co",
  "blob.jr@pointfree.co",
  "blob.sr@pointfree.co"
]
let ids = [1, 2, 3, 4]
let names = ["Blob", "Blob Junior", "Blob Senior"]

let users = zip3(with: User.init)(
  emails,
  ids,
  names
)
// [
//   {email "blob@pointfree.co", id 1, name "Blob"},
//   {email "blob.jr@pointfree.co", id 2, name "Blob Junior"},
//   {email "blob.sr@pointfree.co", id 3, name "Blob Senior"}
// ]

While one fewer id results in one fewer user.

let emails = [
  "blob@pointfree.co",
  "blob.jr@pointfree.co",
  "blob.sr@pointfree.co"
]
let ids = [1, 2]
let names = ["Blob", "Blob Junior", "Blob Senior"]

let users = zip3(with: User.init)(
  emails,
  ids,
  names
)
// [
//   {email "blob@pointfree.co", id 1, name "Blob"},
//   {email "blob.jr@pointfree.co", id 2, name "Blob Junior"}
// ]

This is a nice, expressive way of creating an array of users from arrays of values.

This is pretty neat. We were able to take the User.init initializer that just takes regular strings and ints, and lift it up to work with arrays so that we can initialize arrays of users from arrays of strings and ints.

Now, we don’t really need to explain the point of zip on arrays: the standard library authors have already included it with Swift. Optionals, on the other hand…

Optional

Optionals provide another context with which we can explore zip.

Let’s take the same User data type, but apply a different context. Instead of those fields being held in arrays, what if they were optional?

let optionalEmail: String? = "blob@pointfree.co"
let optionalId: Int? = 42
let optionalName: String? = "Blob"

Well, now we can create an optional user from this data by using our zip3(with:) function on optionals:

let optionalUser = zip3(with: User.init)(
  optionalEmail,
  optionalId,
  optionalName
)
// Optional(User(email: "blob@pointfree.co", id: 42, name: "Blob"))

If we change any of these fields to nil

let optionalEmail: String? = "blob@pointfree.co"
let optionalId: Int? = nil
let optionalName: String? = "Blob"

let optionalUser = zip3(with: User.init)(
  optionalEmail,
  optionalId,
  optionalName
)
// nil

…we see we get nil for the user.

This is a handy way or requiring a bunch of inputs in a declarative way. And in fact, we saw in the first episode of this series that this is nothing more than an expressive version of multiple if/guard lets on the same line. If this was important enough to be made a language feature in Swift 2, it’s clearly a useful concept. And even though we have multiple if/guard lets at our disposal now, zip and zip(with:) can still clean up a lot of code.

So now we have two different ways of initializing users from values that are wrapped up in other context. Let’s explore another.

Validated

Now let’s consider what would happen if our data was held in validated values. We’ll have a few functions that validate each raw input.

func validate(email: String) -> Validated<String, String> {
  return email.index(of: "@") == nil
    ? .invalid(NonEmptyArray("email is invalid"))
    : .valid(email)
}

func validate(id: Int) -> Validated<Int, String> {
  return id <= 0
    ? .invalid(NonEmptyArray("id must be positive"))
    : .valid(id)
}

func validate(name: String) -> Validated<String, String> {
  return name.isEmpty
    ? .invalid(NonEmptyArray("name can't be blank"))
    : .valid(name)
}

Each of these functions just takes a raw value and performs a check to see if it’s valid for the program. If it’s valid, it wraps this value up in Validated‘s valid case. If it’s invalid, it wraps up an error in the invalid case—well, in a NonEmptyArray, since an empty array of errors doesn’t make a lot of sense. We covered non-empty collections earlier in two separate episodes.

Now we can create a validated user from this data by using our zip3(with:) function on validated values:

let validatedUser = zip3(with: User.init)(
  validate(email: "blob@pointfree.co"),
  validate(id: 42),
  validate(name: "Blob")
)
// valid(User(email: "blob@pointfree.co", id: 42, name: "Blob"))

We’ve lifted User.init up to the world of Validated: given validated values, you will get a validated user.

And if we change any of these validations to be invalid, we will get an invalid value!

let validatedUser = zip3(with: User.init)(
  validate(email: "blobpointfree.co"),
  validate(id: 42),
  validate(name: "Blob")
)
// invalid("email is invalid"[])

And multiple failures result in multiple errors. They accumulate in that non-empty array.

let validatedUser = zip3(with: User.init)(
  validate(email: "blobpointfree.co"),
  validate(id: 0),
  validate(name: "")
)
// invalid("email is invalid"["id is invalid", "name can't be blank"])

The Validated context allows us to now see everything that went wrong when we tried to create this user.

With array and optional, it wasn’t a stretch to see the point: the standard library provides zip on arrays, and the language provides its own sugar for zip on optionals. This Validated type is something we introduced to Swift, though, and it might be the strongest case for zip yet. We’re able to reuse the shape of zip on this type and get something that the language has no support for out of the box: accumulating errors. Swift so far has only adopted throws for error handling, and the first error always wins. Validated and the structure of zip gives us a powerful feature today, basically for free.

Func

Now let’s do something really wild. Let’s see what would happen if our data was held in closures. We’ll use the Func type we defined earlier:

let emailProvider = Func<Void, String> { "blob@pointfree.co" }
let idProvider = Func<Void, Int> { 42 }
let nameProvider = Func<Void, String> { "Blob" }

These Funcs merely wrap some constants, but you can imagine that we might be loading these values from disk, from a database, or from the network.

We can create a “user provider” from this data by using our zip3(with:) function on functions:

zip3(with: User.init)(
  emailProvider,
  idProvider,
  nameProvider
)
// Func<(), User>

What we get back is a brand new value in Func which produces a users.

And none of the earlier Funcs have been called. If we were doing a side effect in any of them, calling zip does not execute that side effect.

Let’s replace a value with a side effect.

let nameProvider = Func<Void, String> {
  (try? String(contentsOf: URL(string: "https://www.pointfree.co")!))
    .map { $0.split(separator: " ")[1566] }
    .map(String.init)
    ?? "PointFree"
}

And as it stands, this code doesn’t yet run.

Now, our value in name requires the network, where it’ll pluck a word off the Point-Free homepage. We can call apply on Func to invoke the function it wraps.

zip3(with: User.init)(
  emailProvider,
  idProvider,
  nameProvider
)
.apply(())
// User(email: "blob@pointfree.co", id: 42, name: "Monday")

Now we have our unwrapped User value, where its name is a result of hitting the network.

On Point-Free we stress the importance of isolating side effects and writing as much logic as we can using pure functions, which are easier to test and reason about. What we’ve done here is wrap a bunch of side effects in lazy Func values, and zip allowed us to take some pure logic (the User initializer), and lift it into that world.

This example may be harder to see than the others, but it’s still amazing to see yet another thing that zip is capable of. We’re going to go deeper with this kind of laziness in a future episode.

F3

What if our values were held in those weird F3 types?

In order to make things interesting, we’re going to reuse the delay helper we defined last time, which given a duration, delays a block of code from running while printing some logging along the way.

import Foundation

func delay(
  by duration: TimeInterval,
  line: UInt = #line,
  execute: @escaping () -> Void
) {
  print("delaying line \(line) by \(duration)")
  DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
    execute()
    print("executed line \(line)")
  }
}

We can now define the following values wrapped in F3s.

let delayedEmail = F3<String> { callback in
  delay(by: 0.2) { callback("blob@pointfree.co") }
}
let delayedId = F3<Int> { callback in
  delay(by: 0.5) { callback(42) }
}
let delayedName = F3<String> { callback in
  delay(by: 1) { callback("Blob") }
}

So how can we create a User from this data? We can use zip3(with:) again.

zip3(with: User.init)(
  delayedEmail,
  delayedId,
  delayedName
)
// F3<User>

Now we have an F3 that wraps a User. None of the code in each delayed block has executed. In order to get at the value wrapped in the F3, we need to run it and supply a callback.

zip3(with: User.init)(
  delayedEmail,
  delayedId,
  delayedName
  ).run { user in
    print(user)
}
// delaying line 301 by 0.2
// delaying line 304 by 0.5
// delaying line 307 by 1.0
// executed line 301
// executed line 304
// User(email: "blob@pointfree.co", id: 42, name: "Blob")
// executed line 307

Changing the delays can make things more noticeable, like delaying the email by 3 seconds.

let delayedEmail = F3<String> { callback in
  delay(by: 3) { callback("blob@pointfree.co") }
}
let delayedId = F3<Int> { callback in
  delay(by: 0.5) { callback(42) }
}
let delayedName = F3<String> { callback in
  delay(by: 1) { callback("Blob") }
}

zip3(with: User.init)(
  delayedEmail,
  delayedId,
  delayedName
)
.run { user in
  print(user)
}
// delaying line 301 by 3.0
// delaying line 304 by 0.5
// delaying line 307 by 1.0
// executed line 304
// executed line 307
// User(email: "blob@pointfree.co", id: 42, name: "Blob")
// executed line 301

What we see now is that it waits for that final email value to be delivered before delivering a user.

And we see that the entire execution took about 3 seconds. We didn’t have to wait 3 + 0.5 + 1 seconds in order to get the result. We only had to wait for the longest of the delays.

We’ve spent a lot of time with F3 and its shape, and by focusing on its shape and not its name we got a good feel for the shape of map and zip. But I think it’s time to give it a proper name.

Parallel

Let’s rename F3 to Parallel.

What we have here is a type that is highly specialized for running a task where its execution flow is decoupled from anything else. Because of that, the zip operation can take a bunch of Parallel values and run them independently in parallel before bringing the values together again.

Yet another example of the point! The zip function introduces the notion of concurrency. Swift doesn’t have a concurrency story yet. The main tool we have at our disposal is GCD, but GCD is a complicated API and doesn’t even provide the ability to run a bunch of tasks and collect their values before bringing them all together again, which is what the zip operation does. The zip operation seems to be one of the most fundamental units of concurrency.

Now, Parallel as defined is hardly ready for production, but we’ll have future episodes where we fix these problems and make this type usable in your code base.

All together

Alright, so that was five, mini-“what’s the point”s, where we explored the point of zip for five different contexts. But there’s something magical going on here. Let’s collect all of our zipped users in one place:

zip3(with: User.init)(
  emails,
  ids,
  names
)
zip3(with: User.init)(
  optionalEmail,
  optionalId,
  optionalName
)
zip3(with: User.init)(
  validate(email: "blobpointfree.co"),
  validate(id: 0),
  validate(name: "")
)
zip3(with: User.init)(
  emailProvider,
  idProvider,
  nameProvider
)
zip3(with: User.init)(
  delayedEmail,
  delayedId,
  delayedName
)

They all look almost identical. We have completely abstracted away what it means to take many values boxed up in some kind of container and combine them into just a single instance of that container. All of these disparate concepts, things like arrays, optionals, validations, lazy values and delayed values, have been unified under the umbrella of “zippable” containers. That means we can reuse intuitions. We can look at zip and just know that we’re looking to combine a bunch of values into a single container.

And this is just five types so far. We’re going to continue to explore zip on other types in the future. And our viewers should take a look at their own types and see, if it has a map, what does a zip look like?

The one downside to our current approach is the boilerplate. While zip2 is the fundamental unit that needs to be defined, and all the other zips come out of it with the same higher-order definitions, for free. Unfortunately, we need to redefine these higher-order zips every time.

This is just a current limitation of Swift, and it very well may be fixed in the future. In the meantime, these zips are a powerful enough addition to your code base that we think it’s worth the extra mechanical work, whether you take the time to copy and paste the code or use a source code generation tool.

The Generics Manifesto even outlines a feature called variadic generics, which would do away with the need for this boilerplate and we would be able to define zips for any number of arguments. We still don’t have the ability to abstract over the shape of this kind of container: we can’t, for example, write a generic algorithm against something that is “zippable” or create a Zippable protocol. We still need a language-level feature called “higher kinded types”, which we also hope to get sometime in the future. The more advanced features Swift gets, the more this boilerplate goes away!

In the end, what matters is that “zip” is a universal concept. Boilerplate or not, “zip” is there, waiting for us to take advantage of it, reuse our intuitions on it, and that’s the most powerful point of all.

This isn’t the last of zip. This is just our introduction. It’s a big enough concept that we still have a ton to cover in future episodes.


References

  • Validated
    Brandon Williams & Stephen Celis • Aug 17, 2018

    Validated is one of our open source projects that provides a Result-like type, which supports a zip operation. This means you can combine multiple validated values into a single one and accumulate all of their errors.

Downloads

Sample code

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing