Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discussion: Multicast vs. Unicast #66

Closed
bripkens opened this issue Oct 31, 2015 · 82 comments
Closed

Discussion: Multicast vs. Unicast #66

bripkens opened this issue Oct 31, 2015 · 82 comments

Comments

@bripkens
Copy link

What is your stance on multicast vs. unicast for observables? Well known stream / observable implementations have varying opinions on this topic. RxJS is "unicast" by default [1] while Kefir [2] and Bacon [3] are multicast by default. From what I can tell based on the existing es-observable implementation, es-observable is aligned with RxJS and only provides a "unicast" option.

Note that the RxJS' multicast operator does not result in the behavior of Kefir and Bacon. Kefir and Bacon retain automatic laziness and multicast enabled for all operators. The RxJS multicast operator only lifts the previous operator in the observable chain to a multicast one. Successive operators remain unicast.

We found multicast to be extremely valuable during the development of our application. We used it to model expensive operations that are executed lazily. Result are shared between all subscribers.

export const severityDistribution = allReportedIssues.map(issues => {
  // do expensive work here
  return severityDistribution;
});

[1] http://jsbin.com/cidaheqoqa/edit?html,js,console
[2] http://jsbin.com/wuqarebegi/edit?html,js,console
[3] http://jsbin.com/maciradiya/edit?html,js,console

@bripkens bripkens changed the title Multicast vs. Unicast Discussion: Multicast vs. Unicast Oct 31, 2015
@staltz
Copy link

staltz commented Oct 31, 2015

I'm a strong proponent of RxJS, and I'm comfortable with the concept of hot (multicast) and cold (unicast) observables. I know there are valid use cases for both of these, but like @bripkens, I think multicast makes more sense as a default than unicast does. It is more common to require an observable to be shared than it is to require strict non-shared executions of that observable.

If you make no assumption on how consumers want to use an observable, it is safer to choose to multicast the producer/observable. Usually consumption requirements force us to change the producer to multicast, and that's not a good separation of concerns.

@zenparsing
Copy link
Member

Perhaps those with more direct experience with Rx would like to comment? @Blesh @jhusain

I would like to point out that we should be able to support "multicast chains" via subclassing. Here's a sketch which should illustrate the general idea:

class MulticastForever extends Observable {

    constructor(subscriber) {
        // Maintain a list of subscribers and the source subscription
        this._listeners = new Map();
        this._subscription = null;
        super(subscriber);
    }

    subscribe(observer) {
        let sub = this._listeners.get(observer);

        if (this._subscription && sub)
            return sub;

        sub = {
            unsubscribe() {
                this._listeners.remove(observer);
                if (this._listeners.size === 0)
                    this._subscription.unsubscribe();
            }
        };

        this._listeners.set(observer, sub);

        if (!this._subscription) {

            this._subscription = super.subscribe({
                next(v) {
                    // TODO: Error handling
                    this._listeners.forEach(observer => observer.next(v));
                },
                error(e) {
                    // TODO: Error handling
                    this._listeners.forEach(observer => observer.error(e));
                },
                complete(v) {
                    // TODO: Error handling
                    this._listeners.forEach(observer => observer.complete(v));
                },
            });
        }

        return sub;
    }

}

Because of the way subclassing works in ES6 (and es-observable), when you call map or other methods on something like MulticastForever, you'll get a MulticastForever back.

@benlesh
Copy link

benlesh commented Nov 2, 2015

Honestly, the majority of the time with Observables, all you want is Unicast. (Cold)... and in reality I don't think it should even be thought of as "Multicast" and "Unicast" but more in terms of: 1 Producer -> 1 Consumers or 1 Producer -> N Consumers.

At the end of the day, an Observable is really a template for creating a Producer and pushing values to a Consumer. If a Producer already exists, you could wrap it in an Observable and it's magically 1 Producer -> N Consumers. Consider a shared web socket:

var socket = new WebSocket('ws://echo.websocket.org');

var messages = new Observable(observer => {
  socket.onmessage = ::observer.next;
  socket.onclose = (e) => {
    if(e.wasClean) {
      observer.complete();
    } else {
      observer.error(e);
    }
  };
  socket.onerror = ::observer.error;
});

