Chapters

Hide chapters

Combine: Asynchronous Programming with Swift

Second Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

3. Transforming Operators
Written by Scott Gardner

Having completed section 1, you’ve already learned a lot. You should feel pretty good about that accomplishment! You’ve laid a solid foundation on the fundamentals of Combine, and now you’re ready to build upon it.

In this chapter, you’re going to learn about one of the essential categories of operators in Combine: Transforming operators. You’ll use transforming operators all the time, to manipulate values coming from publishers into a format that is usable for your subscribers. As you’ll see, there are parallels between transforming operators in Combine and regular operators in the Swift standard library, such as map and flatMap.

By the end of this chapter, you’ll be transforming all the things!

Getting started

Open the starter playground for this chapter, which already has Combine imported and is ready to go.

Operators are publishers

In Combine, methods that perform an operation on values coming from a publisher are called operators.

Each Combine operator actually returns a publisher. Generally speaking, that publisher receives the upstream values, manipulates the data, and then sends that data downstream. To streamline things conceptually, the focus will be on using the operator and working with its output. Unless an operator’s purpose is to handle errors, if it receives an error from an upstream publisher, it will just publish that error downstream.

Note: You’ll focus on transforming operators in this chapter, so error handling will not appear in each operator example. You’ll learn all about error handling in Chapter 16, “Error Handling.”

Collecting values

Publishers can emit individual values or collections of values. You’ll frequently want to work with collections, such as when you want to populate a list of views. You’ll learn how to do this later in the book.

collect()

The collect operator provides a convenient way to transform a stream of individual values from a publisher into an array of those values. To help understand how this and all other operators you’ll learn about in this book, you’ll use marble diagrams.

Marble diagrams help to visualize how operators work. The top line is the upstream publisher. The box represents the operator. And the bottom line is the subscriber, or more specifically, what the subscriber will receive after the operator manipulates the values coming from the upstream publisher.

The bottom line could also be another operator that receives the output from the upstream publisher, performs its operation, and sends those values downstream.

collect() 1 1 2 3 ( , , ) 3 2

As depicted in this marble diagram, collect will buffer a stream of individual values into an array of those values once the upstream publisher completes. It will then emit that array downstream.

Add this new example to your playground:

