paldepind / flyd

The minimalistic but powerful, modular, functional reactive programming library in JavaScript.
MIT License
1.56k stars 84 forks source link

Add fluent API #137

Open irisjae opened 7 years ago

irisjae commented 7 years ago

Often it's really annoying to rewrite long chained operations in an functional style. The https://github.com/mindeavor/es-pipeline-operator would help with this when it arrives, but for now, expressions like flyd .on (..., filter (..., filter (..., target_range)) .map (...)) are absolutely unreadable, especially with complex functions in between. I switched from most.js, and their style of target_range .filter (...) .filter (...) .map (...) .observe (...) is much more pleasing and easier to use. I propose to add the following line to amend this problem.

function createStream() {
  function s(n) {
    if (arguments.length === 0) return s.val
    updateStreamValue(s, n)
    return s
  }
  s.hasVal = false;
  s.val = undefined;
  s.vals = [];
  s.listeners = [];
  s.queued = false;
  s.end = undefined;
  s.map = boundMap;
  /*fluent api*/s.thru = function (thru) { return thru (s); };
  s.ap = ap;
  s.of = flyd.stream;
  s.toString = streamToString;
  return s;
}

or perhaps

function createStream() {
  function s(n) {
    if (arguments.length === 0) return s.val
    updateStreamValue(s, n)
    return s
  }
  s.hasVal = false;
  s.val = undefined;
  s.vals = [];
  s.listeners = [];
  s.queued = false;
  s.end = undefined;
  s.map = boundMap;
  /*fluent api 2*/s.thru = function (func, args) { return func .apply (s, [] .concat .call (args || [], [s])); };
  s.ap = ap;
  s.of = flyd.stream;
  s.toString = streamToString;
  return s;
}

Which would also allow usages like stream.thru(filter,[isOdd]).map(square).thru(flyd.transduce, R.take(5))

StreetStrider commented 7 years ago

@irisjay, this bothered me as well. As far as I remember, I ended up using _.flow to compose all of functions and then apply them at once. But I miss fluent interface sometimes too.

There was a package that allows to turn any fp-interface to fluent, but I can't find it in my stars, nor in @paldepind stars. There was a mention of it somewhere, but I can't find id, so sad. It takes and object of functions (data-last functions) which would create fluent interface and returns wrapper object which contains all context with context-bounded version of functions. This may be a possible solution for you.

irisjae commented 7 years ago

That sounds great! Let me look for it. But does the package allow chaining, as in, is a thru-ed stream capable of being thru-ed again? That is the essential thing, I think. I also updated the imaginary implementation 2, to accomodate for not wrapping the arguments in []. It appeared to be a common, if trivial mistake.

Asides, how do you think this issue relates to the aesthetics of flyd? tbh I think a fluent interface comes hand-in-hand with the functional argument order and modularized combinators. Especially annoying about the lack of fluent interface here is that .map is in the fluent interface-ish order, wheras everything is opposite.

StreetStrider commented 7 years ago

@irisjay,

how do you think this issue relates to the aesthetics of flyd?

As I said before, I miss fluent sometimes. And I use fluent extensively on native Array. But maybe I'm not enlightened in FP enough. I'm for readability. Fluent allows me to be more clear, so I use it. If lib does not provide fluent I use _.flow or just do not use method chains at all. BTW, flyd has much in common with Ramda in style. You can look for possible solutions there. I'm sure there's a couple of discussions too.

irisjae commented 7 years ago

The thing is that fluent api is more than some remnant of OOP that some user of this FP library somehow requests, just for him to remain in familiar territory. A fluent api as in a .thru function is decidedly functional, being composable with arbitrary functions. Putting functions in the beginning or the end, then, is technically, only a matter of preference. However, in this case of streams, since streams are intended for such things like dataflow programming, it is much more natural to put your code in a diagram of A-->B-->C. Putting functions at the end of expressions are as valid a choice for functional programming as is putting it in the beginning, etc Forth-like languages (Joy?). Besides, the style of the flyd api already mixes fluent api and regular functions (.map). Choosing either convention for function application is valid, but mixing them makes composed expressions ugly as sin. Just my two cents. I think this issue deserves much merit. I'd also love to hear what @paldepind thinks about it.

squiddle commented 7 years ago

I second with @StreetStrider to use Ramda.pipe (equivalent to _.flow) and currying in this situation. flyd already has a data/stream in last position API.

The code layout becomes nearly same as with fluent apis, but gets the additional benefit of being more declarative without introducing a feature coming from an OOP style.

const flydFilter = require('paldepind/flyd-filter');

R.pipe(
  flydFilter(isOdd), 
  flyd.map(square), 
  flyd.transduce(R.take(5))
)(stream);
paldepind commented 7 years ago