In the above scenario, the socket is shared with all subscribers to the observable. That's really the simplest way to do it.

When you're talking about multicast observables, you're talking about using observables in more advanced ways (in order to leverage the set up and tear down processes to do things like connect the websocket or close it cleanly). I do this all the time, but it's not completely necessary.

@benlesh
Copy link

benlesh commented Nov 2, 2015

Another thing to think about is Observables are analogous to Functions.

Anything you can do with a Function you can do with an Observable. If Observables become multicast-only, that's not necessarily true. Functions, by default, execute some logic and return a value to a user, and/or create some side effects, once per call. Observables can model that. Functions could be written to share that logic per N calls, as can Observables.

... I read that and it looks like babbling, haha.. Does it make sense?

@benlesh
Copy link

benlesh commented Nov 2, 2015

TL;DR: They should say unicast/cold by default, IMO.

@RangerMauve
Copy link

Wait, does this mean that I'll need to create several observables for a dom event if I have multiple consumers for that event? I thought that Observables would be able to pipe into any number of consumers via the subscribe function. Am I missing something?

@benlesh
Copy link

benlesh commented Nov 2, 2015

Wait, does this mean that I'll need to create several observables for a dom event if I have multiple consumers for that event?

Yes, technically an Observable is analogous to a function. So if your Observable calls addEventListener in it's subscriber, then it will add multiple event listeners. DOM events multicast themselves, why add another layer on top of that?

In essence, what's the difference between:

let counter = 0;
function alertEveryOther(e) {
  if(counter++ % 2 === 0) alert(e);
}

function alertHi(e) {
  alert('hi');
}

domElement.addEventListener('click', alertEveryOther);
domElement.addEventListener('click', alertHi);

and...

var clicks = new Observable(observer => {
  var handler = ::observer.next;
  domElement.addEventListener('click', handler);

  return () => {
     domElement.removeEventListener('click', handler);
  };
});

clicks.filter((e, i) => i % 2 === 0).subscribe({ next(e) { alert(e) } });
clicks.map(e => 'hi').subscribe({ next(e) { alert(e) } });

It's really not going to be any more performant to maintain an extra array of consumers in JavaScript in the first example. Why would that be any different with the observable? The native DOM event wiring will handle "multicasting" for you.

@RangerMauve
Copy link

Interesting, I guess I just hadn't thought about it like that. In a module I made, streamemitter, when you subscribed to an event, you'd get back a readable stream which attached one listener on the actual event emitter. Then you could pipe that stream into whatever number of writable streams for processing.

Though, I admit a lot of the time I did end up having a single writable stream per readable stream, having multiple consumers was also quite common.

In your example, could you have something like

