A materialization primer
09 Apr 2020(Assumed audience: folks with a working knowledge of Combine and the ObservableObject protocol.)
Materialization, like “fusion” and “dual,” is an intimidating term for a not-so-intimidating concept.
When I first encountered the word, I thought it was over the top. And, it kind of is. This entry tries to bring it down from abstraction and into Swift.
Let’s run into needing it by way of example, fetching integers from random.org.
Fetching random integers
We’ll extend Point-Free’s pinging of random.org for a random number.
With randomNumberPublisher in hand, we can imagine a view model using it when responding to pings to fetch a number.
Now’s let’s send on randomNumberPing a few times and see what happens.
And while not ideal, it checks out that a failure in the first request would preclude attempting the second. The Publisher contract states that an error event means a publisher won’t further emit values, effectively rendering our viewModel inert after a failure.
It might be tempting to rework RandomNumberViewModel.init with error handling.
Then, well, we run into another problem. We have no way of disambiguating between an initial and error state, since they both bear nil values. Moreover, if we had more errors than RandomDotOrgError.failedRequest, we’d lossily squash them.
We could lift RandomNumberViewModel.randomNumber into a Result, but, doing so would put the burden on consumers to switch over Result.success and .failure when focusing in on the cases they’re concerned about (e.g. content views, the success case, and error views, the failure).
Materialization—as you probably guessed—has a say in this matter.
First, let’s upgrade our view model output from an Int? to a type that better relays error and loading states.
And then, trying to wire everything up with DataLoadState.
Let’s take a breather. That was a ton of scaffolding to climb through, but, it’ll pay off—I promise!
Notes (3), (8), and (10) are gaps we’ll fill in with materialization.
<aliens-guy>Errors as values</aliens-guy>
We’ll often need publishers to drive client-side errors views—which, seem at odds with Combine’s Publisher contract:
[After sending a subscription to a subscriber and they request values,] the publisher is free to send that number of values or fewer to the subscriber.
…, if the publisher is finite, then it will eventually send a [single] completion or an error.
—WWDC ’19, Session 722 (timestamped transcript)
Receiving only a single failure (or finished) event makes user-initiated retries and displaying error views difficult. We’d have to peek into the receiveCompletion argument of either Publisher.handleEvents or .sink and then re-attach subscribers to new publisher instances.
Not ideal.
This not-ideal’ness stems from completion events being terminal. There’s sound practical (and theoretical1) reasons for this and we’ll need to bridge the gap by moving our involved Publisher’s Failure generic into its Output type.
“Into” in that sentence is a bit vague. Let’s pause with it.
Publishers emit either some (or none) Output-typed values or a single (or no) Subscribers.Completion<Failure> terminating event.
So, we need a way of translating a publisher to one in this form (subbing in AnyPublisher as a placeholder),
AnyPublisher<Output, Failure> ⇒ AnyPublisher<Event<Output, Failure>, Never>.
It’s worth noting our old friend2, Never prevents the transformed publisher from error’ing out.
I’ll also need to properly introduce our new friend, Event. It’s an enumeration to represent the possible events a subscriber may receive.
This is all sounds nice and well. But, what’s the point?
By materializing errors into a publisher’s output, we can then react to them in the same way we would handle ordinary values.
Or, rather, we’re considering errors as values.
Two implementations
We have two options: lean on existing operators or sketch out our own Publisher conformance. There’s tradeoffs for each and since there’s prior art for the latter in CombineCommunity/CombineExt, we’ll go with the former.
Now, we can start chaining operators, case by Event case.
Values? We need to box ‘em into Event.value.
Completion.finisheds? Well, we need a way of silencing upstream’s finished event and then turning around and publishing an Event.completion(.finished). Thankfully, the framework ships with a few .append(_:) overloads.
And lastly, (and densely3), catching error events.
That’s a lot to digest—let’s recap (and maybe take some Tums).
We’re doing three things.
- Lifting emitted values from upstream into the
Event.valuecase. - Intercepting completions with the
appendoperator and turning around and sending anEvent.completion(.finished)to subscribers. - Catching any
errors, boxing them up into aEvent.completion(.failure(error)), and pass it along as a value event.
Dematerializing
Materializing—in a sort of pseudo-Swift syntax—takes a Publisher<Output, Failure> and lifts it to a Publisher<Event<Output, Failure>, Never>, shifting errors to the value side.
However, consumers might only be concerned with specific Event cases. Think content views backed by Output instances or error views driven by Failure values.
We need a way of lowering a previously-materialized sequence, or as our wise elders called it, “dematerializing”
To start, we might be tempted to extend Publisher with an Output == Event<Output, Failure> constraint.
Sadly, the compiler can’t reconcile the recursive constraint. We’ll need to introduce an intermediary protocol, EventConvertible, following Rx’s lead.
In both values and errors we’ll wanna focus in on upstream Event.value and .completion(.failure(_))s, respectively. There’s a couple of ways we could go about this—e.g. filtering cases or compacting associated values à la Point-Free’s enumeration properties.
The latter route is more fun—and of course, that doesn’t make the former any less valid.
To start, let’s add computed properties to EventConvertible to pluck out either an associated Output or Failure value.
These optional properties tee up Publisher.values and .errors to compactMap them out.
“All together now (materializing and dematerializing).” — The Beatles…I think.
The above snippets should all compile, again, and now we can sketch out a ContentView against RandomNumberViewModel with a button to fire off requests, honor loading states, and present a sheet with either a random number or error in under thirty lines.
In GIF action,
Sample `ContentView` using `RandomNumberViewModel`.
Prior art
You’re probably—and rightfully—thinking that performing the materialize-share-values-errors dance on every ObservableObject conformance is going to be…a lot.
The community agrees. Thankfully, we can stand on their shoulders. In the past, folks have abstracted away materialization under a higher-level type called Action.
It comes in a few flavorings:
Each exposes observables and signals (synonyms for “publishers”) like Action.errors, .elements, and .executing corresponding to Publisher.values and .errors, and DataLoadState.loading above.
…is there a Combine Action analog?
Stay tuned, Adam Sharp has something in the works over at thoughtbot/CombineAction.
■
Special thanks to Van for feedback on an early draft of this entry.
Related reading and footnotes
⇒ Shai’s implementation of materialization.
⇒ The ReactiveX reference for materialize and dematerialize.
⇒ “TIL about Publishers.MergeMany.init”
⇒ “Weak assignment in Combine”
-
I’ll have more on this soon. Until then, Brian Beckman and Erik Meijer’s ’09 Expert to Expert session is worth the time and in prose form, my post on duals translates Casey Liss’ from Rx. ↩
-
No fret if you’re meeting
Neverfor the first time. The NSHipster folks have a fantastic entry on the type. ↩ -
If you’re open to operators, importing your Prelude of choice can rework the
catchto this form. ↩