-
Notifications
You must be signed in to change notification settings - Fork 86
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
flimflam - yet another approach #23
Comments
For anyone interested, I have simplified this concept further into a single state object that contains multiple flyd streams to control the UI. The snabbdom gets patched everytime any stream in the state is updated. It is similar to the union-type/Elm/Redux concepts, but diverges significantly in its use of many Flyd streams. I have found it very fun to program in, as I personally enjoy abstracting everything with streams/Flyd. Many people may find the heavy use of streams to be too difficult or too weird. |
@jayrbolton Interesting project! // Render the state into markup
const view = function(state) {
return h('body', [
h('button', {on: {click: state.showModal$}}, 'Say Hello')
, modal({show$: state.showModal$, body: h('p', 'Hello World!')})
])
} |
Hi @dmitriz, thanks for the feedback! The initial reasoning is for simplicity in implementation, and also being able to scope the event stream easily. Eg you could initialize multiple states, and they'd each have their own However, I agree that it could also be cleaner to keep the actions separated. I've actually been experimenting with a modification of this architecture that would separate out the actions. The event streams ("actions") instead are pulled out of the virtual DOM, are received only as input into your state functions, and are initialized by a snabbdom module: Here is a counter example:
Let me know what you think! I have actually implemented all this in an experimental repo, and also did a multi-counter example. |
You are welcome!
The way I see it, the initialization part is separate from my functions. When initializing, I can always create new streams or reuse the parents.
That looks a bit "magical" to me, as I have no idea where that stream is and where the event goes. Does it have any advantage over this: function view(actions, count) {
...
, h('button', {onclick: () => actions(['add', 1])}, ... This way I can test it as pure function of its arguments. // Our UI logic comes from functions that take input/event streams as arguments,
// and return a stream of state data
function counter(add$) {
return flyd.scan((sum, n) => sum + n, 0, add$)
}
// this initialization would get called once per page.
// its main responsibility would be take your event streams from the dom and use them to
// initialize your UI logic. Similar to Cycle.js
function init (dom) {
// dom is a function that fetches the event streams that we declared in the view
const add$ = dom('add')
return counter(add$)
}
render(init, view, document.body) So the Here is some description of the architecture I came up with, any feedback is welcome! |
This is the kind of feedback I wish I had gotten a year ago! I read through your post on Cycle.js and I think we share a lot of the same sentiments, especially around simplifying view functions, not querying on classnames, etc. Regarding reducer functions: I myself am not a proponent of simplifying everything to reducer functions. I think you lose a lot of the power of streams when you do that. For example, I have had a lot of success using abstracted undo functionality that I can then combine with some abstracted ajax functionality that gives a lot of power in a small amount of declarative code, which is not possible with reducers. You can always have "reducer" style functions in a call to I've now used the Flimflam style architecture in two large production apps and have had good success. My major lessons have been:
Regarding the "magic" actions: I agree that it looks too much like magic, and another developer I showed it to had a similar reaction. We could easily do the style you suggest, where you pass an Also, I want to present another idea we've developed that is helpful to manage a large one-page app. We tentatively call the pattern "models" (this is a loaded term, I know). These are functions that take input streams (actions), do some stream logic, and create a result object of streams. There is a // A model for creating and deleting foos via ajax
function manageFoos (newFoo$, deleteID$) {
const postResp$ = flyd.flatMap(postRequest, flyd.map(setDefaults, newFoo$))
const postError$ = flyd.map(formatError, flyd.filter(statusErr, deleteResp$))
const postedFoo$ = flyd.map(resp => resp.body.foo, flyd.filter(statusOk, postResp$))
const deleteResp$ = flyd.flatMap(delRequest, flyd.map(id => ({foo_id: id}), deleteID$))
const deleteError$ = flyd.map(formatError, flyd.filter(statusErr, deleteResp$))
const deletedFoo$ = flyd.map(resp => resp.body.foo, flyd.filter(statusOk, deleteResp$))
const foos$ = flyd.scanMerge([
[postedFoo$, appendFooToArray]
, [deletedFoo$, deleteFooFromArray]
], [])
const error$ = flyd.mergeAll([postError$, deleteError$])
const loading$ = flyd.merge(
flyd.map(R.always(true), flyd.merge(newFoo$, deleteID$))
, flyd.map(R.always(false), flyd.merge(postResp$, deleteResp$))
)
// This converts the object containing mutiple streams into a single stream of plain, static objects
return model({
loading: loading$
, error: error$
, data: foos$
})
}
const newFoo$ = flyd.stream()
const deleteID$ = flyd.stream()
const foos$ = manageFoos(newFoo$, deleteID$)
newFoo$({name: 'bob'})
foos$().loading // returns true
// ... wait for ajax to finish ...
foos$().loading // returns false
foos$().data // returns [{name: 'bob', id: 0}] The user of the manageFoos function would call it once for the page, at the top level Finally, I invited you to a repo called Uzu, where I am in the process of implementing these ideas. I would like Uzu to be the next generation of flimflam (and I think it is a better name, lol), and maybe you'd be interested in helping. |
This is an interesting discussion 😄 I think you have some really good ideas and some impressive implementations, @jayrbolton. I looked into flimflam a while ago and really liked it. I'm currently working on a new approach to functional frontend development myself that has important similarities to flimflam.
I strongly agree with this. Not only do you loose the power of streams. You also loose clear definitions of your data. In flimflam I can look at a value and see it's definition which will tell me everything about what influences the value. For instance, in the example on the flimflam webpage, if I look at the line const celsius$ = ...` I know that the right-hand side of the equals sign will tell me everything about what However, in flimflam the way events are handled breaks the principle of having "definitions". When I look at this line: const keyupCelsius$ = flyd.stream() I don't know what will affect If you're curious the framework is called Turbine. A while ago I wrote an implementation of the fahrenheit to celcius converter from the flimflam frontpage with Turbine. That example can be found here. We also have a bunch of other examples and some documentation. Here are a few key ideas in the approach we're taking:
I hadn't seen Uzu before. I'll be sure to take a look at it 😄 |
Hi @paldepind, it's nice to know you've looked at and considered flimflam before. Uzu is just an iteration of flimflm -- I'm going to change the name and migrate everything, but get rid of the event stream initialization that you and @dmitriz cited. Of course I always check on your new projects as you work on them and have taken a look at the funkia repos in the past. I'd enjoying trying a project in Turbine, and I would love to join the project as a contributor rather than work on a separate library. However, I have a few major hangups:
|
Glad to hear!
Reducers are great and simple if you can reduce your code to them, pun intended ;)
I'd be curious to see the examples.
That would be the reducer way. :)
Streams are more powerful but you can't avoid the cost of extra complexity.
Any good examples?
LOL, love that :)
I actually prefer pure JS with no CSS. One language is easier to manage than two.
You can always pass it in a single object. Like
const postResp$ = flyd.flatMap(postRequest, flyd.map(setDefaults, newFoo$)) I am a bit lost here - was the |
Hi Simon, many thanks for taking your time to comment, greatly appreciated! Amusingly, I had a related discussion with @JAForbes on Mithril's Gitter, where I was suggesting exactly along the lines of Turbine :) Which is of course, mathematically dual. Like functions are dual to values. And values are primary, whereas callback functions create indirections, adding the function invocation details. But then James convinced me that emitting stream would create more complexity in practice, whereas functions are simpler objects by their nature, because they lack the time component. So you pay the price of the function callback indirection but win on the complexity side but getting rid of the time. Granted, it will not suffice in all cases, but in many it will. So he had convinced me of the simplicity of the strategy I tried to outline in that Cycle issue. If I were to follow it, I would come up with this kind of implementation of the same example: // everything here are pure values, no streams
const model = ({farenAction, celciusAction}) => ({faren, celcius}) => ({
celsius: farenAction ? farenToCel(faren) : celcius,
faren: celciusAction ? celcToFaren(celcius) : faren
})
// pure function accepting pair of functions and pair of values and returning vnode,
// again no streams
const view = ({farenAction, celciusAction}) => ({faren, celcius}) => [
div([
label("Farenheit"),
input({value: faren, oninput: e => farenAction(getValue(e))})
]),
div([
label("Celcius"),
input({value: celcius, oninput: e => celciusAction(getValue(e))})
])
] It is surely a very naive implementation and possibly problems will arise when trying to scale it, and I would like to understand those problems better. Perhaps this example is just too simple? I would also like to understand better the problem with the stream mutation. The way I see it, the functions themselves are pure and testable. The actual mutation is separated away into the drivers (like in the Cycle). But again, I might be missing some point here. Funny enough, we also had a long discussion with James about behaviour vs stream in MithrilJS/mithril.js#1822 and on Gitter. In fact, he wanted to see streams as continuous functions of the time but decoupling away the time, which is kind of resembles what you call a behaviour, where my point to view streams as discreet sequences of values seems to fit with yours. So I wonder about your opinion on the subject of the |
That's really great to hear. Then I'll do my best to address your points 😉 Maybe we can work them out 😄
I think this is a very valid criticism. For a typical JavaScript developer, there will be a bit of a learning curve. Accessibility is important to me. But, I also think some of the approaches we're taking has clear benefits when using the framework and I wouldn't want to sacrifice that. I definitely took inspiration from Haskell. But only because I though it would make for a better framework for JavaScript. In my opinion some of the things that may make Turbine harder to learn initially actually makes it easier to use once learned. Sometimes simple tools can only solve complex problems in complex ways. But sometimes complex tools can turn complex problems into simple problems. I think FRP itself is a great example of that. It does have an initial learning curve and some added complexity. But when that is mastered problems that previously where hard are now simple. Are there any things in particular you think are too esoteric? We are definitely not trying to make things more complex than strictly necessary. Maybe having Jabz as a peer dependency is a mistake. One doesn't have to understand nor use Jabz to use Turbine. We could just reexport the few functions from Jabz that Turbine users need. My hope is that with good well thought out documentation we can make things understandable and approachable even for normal JavaScript developers. But, my vision is also that Turbine will push JavaScript developers in a more functional direction. We could have coded it all Haskell and transpiled it. But then we wouldn't be giving JavaScript developers a functional framework.
In what way do you find TypeScript to be extremely heavy-handed? Writing types definitely has an overhead but it can also be very helpful. TypeScript has a new feature that seems a bit similar to tipo. It can now do type checking in plain JavaScript files (see here). No annotations are necessary. It uses inference to the extent possible and TypeScript definition file if they are available (which they are for many libraries). By writing Turbine in TypeScript we get the benefit that we can support both JavaScript and TypeScript. Our users can decide for themselves what they prefer. And even if they use JavaScript they can benefit from our types. Either by using TypeScript to check JavaScript files or from the fact that some editor will use TypeScript types from libraries to offer suggestions even in plain JavaScript files.
I fully agree. We did realise that along the way and thought hard about the problem. To solve it we have created an alternative way of expressing views that don't use generators and that looks pretty much like virtual-dom. Here is the tempeature converter rewritten using that style. function model({ fahrenInput, celsiusInput }) {
const fahrenChange = fahrenInput.map((ev) => ev.currentTarget.value);
const celsiusChange = celsiusInput.map((ev) => ev.currentTarget.value);
const fahrenToCelsius =
fahrenChange.map(parseFloat).filter((n) => !isNaN(n)).map((f) => (f - 32) / 1.8);
const celsiusToFahren =
celsiusChange.map(parseFloat).filter((n) => !isNaN(n)).map((c) => c * 9 / 5 + 32);
const celsius = stepper(0, combine(celsiusChange, fahrenToCelsius));
const fahren = stepper(0, combine(fahrenChange, celsiusToFahren));
return Now.of([{ fahren, celsius }, []]);
}
const view = ({ fahren, celsius }) => div([
div([
label("Fahrenheit"),
input({ props: { value: fahren }, output: { fahrenInput: "input" } })
]),
div([
label("Celsius"),
input({ props: { value: celsius }, output: { celsiusInput: "input" } })
])
]);
const component = modelView(model, view); As you can see the model is nicely seperated from the view. And the view looks pretty much like virtual-dom. However, this still does not depend on any impure pushing into streams. The view function returns a component. The component contains both the DOM and the output streams from the DOM. So instead of pushing into streams the view creates and returns streams. This solves the problem I mentioned earlier. As far as I understand, in Uzu any number of locations in the view can trigger a specific action. So there is no single definition that tells me everything about the input streams.
Again, I agree. When using the
I definitely think that what you're describing could be implemented in Turbine with a single tree of unbroken markup. If you describe the the example in a bit more detail I'll take a stab at implementing it in Turbine to see how it works out. |
You too. I appreciate it as well. Also, I apologise for not having replied to the issue you recently opened in the Snabbdom repo.
That sounds interesting. Could you maybe send a direct link to one of the messages?
I don't really understand this?
As far as I can tell
That's an interesting discussion. Both you and @JAForbes has good points. In my opinion, you are both right and you are both wrong. Having a discrete sequence of time-ordered events is very useful. But, having a continuous function over time is also useful. None of them is more powerful than the other. None of them can replace the other. So I don't agree with James when he writes this
It is not "more powerful" to use an abstraction that is unaware of discrete points in time. How would you use such an abstraction to model mouse clicks? Mouse clicks are events that happen at discrete points in time. The world contains both things that are continuous (like time) but it also contains things that are discrete (like mouse clicks). Arguing about which of these representations are best is like arguing about whether functions are better than lists. Some things are best expressed as functions and other things are best expressed as lists. Separate things should have separate representations. Mixing them together only creates problems. So my key point is this: Only having a single thing called "stream" or "observable" is too limiting. What is needed it to have both a structure that represents discrete events and a structure that represents continuous things that always has a value. That is exactly what classic FRP did and it is what I'm doing in the FRP library Hareactive. We have streams which are discrete and we have behaviours which are continuous. If you're interested I have written a bit more about it in the Hareactive readme. I have also written a (currently unpublished) blog post about classic FRP. In the blog post I try to explain the ideas in FRP, the semantics of streams and behavior and why these are fundamentally different things. |
Let me just point out that if we use an abstraction that is unaware of discrete points in time a function like |
Thanks, and no worries, whenever you find time.
Here is about the place (begins from 6am I think):
I am sorry for being cryptic. I was referring to the maths duality between values and functions - the pairing (the so-called K-combinator): const T = (x, f) => f(x) Now you can recover the value Saying it differently, if you accept callback const fn = (anything, f) => ... and then you want to transform it with some HOF like Don't know how much useful is that difference though in practice... but it gives some merit to directly emitting streams vs callbacks by nailing down the essence of it on the conceptual level. I understand that your reasoning is from another angle that the stream should be owned by the view and not come from outside. But I guess from James and many other JS dev perspective, functions are easier than streams, so they would only use streams if they can't get away with functions :)
The way I see it, they both enter as parameters, just like in the K-combinator. Which makes both the reducer and the view pure. Just like the K-combinator is pure in both
😄 I had look at There are few things a bit puzzling there, I can write an issue or two. But your blog post really does great job at explaining it. Would you allow to share it? Especially when you define the So you regard the state as behaviour, not stream, that is very interesting, and perhaps is missing in places like Cycle, making it more complex and less intuitive. |
Just wanted to comment on this: const view = ({ fahren, celsius }) => div([
div([
label("Fahrenheit"),
input({ props: { value: fahren }, output: { fahrenInput: "input" } })
]),
div([
label("Celsius"),
input({ props: { value: celsius }, output: { celsiusInput: "input" } })
])
]); That might hit some confusion/resistance as the function seems to return something you don't see in the return statement. You only see the vnode. It has some common feature with what I was trying to propose to James, but I was thinking of exporting events instead: const view = ({ fahren, celsius }) => {
const fahrenStream = flyd.stream()
const celciusStream = flyd.stream()
const vnode = div([
div([
label("Fahrenheit"),
input({ props: { value: fahren }, oninput: fahrenIStream("input") })
]),
div([
label("Celsius"),
input({ props: { value: celsius }, oninput: celsiusInput("input") })
])
]);
// and now we return everything explicitly the Cycle way
return {vnode, farhrenStream, celciusStream}
} However, it is less general than updating external stream with the same values, when you simply pass an empty stream as argument. But the latter is more powerful, because you can reuse stream, passing them to several views, which will save you some heavy stream combining. Another advantage of updating stream passed as argument, is that it looks semantically the same as passing a callback function, a pattern that many people should like because of the familiarity. Of course, it plays out the trick I mentioned above, that the view can be treated as pure, even if streams are mutated at the run time. |
Usually, in UI programming time is absolutely irrelevant. Not always, but usually. Usually we want a ratio, or the latest computed value. But we're not interested at all in the particular events, we're interested in a result based on a relationship we defined via a pure function. I'm not saying we should never have a discrete model, I'm saying that we emphasise discrete far too much. Clicking is a great example of a discrete model, there's no way around it. But its also not the common case at all. A common case is "the borders colour is green if this text is a valid email". Yeah they're are individual input events. But we aren't modelling our application using events, we just need our equation to hold for all text values. For movement events, we only emit discrete mouse events because we have no way to model a continuous curve of the mouse's position. But that, again, is just an implementation detail. And if I want to create a relationship between the mouse's x / window's width and use that to decide how much red there should be in a colour, I shouldn't be thinking in terms of discrete events. I can just think of an equation. But coming back to discrete events. Let's think of a game like space invaders, whenever I press a mouse button, I shoot a bullet, sounds discrete. But if we have a stream of actions, where the action may or may not be a click, we have a continuous model of whether we are shooting. const shooting = actions.map( a => a.name == 'ButtonDown') The source of that action is a mouse click, but its at the edge of the system, we don't need to think about it after this point: {
onclick: () => actions({ name: 'ButtonDown' })
} Its so often, even in the case of mouse clicks, and button presses, the most unimportant detail in the system, we get to model our application and remove the entire dimension of time, that's very powerful. In the rare case we want to think of time, we can, that's okay, but I wish we'd emphasise it far less, because the value of abstracting over time is that we don't need to think about it. And yet it seems all FRP proponents ever talk about it is time. Some things can only be modelled using discrete events (like visualisations of discrete events) and in those cases we shouldn't hide from that, but "arrays over time" and "promises but with multiple values" and marble diagrams, observe-ables are common examples of discrete evangelism, and they are often encouraging an imperative, procedural conceptual model: being notified then modifying something. That's a shame in my opinion. Also comparing discrete to continuous generally may seem bizarre, but in the context of solving problems with this data type in user interfaces; it isn't at all. Discrete isn't necessarily imperative, but modelling things as responses to discrete events is. Modelling thing as relationships is always declarative. Hopefully we can agree declarative code is usually something to strive for. A great example of the success, simplicity and flexibility of continuous only is Excel. Somehow the business sector is fine modelling in a reactive way with no concept of time. So I think we should aim for continuous, encourage continuous, push discrete to the edges of our system, just like side effects. It leads to a model that is easier to reason about because its missing an entire dimension we'd usually have to consider. |
Thanks @JAForbes for chiming in, just wanted to recommend @paldepind unpublished blog post, where many questions from our discussion have been answered, in particular, the |
Yes. Exactly. That way we get output from the view and a pure way. Without using selectors, without pushing into streams and without going through some dispatcher.
Yes. You could do both of these things. But you would loose precision in what you're talking about. Conceptually the movement of the mouse is continuous. So if you represent it with a continuous structure in code then the code is intuitive. If instead, you represent mouse movements with a discrete approximation then the code is no longer talking about the actual thing. It's just talking about an approximation. I think representing things that are conceptually different with different structures is very beneficial. In the beginning, I thought that having both behavior and stream was more complex. But now I actually think it makes things more simple. An analogy could be strings and numbers. In theory, one could represent all numbers as strings. And one might even say that getting rid of numbers as a primitive is simpler. But of course, it just makes a bunch of other things much more complex.
That would be wonderful 😄
I may not seem like it at first. But it actually is a part of the return statement. What these element functions create are not vnodes. They create components. And components contain some DOM along with some output streams/behaviors. Furthermore each component passes along the output from it's children. So the output from the component returned by
I can see that the view is pure from the outside. But inside it isn't which is think is a downside. I don't think merging streams is a problem. I actually think it is an advantage because merging makes it explicit that the output stream is made up of several other streams. |
Thanks @dmitriz 👍 |
Thank you for commenting. It's really great to have your input as well. I agree with most of what you said.
If I understand you correctly you are saying that we rarely want to talk explicitly about time in our programs? I think that is true. Butb I still think time is relevant at an implicit level. Usually what the user sees at the screen is different from what he saw a minute ago. So time does play a part implicitly even though a program doesn't explicitly mention it.
Ok. Thank you for clarifying. I completely agree with you on that 👍
Or maybe one could say that FRP talks about time so that others don't have to?
I don't fully agree with that. I agree that the source of discrete phenomenon is often input from the user. Mouse clicks and keypresses. These happen at the edges. And you can often turn those into continuous things and start modelling relationships. But, discrete things can also come from internal things. To continue you asteroids example. Let's say I have the position of my spaceship and the position of an asteroid. Both of these are continuous. But, I want to know when my spaceship collides with an asteroid. That is suddenly a discrete list of collision events. And it's happening somewhere inside my code. I can't push it to the edges. So while you can go from discrete things (streams) to continuous things (behaviours) you can also go the other way around. In FRP-like libraries that only have a single stream/observable the difference between when something is continuous and when something is discrete is very implicit. I don't think we need to push all discrete things to the edges. Discrete isn't bad. I just think we need to be explicit about what is continuous and what is discrete. Edit:
I don't understand this? Why is modelling things as responses to discrete events imperative? In your example you turn a discrete event stream into a continous |
Absolute pleasure, I'm a long time fan of your work as you probably know, so its always good to have a yarn 😄
Agreed
I like this too.
Well at this point we're really debating the merits of modelling techniques ⛳️ so all I can say is, in my implementation I would model collisions without respect to time, but instead as a function of dimensions and position. Then I would store all the collisions, and in the next frame process them, we end up with the familiar function But I can imagine a lot of things in a game where discrete is unavoidable, and when that occurs I don't think we should avoid discrete. I think one example could be animations, trying to abstract away time from animation code is probably severely misguided, but then again I probably wouldn't use streams to model it, I'd use a promise/task/future. The source of my frustration is that continuous is simpler and is often the right answer to a modelling question, but many don't know its an option because most resources emphasise time, but do not emphasise relationships. So often I field questions like "I want this to happen when that happens" and when we get to the point where its "this is f(that)" they seem to always respond "that's so much easier" e.g. https://gitter.im/lhorie/mithril.js?at=58bb5fd2e961e53c7f9debd8 You can tell by the names of the functions in this chat, the streams are called
And
This sort of thing comes up a lot, where someone is thinking "when this event flows through the stream I'll do this procedural thing and return it", but they immediately get confused when they have to do something as trivial as merge 2 streams, and these are very smart people. So I often rant about "is vs when". And now I see a lot of people seem to be enjoying streams a lot more, its now a part of mithril 1.0, its seen as a staple for solving common UI problems like cross component communication, where previously it was seen as impractical or convoluted. And I'm not taking credit for that but I am definitely observing a trend of people getting tripped up on discrete and imperative, and the moment continuous and declarative clicks, they are fine. |
Discrete isn't necessarily imperative. But all procedural code is discrete. Procedural code is a series of discrete steps. Code that isn't discrete cannot be procedural. When we step into the discrete model, its natural to continue thinking in terms of procedure. It's also how web programmers have been working for decades "when this mouse clicks on that button I'm going to modify this variable" or "when this button is pressed I'm going to push onto this list" But if we model as relations, notions of modification or mutation make no sense at all. I guess it's a discussion of affordances. |
Regarding continous vs discrete: Could you all give a real-world application example where a discrete-focused system (like flyd) will cause you significant engineering trouble, whereas if you switched to a system like Hareactive, your problems would be mitigated? I have personally never experienced such a difficulty with flyd. I understand the conceptual distinctions you all are putting forth around continuous vs discrete, and I understand that continous streams have the potential to be more declarative. But I'd also like to relate these ideas back to how it actually affects real-world application building in a practical way using a concrete example. Regarding returning actions: Thanks @paldepind for the alternate definition of the celsius-fahrenheit converter with a stronger view-model separation. I think I am now convinced that returning the DOM + the streams in your I actually have some paid time right now to devote to getting one of these types of libraries off the ground, including making a Bootstrap-style prototyping library, with all base UI components and styles. I'd also be interested in creating components like FRP ajax or websockets in hareactive. I will post in the Turbine repo to see if I can join you there, but otherwise I'll be working on all that with Uzu. |
Just making sure we're talking about the same thing 😄 What I was discussing wasn't specific to the implementation details of any library (e.g. I use flyd everywhere!), instead its how we model using a particular library. And how a lot of blogs and courses, and talks etc focus on a sequence of events as opposed to relationships. And this is all in response to talking with devs working on real world apps. There seems to be a pattern of over thinking streams, and over emphasising time, often when its of no modelling benefit. I think of it like Monads vs Functors, pick the least powerful/complex tool by default. Continuous is simpler, yet in the context of "Observables" I notice people choose discrete by default, maybe because its more natural for them, but I think a large part of the problem is the way people who understand streams, frame it |
That makes sense. I think I specifically had in mind what Simon said about behaviors and streams: it is analogous to not separating strings and ints, or perhaps ints and floats (which we are all too familiar with). I can think of many real world examples why we want to separate ints and floats -- eg currency calculation. I was wondering if there were similar specific examples for streams/behaviors. |
Could you explain this?
Using a
I can see where you come from, both models may work for the text input, but in case of the input text, the discrete stream model is perhaps all that you need?
Started, see here:
I think I can see the problem. We really want to return the event streams from the same function. But on the outside we still want the return value to be a Node, or more precisely, to conform to the Node interface. It is conceptually the same as multiple exports from a module with one being default. Except that we want the same from a function instead of module ;) So I can see now better your idea of using the const glorifiedInput = go(function*() {
// return vNode and export event stream in one line!
return input({ type: 'text', oninput: e => yield e.target.value })
// or even shorten to
return input({ type: 'text', inputVal: yield })
}); and consume it like const mainView = go(function*() {
return div([
// inline the Node and get its value in one line!
const name = yield glorifiedInput(),
// name is available, so we can just use it!
div(`Hello, ${name}`)
])
}) What do you think? |
Can you explain how is it not pure inside? const compose = (f, g) => x => f(g(x))
It would be nice to limit the stream interface use as much as possible. As there is no JS standard for streams, unfortunately, any code using streams must rely on a custom library. And by limiting the interface, you would gain better interoperability with more universal interface to allows for easier plug-in of a larger variety of libraries. That is why I feel, using any additional method on the stream side is a minus, and I see some merit in Meiosis idea to limit to only two stream methods -- |
, h('button', {streams: {click: ['add', 1]}}, 'increment') I like this way too :) There is a downside here, unfortunately, Another downside, your stream library is not explicit here. So this is where the generators come to help if I see it correctly. Their "scary"
It is not to say, I don't see merit in the beautiful simplicity of the dumb plain functions (it is the opposite as I've tried to explain in that Cycle issue). Give me a plain function any day! But there is the cost of the external "magic" API. Or the extra verbosity by declaring the stream in one line and piping into it (see my other posts here). Perhaps both ways should be made available at this stage, then later we'll see which approach gets better adoption. And perhaps, there is a way to decouple the complexity by plugging a driver into the plain function? What do you think? |
Thank you, I love the name, not sure what it means and where it comes from, but instantly memorable and recognisable :) If I see it right, |
I find the @paldepind blog post does a great job here. The basic class of examples is when you have continuous moving visual objects responding to the user actions (which can be both discrete like mouse clicks and continuous like mouse move). So on the one side, you have discrete user actions. On the other side, smooth continuous reaction is a better UX than the discrete jumps. So the stream is a better model for the user actions (in most cases), where the behavior is better to model the UI state. And But I can see where your questions come from and, yes, you can implement the behavior by keeping piping values into a Instead, as @JAForbes is pointing out, it should really be a relationship, basically a dumb plain function from the time to the object coordinates. And functions are both easier than streams and JS-native. |
You can model touch input either way. pan/pinch/hold etc. I wrote a library that is designed to abstract over mouse and touch in a continuous way. I just want to point out, my aside on imperative responses and continuous relationships is completely orthogonal to the design of returning vtrees that emit to streams vs returning streams of vnodes. Both are valid and can be modelled using either approach. I was really just clarifying my position. I think in the past @dmitriz when we were discussing returning a stream of vtrees, I was refuting that it was automatically simpler. When I was asking what the return type of the view function was (in our gitter chats), it was hard to pin down, it seemed like magic behaviour. It also may require an operator like flatMap or chain which means we're moving into monad territory, and so that is going to be more complex than just functors. But that isn't to say you can't have a consistent return type, or model it in a way that isn't magic. And that isn't to say flattening nested streams is bad. Maybe there are benefits to that approach which far outweigh the simpler implementation, maybe it ends up making the application simpler. I was just refuting that it was automatically simpler. |
You are right, I was thinking dragging over mobile screen were inferior UX to simple taps, but sometimes unavoidable, so I have to take it back. :)
Awesome library, nicely hiding the ugly parts 👍
Yes, you have convinced me that passing actions as arguments is simpler which works great if you can reduce your code to a pure reducer :) Otherwise, as @jayrbolton and @paldepind point out, when you pass an actual stream (as opposed to functorially lifting your pure reducer) you are stuck with mutating that argument stream, leading to impure functions in your code. That is the problem @paldepind pointed out here. And once you mutate your arguments, a lot of simplicity is lost, so I see the merit to look for other ways. So you try to return the streams instead of mutating them, and then run into that "return type" question, for which I have tried to propose the interface solution. Basically, you decorate your node with streams that should still keep all the node's properties. But possibly Turbine provides a better more elegant way with its generators? Yes, you do run into the stream-of-stream problem when lifting your views, so the Monad interfaces seems unavoidable, but it is already there and hidden in your library, so a fair cost to reduce the complexity on the other end imo. Not to say I'd rather not stay with Functors, but that seems to lead to impure code, unfortunately. |
Thank you 😄
I really like where these alternative interfaces are leading, but at the same time I don't think the view is impure if it has event listeners that call streams. If the view is impure because it contains functions that call functions, then compose is impure and The view function returns the same output given the same input, so its pure. I think a lot of this comes down to what you consider a "value" though. |
That is not what I meant. Sorry, I didn't explain myself properly. Here is another try 😄 Let's say we want to model this scenario: We have the position of the spaceship and the position of an asteroid. Positions are defined for all moments in time and can change infinitely often. That means they are continuous. Whenever the difference between these two positions becomes small enough we have a collision. We can count the number of collisions. That means they are discrete. Not only that, we need to count the number of collisions if each collision should subtract health from the spaceship. So, our model should turn the interaction between two continuous behaviour into a discrete stream. There is no way to move collision detection and response to the edge of our model. My point is that discrete phenomenon can unavoidable occur at all places. Things that are conceptually discrete can arise from the interaction between continuous things. And when we model those we can't push them to edges. |
You could model it that way @paldepind, but if we receive a list of collisions as part of our continuous stream of actions, then we've moved our discrete modelling out of the stream, and now we're just working with functions of state. E.g. take streams out of the equation, is this discrete? State -> Action -> State
update(
{ collisions: [1,2,3,4 ], health: { 1: 100, 2: 50, 3: 80, 4: 100 }}
, { action: 'ProcessCollisions' }
) We can say the new health is a function of the existing health, the collisions and the action. We could run that function 100 times with the same input it would give us the same output. So we can model collisions as continuous, we can push discrete to the edges here. |
I don't think I understand your point @JAForbes.
If we can count something it is discrete. You can count how many actions occur. That means they are discrete. So that is not a continuous stream of actions. It is a discrete stream of actions. The fact that you can store the collisions in a list means they're discrete. Anything that you can store in a list can be counted so it is discrete. I think it is very helpful to think about the semantics of behavior and stream. I write about that in my blog post. Behaviors are conceptually a function from continuous time to value. Streams are conceptually a list of time ordered values. When thinking about a phenomenon one can ask: Does it make sense to think of this as a function over time or as a list of values over time? It is easy to see how collisions can be thought of as a list of values over time. |
Indeed, both input and return values must be correctly defined :) The way I see it, the input should include the UI events as well, then the output stream is completely determined. And when testing you have to supply those events as input, then you can test you view like any other pure function :) |
Not all function calls are equal. You have to make a distinction between calling a pure function and calling an impure function. Calling streams is impure. And if code calls a function that is impure then that code is also impure. If your event listeners call streams then they are impure. |
I can see your point. What I mean is, you can hide all the impure function calls inside your drivers. But your view and reducer functions can remain pure in their parameters. Then calling streams is done inside the driver, and so is allowed to be impure. Does it make sense? EDIT. I have removed what I previously said about difference between functions and streams passed as parameters. Actually I don't see any difference. Even with streams, the impurity is hidden inside the calls, and is not our view's responsibility. And yes, both times it is pure on the outside but can be impure at the call time. |
Just because we can count something, doesn't mean we are counting something. A list is discrete, but the list is a value within a stream, we're not modelling with any awareness of discrete events, the entire program is modelled as a function ignorant of counts of actions. Time isn't part of the model at all. Instead one frame we compute a list of collisions, from a list of positions and dimensions (these aren't events). In another frame we process that list. But we receive the entire list in one frame and we process them without regard to the list's length. So the model isn't discrete.
But the discrete model isn't at the level of streams, at the level of a list, which we receive in its entirety and process relationally: we've removed time. If this was a SQL database, we could write a query relationally. with pair as (
select e1, e2 from entities e1
cross join entities e2
where e1.id <> e2.id
)
with collisions( uuid, uuid, bool ) as (
select e1.id, e2.id, not (
e1.right < e2.left
or e1.bottom < e2.top
or e1.top > e2.bottom
or e1.left > e2.right
) as collided
)
select * from collisions -- or do whatever There is absolutely no notion of time here. And while there are many entities, and they are discrete. We are not working with discrete events in time. Its not that the discrete entities are a problem, its that discrete events in time are a problem. This relationship can be expressed to hold continuously for all entities without regard for time. Just because we could count the number of entities, doesn't mean we are counting them in our model, that's the difference.
But we are not invoking streams in the same invocation when we create the virtual dom. So the view is pure. If this weren't true, the Future/Task/IO monad wouldn't be pure. const impure = x => console.log(x)
const pure = x => () => console.log(x) The 2nd example is pure, it has no side effects and it returns the same function when given the same args. The same is true of a view function. function view(){
return m('button', { onclick: () => actions( 'INCREMENT') })
} We're not writing to the stream when we call |
I don't think we mean the same things when we say discrete/continuous.
To me that description sounds very discrete. You talk about one frame, then another frame and another frame. I can count the number of frames. That is what I would call discrete.
When you program in the IO monad you never mention any impure functions. There is not a single subexpression inside IO-code that is impure. Not a single subexpression that doesn't satisfy referential transparency. I think there is a difference between a function that is pure and a function that is implemented in a pure way. Sure, this implementation of function map(fn, list) {
const list2 = [];
for (var i = 0; i < list.lenght; ++i) list2[i] = fn(list[i]);
return list2;
} But while the function is pure the implementation has none of the qualities of pure code. This function view(){
return m('button', { onclick: () => actions( 'INCREMENT') })
} But it returns data that contains an impure function with side-effects. And someone else will take the impure function and call it. So while function view() {
return m('button', { onclick: () => globalstate.count++ })
} The As you showed with |
That's fair. I just wanted to pin down that a view function with a stream in an event listener is pure.
I see what you're saying and the value of it. But that's not necessarily true. I could compose an entire program without ever invoking those inner functions. I'm sure you've done this too. Maybe its preferable to write it the way you're suggesting, but that's a different conversation I think, one where I'd probably agree with you in many contexts.
The implementation will always be discrete, that's how CPU's work. I'm using frames to explain to you the implementation outside of the model. But the model isn't aware of the other frames, just like the SQL query I wrote isn't aware of the number of rows. Its the same relationship for 0 rows as it is for 1000 rows. You can model a relation discretely, you might have a case statement that depends on the |
Ok. But the difference between that and actually impure code is small. In daily talk Haskell programmers call code that uses IO for impure code. Even though IO is pure as it's just a description of what we'd like to happen (as you say). But for all practical purposes code that uses IO is no better than normal imperative code. So calling it impure is an accurate description of the characteristics of the code. In Turbine we express the dataflow between model and view in a completely pure way. I think that gives a great practical advantage.
That depends on how far down you go. At the physical level CPUs are continuous. At least so I've been told 😄 But again, I think we're talking past each other with regards to discrete/continuous. Here is a fantastic podcast with Conal Elliott in which he talks about how he invented FRP and his view on the relationship between the continuous and discrete. Possibly he explains it better than I do. |
@paldepind I'll give it a listen 😄 |
That was a great listen. I listened three times. So many things to reflect on, but ironically I found a lot of what he said to echo my thoughts. I also think my previous arguments hold. Perhaps I'm not communicating them very well though. There are a lot of things you're saying where I don't agree with your logical deductions, on purity, on what makes something continuous/discrete, on CPU's in particular being continuous 😄 - but I think I agree with you on far more things than I disagree with and I'm happy we disagree on these points, because its our diverging perspectives that have lead to this interesting discussion. I also think we may be talking past each other as you say, but not just on continuous vs discrete but on streams themselves. I haven't adopted Conal's Stream/Behaviour/Event vernacular, and I think that is leading to confusion. What I'm saying is, its better to move from Event's to Behaviours as quickly as we can. And that is always possible. All it requires is changing our model from "when did x happen" to "is x happening"? Being as opposed to Doing, as Conal puts it. From what I can tell from your blog post (please correct me): a behaviour is always continuous while at the same time, it's a Functor which means you can have a Behaviour of any value, in typescript Parlance: I'd also say: A CPU cannot be continuous, even if the electrical signal is. A CPU has a speed, and that is how many instructions it can handle at a given rate. And those instructions aren't just samples of a curve, from the perspective of the CPU they are discrete instructions and each instruction can have an abrupt output in the system. I think it's important to accept that computers internally operate on a discrete model, but that doesn't mean we have to build our abstractions in the same way. So arguments of the form "X events are discrete so therefore your model must be discrete" would logically mean we can never able to model continuous systems, and that obviously isn't true.
In Conal's model, time is a real number instead of a natural number, but that is only important as an allegory or primitive. You could substitute real numbers for any other domain, and as long as our model supports every infinitesimal possibility in the type's domain we are continuous. If we wanted, we could imagine a universe that only had natural numbers, and in that universe, we could have a continuous model of that domain. We can take this further... Counter intuitively, if our model is a list, as long as we support every possibility from an empty list to an infinite list, and our model is unaware of each case: we are continuous. If our model was simply binary e.g. a In other words, we can compress continuous space into events, and we can expand discrete events into a continuous signal that demonstrates whether or not the event is taking place. Its just a matter of resolution. // continuous boolean model (unaware of the specific value)
// modelled as a shared relationship for all values
b.map( x => !x )
// discrete boolean model (aware of each "event" as a discrete unit)
// modelled as discrete outputs for discrete inputs
s.map( x => x == true ? false : true ) The first example is relational, the second is conditional. Notice the output is the same, but the model is very different. A discrete model is aware of each possibility, and handles them discretely. If we have a case statement for each discrete possibility, we are not continuous. Even the notion of a click being discrete is entirely abstract. In reality a mouse button receives an amount of continuous pressure, there is a threshold where we decide that pressure warrants a press. Then there is a continuous amount of time a user can hold the button down before releasing, and releasing in itself is a continuous model of force. Finally we encounter a minimum force threshold and we call this a click. But we can always go back to the force/pressure model. And a click is just a merging of the continuous force with the continuous time held, and we convert it into a discrete model. A discrete model is just a highly specific low resolution continuous model. Just as clicks are modelled as discrete abstraction on top of a continous framework, we can easily go the other direction. We could say "when the user clicks we emit 1" which is discrete but now we have a number, we can create a continuous model: In the past @dmitriz and I had a discussion about continuous models having the same output irrespective of the number of events. And I conceded back then, but now I rescind that 😄 ... the fact the sum changes if we receive a different number of discrete events doesn't change the fact that there are no "holes" or "jumps" in a model: That's also why my example of using SQL to model collision detection as a relationship is relevant. Its a model unaware of the specific number of entities. It holds for all possibilities in its domain (including 0,1 and Infinity). Even though in practice the database would never evaluate an infinite table that's completely orthogonal: the model is continuous. I think the universe is always continuous / analogue while abstractions can be discrete. Operating systems are digital / discrete. Events, as we think of them, do not actually exist physically. I'm currently reading a book on serial verb construction, how we model events in different languages, and event modelling is unsurprisingly unscientific there as well. It's up to us overcome the specific discrete technical environment we inhabit. That's a lot like Conal modelling an image not as a 2d buffer of pixels, but as a infinite continuum of colour that can vary. Or in his animation example he turned a low resolution event emitter into a high performance curve. Even though in reality the computer was discretely emitting events, he still chose to model continuously and to great advantage. We can always return to the discrete buffer of pixels when we need to, but not within our model; at the edges of the system, when we must interface with a discrete operating system or environment. And! That's not to say events are bad, or that discrete is bad. Its just that a model that is unaware of discrete events is simpler, and simplicity is valuable. And I wish the Observable/Stream community would emphasise Stream's power for modelling relationships, instead of always focusing on "Arrays over time", which to me is reductive. Not because its inaccurate (its perfectly accurate), but because the idea of what an Array is, (to many developers) is overly concrete and limited, and therefore their models end up being far too concrete and limited. |
It is perhaps worth mentioning that we had this discussion in the context of deciding what would be best fit for the And as the I think making streams more simple and accessible to broader public is a worthwhile goal and I am sure both of you would do great job at it. One thing I'd think would makes streams simpler, is building a better link with arrays. Cutting out a period, a snapshot from a stream, and replacing time with discrete order, defines a simple pure function from streams into arrays. Any array method can now be used. Of course, it is a different issue in the more broad context of this discussion. For which however, I would like to see more concrete practical real world examples, where we can tests and compare different theoretical approaches and use the results to make compelling arguments to others. Especially, being a mathematician myself, I have to watch for being regarded by others as too abstract and theoretical :) Finally, I'd like to share the project I have just started working on. It is at very early stage but I have tried to explain the main principles and provided a simple working example, which I call the "active counter", and the |
@jayrbolton I didn't get around to answer your question here
and here
I think this is a really good question. When you're used to a library that only has streams or observables it is not immediately obvious why you need behavior. On the other hand, I'd say that once you've gotten used to thinking about behaviors and streams it will be very hard to go back to a library that doesn't make the distinction. To answer your question I've written a blog post: Behaviors and streams, why both? In the blog post, I discuss some of the benefits to keeping streams and behaviors separate. I'd love to hear what you all think about the blog post and if the reasons I give makes sense to you. @JAForbes Sorry I haven't replied to your last post yet. I haven't forgotten. I just haven't found the time yet. |
@paldepind do not feel obliged by any means to reply. Time is fleeting and there are far more important things in life than programming debates 🌈
I loved your blog post btw |
Thank you 😄 But, I'd very much like to reply. I still think there are some interesting things left in the conversation.
Are you thinking about the previously linked blog post? This is a brand new blog post solely about the benefits of making a distinction between behaviors and streams. I think some of the points I make will resonate with you as they are similair to the arguments you present against the problems of only working with streams as "arrays over time". |
👍
oh! I missed this one. Reading 👓 |
I've felt this pain before. Great point. |
Enjoyed the blog post @paldepind, many thanks |
Great post and well written again! I somehow feel puzzled by what is really happening with the FRP in JavaScript community, I came accross interesting projects like this one that feel abandoned and forgotten: It is interesting how the Redux intro mentions it casually in passing:
It could be the RxJS "unreasonable" complexity, and, unfortunately, CycleJS doesn't look too simple either. And perhaps, the problem is not so much the complexity itself, but the lack of sufficient motivation and justification for it. So from this perspective, it is important to motivate any additional complexity, where such posts are of great help. It might be good to try to exploit the best parts of the streams first, such as FL and static land compliance, clarify the correspondence (natural transform) into arrays, which will help to make most methods derived from very few basic ones, then move to behaviours in the situations when there are clear advantages. |
I'm happy you got something out of the blog post 😸
I know that a lot of people publish on Medium. But, will doing that get more people to see it? I think one problem with the adoption of FRP is that many reactive libraries have a lot of accidental complexity. Hot vs cold observables, laziness, updates that don't happen as one would expect. The key to avoiding this complexity is to specify simple semantics that can explain the behavior of the entire library. In this way, users can have a simple mental model that the library should obey without dragging users into implementation details. |
@JAForbes This is an answer to #23 (comment). I think your post was very interesting. There are definitely things I don't agree with and things I don't understand. But, to make sure the conversion is moving forward and not in circles I think it would be beneficial to figure out what we do agree on 😄 So below is a CPUs I think we fully agree with regards to CPUs. What you're saying is that from the point of view of a programmer a CPU is discrete. It provides a discrete abstraction consisting of instructions, memory cells, etc. But you also write that if we dive deep enough we will find that:
So at some level CPUs are continuous. Just not at the programmable level. That was what I meant when I said that CPUs are continuous at the physical level. As far as I can tell you are saying the same thing. Implementation details should not affect our model But, the above is pretty irrelevant 😉. Because, we also agree that technical details of the CPU should not dictate how we write our code. Which I think is what you're saying here.
I completely agree with that. CPUs are imperative and discrete but that is just details that should be abstracted away. Details about computers should not prevent us from writing the code that we want to write. The universe is fundamentally continous You write
Which I fully agree with. I think discrete models are always invented as concepts in our brain. You have a great example of that here:
The mouse itself isn't discrete. But we as humans invent the concept of a discrete "click". Now to what I don't understand: your definition of continuous. I only know what continuous means on real-valued functions. Myy understanding is exemplified by this image: The red function is continuous while the blue function is not. The explanation of continuous from Wikipedia that you quoted matches this. But, that definition is only valid for real-valued functions. And you also use continuous when talking about functions whose domain is not the reals. Continuous is a mathematical property. And mathematical speaking two functions are equal if they for all input returns the same value. And if two functions are equal they can either both be continous or both be discontinous. That is why this code example doesn't make any sense to me: // continuous boolean model (unaware of the specific value)
// modelled as a shared relationship for all values
b.map( x => !x )
// discrete boolean model (aware of each "event" as a discrete unit)
// modelled as discrete outputs for discrete inputs
s.map( x => x == true ? false : true ) The two functions passed to This makes me think that maybe you're not using the word continuous in the mathematical sense. Maybe you're using it to talk about some related property. But I still don't see any significant difference between the two models in the example. You write:
I would say that both ways of writing the functions are "aware of each possibility". When you write |
There is unfortunate mismatch of terminology in- and outside math. What people outside math call "continuous" means often what mathematicians call "continuum". The set of all values in real line without gaps is a continuum. A function on a continuum does not need to be continuous. This might be the confusion here. |
I have spent some months updating and refining an approach to an FRP driven pattern that I'm calling "flimflam". Main points:
Examples:
The text was updated successfully, but these errors were encountered: