-
Notifications
You must be signed in to change notification settings - Fork 107
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
Proposal: Hack-style Pipelining #84
Comments
Thanks for writing this out in such a detailed way. I didn't understand exactly what you were suggesting before. I like how this direction is very explicit and general; it completely handles nested subexpressions in a way that I didn't previously understand was possible. The cost seems to be the loss of terse syntax for Bikeshedding: I'm not sure if we want to use |
True, using Another thought: a pipe operator with these semantics would obviate the need for the current class XElem extends HTMLElement {
_onClick() {
// ...
}
connectedCallback() {
this.addEventListener('click', this::_onClick);
}
disconnectedCallback() {
// Assuming this::_onClick === this::_onClick
this.removeEventListener('click', this::_onClick);
}
} which would be pretty nice and intuitive. |
Nicely done! The loss of terse syntax for unary and curried functions is arguably significant, but in return you get pretty much everything else one could ask for 😄 Some more fun features of this syntax: let lastItem =
getArray() |> $[$.length-1]
let neighbors =
array.indexOf('abc') |> array.slice($ - 1, $ + 2)
let silly =
f |> $(x, y) |
This looks a lot like a variant of the partial application proposal, just using a Maybe we could take some of the features proposed here and integrate them into that proposal, but from the surface, this looks flat out identical to that proposal mod the change in syntactic marker. |
I guess I'm surprised const $ = require('ramda') // using ramda cuz I'm familiar
[1, 2] |> $.map(x => x + 1) it can't tell when it should use the return value of Ramda's curried @isiahmeadows My understanding is this proposal explicitly evolved out of prior attempts to combine the two proposals. Given that, though is the intention to actually combine the proposals or use the placeholder semantics defined as part of the pipeline proposal as a springboard for adding partial application to the language generally? |
I'm not actually proposing this, but, for what it's worth, the Clojure programming language has a similar pipelining feature called the That is, the example in the original post would become something like:
…but the Having said all that, like @isiahmeadows I too am wondering if |
I see the benefit of this for multi arg functions, but I don't understand why we need a placeholder for single arg functions? A lot of the FP community in JS has moved or is moving to unary only functions, and so for us it'd be quite natural to do |
I'm also really surprised anArray
|> pickEveryN($, 2)
|> $.filter(...)
|> shuffle($)
|> $.map(...)
|> Promise.all($)
|> await $
|> $.forEach(console.log); Should just be with lodash/fp, ramda, sanctuary ( etc ) anArray
|> pickEveryN(2)
|> filter(f)
|> shuffle
|> map(f)
|> Promise.all
|> then( forEach(console.log) )
If we want to use legacy OO style libraries with this syntax, we should have a separate proposal (like the placeholder proposal), and it should not be pipeline specific, it should be language wide. The worst case scenario with arrow functions is completely fine. And the fact it's not as convenient as unary functions is a good thing, it's a subtle encouragement to think compositionally and design API's that enable that. E.g. let's say we're using pipeline with no placeholder, with no await, with standard lodash and bluebird. anArray
|> x => pickEveryN(x, 2)
|> x => filter(x, f)
|> shuffle
|> x => map(x, f)
|> Promise.all
|> then( xs => xs.forEach(console.log) ) It's not bad for an API that is not designed for unary application. I think we may be over-complicating things to support functionality we don't need to support. The work-around of using arrow functions is still simple to follow, and any special placeholder syntax should be language wide anyway and probably beyond the scope of this proposal. |
Unfortunately, latest guidance from TC39 is they want await support. This is a syntax that will need to be solved before it can advance. |
Is nobody at TC39 willing to oppose |
To be fair, I haven't heard the discussion, so maybe I'm missing something? |
The notes from the discussion seem more negative towards the idea of bolting on |
Yeah I agree, and the arguments for
This is supported by the above
I think we need more guidance than this. Its a very simple operator, we don't need to complicate it. I think @zenparsing's suggestion is an elegant solution for the constraints tc39 have provided, but I don't think the constraints have any merit and we should seek further guidance. If we ship it with await banned, and then people want to add await later if/when there's a legitimate justification, we can. It won't break any code. But I personally doubt there will be a justification, I'd be in favour of some static functions on the Promise object (e.g. |
I don't think this is the right thread to revisit |
Personally, I think part of what makes the pipeline operator so elegant is its simplicity. I'd be extremely happy simply writing code like this: anArray
|> x => pickEveryN(x, 2)
|> x => filter(x, f)
|> shuffle
|> x => map(x, f)
|> Promise.all
|> then(xs => xs.forEach(console.log)) This builds on syntax I'm already familiar and comfortable with (arrow functions), themselves already beautifully simple and elegant, and it feels like a natural evolution for them. Introducing both the I still think I prefer inlining anArray
|> x => pickEveryN(x, 2)
|> x => filter(x, f)
|> shuffle
|> x => map(x, f)
|> Promise.all
|> await ?
|> xs => xs.forEach(console.log) such that the placeholder is only used in Thoughts? |
@JAForbes Javascript is a big, huge tent, filled with many people that don't consider OOP "legacy". We need to consider everyone under that tent when designing syntax. @mAAdhaTTah I don't think we can assume yet that "arrow functions without parens" syntax is going to work out: see #70 (comment). If we have to rewrite your example with the parentheses, it doesn't look as attractive: anArray
|> (x => pickEveryN(x, 2))
|> (x => filter(x, f))
|> shuffle
|> (x => map(x, f))
|> Promise.all
|> await
|> (xs => xs.forEach(console.log)) |
Let me clarify what I mean by legacy. I don't mean legacy today, I mean legacy after this feature lands and is adopted by the community. This feature will be so transformative for the community, it's hard to over estimate. Currently if you want a library to support some functionality you need to open a pull request and add a method to their prototype. This feature allows us to do all of that in user land by just piping their library into our own functions. Inevitably that will happen. That is going to affect the status quo, and we should design for that reality not for what is currently normal.
I think it still looks fine, and it keeps this language feature simple. Let's keep the semantics simple and ship it. |
@JAForbes Can you provide an example, using the semantics of this proposal, that shows how it is unergonomic for some use cases? |
I really don't think this feature will drive OO programing out of JavaScript.
See the interfaces proposal for a different approach to this problem. |
@bakkot I didn't say it would drive OO programming out of JS. I'm saying the release of this operator will change ecosystem norms for data transforms. You can still have classes, and OO and all that stuff, but for data transformation E.g. waiting for Array utilities to land becomes less of a concern, we can do that in userland. Or waiting for Promise, Observable, Iterator functions etc. We can add features immediately and get the same convenience we are used to with chaining. It relieves pressure on library authors, on language designers, it gives more power to the community. We're going to see the pipeline being used in all kind of innovative contexts e.g. decorating hyperscript and jsx. It's hard to predict beyond that, but this feature is a huge deal and it will have drastic affects on the JS ecosystem. So let's design for that. Just supporting the core functionality will bring a lot of value with very little risk. I'm not at all saying OO will become legacy. I get why you are reading what I'm saying that way, and I apologise sincerely for being unclear but that is not what I am trying to communicate at all. I just want to make sure we don't bake in things that make the feature confusing for an imaginary use case when a simpler proposal provides so much value and is so much easier to teach and adopt. Designing languages is like designing infrastructure for a city. You need to plan for the future. Future economies, future culture, future populations, future needs. I'm saying, let's not bind ourselves to our assumptions about usage based on the present day, let's keep our options open and add affordances for await and methods if the community needs it. In the mean time, arrow functions work just fine and there's a whole suite of libraries that are made to work perfectly with this feature. Let's ship the simplest possible operator, and give ourselves room to extend if we need it. |
I think your proposal is a brilliant design for the constraints that have been presented. It really is elegant. But I want to ensure that there is a legitimate need for a compromise. The common case for pipeline execution is composing sync functions. This proposal solves for a lot of contexts but sacrifices the absolute simplicity of If it turns out in #86 there are some solid justifications, I'd support your proposal because I think it's probably the best compromise that could be concocted. But I don't want to compromise without justification. And I'd really like us to ship the simplest possible operator. |
I think you need to define what you mean here. "Simple" is often treated as an always-good attribute instead of the tradeoff that it truly is. Aren't function parameters simple? Then why complicated them with default values? Aren't variable assignments simple? Then why complicate them with destructuring? Aren't script tags simple? Then why complicate things with modules? Reconsidering the meaning of the word, both the original and this Hack-style proposal are equally simple. They both have a simple rule: "call the right-hand side" and "create a binding for the right-hand side", respectively. It just so happens that Hack-style is more ergonomic for a much broader variety of use cases, but at the cost of being more verbose for the simplest use case (unary function calls) Edit: and also curried functions, to be fair. |
Going back to pointing out issues with this proposal, what happens when the temporary identifier is not used on the right hand side? Example: var result = x |> f($) |> g Should this result in a syntax error, or would I think this is important to address since I imagine some will forget to pass |
I agree with the concerns about the choice of binding. That aside, I want to point out another nice synthesis (with a proposed feature): this works nicely with anArray
|> pickEveryN($, 2)
|> do { let [fst, snd, ...rest] = $; [snd, fst, ...rest]; }
|> $.map(whatever) Maybe that |
Yeah good point. I think this proposal is simple in the abstract. It's design is simple. But it's design also inherits the complexity of the surrounding language, unlike a traditional Additionally in terms of actual engine implementation and in terms of the things a user can do with this new syntax, it's more complex. There will be reams of edge cases and blog posts demystifying it's use in corner cases, because it's wide open in what it permits. There'll be deopts for years to come because it will be so hard to predict behavior. Where as in lining a composition pipeline is far more straight forward. You are right simplicity is a trade off. I think this design is elegant given the constraints. But that's why I'm questioning the justification for those constraints in #86. I also do not want to have to include a placeholder for every composition, particular when my functions are already unary anyway and I use composition exhaustively. I'll have Yes shipping |
Edit: Made a few small clarifications. Here's my thoughts on
Now, to resolve 1, you could alter
I know that complicates a potential use case, but it does so in favor for another more frequent one. Similarly, I'll propose a few more useful additions:
* I mean this exceptionally loosely, in the sense that es5-sham "shims" |
@isiahmeadows The "Hack-style" proposal here isn't trying to introduce any kind of partial application to the pipeline operator. Under this "Hack-style" proposal, the pipe operator simply creates a constant variable binding for the left operand when evaluating the right operand. Also, see #83 for a longer discussion on the
But other people will have those use cases. Should we make the syntax unergonomic/confusing for them? Also, are we sure that we even need syntax for the implicit-call semantics? If you are really using composition for everything, can we use a function for that? let result = purePipe(anArray,
pickEveryN(2),
filter(f),
shuffle,
map(f),
Promise::all, // Future method-extraction syntax
then(xs => xs.forEach(console.log)),
); You could even combine that with anArray
|> purePipe($)
|> $( pickEveryN(2),
filter(f),
shuffle,
map(f) )
|> Promise.all($)
|> await $
|> $.forEach(console.log); |
For this reason I would say that a syntax error is probably the best option. |
Is a placeholder required for unary functions? Is there a reason we couldn't allow mixed-use? Modified example from OP w/ anArray
|> pickEveryN(?, 2)
|> ?.filter(...)
|> shuffle // Doesn't require the ?
|> ?.map(...)
|> Promise.all // neither does this
|> await ? // maybe even drop it here?
|> ?.forEach(console.log); Are we mostly concerned here about developer confusion? |
@kaizhu256 and again, please stop making those kind of comments; reassignment is not a good practice to everyone, and there is a need for many of us, even if you don’t have one. There is nothing ergonomic about a list of statements that all reassign to the same variable. |
yes it is more ergonomic. javascript development is messy with entire codebases frequently rewritten every week (or every day) as a product's ux-wiring and ux-kinks are worked out. pipeline-operators, and needless abstractions like |
On the other hand, I'm starting to wonder what's bad about dot-chaining. JavaScript is an OO language after all, not a functional one. OO tries to keep functions organized; most pipe examples I see are using global functions. Why are stream libraries moving from dots to pipe()? let result;
result = await fetch();
...
result = Array.from(result).
filter(function (elem)
{ return elem.age >= 18 && elem.age <= 65 }).
sort(function (aa, bb) {
aa = aa.rating;
bb = bb.rating;
return aa-bb;
}).
reverse().
slice(0, 100).
map(function (elem) { return elem.age; }); I mean, there are a few cases where a pipe would be useful, in particular when I need the value more than once. |
dot-chaining is generally the "right" way to transform arrays and strings in javascript (when builtin methods will suffice). for the
above code is easier to refactor than your pipeline example in the endless javascript-churn we all deal with. |
@MrVichr I think the main reason for it is tree shaking. From RxJS docs https://v6.rxjs.dev/guide/v6/pipeable-operators
|
@mAAdhaTTah yes totally agree there! |
I agree. I have tried to use the temp-variable assignment method in practice, and learned quickly that it's not a good replacement for the Hack proposal. |
Hi @mAAdhaTTah, I added the bind token syntax partially to make the short form easier to define. It also makes the canonical form a little more precise. With the short form, we won't need to use the canonical form as much, so we can afford to be a little more verbose. But you could be right, and people might prefer the convenience of binding it to an implicit token. There is still some difference between the token-binding syntax here and the F# proposal + arrow functions, because the token-binding syntax here works with generators and awaits, and have no performance penalties. |
@highmountaintea I understand the thought process. I'm just pointing out that in doing so, you're reducing/eliminating one of the main advantages Hack has over F# (terseness) while compounding one of its main downside (novel syntax). |
It's an interesting idea. Seems versatile, but leaking the variable scope into the next pipe might feel like an issue. |
It probably is an issue. It's no longer easy to read since you have to go make sure bindings aren't reused below, unless you use the same name in every binding. |
Why can't we get a champion here that will push the simpliest, minimal, F# proposal forward without this needless spinning our wheels all these years @mAAdhaTTah? We can always build on the minimal foundation as needed. |
@aadamsx I've been working on this for 3+ years. Daniel was championing it most of that time and preferred F#. Tab has picked it up now and prefers Hack. The problem is not the champion, it's consensus. |
I think this is an instance of the law of triviality at work. I'm guilty of it too, in this instance and others. But at the end of the day, does it matter, really? I suspect that in the future we'll have linters and peer pressure stopping us from doing crazy things in pipes. Having worked at an elixir shop I was under strict guidelines on what to do and not to do, because if you overuse the pipe operator things get unreadable pretty fast. So whatever syntax we choose, people will use it in the same way. It's just a matter of balancing power for crazy devs like me, with how much we need to teach new JS developers to get them up to speed on this syntax. |
The problem is there will NEVER be a consensus on this! The TC39 guys have also aluded to the process not being based on concensus. So a champion leaning one way or another is the only thing I think that can tip the scales one way or another. You've been on this from the start, why can't YOU be the champion here @mAAdhaTTah? |
Making up for flawed syntax with linters -- not a great idea @fabiosantoscode |
TC39 is exclusively based on consensus; I’m not sure where anything else has been alluded to. |
Who is to say when we've reached a concensus other than the champion and/or TC39? For example @ljharb, could Tab take champion status over this repo and move forward with Hack in this repo's current state (when we obviously do not have concensus)? From what I've read on this repo, TC39 takes our input to an extent, but ultimatly the decision is in their hands, and a concensus is not required for them to make a decision on way or another. I'll have to do searches for these types of posts, but don't have that much time today. |
Consensus is among tc39 delegates, so yes, if every delegate agreed with a direction despite the concerns of participants on this repo, then it would move forward. However, i think it’s unlikely that the committee would agree to proceed with anything if there remains this much contention here. In other words, in the absence of a compelling and persuasive argument that can override aesthetic preferences, if one group doesn’t cave, neither group may get the feature. |
Not with that attitude! I'm being flippant but ES5 took like a decade after ES3 was released to come to an agreement. ES6 took another 6 years. Even optional chaining, which is far simpler than pipeline, took like 4 years. Some features never advance (bind operator), some features go through multiple rounds of revisions (decorators) or last minute changes (
I'm not a TC39 member. |
Funny but potentially true :) I picked two operators to illustrate my point, but it might be possible to merge the two operators into one. It depends on how intuitive the overall feature is in practice. Two operators might seem more expensive than one operator, but it might not be so in terms of mental load if the two operators are similar (e.g. |
Exactly. This was an argument for the F# proposal, which does less, but is more predictable and less like something people would write lint rules for. |
I think @JAForbes makes an excellent point Let's separate and generalize Hack's alternative syntax as a new, terser alternative to Fat Arrow syntax.Anywhere a Fat Arrow function would work, an expression containing the Hack-style argument reference token should work. We can call them "implicit lamdas" and they can be N-ary and N-order.
[1,2,3,4,5,6,7,8,9]
|> pickEveryN(#, 2)
|> #.filter(# => 0 === # % 4) // `#` in filter lambda explicitly rebinds the token to disambiguate with outer.
// next 4 lines are equivalent, but allow for different nuances in the semantics:
|> shuffle // just a unary function
|> # => shuffle(#) // a unary lambda, which manually binds `#`
|> #0 => shuffle(#0) // a unary lambda, which manually binds `#0`
|> shuffle(#) // interpreted as `(#0) => shuffle(#0)` which is a unary lambda
|> #.map({[##]: `value was ${await dbLookup(##)}`}) // first-order unary lambda of `#0` composing second-order unary lambda of `##0`
|> Promise.all // unary function
|> await // operators are just unary functions
|> forEach(console.log) // TYPE ERROR, assuming no forEach is in scope other than Array.prototype.forEach, which is not called because there is no dot operator.
// Should be `#.forEach(console.log)` or `#.forEach(console.log(##)` or some explicit lambda version of those |
The Hack style is just an awful looking compare to F# style. |
Beauty is in the eye of the beholder, but conciseness / terseness / signal-to-noise ratio are objectively measurable by information-theoretic techniques independently discovered by civilizations separated across the farthest reaches of space-time. Over Fat Arrow syntax, Hack syntax offers the benefit of several characters savings, and savings of 2 to 4 tokens depending on parentheses, while also promoting a standard convention for referencing "the obvious parameter binding" that once learned by devs, reduces cognitive load in mentally binding the symbol to the meaning. As long as we can still use Fat Arrow when preferred, the availability of Hack shorthand has a lot to offer in reducing the amount of mental effort devs spend on "pretending to be a human compiler" while navigating the code with their eyes. My main suggestion is that the argument in favor of Hack syntax can be disentangled from the design of a functional application operator ( also called infix, or pipeline
|
An important aspect of this proposal is that it is not a short-hand for function definition. |
@davidvmckay - that idea is being discussed over here. The issue is that as soon as we cross the road of doing F#-style, there are many nice benefits to hack-style that would become inaccessible to us. It's not an easy task to turn the topic idea into a general-purpose concept, and doing so in a reasonable way adds so many restrictions as to render it much less useful - something I talked a little more about in that thread here. So, even if we decide to go the F# route, we still have to be absolutely sure that the current hack-style idea is not what we want, because there's no turning back once we've picked a path. |
Closing, as the proposal has advanced to Stage 2 with this syntax. |
The current proposal (in which the RHS is implicitly called with the result of the LHS) does not easily support the following features:
In order to better support these features the current proposal introduces special-case syntax and requires the profilgate use of single-argument arrow functions within the pipe.
This proposal modifies the semantics of the pipeline operator so that the RHS is not implicitly called. Instead, a constant lexical binding is created for the LHS and then supplied to the RHS. This is similar to the semantics of Hack's pipe operator.
Runtime Semantics
left
be the result of evaluating PipelineExpression.leftValue
be ? GetValue(left
).oldEnv
be the running execution context's LexicalEnvironment.pipeEnv
be NewDeclarativeEnvironment(oldEnv
).pipeEnvRec
bepipeEnv
's EnvironmentRecord.pipeEnvRec
.CreateImmutableBinding("$", true).pipeEnvRec
.InitializeBinding("$",leftValue
);pipeEnv
.right
be the result of evaluating LogicalORExpression.oldEnv
.right
.Example
Advantages
Disadvantages
Notes
The choice of "$" for the lexical binding name is somewhat arbitrary: it could be any identifier. It should probably be one character and should ideally stand out from other variable names. For these reasons, "$" seems ideal. However, this might result in a conflict for users that want to combine both jQuery and the pipeline operator. Personally, I think it would be a good idea to discourage usage of "$" and "_" as variable names with global meanings. We have modules; we don't need jQuery to be "$" anymore!
The text was updated successfully, but these errors were encountered: