Modifying the pairwise operator with a duplicated start
11 Feb 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.”)
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 share
ing 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 pairwise
ing the result (…that probably made zero sense in prose, here’s a sketch hah).
The repeat (1, 1)
surprised me — I had assumed share
ing upstream would ensure that the resubscribe in the flatMap
would carry on with 2
. I scratched my head and dug further into this bit.
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 delay
ing the first value event and then noticing the second subscription missed its chance entirely.
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.
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).