example(of: "collect") {
  ["A", "B", "C", "D", "E"].publisher
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

This is not using the collect operator yet. Run the playground, and you’ll see each value is emitted and printed individually followed by the completion:

——— Example of: collect ———
A
B
C
D
E
finished

Now insert the use of collect before the sink. Your code should look like this:

["A", "B", "C", "D", "E"].publisher
  .collect()
  .sink(receiveCompletion: { print($0) },
        receiveValue: { print($0) })
  .store(in: &subscriptions)

Run the playground, and now the sink receives one emitted collection followed by the completion event:

——— Example of: collect ———
["A", "B", "C", "D", "E"]
finished

Note: Be careful when working with collect() and other buffering operators that do not require specifying a count or limit. They will use an unbounded amount of memory to store received values.

There are a few variations of the collect operator. For example, you can specify that you only want to receive up to a certain number of values.

Replace the following line:

.collect()

With:

.collect(2)

Run the playground, and you’ll see the following output:

——— Example of: collect ———
["A", "B"]
["C", "D"]
["E"]
finished

The last value, E, is emitted as an array. That’s because the upstream publisher completed before collect filled its prescribed buffer, so it sent whatever it had left as an array.

Mapping values

In addition to collecting values, you’ll often want to transform those values in some way. Combine offers several mapping operators for that purpose.

map(_:)

The first you’ll learn about is map, which works just like Swift’s standard map, except that it operates on values emitted from a publisher. In the marble diagram, map takes a closure that multiplies each value by 2.

2 4 6 map { $0 * 2 } 1 2 3

Add this new example to your playground:

example(of: "map") {
  // 1
  let formatter = NumberFormatter()
  formatter.numberStyle = .spellOut
  
  // 2
  [123, 4, 56].publisher
    // 3
    .map {
      formatter.string(for: NSNumber(integerLiteral: $0)) ?? ""
    }
    .sink(receiveValue: { print($0) })
    .store(in: &subscriptions)
}

Here’s the play-by-play:

  1. Create a number formatter to spell out each number.
  2. Create a publisher of integers.
  3. Use map, passing a closure that gets upstream values and returns the result of using the formatter to return the number’s spelled out string.

Run the playground, and you will see this output:

——— Example of: map ———
one hundred twenty-three
four
fifty-six

Map key paths

The map family of operators also includes three versions that can map into one, two, or three properties of a value using key paths. Their signatures are as follows:

  • map<T>(_:)
  • map<T0, T1>(_:_:)
  • map<T0, T1, T2>(_:_:_:)

The T represents the type of values found at the given key paths.

In the next example, you’ll use the Coordinate type and quadrantOf(x:y:) method defined in Sources/SupportCode.swift. Coordinate has two properties: x and y. quadrantOf(x:y:) takes x and y values as parameters and returns a string indicating the quadrant for the x and y values.

Note: Quadrants are part of coordinate geometry. For more information you can visit mathworld.wolfram.com/Quadrant.html.

Feel free to review these definitions if you’re interested, and then add the following example to your playground:

Add this example to see how you’d use map(_:_:) to map into two key paths:

example(of: "map key paths") {
  // 1
  let publisher = PassthroughSubject<Coordinate, Never>()
  
  // 2
  publisher
    // 3
    .map(\.x, \.y)
    .sink(receiveValue: { x, y in
      // 4
      print(
        "The coordinate at (\(x), \(y)) is in quadrant",
        quadrantOf(x: x, y: y)
      )
    })
    .store(in: &subscriptions)
  
  // 5
  publisher.send(Coordinate(x: 10, y: -8))
  publisher.send(Coordinate(x: 0, y: 5))
}

In this example you’re using the version of map that maps into two properties via key paths.

Step-by-step, you:

  1. Create a publisher of Coordinates that will never emit an error.
  2. Begin a subscription to the publisher.
  3. Map into the x and y properties of Coordinate using their key paths.
  4. Print a statement that indicates the quadrant of the provide x and y values.
  5. Send some coordinates through the publisher.

Run the playground and the output from this subscription will be the following:

——— Example of: map key paths ———
The coordinate at (10, -8) is in quadrant 4
The coordinate at (0, 5) is in quadrant boundary

tryMap(_:)

Several operators, including map, have a counterpart try operator that will take a closure that can throw an error. If you throw an error, it will emit that error downstream. Add this example to the playground:

example(of: "tryMap") {
  // 1
  Just("Directory name that does not exist")
    // 2
    .tryMap { try FileManager.default.contentsOfDirectory(atPath: $0) }
    // 3
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

Here’s what you just did, or at least tried to do!

  1. Create a publisher of a string representing a directory name that does not exist.
  2. Use tryMap to attempt to get the contents of that nonexistent directory.
  3. Receive and print out any values or completion events.

Notice that you still need to use the try keyword when calling a throwing method.

Run the playground and observe that tryMap outputs a failure completion event with the appropriate “folder doesn’t exist” error (output abbreviated):

——— Example of: tryMap ———
failure(..."The folder “Directory name that does not exist” doesn't exist."...)

Flattening publishers

This section’s title might not shed any light on what you’re about to learn, unless you have some prior experience with reactive programming. However, by the end of this section, everything will be illuminated for you.

flatMap(maxPublishers:_:)

The flatMap operator can be used to flatten multiple upstream publishers into a single downstream publisher — or more specifically, flatten the emissions from those publishers.

The publisher returned by flatMap does not — and often will not — be of the same type as the upstream publishers it receives.

A common use case for flatMap in Combine is when you want to pass elements emitted by one publisher to a method that itself returns a publisher, and ultimately subscribe to the elements emitted by that second publisher.

Time to implement an example to see this in action. Add this new example:

example(of: "flatMap") {
  // 1
  func decode(_ codes: [Int]) -> AnyPublisher<String, Never> {
    // 2
    Just(
      codes
        .compactMap { code in
          guard (32...255).contains(code) else { return nil }
          return String(UnicodeScalar(code) ?? " ")
        }
        // 3
        .joined()
    )
    // 4
    .eraseToAnyPublisher()
  }
}

From the top, you:

  1. Define a function that takes an array of integers, each representing an ASCII code, and returns a type-erased publisher of strings that never emits errors.
  2. Create a Just publisher that converts the character code into a string if it’s within the range of 0.255, which includes standard and extended printable ASCII characters.
  3. Join the strings together.
  4. Type erase the publisher to match the return type for the fuction.

Note: For more information about ASCII character codes, you can visit www.asciitable.com.

With that handiwork completed, add this code to your example to put that function and the flatMap operator to work:

// 5
[72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]
  .publisher
  .collect()
  // 6
  .flatMap(decode)
  // 7
  .sink(receiveValue: { print($0) })
  .store(in: &subscriptions)

With this code, you: 5. Create a secret message as an array of ASCII character codes, convert it to a publisher, and collect its emitted elements into a single array. 6. Use flatMap to pass the array element to your decoder function. 7. Subscribe to the elements emitted by the pubisher returned by decode(_:) and print out the values.

Run the playground, and you’ll see the following:

——— Example of: flatMap ———
Hello, World!

Recall the definition from earlier: flatMap flattens the output from all received publishers into a single publisher. This can pose a memory concern, because it will buffer as many publishers as you send it to update the single publisher it emits downstream.

To understand how to manage this, take a look at this marble diagram of flatMap:

P1 flatMap(maxPublishers: .max(2)) { $0.value } 1 1 4 P2 2 4 2 5 5 P3 3

In the diagram, flatMap receives three publishers: P1, P2, and P3. Each of these publishers has a value property that is also a publisher. flatMap emits the value publishers’ values from P1 and P2, but ignores P3 because maxPublishers is set to 2. You’ll get more practice working with flatMap and its maxPublishers parameter in Chapter 19, “Testing.”

You now have a handle on one of the most powerful operators in Combine. However, flatMap is not the only way to swap input with a different output. So, before wrapping up this chapter, you’ll learn a couple more useful operating for doing the ol’ switcheroo.

Replacing upstream output

Earlier in the map example, you worked with Foundation’s Formatter.string(for:) method. It produces an optional string, and you used the nil-coalescing operator (??) to replace a nil value with a non-nil value. Combine also includes an operator that you can use when you want to always deliver a value.

replaceNil(with:)

As depicted in the following marble diagram, replaceNil will receive optional values and replace nils with the value you specify:

2 1 3 replaceNil(with: 2) Optional(3) Optional(1) nil

Add this new example to your playground:

example(of: "replaceNil") {
  // 1
  ["A", nil, "C"].publisher
    .eraseToAnyPublisher()
    .replaceNil(with: "-") // 2
    .sink(receiveValue: { print($0) }) // 3
    .store(in: &subscriptions)
}

What you just did:

  1. Create a publisher from an array of optional strings.
  2. Use replaceNil(with:) to replace nil values received from the upstream publisher with a new non-nil value.
  3. Print out the value.

Note: There is an issue which causes the wrong overload of replaceNil(with:) to be used. This results in the type remaining as Optional<String> instead of being fully unwrapped. The eraseToAnyPublisher() in the code is used to go around that bug. You can learn more about this issue in the Swift forums: https://bit.ly/30M5Qv7

Run the playground, and you will see the following:

——— Example of: replaceNil ———
A
-
C

There is a subtle but important difference between using the nil-coalescing operator ?? and replaceNil. The ?? operator can still result in an nil result, while replaceNil cannot. Change the usage of replaceNil to the following, and you will get an error that the optional must be unwrapped:

.replaceNil(with: "-" as String?)

Revert that change before moving on. This example also demonstrates how you can chain together multiple operators in a compositional way. This allows you to manipulate the values coming from the origin publisher to the subscriber in a wide variety of ways.

replaceEmpty(with:)

You can use the replaceEmpty(with:) operator to replace — or really, insert — a value if a publisher completes without emitting a value.

In the following marble diagram, the publisher completes without emitting anything, and at that point the replaceEmpty(with:) operator inserts a value and publishes it downstream:

1 replaceEmpty(with: 1)

Add this new example to see it in action:

example(of: "replaceEmpty(with:)") {
  // 1
  let empty = Empty<Int, Never>()
  
  // 2
  empty
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}

What you’re doing here:

  1. Create an empty publisher that immediately emits a completion event.
  2. Subscribe to it, and print received events.

The Empty publisher type can be used to create a publisher that immediately emits a .finished completion event. It can also be configured to never emit anything by passing false to its completeImmediately parameter, which is true by default. This publisher is useful for demo or testing purposes, or when all you want to do is signal completion of some task to a subscriber. Run the playground and its completion event is printed:

——— Example of: replaceEmpty ———
finished

Now, insert this line of code before calling sink:

.replaceEmpty(with: 1)

Run the playground again, and this time you get a 1 before the completion:

1
finished

Incrementally transforming output

You’ve seen how Combine includes operators such as map that correspond and work similarly to higher-order functions found in the Swift standard library. However, Combine has a few more tricks up its sleeve that let you manipulate values received from an upstream publisher.

scan(_:_:)

A great example of this in the transforming category is scan. It will provide the current value emitted by an upstream publisher to a closure, along with the last value returned by that closure.

In the following marble diagram, scan begins by storing a starting value of 0. As it receives each value from the publisher, it adds it to the previously stored value, and then stores and emits the result:

1 3 6 scan(0) { $0 + $1 } 1 2 3

Note: If you are using the full project to enter and run this code, there’s no straightforward way to plot the output — as is possible in a playground. Instead, you can print the output by changing the sink code in the example below to .sink(receiveValue: { print($0) }).

For a practical example of how to use scan, add this new example to your playground:

example(of: "scan") {
  // 1
  var dailyGainLoss: Int { .random(in: -10...10) }

  // 2
  let august2019 = (0..<22)
    .map { _ in dailyGainLoss }
    .publisher

  // 3
  august2019
    .scan(50) { latest, current in
      max(0, latest + current)
    }
    .sink(receiveValue: { _ in })
    .store(in: &subscriptions)
}

In this example, you:

  1. Create a computed property that generates a random integer between -10 and 10.
  2. Use that generator to create a publisher from an array of random integers representing fictitious daily stock price changes for a month.
  3. Use scan with a starting value of 50, and then add each daily change to the running stock price. The use of max keeps the price non-negative — thankfully stock prices can’t fall below zero!

This time, you did not print anything in the subscription. Run the playground, and then click the square Show Results button in the right results sidebar.

Talk about a bull run! How’d your stock do?

There’s also an error-throwing tryScan operator that works similarly. If the closure throws an error, tryScan fails with that error.

Challenge

Practice makes permanent. Complete this challenge to ensure you’re good to go with transforming operators before moving on.

Challenge: Create a phone number lookup using transforming operators

Your goal for this challenge is to create a publisher that does two things:

  1. Receives a string of ten numbers or letters.
  2. Looks up that number in a contacts data structure.

The starter playground, which can be found in the challenge folder, includes a contacts dictionary and three functions. You’ll need to create a subscription to the input publisher using transforming operators and those functions. Insert your code right below the Add your code here placeholder, before the forEach blocks that will test your implementation.

Tip: A function or closure can be passed directly to an operator as a parameter if the function signature matches. For example, map(convert).

Breaking down this challenge, you’ll need to:

  1. Convert the input to numbers — use the convert function, which will return nil if it cannot convert the input to an integer.
  2. If nil was returned from the previous operator, replace it with a 0.
  3. Collect ten values at a time, which correspond to the three-digit area code and seven-digit phone number format used in the United States.
  4. Format the collected string value to match the format of the phone numbers in the contacts dictionary — use the provided format function.
  5. “Dial” the input received from the previous operator — use the provided dial function.

Solution

Did your code produce the expected results? Starting with a subscription to input, first you needed to convert the string input one character at a time into integers:

input
  .map(convert)

Next you needed to replace nil values returned from convert with 0s:

.replaceNil(with: 0)

To look up the result of the previous operations, you needed to collect those values, and then format them to match the phone number format used in the contacts dictionary:

.collect(10)
.map(format)

Finally, you needed to use the dial function to look up the formatted string input, and then subscribe:

.map(dial)
.sink(receiveValue: { print($0) })

Running the playground will produce the following:

——— Example of: Create a phone number lookup ———
Contact not found for 000-123-4567
Dialing Marin (408-555-4321)...
Dialing Shai (212-555-3434)...

Bonus points if you hook this up to a VoIP service!

Key points

  • Methods that perform operations on output from publishers are called operators.
  • Operators are also publishers.
  • Transforming operators convert input from an upstream publisher into output that is suitable for use downstream.
  • Marble diagrams are a great way to visualize how each Combine operators work.
  • Be careful when using any operators that buffer values such as collect or flatMap to avoid memory problems.
  • Be mindful when applying existing knowledge of functions from Swift standard library. Some similarly-named Combine operators work the same while others work entirely differently.
  • Multiple operators can be chained together in a subscription.

Where to go from here?

Way to go! You just transformed yourself into a transforming titan.

Now it’s time to learn how to use another essential collection of operators to filter what you get from an upstream publisher.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.