When I wrote Flyd I was very much against methods. But these days I definitely think that fluent APIs do have their benefits.

I think the biggest issue is that Flyd tries to be modular. But, with a fluent API all methods will have to be available at the prototype.

I think @squiddle's suggestion of using R.pipe is nice. It sorta achieves the same thing as the pipeline operator.

@StreetStrider

There was a package that allows to turn any fp-interface to fluent, but I can't find it in my stars, nor in @paldepind stars. There was a mention of it somewhere, but I can't find id, so sad. It takes and object of functions (data-last functions) which would create fluent interface and returns wrapper object which contains all context with context-bounded version of functions. This may be a possible solution for you.

That sounds very interesting! Definitely share it if you find it.

paldepind commented 7 years ago

@StreetStrider, I just had a though that maybe it's dot-compose you're thinking of?

StreetStrider commented 7 years ago

@paldepind, ye, that's it. ☝️

dmitriz commented 7 years ago

I would add my vote to support the fluent version.

I found it for instance, more elegant to write stream.scan(reducer, initVal) than scan(reducer, initVal, stream), it looks cleaner separating the stream argument from the rest.

Would anyone know how to achieve it?

Another interesting advantage is when dealing with unknown or parametrized factory, or even with different factories at the same time. Say I am using third party library also providing streams, with possibly different api. But with fluent style, I can write stream.scan(...) without any thinking. As opposed to functional style, where the right factory would need to be chosen for every stream.

Also perhaps the constructor property referencing the type representative as prescribed by https://github.com/fantasyland/fantasy-land#type-representatives would help?

paldepind commented 7 years ago

@dmitriz It's not really feasible. Adding methods is best done by modifying a prototype. But, a flyd stream is a function. That is why you can do fooStream() and fooStream(value). And it would be a bad idea to modify the prototype of Function.

dmitriz commented 7 years ago

@paldepind

What about transforming the flyd.stream factory into new one creating all its streams objects with the same prototype object protoObj?

Then you would set all the instance methods on that object:

const protoObj = {
    scan: (...args) => scan(...args, this)
}

Here is a real situation, taken from https://github.com/dmitriz/un/blob/master/index.js, where the fluent syntax might be superior. The fluent version would have the linear flow starting from the stream at the top:

actionStream
  .scan(reducer, initState)
  .map(state => view(createElements)(state, actions)),
  .map(vnode => createRender(el)(vnode))

Note how explicit and easy to see are the values entering each function.

In contrast, the R.pipe syntax, apart from its additional verbosity, requires you to put the actionStream to the end, which means you have to look there, and then jump back to the top:

R.pipe(
  actions => flyd.scan(reducer, initState, actions),
  flyd.map(state => view(createElements)(state, actions), state)),
  flyd.map(vnode => createRender(el)(vnode))
)(actionStream)

This is no more linear as it requires jumping back and forth between the lines, that can create additional friction when reading the code.

dmitriz commented 7 years ago

This is the working code (with tests passing):

/**
 * Set prototype object for all instances created by the Factory
 * (can be used to provide additional instance methods)
 */ 
const setInstanceProto = protoObj => Factory =>
  (...args) => {
    const factoryObj = Factory(...args)
    let newObj = Object.create(protoObj)
    Object.assign(newObj, factoryObj)
    return newObj
  }

https://github.com/dmitriz/un/blob/master/adapters/factory.js

irisjae commented 6 years ago

Sharing my experience on how I'm facing this issue.

Firstly, I don't understand why some of you are still mentioning changing prototypes being a hassle or whatever. Just look at my explanation of the kind of fluent API I proposed, and my proposed one-liner implementation. What I wanted was just one single method which is capable of dynamically calling any other function on the stream itself, so there will just be one single dispatching method to maintain, ever, which IMHO is pretty simple, and is the way several libraries such as I mentioned, mostjs, implemented it.

To work around this, I'm honestly not that satisfied with the solution of using R .compose, so initially I used a forked version of flyd that contained my simple fluent api implementation up there. But recently I discovered this pretty cool idiom that I now use pretty extensively, hope might help out anyone else. Turns out (by currying and cool functional stuff) we already can do post application of functions in vanilla Javascript!

var swipe_events = [touches]
    .map (filter (within_viewable_area))
    .map (map (gesture_events))
    .map (switchLatest)
[0]

And if you wanted to explicitly use/mutate something with the stream, just do:

[clicks]
    .map (throttle (1000))
    .map (map (get_target))
    .forEach (function (el) {
        var background = el .style .backgroundColor;
        el .style .backgroundColor = 'red';
        setTimeout (function () {
            el .style .backgroundColor = background;
        }, 500);
    })

Voila!

nordfjord commented 6 years ago

166 adds pipe as a method on streams. Which should resolve this issue.