Modifying the pairwise operator with a duplicated start

⇐ Notes archive

(This is an entry in my technical diary. 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.”)

Bas van Kuijck PR’d Publisher.pairwise() (and .nwise(_:)) to CombineExt back in August and the operator came up in iOS Folks’ #reactive channel the other day. Jon Shier asked:

I need to compare changes to values within a sequence, they’re non-optional already, so I need a way to see the previous value and the current value. I could probably figure that part out, but what about the first pair? I can’t wait until I get two values and I’d rather not introduce optionals, but what if I duplicated the first value that came down the stream but otherwise always had the previous and current values?

Unfortunately, pairwise — which is an overlay on nwise(2) — will only send values downstream after two are received from its upstream. So, it doesn’t meet the “I can’t wait until I get two values” requirement.

And further, the “rather not introduce optionals” mention makes things trickier. Let’s take a look. An initial approach we could take is shareing upstream, spitting it off into two —  one left as is and another that’s first’d — and then connecting the split sequences with a flatMap that duplicates the first value and then finally pairwiseing the result (…that probably made zero sense in prose, here’s a sketch hah).

(Gist permalink.)

The repeat (1, 1) surprised me — I had assumed shareing upstream would ensure that the resubscribe in the flatMap would carry on with 2. I scratched my head and dug further into this bit.

(Gist permalink.)

The second subscription to upstream in the flatMap seems to synchronously win out over the lingering 2 from the first subscription (notice it comes in at the bottom of the logs, yet never makes it to the Post-share print operator). We can further suss out this race condition by delaying the first value event and then noticing the second subscription missed its chance entirely.

(Gist permalink.)

Stepping back, there miiight be a way to make a share based approach without race conditions like this while also accounting for upstream’s temperature (please reach out, if you know how (!)), but at this point, I decided to ease the “rather not introduce optionals” requirement and use a scan-based approach to work around these concerns.

(Gist permalink.)

The core of the implementation is at (1) — to kick off the scan, we coalesce the initial (nil, nil) to (next, next) for the first upstream value. From then on, we pair à la pairwise since subsequent calls to optionalZip will have a non-nil return value. It’s a bit unfortunate that the local, free-function optionalZip couldn’t simply be named zip, since that would collide with Publisher.zip in the implementation. Adam mentioned he runs into this often with Swift.print and Publisher.print, too. Wonder if there’s any reason Swift can’t have an inverse to the @_disfavoredOverload annotation — maybe called @_preferredOverload — to nudge the compiler here? We could prefix with the current module’s name in most cases, but I was working within a Playground (and I’m guessing they have generated module names that are out of reach at compile time).