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.finished
s? 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.value
case. - Intercepting completions with the
append
operator and turning around and sending anEvent.completion(.finished)
to subscribers. - Catching any
error
s, 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
Never
for 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
catch
to this form. ↩