var clickTargets = clicks.map(e => e.target);
clickTargets.subscribe({next(target){ log(target); });
clickTargets.subscribe({next(target){ target.blur(); });

@benjamingr
Copy link

I disagree with @Blesh about the common use case - multicast is pretty common (but probably not as common as unicast). I think multicast is what people would expect in the first place.

That said - I think unicast is a lower level primitive and there are APIs where it makes more sense. It should indeed be the default. That fact needs to be communicated very well since unlike languages like C# where a lot of APIs are lazy in JS all the APIs are strict which means you work with concrete values which are multicast a lot more often.

Analogy to generators also indicates unicast since both iterators would re-run the generator.

@staltz
Copy link

staltz commented Nov 2, 2015

@Blesh

Honestly, the majority of the time with Observables, all you want is Unicast. (Cold)...

No I don't want. Unless you're talking about radically changing the way I write RxJS, which I don't want to change.

and in reality I don't think it should even be thought of as "Multicast" and "Unicast" but more in terms of: 1 Producer -> 1 Consumers or 1 Producer -> N Consumers.

Assuming strictly 1 consumer by default is less generic than assuming many consumers, that's why I think multicast/hot by default is safer for the average case.

@trxcllnt
Copy link

trxcllnt commented Nov 2, 2015

The RxJS multicast operator only lifts the previous operator in the observable chain to a multicast one.

The behavior is identical whether the Observer list is maintained at the tail of an Observable sequence, or further up in the chain, though the allocation count for the latter is higher (due to the extra Subscribers created for each of the unicast operators between multicast and the end of the sequence).

edit:

Assuming strictly 1 consumer by default is less generic than assuming many consumers, that's why I think multicast/hot by default is safer for the average case.

I couldn't disagree more.

@staltz
Copy link

staltz commented Nov 2, 2015

I couldn't disagree more.

... and how?

@benlesh
Copy link

benlesh commented Nov 3, 2015

No I don't want. Unless you're talking about radically changing the way I write RxJS, which I don't want to change.

So you're saying you share every single observable? Seems wasteful. I rarely multicast unless it's necessary to protect a scarce resource.

Assuming strictly 1 consumer by default is less generic than assuming many consumers, that's why I think multicast/hot by default is safer for the average case.

No way. If you make Observable multicast, then the Observable itself becomes stateful. That's super gross. Think about it, every Observable has a collection of subscribers it has to maintain?

Assume this:

let source = getSomeObservable();
let filtered1 = source.filter(inSomeWay);
let filtered2 = source.filter(inSomeOtherWay);
let mapped1 = filtered1.map(x => x + '!!!');

filtered1.subscribe(print);
filtered2.subscribe(print);
mapped1.subscribe(print);

Everything is happy, right? source gets subscribed to one time, as does filtered1, filtered2 and mapped1... everyone only gets subscribed to once, yay!

Now what do you do if you want source and filtered2 to be unicast? (i.e. you only want to multicast filtered2) ... clone source every time it's subscribed to? Ditching its current subscribers? Is their a special call for a "cold subscribe"? Do you need to do the same for filtered2?

There is a reason in Rx that there are Observables and there are Subjects. Observables are better off as stateless templates for setting up a producer and pushing values to a observer provided by a consumer.

@zenparsing
Copy link
Member

Thanks @Blesh and @trxcllnt for providing input.

It seems clear to me that unicast (the current spec) represents the core primitive, and that we can build multicast observables on top. It seems there is also potential here for controversy, which we of course want to avoid.

@bripkens @staltz What do you think of the subclassing solution above? Those that want always-multicast semantics can use the subclass.

@benlesh
Copy link

benlesh commented Nov 3, 2015

FWIW: I used to think "why aren't these all multicast?"... I really, really did... until I started working so heavily with the type. So I've struggled to come to the mindset I have.

(Further truth: when I first saw Rx I was like "why do we need another lodash or whatever?" LOL)

@bripkens
Copy link
Author

bripkens commented Nov 3, 2015

@Blesh: I don't think it should even be thought of as "Multicast" and "Unicast" but more in terms of: 1 Producer -> 1 Consumers or 1 Producer -> N Consumers.

+1

The RxJS multicast operator only lifts the previous operator in the observable chain to a multicast one.

@trxcllnt: The behavior is identical whether the Observer list is maintained at the tail of an Observable sequence, or further up in the chain, though the allocation count for the latter is higher (due to the extra Subscribers created for each of the unicast operators between multicast and the end of the sequence).

Unfortunately it is not identical to the behavior of Bacon and Kefir. See the following example which calls the map function twice:

http://jsbin.com/tivivesudi/1/edit?html,js,console

It's really not going to be any more performant to maintain an extra array of consumers in JavaScript in the first example.

The applications we are writing at Instana may not be representative, but we found that the cost of maintaining one array per observable is negligible when compared to the processing overhead of multiple operator (map, filter…) invocations. As such we tried to reduce the number of operator invocations as much as possible.

@Blesh: Now what do you do if you want source and filtered2 to be unicast?

This is not meant to offend you, I am generally curious:

In what situation do you need operators to be unicast? I am having trouble understanding the need for the following behavior: "Please make it look as if we can reuse intermediate processing steps, but actually incur the processing overhead per subscriber."

@bripkens @staltz What do you think of the subclassing solution above? Those that want always-multicast semantics can use the subclass.

I'd be happy to see the requirements of all parties being satisfied. The sub classing approach still lacks support for multicast operators and a few other things though.

If you would be interested to incorporate support for multicast and "unicast", I could provide more information about multicast usage patterns and help you out coming up with a base for discussion.

@trxcllnt
Copy link

trxcllnt commented Nov 3, 2015

@bripkens "Please make it look as if we can reuse intermediate processing steps, but actually incur the processing overhead per subscriber."

scan is a great example, as it's encapsulating the aggregate state within the Monad. Two subscribers to a scan Observable must compute separate values, otherwise you can't parallelize computations.

@trxcllnt
Copy link

trxcllnt commented Nov 3, 2015

@bripkens Unfortunately it is not identical to the behavior of Bacon and Kefir. See the following example which calls the map function twice:

http://jsbin.com/tivivesudi/1/edit?html,js,console

Since your source is a Subject, and none of the intermediate operators are maintaining state, publishing the filtered Observable is unnecessary. Additionally, given the new lift architecture for Observables, map and filter will be returning Subjects (albeit, they're pass-throughs). I suppose you could implement a Subject instance with a lift implementation that returns multicast Subjects.

@zenparsing
Copy link
Member

@bripkens

The sub classing approach still lacks support for multicast operators and a few other things though.

By "multicast operators", do you mean multicast versions of "map", "filter", etc?

The way that this spec is currently written, MulticastForever#map, MulticastForever#filter, etc. will automatically return instances of MulticastForever, by virtue of the "Symbol.species" approach.

Take a look at

https://github.com/zenparsing/es-observable/blob/master/src/Observable.js#L401

to see how that works.

Basically, it consults observable.constructor[Symbol.species] to determine the constructor to use when creating the new instance. In this case, it will just be the same as observable.constructor (MulticastForever). Then observable.subscribe is used to start the subscription, and in this case the subscribe method has been overridden to support multicasting.

@bripkens
Copy link
Author

bripkens commented Nov 3, 2015

@bripkens "Please make it look as if we can reuse intermediate processing steps, but actually incur the processing overhead per subscriber."

@trxcllnt: scan is a great example, as it's encapsulating the aggregate state within the Monad. Two subscribers to a scan Observable must compute separate values, otherwise you can't parallelize computations.

I am not sure I understand how scan is affected by the difference between one and multiple consumers. As far as I see it, scan emits values and the point of discussion is whether these values should be directed towards one or multiple consumers.

@bripkens
Copy link
Author

bripkens commented Nov 3, 2015

@trxcllnt: Since your source is a Subject, and none of the intermediate operators are maintaining state, publishing the filtered Observable is unnecessary.

Without publish, filter would be executed for every consumer in the referenced example. With publish it is only executed once. Meaning: A 100% increase in invocation count without publish.

@bripkens
Copy link
Author

bripkens commented Nov 3, 2015

@zenparsing: Cool, didn't know that! I have a set of test cases ready for our custom multicast implementation and I could translate them to es-observable.

It would probably be a good idea to come up with another name though. Multicast may not be the most accurate description of what we want to achieve?

@trxcllnt
Copy link

trxcllnt commented Nov 3, 2015

@bripkens I am not sure I understand how scan is affected by the difference between one and multiple consumers. As far as I see it, scan emits values and the point of discussion is whether these values should be directed towards one or multiple consumers.

Scan doesn't emit values, it encapsulates the logic to perform a computation with state.

This example (correctly) prints different sums:

const randSum = Observable.range(0, 10)
  .map(_ => Math.random())
  .reduce((sum, rand) => sum + rand, 0);

randSum.subscribe((x) => console.log(x));
randSum.subscribe((x) => console.log(x));

This example doesn't:

const randSum = Observable.range(0, 10)
  .map(_ => Math.random())
  .reduce((sum, rand) => sum + rand, 0)
  .publish();

randSum.subscribe((x) => console.log(x));
randSum.subscribe((x) => console.log(x));
randSum.connect();

@bripkens Without publish, filter would be executed for every consumer in the referenced example. With publish it is only executed once. Meaning: A 100% increase in invocation count without publish.

Yes, this is by design. Observables aren't EventEmitters, they are very deliberately pure functions with isolated memory space for each Observer. Again, without this guarantee, parallelism is impossible.

@bripkens
Copy link
Author

bripkens commented Nov 3, 2015

Yes, this is by design. Observables aren't EventEmitters, they are very deliberately pure functions with isolated memory space for each Observer.

If this is the case and this spec wants to model this, then we might want to prominently state this. I questioned this because it is often mentioned as a source of confusion (recent JavaScript Jabber and RxJS docs) and two of the three libraries referenced above have different default behaviors.

This would be okay for me, though it would mean that this is of little / no value to our organization.

This example (correctly) prints different sums:

Yes, when the assumption is that observables share nothing and a new observable chain is created for every consumer. I do not share this assumption.

@RangerMauve
Copy link

I fully agree with @bripkens. At the very least it'd be nice to have the 1 Producer -> 1 Consumer thing explicitly stated in the README somewhere.

@trxcllnt

Again, without this guarantee, parallelism is impossible.

Since we're talking about JavaScript here, it's not like we're actually going to have parallel computation for observables any time soon, so I'm not so sure this is an entirely relevant point.

@zenparsing
Copy link
Member

@bripkens Yeah, "MulticastForever" was just a (somewhat cheeky) fill-in name : )

Let me know if I can help validate that the subclassing approach will work.

@benlesh
Copy link

benlesh commented Nov 3, 2015

In what situation do you need operators to be unicast?

@bripkens in cases were you want to control the number of producers set up and torn down.

Consider the following:

  • You have an Observable that wraps an AJAX call that takes up to 5 seconds to complete.
  • The end point it hits gives you different responses every time you hit it.
  • You call it once every second and display the results as they come in.
Observable.interval(1000).flatMap(() => longAjaxObservable)

If all Observables were multicast the above would share the first request the following 4-5 times until it completes, then repeat the results.

@benlesh
Copy link

benlesh commented Nov 3, 2015

Actually, a more simplistic example would be the Observable equivalent of setTimeout. Do you really want to share that by default?

let wait5seconds = new Observable(observer => setTimeout(::observer.next(), 5000));

let source = wait5seconds.map(() => 'hello there!');

source.subscribe(::console.log);

// subscribe again two seconds later
setTimeout(() => source.subscribe(::console.log), 2000);

If Observables were multicast, in the above scenario, five seconds after the first subscription "hello there!" would print twice in console. It should be staggered at 5 and 7 seconds.

In essence: Multicast actually hurts the reusability of Observables ... basically because it makes them stateful.

@aebabis
Copy link

aebabis commented Nov 3, 2015

Observable in Java, for example, stores a list of Observers. All the literature I can find about the Observer Pattern says that allowing multiple observers is part of the definition (first paragraph in each of these sources):

https://msdn.microsoft.com/en-us/library/ee850490%28v=vs.110%29.aspx
https://sourcemaking.com/design_patterns/observer
https://en.wikipedia.org/wiki/Observer_pattern
Head First Design Patterns

Historically, the Observer Pattern has been 1-to-N. If you have to deviate from this for practical reasons, maybe it should have a different name?

@benlesh
Copy link

benlesh commented Nov 5, 2015

Let's simplify it: observables aren't unicast or multicast. They tie any producer to any consumer.

This thread is a giant misnomer.

@trxcllnt
Copy link

trxcllnt commented Nov 5, 2015

@RangerMauve AFAIK it just means that a new observable is made per addEventListerner call, if that's what you were referring to.

Do you mean Observer?

const eventObservable = new Observable((observer) => {
  domElement.addEventListener(function handler(e) { observer.next(e) });
  return () => domElement.removeEventListener(handler);
});

eventObservable.subscribe((x) => console.log(x));
eventObservable.subscribe((x) => console.log(x));

Only one Observable exists, but two Observers are created/addEventListener is called twice (as subscribe is executed twice).

@domenic Subject is exactly the same thing as an EventEmitter: it's an Object that has a list of callbacks internally. When you send it an event, the Subject loops over the callbacks and calls them in turn with the event. Easy-peasy.

@benlesh
Copy link

benlesh commented Nov 5, 2015

Put another way: observables are no more "unicast" or "multicast" than a function that accepts a callback. They're a primitive type. That's all.

@trxcllnt
Copy link

trxcllnt commented Nov 5, 2015

@domenic so that they can come together into a coherent whole. (Or maybe they already have, just not in the proposed spec, or in non-RxJS JavaScript libraries.)

That has been the general theme. Erik Meijer didn't leave much undone when he formalized Rx, and most every deviation from the behavior (async subscribe, etc.) break the library for a certain subset of use-cases (and users). "Any sufficiently complicated FRP or Stream library contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Reactive Extensions."

Observables are referentially transparent, so each Observer exists in its own isolated memory space. This makes Observables a great primitive to build on, because you can solve problems that can't be expressed if your primitive is stateful. For the problems where you do need to introduce state, you can graduate the state-less Observable to a stateful Observable by using your choice of Subject (the four default Subject types are distinguished by how they notify Observers, whether they replay events from the past, etc.).

@trxcllnt
Copy link

trxcllnt commented Nov 5, 2015

@domenic an interesting data-point: Facebook is investing in a new language with native Observables (similar to what Google's done with Dart) to power their next-gen cloud infrastructure. There's precedent for language-level Observables, but the mathematics leaves very little room to deviate from the design.

@domenic
Copy link
Member

domenic commented Nov 5, 2015

I completely realize observables have been outlined in a mathematical fashion in a way their designers find appealing. What I have been trying to ask about in this thread is how or whether that carries over in to ecosystems like the DOM in practice, especially in relation to this spec. Otherwise they are best left as a library, if they form an isolated ecosystem that is self consistent but requires extensive machinery to integrate into existing (stateful, etc.) systems. Unfortunately nobody has really taken the time to answer my questions, or if they did, I was not able to understand the answers :(.

@staltz
Copy link

staltz commented Nov 5, 2015

@domenic I'll keep it simple: yes, these ES Observables would be a practical interface for event streams from the DOM, e.g. to wrap click listeners. Observable is just an interface. However, we usually talk about the Observable class implementing the Observable interface (to be more correct, that'd be Observable class and IObservable interface). The Observable class is by default unicast. So, as long as we just use the IObservable as an interface, we can definitely wrap DOM streams with that.

@zenparsing Why is the current observable spec not working for you? If you want "multicast always" behavior, you only need opt-in with a subclass (as I sketched out above).

That's perfect. And to me this issue is closeable because of that.

@domenic
Copy link
Member

domenic commented Nov 5, 2015

@staltz sorry, you lost me. JavaScript doesn't have interfaces, so how can we use IObservable to wrap "DOM streams" (whatever they are?)? Is this spec's Observable class going to be useless for the DOM, and some un-specced IObservable thing is necessary?

@RangerMauve
Copy link

@domenic Well, this spec won't really cover how to create event producers. For example the method for actually getting click events into the rest of an application will have to be defined elsewhere, but actually working with the events will be easier with Observables. Rather than having a bunch of callback spaghetti for propogating the events between different functions, one could construct something like a pipeline declaratively. Now I'm not too knowledgeable on the actual implementations of low level DOM APIs, however I'm sure that parts involve streams of events, and those parts will likely benefit from this as a primitive.

Are there any particular areas of the DOM that you think might want a primitive for handling events over time? It'd be easier to get concrete code examples on how to solve particular problems if we had some more knowledge on what existed to be solved.

@RangerMauve
Copy link

If we're talking about potential new APIs, then making EventTarget be observable would make writing declarative reactive code a lot nicer. It'd make UI events, XHR, Websockets, WebWorkers, and History really useful right out of the box for writing clean, declarative, and reactive code. It'd mean that all of the different libraries made by the community which respect the spec will be able to easily inter-operate with native browser APIs in really powerful ways. It could really make working with events in the browser totally different and for the better.

@zenparsing
Copy link
Member

@staltz Sounds good. Let me know if you run into any issues with that approach.

@domenic Thanks for jumping in here. Let's first just look at the specific issue called out in this thread and then we can zoom out to the larger issue and how it relates to the DOM.

In some reactive libraries, all "observables" maintain their own subscriber lists, and each node in a chain supports fan-out. Let's say your origin source is a sequence of numbers, and then you map it, and then subscribe two times.

let mappedNumbers = numbers.map(x => x * 2);
mappedNumbers.subscribe(observerA);
mappedNumbers.subscribe(observerB);

In Rx, and the current spec here, that will create two separate chains:

map ----> observerA
map ----> observerB

(I'm leaving numbers out of this diagram because it could be unicast or multicast, we don't know.)

If each node supports fan-out by default, then it would look like this:

map ---> observerA
    \--> observerB

Clearly we're not going to get agreement on fan-out-by-default. However, those that want fan-out-by-default can "enable" it by using a subclass.

As an aside, you could also opt-in to fan-out by using a multicast combinator. To get the second graph above, you could do something like:

let mappedNumbers = numbers.map(x => x * 2)::multicast();
mappedNumbers.subscribe(observerA);
mappedNumbers.subscribe(observerB);

More on the DOM situation in a bit...

@benlesh
Copy link

benlesh commented Nov 5, 2015

sorry, you lost me. JavaScript doesn't have interfaces

@domenic You might be familiar with "thennable". Or iterable. While there's no formal keyword, the idea of "interface" that @staltz portrayed is pretty crisp and well known.

@benlesh
Copy link

benlesh commented Nov 5, 2015

addEventListener does the multicasting. I think this hand-wringing is more of a misunderstanding than anything.

@domenic
Copy link
Member

domenic commented Nov 5, 2015

@Blesh so the DOM won't even use this type we're speccing here, it'll just use the "thenable-equivalent"?

@RangerMauve
Copy link

@domenic

@Blesh Was pointing out that thennables and iterables are Interfaces in the same way that observables are interfaces.

@zenparsing
Copy link
Member

(Sorry for the length on this one!)

So what does this mean for the DOM?

Even if we specced "Subject" it wouldn't be directly applicable to EventTarget, because with EventTarget listeners are registered by event name and capture flag. If we wanted to re-spec addEventListener/removeEventListener on top of Observable, then I think the best approach might be something like this:

https://github.com/zenparsing/es-observable/blob/master/dom-event-dispatch.md

...where the on method would take an event name and capture flag, and return an observable.

element.on("click").map(evt => ({ x: evt.x, y: evt.y })).forEach(point => {
    console.log(point);
});

We want on (or whatever we want to call it) to return an instance of Observable so that it has whatever combinators we add to Observable.prototype.

Since the DOM wouldn't use a Subject type anyway, then it seems to me that there's no problem in leaving it out.

Let's look at another question. Assume that Observable.prototype has a map method, and that map is unicast as described earlier. Does the fact that map is duplicated for different observers represent a footgun in UI programming?

Say we have this:

let mappedPoints = element.on("click").map(e => {
    return { x: e.x, y: e.y };
});
mappedPoints.forEach(p => console.log(p));
mappedPoints.forEach(p => console.log(p));

So far so good. But what if I add some side-effects to our map callback?

let sideEffectCount = 0;
function doSideEffect() { sideEffectCount++ }

let mappedPoints = element.on("click").map(e => {
    doSideEffect();
    return { x: e.x, y: e.y };
});
mappedPoints.forEach(p => console.log(p));
mappedPoints.forEach(p => console.log(p));

// After one click, sideEffectCount will be 2

Does this situation represent a footgun? (It's an honest question, I'm interested in the answer.)

@benjamingr
Copy link

Does this situation represent a footgun? (It's an honest question, I'm interested in the answer.)

Definitely. However, I'd argue it's not because of multicast vs. unicast - it's because of non-strict semantics. It's a footgun programmers in other languages with any deferred execution streams like Dart, C#, Java and Python have learned to deal with - and it's a footgun programmers that use generators in ECMAScript have to deal with today anyway.

Causing side effects in a method that runs in a deferred way is always risky. Even with multicast semantics I think the following would be a much more common footgun:

 ajaxRequest("foo.json").map(x => alert(x)); // why did this not alert anything?

Or, similarly:

  var iterator = getIterator();
  [...iterator]
  [...iterator]; // problem! the iterator is stateful. This is of course by design.

So - I think unicast is just one of those things people are going to have to grasp about observables. Observables were never meant to be "easy" - they were meant to be simple to use in complex concurrency scenarios where other solutions are less elegant. Let's not forget a large majority of the JS community doesn't know how to return the response from an AJAX call :)

@benlesh
Copy link

benlesh commented Nov 5, 2015

Does this situation represent a footgun?

No. It represents a property of the type. Observables are analogous to functions. If you called a function twice, you'd expect two side effects in most cases, unless the function was written in a "multicast" manner and was stateful.

@benlesh
Copy link

benlesh commented Nov 5, 2015

Making Observable stateful and adding a list of observers to it will break the type. It would be a mistake.

@zenparsing
Copy link
Member

Making Observable stateful and adding a list of observers to it will break the type. It would be a mistake.

For sure! I just want to explore all of the ramifications. I'm fine if there's a small, graded learning curve (there certainly is with promises).

@domenic
Copy link
Member

domenic commented Nov 6, 2015

@zenparsing thanks for the clear illustration of what's going on; that's the first thing in this thread that actually seems to address my questions.

I tend to agree with @benjamingr on this I guess. It is a footgun, but there are simpler footguns in the observable paradigm, and in the end it's all down to whether people expect methods named "map" etc. to be lazy or eager. Our only precedent so far in the language is eager, so his alert example will be surprising to people trying to use observables in contexts like the DOM where they have clear expectations already. (It obviously won't be surprising to @Blesh, who is able to use phrases like "it's a property of the type" to dismiss peoples' concerns.)

I guess what's really required here would be acclimatizing people to the idea of lazy combinators, probably by putting some on Iterator.prototype. Once they are present on Iterator.prototype and lazy there, it makes sense for them to be on Observable.prototype and lazy there as well. The only alternative I can see is coming up with some new convention like .mapLazy(...) or .lmap(...) to make things clear, but that is ugly, and I understand the desire to use the same name for something that is isomorphic at a higher level of abstraction, even if in day to day usage in a stateful language it is very different.

In any case, that makes me more comfortable, since the current proposal only includes the straightforward forEach and not the lazy map or filter; we can leave those for a later proposal that adds them to both Iterator and Observable at the same time.

@benjamingr
Copy link

Ok so to conclude:

  • unicast seems like the better approach for Es-observable. It is relatively straightforward to model DOM events with both but unicast is a lower level primitive. In any case - APIs in host environments like the DOM don't need to care about it since it is a care of the producer and not consumers.
  • As discussed elsewhere - methods like map and flatMap are delayed to a future version - much like was done with finally with promises. User land libraries are expected to step in in the meantime to give us a better idea on how to evolve the spec.

I'd love to see map in the spec - but I agree iterator.prototype should probably have lazy methods first to acclimate JS developers to lazy semantics. In any case it's a good idea not to tie this proposal tomap.

@zenparsing
Copy link
Member

Sounds like we can close this one. I've also removed map and filter from the polyfill and tests.

Thanks everyone!

@jedwards1211
Copy link

@zenparsing I kind of wish that a multicast wrapper were standardized, because if even two different dependencies need to do multicast, they might provide their own redundant implementations (or use two different libraries people have created to provide a centralized multicast implementation)

@jedwards1211
Copy link

@zenparsing

The way that this spec is currently written, MulticastForever#map, MulticastForever#filter, etc. will automatically return instances of MulticastForever, by virtue of the "Symbol.species" approach.

FWIW, I don't think Flow or TypeScript currently provide a way for base class method type defs to specify that the return type is an instance of the inheriting class. In other words, without manually overriding the return type defs for all methods in MulticastForever, they return types would just be Observer. That said, it's a common enough thing that I do hope Flow and TypeScript eventually support it.

@benjamingr
Copy link

@jedwards1211 this proposal is stalled for over 5 years. You can just use RxJS which can multicast.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants