Chapters

Hide chapters

Combine: Asynchronous Programming with Swift

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

3. Transforming Operators
Written by Scott Gardner

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

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.

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

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.

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)
}
——— 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:

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))
}
——— 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)
}
——— 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.

public struct Chatter {
  public let name: String
  public let message: CurrentValueSubject<String, Never>
  
  public init(name: String, message: String) {
    self.name = name
    self.message = CurrentValueSubject(message)
  }
}
example(of: "flatMap") {
  // 1
  let charlotte = Chatter(name: "Charlotte", message: "Hi, I'm Charlotte!")
  let james = Chatter(name: "James", message: "Hi, I'm James!")
  
  // 2
  let chat = CurrentValueSubject<Chatter, Never>(charlotte)
  
  // 3
  chat
    .sink(receiveValue: { print($0.message.value) })
    .store(in: &subscriptions)
}
——— Example of: flatMap ———
Charlotte wrote: Hi, I'm Charlotte!
// 4
charlotte.message.value = "Charlotte: How's it going?"

// 5
chat.value = james
Charlotte wrote: Hi, I'm Charlotte!
James wrote: Hi, I'm James!
chat
  .sink(receiveValue: { print($0.message.value) })
  .store(in: &subscriptions)
chat
  // 6
  .flatMap { $0.message }
  // 7
  .sink(receiveValue: { print($0) })
  .store(in: &subscriptions)
Hi, I'm Charlotte!
Charlotte: How's it going?
Hi, I'm James!
james.message.value = "James: Doing great. You?"
charlotte.message.value = "Charlotte: I'm doing fine thanks."
James: Doing great. You?
Charlotte: I'm doing fine thanks.
.flatMap { $0.message }
.flatMap(maxPublishers: .max(2)) { $0.message }

// 8
let morgan = Chatter(name: "Morgan",
                     message: "Hey guys, what are you up to?")

// 9
chat.value = morgan

// 10
charlotte.message.value = "Did you hear something?"
——— Example of: flatMap ———
Hi, I'm Charlotte!
Charlotte: How's it going?
Hi, I'm James!
James: Doing great. You?
Charlotte: I'm doing fine thanks.
Did you hear something?

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:

example(of: "replaceNil") {
  // 1
  ["A", nil, "C"].publisher
    .replaceNil(with: "-") // 2
    .sink(receiveValue: { print($0) }) // 3
    .store(in: &subscriptions)
}
——— Example of: replaceNil ———
Optional("A")
Optional("-")
Optional("C")
["A", nil, "C"].publisher
  .replaceNil(with: "-")
  .map { $0! }
  .sink(receiveValue: { print($0) })
  .store(in: &subscriptions)
A
-
C
.replaceNil(with: "-" as String?)

replaceEmpty(with:)

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

example(of: "replaceEmpty(with:)") {
  // 1
  let empty = Empty<Int, Never>()
  
  // 2
  empty
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
}
——— Example of: replaceEmpty ———
finished
.replaceEmpty(with: 1)
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.

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)
}

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:

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)
.replaceNil(with: 0)
.collect(10)
.map(format)
.map(dial)
.sink(receiveValue: { print($0) })
——— Example of: Create a phone number lookup ———
Contact not found for 000-123-4567
Dialing Marin (408-555-4321)...
Dialing Shai (212-555-3434)...

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.

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.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now