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.
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
.
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:
- Create a number formatter to spell out each number.
- Create a publisher of integers.
- 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:
- Create a publisher of
Coordinate
s that will never emit an error. - Begin a subscription to the publisher.
- Map into the
x
andy
properties ofCoordinate
using their key paths. - Print a statement that indicates the quadrant of the provide
x
andy
values. - 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!
- Create a publisher of a string representing a directory name that does not exist.
- Use
tryMap
to attempt to get the contents of that nonexistent directory. - 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:
- 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.
- 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. - Join the strings together.
- 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
:
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 nil
s with the value you specify:
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:
- Create a publisher from an array of optional strings.
- Use
replaceNil(with:)
to replacenil
values received from the upstream publisher with a new non-nil
value. - 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 asOptional<String>
instead of being fully unwrapped. TheeraseToAnyPublisher()
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:
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:
- Create an empty publisher that immediately emits a completion event.
- 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:
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:
- Create a computed property that generates a random integer between
-10
and10
. - Use that generator to create a publisher from an array of random integers representing fictitious daily stock price changes for a month.
- Use
scan
with a starting value of50
, and then add each daily change to the running stock price. The use ofmax
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:
- Receives a string of ten numbers or letters.
- 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:
- Convert the input to numbers — use the
convert
function, which will returnnil
if it cannot convert the input to an integer. - If
nil
was returned from the previous operator, replace it with a0
. - 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.
- Format the collected string value to match the format of the phone numbers in the contacts dictionary — use the provided
format
function. - “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 0
s:
.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
orflatMap
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.