Discuss the value of `lift` in the 6.0 world
See original GitHub issueI’d like to talk about removing lift
and the ceremony around that.
Why we had lift
lift
is a really cool architecture for a couple of reasons:
- It allowed us to control the return type of operators for better dot-chaining. This meant that users could create custom operators on a class that extends Observable, then override
lift
to return the custom class, allowing the custom operators to mix in the same dot-chains as standard operators. - It gave us a centralized point that we could hook to affect (almost) all operators. This was going to enable us to do fancy reporting or build tooling around RxJS Observables.
Lift In The World Of Pipeable Operators
With Pipeable operators, we’re no longer dot-chaining everything. This means that controlling the return type no longer matters, and developing a custom operator is as simple as writing a higher-order function. This calls into question the value of lift
, since that eliminates benefit number 1 listed above.
Given that all operators will likely go through the pipe
method, or otherwise carry a similar signature. It seems like there would be ample ways to hook our operators for any sort of tooling we might want to do. Either way, the promise of lift
as a centralized point to hook everything never came through, and in fact there were always some operators that just didn’t use lift
so it wouldn’t have been perfect.
Proposed change for 6.0
Remove lift process entirely.
I think the benefit here will be a sharp reduction in the size of the library. As there will no longer be a need for all of the Operator
classes in the library, nor will there be calls to lift
or implementations of lift
on Observable
or Subject
.
Interested in all of your thoughts… especially @trxcllnt
Issue Analytics
- State:
- Created 6 years ago
- Comments:16 (13 by maintainers)
The Operator classes were an optimization tailored for what JIT’d well in v8 at the time (more details in the original Lifted Observable proposal, and below). If operator functions JIT (or tree-shake, etc.) better now, then we should do that.
In fact, the
Operator
interface was explicitly designed to mimic the runtime signature of regular functions, but for perf we implemented all of them as classes:I’ve even proposed updating the Operator interface to reflect this (at the time the interface was written, TS didn’t support hybrid types), but got push back that it could be confusing.
What’s the deal with
lift
?Totally! That’s how Rx < 5 was implemented (until ~1.5 years ago or so, when Matt switched it to Observable/Observer pairs). The issue is that (at the time) JITs weren’t great at inlining anonymous function invocations, so no matter how much we optimized things like inner disposables, there was always a performance barrier for throughput.
JITs did perform well on the linked-list-of-subscribers-style (as opposed to the “linked-list-of-closures” style), which was a big reason we went with that in 5. We hypothesized the linked-list-of-subscribers style is faster because vtable lookup for the Subscriber methods is very fast, and v8 can represent them as
constant_function
descriptors since they’re on the prototype. For comparison:The same goes for operator classes vs. operator closures, with the added bonus that v8 optimizes lookups of properties assigned in the constructor. At the time these lookups were faster than accessing values from closure scope. I’m not sure if this is still true in turbofan, or whether turbofan does something similar as other VM’s (like .NET), optimizing closures to anonymous inner classes?
If we strip away all the ceremony around JIT optimizations,
lift
is just a function that maps a source Observable to a result Observable using an operator function that maps a sink Observer to a source Observer:Another way to think about it is you can create an Observable chain going “down”:
…by composing operator functions that create Subscriber chains going back “up”:
In a sense the Observable chain is really just a factory for creating Subscriber chains, which is where the real logic lives.
In practice, we found it was necessary for the
operator
function to control thesource.subscribe()
call in a few cases (skip/take, multicast, etc.), so the real definition oflift
is more like this:But since we know the VM can represent functions on the prototype as constant_functions (and can embed the function pointer into optimizable functions), we can transform the
lift
function into its prototype form:We can also get rid of that non-constant anonymous function that binds the operator function to the source and sink. All we have to do is broaden the scope of our design to include the Observable prototype’s
subscribe
method. We can assign the source and operator to the Observable being returned, then invoke theoperator
function inside thesubscribe
method:Even if turbofan is faster than crankshaft at invoking anonymous functions, the closure approach has 1 more allocation (aka 2x as many total allocations) than assigning the source and operator to the Observable instance being returned.
lift vs. pipe
Now let’s compare the “pure”
lift
andpipe
signatures:Looking closely,
pipe
is just the partially-applied form oflift
! Pipe just re-arranges the position of the source Observable<T> and operator function, allowing the source Observable to be supplied at the end instead of the beginning. They’re so close that we wouldn’t even need two methods if we were in a functional language that partially applies functions by default.From this perspective, the whole “
lift
+ operators implemented in terms of Observers, + Operator classes” scheme is a thing we found that let us efficiently implement the pipe-style for operators internally before the VMs started helping us out.I should note, I did take some liberties with the
operator
definitions above:operator
function maps anObservable<T>
to anObservable<R>
operator
function maps anObserver<R>
to anObserver<T>
This is just another way to represent the idea of either:
I guess I’m unclear on what removing
lift
entirely would mean? Taking it off the prototype? Inlining the contents of thelift
method into every operator? Reimplementing operators in terms of Observables (and anonymous Observers + Disposables)?While I believe it’s always valuable to re-evaluate our assumptions on performance, my intuition is that implementing operators in terms of Observers (or rather, Subscribers) is still faster than implementing them in terms of Observables. All the operators are currently still implemented in terms of Observers and
pipe
is just a wrapper aroundlift
, so today we see no difference. With the obvious caveat that JITs are spooky, I’m dubious about any plans to reimplement the operators without some form oflift
.Unless something’s changed, I don’t think this has been true since #1941 landed?
I use it in a number of public projects, and a good number of proprietary projects as well. I know a number of other folks who rely on it to extend Observable and build custom asynchronous DSLs, but they’re understandably not very active in our GH issues.
Thanks for commenting on this @trxcllnt, I remember almost all of this from our original work, given our concerns with size, I’m trying to look at every avenue I can to reduce the footprint of the library. That’s the major catalyst here.
Another concern I have is that at some point, a standardized Observable will land, and it will not include a
lift
method, so any operators we create will not work with those Observables, because all of our operators rely onlift
existing on the observable instance. I suppose we could make lift a standalone function, it could cost perf, but only when you first set up the observable, not per-next or even on per-subscription.As of even a week ago, playing around with this, I’m pretty sure I want to keep
lift
regardless.What do you think about moving off as a standalone function?
pipe
is a different, and unfortunate animal. It’s mostly on the prototype for ergonomic reasons. I couldn’t think of anything prettier (that wasn’t dot-chaining). I’d love it if we could have the pipeline operator, but the TC39 seems rather anti-functional programming syntax from what I’ve been told.