withLatestFrom as a composed operator

⇐ 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):

(Gist permalink.)

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 weakly capture class-bound publishers? Turns out all but three conformances in the Publishers namespace are structs1, so let’s account for those.

(Gist permalink.)

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.

(Gist permalink.)

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.

(Gist permalink.)

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.

(Gist permalink.)

…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:

(Gist permalink.)

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.

(Gist permalink.)

Which begs the question, if our initial, non-zipped implementation passes CombineExt’s test suite? Does its implementation handle lone upstream completions? Let’s add a test case and take a look:

(Gist permalink.)

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


  1. Subject conformances are also generally reference types. 

  2. Some chatter about this on the Swift Forums when map plus switchToLatest didn’t quite match Rx’s flatMapLatest

  3. 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