withLatestFrom as a composed operator
10 Apr 2021 ⇐ Notes archive(This is an entry in my technical notebook. There will likely be typos, mistakes, or wider logical leaps — the intent here is to “let others look over my shoulder while I figure things out.”)
I kind of took CombineExt’s — and the ReactiveX specification of — Publisher.withLatestFrom
for granted. “It probably subscribes to both upstream and the provided sequence, suspending output from upstream until the other emits, and subsequently forwards the latest values down from the latter when the former emits” was my reading of the filled out Publisher
conformance. Which made me assume this dance wasn’t possible as a composed operator.
And the other day Ian showed me the way.
Here’s a sketch of the approach (with a selector variant):
If this isn’t the Combine equivalent of a kickflip — I don’t know what is.
The implicit capture of the upstream self
in the other.map { … }
closure is worth checking in on. Maybe we can write AnyObject
-constrained overloads that weak
ly capture class
-bound publishers? Turns out all but three conformances in the Publishers
namespace are structs1, so let’s account for those.
What’s wild is, at this point, we can sub in this implementation for CombineExt’s and the test suite still passes (!). Let’s check our work when it comes to terminal events, though.
Failures?
Failure events from either upstream or other
are propagated down. Check.
Completions? This event type isn’t as intuitive. Should the argument’s completions be forwarded downstream? Let’s import CombineExt
to see how the non-composed implementation handles this.
Hmm, alright, I can buy that second
’s completions shouldn’t be sent downstream since withLatestFrom
is essentially polling it for value events, caching the latest.
Now let’s nix the CombineExt import and see how our operator handles this.
…neither scenario completes? Oof — and this checks out because the implementation’s map
-switchToLatest
dance only completes when upstream and all of the projected sequences complete2 (i.e. the scenarios in the snippet finish if you tack on first.send(completion: .finished)
and second.send(completion: .finished)
to each, respectively).
But wait, didn’t Ext’s test suite pass with this implementation? It did, because at the time of writing (commit 8a070de
) every test case in WithLatestFromTests.swift
checks for withLatestFrom
’s completion only after every argument and upstream has completed (missing the cases where only upstream finishes or the arguments do, but not both).
Here’s Rx’s handling of the parenthesized cases:
Back to the drawing board.
To recap, our implementation handles value and error events to spec. and needs to be reworked to finish when upstream does, even if the operator’s argument doesn’t.
We can pull this off by using a note I wrote about on Publisher.zip
completions — specifically, that if any one of the publishers in a zip completes, the entire zipped sequence completes.
Which begs the question, if our initial, non-zip
ped implementation passes CombineExt’s test suite? Does its implementation handle lone upstream completions? Let’s add a test case and take a look:
Shoot. Ext’s implementation doesn’t handle this.
So, we have two options: either rework Ext to handle this case or sub in our composed variant which does. To kick off the discussion, I wrote an issue over at CombineCommunity/CombineExt/87 with a sketch of how a PR for the latter approach could look.
⬦
The tl;dr is it’s possible to pull off Publisher.withLatestFrom
as a composed operator! And while a full Publisher
conformance can be more idiomatic, it’s fun to think on what it means to factor the operator’s behavior into the composition of ones that ship with the framework.3
-
Some chatter about this on the Swift Forums when
map
plusswitchToLatest
didn’t quite match Rx’sflatMapLatest
. ↩ -
It’s worth calling out though that composed operators can break under pressure compared to their
Publisher
-conformance counterparts. e.g. variadic zipping might crash on the order of hundreds of arguments. ↩