tc39 / proposal-pipeline-operator

A proposal for adding a useful pipe operator to JavaScript.
http://tc39.github.io/proposal-pipeline-operator/
BSD 3-Clause "New" or "Revised" License
7.54k stars 108 forks source link

Could `|>` be amended with a `...` spread? #275

Closed getify closed 2 years ago

getify commented 2 years ago

Given that the pipe(..) / flow(..) proposal failed to gain stage-1 status and was subsequently revoked, and the main justification for that was because it was felt that we should just throw all our eggs in the |> basket... I am here to ask for considering an addition to |> that would help address some of the use-cases I was advocating for in the pipe(..) / flow(..) proposal, which seem to have been glossed over or discounted.

I'd like to ask that |> be extended with a ... syntax variant (similar to function arg spread):

const compSteps = [ fn1, fn2 ];
if (whatever) compSteps.push(fn3);

"Hello" |> ...compSteps |> console.log(^);

Put simply, ...compSteps in a step of a |> pipeline would be spreading out all the contents of that compSteps array/iterable as individual |> fn(^) expression pieces. It would basically be like doing:

const compSteps = [ fn1, fn2 ];
if (whatever) compSteps.push(fn3);

{
   let res = "Hello";
   for (let step of compSteps) {
      res = step(res);
   }
   console.log(res);
}

By having a ... option with |>, this operator can be bent to some of the "dynamic composition" use-cases that it currently cannot really serve. The rejected/deferred pipe(..) and flow(..) would have served them more directly, IMO, but since those have been shelved, I'm hoping maybe |> + ... is the next best option.

+@js-choi

ljharb commented 2 years ago

That seems like it would require forcing a choice of unary functions, which would mean that the array would be likely to contain new wrapper functions whenever more than one argument was provided, which was a major sticking point for implementations who want to avoid encouraging excessive creation of new functions.

getify commented 2 years ago

I don't think developers, especially the FP inclined, care whatsoever that a JS engine wants to "discourage" creating wrapper/adapter functions. The design philosophies behind FP style code have pretty much always been more important to the FP developer than the capabilities of the environment that's running the code.

IOW, FP developers are going to compose functions -- whether they be natively unary or adapted via wrapper functions, currying/partial-application, etc -- regardless of what syntax is or is not present in the language.

Trying to discourage FP patterns because JS engines find them hard to optimize seems like a flimsy argument against providing affordances to developers who are already doing it the harder/uglier way, and will just keep doing so unless/until the language offers them a nicer path.

ljharb commented 2 years ago

While i agree with you, it’s nonetheless an intractable obstacle.

js-choi commented 2 years ago

When I presented proposal-function-pipe-flow, my argument had four thrusts:

  1. That static series of unary callbacks would be more convenient than with the pipe operator.

  2. That dynamic sequences of unary callbacks are not served at all by the pipe operator.

  3. That these patterns are common enough to standardize.

  4. That a function API would be lightweight enough to not add an additional large burden to the language.

The objections that I got from the Committee were twofold.

One objection was that the pipe operator may address the static-series use case well enough.

The second objection was that both the static-series use case and the dynamic-series use case are easily addressed with a userland function.

Actually, the second “just make a userland function” objection was arguably more strongly made than the “just use the pipe operator” objection. I should change my summaries to emphasize this.

With that said, I think the |> ...fns syntax is an intriguing idea, but it probably would belong in a follow-up proposal. But it also faces the same “why not just make a userland function?” objection that Function.pipe did…

function pipe (input, ...fnArray) { // Userland function.
  return fnArray.reduce((value, fn) => fn(value), input);
}

const compSteps = [ fn1, fn2 ];
if (whatever) compSteps.push(fn3);

"Hello" |> pipe(^^, ...compSteps) |> console.log(^^);

…except that |> ...fns syntax faces an even higher burden than Function.pipe, as syntax instead of a standard function.

getify commented 2 years ago

"Hello" |> pipe(^^, ...compSteps) |> console.log(^^);

There's absolutely no advantage to that vs:

pipe( "Hello", ...compSteps, console.log );

But comparing to:

"Hello" |> ...compSteps |> console.log(^^)

I can certainly see that as being a reasonable evolution to consider, especially since it could mean ditching part or all of a userland lib, and maybe even getting (eventually) some nice perf optimizations from the declarative syntax.

So what it keeps coming back to is, of course there are ways (in userland) to do this stuff... we've been doing it for 15+ years. But if JS is contemplating a new syntax/operator that is ostensibly fairly closely related to stuff devs have been solving with userland functions, and they are hoping that at least some of that userland usage shifts over to the native syntax..... in that case, it seems like a small affordance to add ... to it, which could bring over more of such usage into the native syntax.

Why wouldn't that be a sensible thing to consider?

If TC39 isn't hoping/contemplating that a good chunk of userland composition will move over to |> once it lands, I don't know why TC39 would be seriously entertaining it at all? Why add a whole new operator if it only serves a narrow slice of the overall composition landscape?

ljharb commented 2 years ago

I can’t speak for anyone else, but i don’t think it does - i think strict FP/fantasyland usage is the very narrow slice. Whether it should be is a very different discussion, but doesn’t change the reality.

The vast majority of my composition uses are not strictly unary functions, and the current proposal is a much better way to express that composition than current options for me.

getify commented 2 years ago

It may very well be that non-canonically-FP styles of composition, which are looser and more imperative/pragmatic, are in fact the wide majority of "composition" in the wider JS landscape. I'll certainly concede that.

But... most of the people who do things like fn1(fn2(fn3("Hello"))) are not even aware of the terminology of "pipelines" or "composition", nor any of the thinking about why that nested-function-call form is less preferable. They probably just do that because it works and that's perfectly good enough.

Unfortunately, they're not likely to be part of the early- (or even mid-) adopter crowd, since they don't know the terms and this topic is probably not on their radar screen at all.

The FP-focused crowd is absolutely aware of the topic, and likely aware of the proposal (to some degree) and is avidly watching to see how it all shakes out. They're the ones who are going to either use, or not use, this new operator predominantly.

So I would have thought that factoring in their needs and desired affordances might have had a higher priority even if the count of lines of code potentially affected is absolutely smaller given that it's a more interested/vocal minority that's tracking this topic.

js-choi commented 2 years ago

It’s certainly true that x |> pipe(^^, ...fnArray) |> console.log(^^) would be roughly equivalent to pipe(x, ...fnArray, console.log). The difference between them is whether the last function call is “static” or “dynamic”, but in this case it’s small.

The purpose of the proposal is explained in the explainer, although perhaps it is too wordy. Basically the intent is that:

“The ES pipe operator is a zero-cost abstraction for flattening deeply nested expressions, including but not limited to n-ary function calls, array/object literals, and await operations.”

A pipe-function API could also flatten deeply nested expressions, but it has a runtime callback-allocation cost for anything other than already-unary function calls, which several engine implementers do not want to encourage especially in hot paths. (A pipe-function API also may be clunkier for many types of expressions, such as array/object literals and template literals.)

273 already discusses this; feel free to comment there regarding “why an operator instead of a function”.

In particular, take a look at the real-world React example in https://github.com/tc39/proposal-pipeline-operator/issues/273#issuecomment-1192159213. The explainer already has this example and many more real-world examples from various codebases, but it still needs to contrast it with the pipe-function-only approach.

getify commented 2 years ago

@js-choi

It’s certainly true that x |> pipe(^^, ...fnArray) |> console.log(^^) would be roughly equivalent to pipe(x, ...fnArray, console.log)

IMO, they're not "roughly equivalent" or even close. the |> form of that is strictly worse in every way.


I'm not here asking for the function form (that horse was already beaten... to death apparently). I don't even prefer to keep using my userland function approach.

I'd like to be one of the folks (hopefully many) who can see myself converting to using |> when it lands. The main obstacle I see to that is, most of my compositions are dynamic (in some way).

So I'm asking to consider an affordance that actually makes |> possible to convert to. If ... could be added, many (not all) of my compositions could reasonably shift to the |> operator, and that would be a win because of shifting away from userland code into native (and engine-optimizable) declarative syntax.

Without something like ..., I can't see that I'd move hardly any of my compositions to |>, as I'm still going to be unfortunately reliant on the userland utilities. |> won't offer any compelling benefit to me in that sense.

But I'd like to be a |> convert. And more to the point, I'd like to see TC39 be receptive to the need to serve that dynamic composition use-case.

getify commented 2 years ago

BTW, someone else asked me for more real-code examples of dynamic composition, of the form where |> + ... could work together... so I pulled this together: https://gist.github.com/getify/21148d8f49143980765ded4abb139012

File (2) is how I currently do things, file (4) is what it would look like trying to use |> as it currently stands -- which I would basically just never do -- and file (5) is what it would look like if ... were added to |>.

Stretching my dream wishlist even further, file (6) shows a |=> arrow-pipeline combination -- a unary arrow function whose body is automatically a pipeline expression, and the function's parameter is bound as the topic.

tabatkins commented 2 years ago

IMO, they're not "roughly equivalent" or even close. the |> form of that is strictly worse in every way.

Sure, when the entire meat of the expression is already in the pipe(), mixing in |> for one additional call (that could have been folded into the pipe() as well) doesn't gain you much, if anything. Feel free to keep using pipe() for that! I disagree that it's strictly worse, tho. If your pipeline has both a dynamic and static part, it can be worthwhile for readability to have that reflected in how you write it, as shown in the example. It can be even better if the composition is reflected more directly, either by using a compose() to build a function as you go, or if stacks of functions are concerning, maybe a FnCompose class with a .push() and .invoke() - "Hello" |> fns.invoke(^^) |> console.log(^^) looks much better to me, personally.

So I'm asking to consider an affordance that actually makes |> possible to convert to. If ... could be added, many (not all) of my compositions could reasonably shift to the |> operator

The issue here is that this isn't a pipeline-specific proposal. You're asking for a function-composition operator (an n-ary one that works over a list), and suggesting a syntax based on spreading a list into the arglist of pipe():

  1. Function-composition isn't specific to pipeline; it's useful anywhere in the language. Adding the functionality just to pipeline isn't great language design for such a generic feature. It could potentially be justified anyway if it was very common in general usage already and we expected that common-ness to carry over to pipeline, but I think you'll agree that's not the case.
  2. Function composition was, unfortunately, already rejected by the committee for adding to the stdlib, which (theoretically, at least) has a much lower bar for inclusion than new syntax does. Sneaking in a limited form of compose() as syntax is much less likely to pass the committee. (Again, unless there's a strong argument from usage to be made.)
  3. This breaks from the syntactic model of pipe bodies, which is just "an expression with a special binding environment" - instead, it's evaluating some code to produce the pipe body, which is an unary function that is implicitly passed the topic. This would be the sole exception in the entire feature where the topic doesn't need to be stated, and evaluating the body doesn't immediately produce the value to pass down the pipe.

    (This fits somewhat better in the F#-style pipe, but the first two objections still stand then, and are perhaps stronger since function composition would be something I'd expect to be encouraged more generally.)

getify commented 2 years ago

The issue here is that this isn't a pipeline-specific proposal. You're asking for a function-composition operator... Function-composition isn't specific to pipeline; it's useful anywhere in the language.

I don't resonate with that perspective at all. From where I sit, |> is absolutely and only (now) the singular feature that JS would entertain for composition. I mean, yes, the hack-style |> is technically expression based, but... for those of us who are into FP and composition, it's primarily a composition operator, not an expression-evaluating operator. I haven't found a single example of using |> with expressions to be compelling, but all the usages of |> that are function composition are.

So I don't see it as so outlandish to suggest a composition-related feature/affordance to be tacked onto the single syntax operator that does composition.

I'm definitely not advocating for "general function composition" everywhere... I am only asking if we could make |> more ergonomic for the dynamic composition cases? Your more wide-ranging view of the idea doesn't at all match my aspirations or request. IMO, it's more akin to when ... was later added to { .. } object literals for spread/rest.

tabatkins commented 2 years ago

I don't see how function composition is useful for piping, but not useful to produce, say, a .map() callback.

mAAdhaTTah commented 2 years ago

FWIW, I don't think the example in your Gist actually requires the feature you're asking for. You don't need this spread syntax to choose which function to call. You can just use a ternary inline. Typing from my phone, so apologies if the syntax isn't quote right, but I think you can just do:

record |> ^.isEmployee ? getEmployeeName(^) : getCustomerName(^)

No?

getify commented 2 years ago

@mAAdhaTTah

I never implied "requires"... the examples were to illustrate an ergonomic advantage (namely, syntax in place of a userland lib).

In particular, if getEmployeeName / getCustomerName are functions (so they're callable as in your snippet), that means we had to use a flow(..) utility to make them such. But I've already asserted that if I was already using a flow(..) style utility, I don't think |> offers any advantage at all and I'd just use the utility itself. In that case, this whole discussion is moot.

The spirit of asking for ... added to |> is to support the dynamic composition use case but avoid needing a flow(..) utility to do it. As such, in my |> + ... example, getEmployeeName / getCustomerName are arrays, not functions. So, to answer your question directly: "No, they're not callable as you've implied."

ljharb commented 2 years ago

To me, their names imply they’re callable, since they’re verbs, and singular - which may be the source of confusion.

getify commented 2 years ago

@ljharb -- I don't think it's productive to be bikeshedding about variable names here, as that's obviously missing the point. But... in the first example (2) they were functions, and I kept them the same name when I changed them to arrays so that the subsequent example had consistency with the previous.

Moreover, in files (4), (5), and (6), the very first line of each snippet, where the variables are being declared, has a clear code comment indicating they're arrays, contrasted with the code comment in (2) that says they're functions.

mAAdhaTTah commented 2 years ago

It's an ergonomic advantage specifically for the case where you build up a composition in an array and spread it lazily into flow/pipe, which is not something I've ever done nor seen before now. I'll also say, I find these examples a bit confusing; getCustomerName doesn't return the customer name in any of the examples. In (2), it accepts additional functions to create a new composition, before it returns the customer name + the prefix/suffix from the added functions when called the second time. In the others, it's not a function, despite the name implying it is. I know that's orthogonal to your suggestion, but does add to my perception that this approach is somewhat idiosyncratic.

getify commented 2 years ago

where you build up a composition in an array

That's only one possible incarnation of "dynamic composition" (meaning dynamically determining the steps in a composition). I used the array approach because it happens to be something a theoretical ... could reasonably unwind and apply. At least as often as I would model dynamic composition with holding functions in arrays, I would prefer to do so with partial application or currying of the pipe(..) / flow(..) / compose(..) utilities -- as was done in file/example (2).

Functions composed via partial-application of flow(..), vs functions composed via ... array spread into the flow(..) arguments -- these are just isomorphic representations of the same composition.

But I wasn't proposing that some form/extension of |> be able to work with partially applied functions, thus I constrained examples (4), (5), and (6) to holding the composition steps in an array instead.

It may seem idiosyncratic to dynamically construct compositions, but I didn't invent the idea -- saw it years ago -- I've simply been a fan of and using it for many years. I've also taught dynamic composition as part of my Functional-Light JavaScript book and the corresponding course, so many thousands of other JS devs have at least learned it from me. I can't say how popularly used it is, but I use it quite often.

I'll also say, I find these examples a bit confusing; getCustomerName doesn't return the customer name in any of the examples

Again, I don't know why we're bikeshedding on how you didn't like the name I picked for the variable.

In (2), getCustomerName is holding a partially-applied function: the flow(..) composition itself... that's why it's waiting for more functions. The first two steps of the partially-applied composition are absolutely about "getting the customer name", hence the name.

The rest of the composition does more than simply "get the customer name", because it also formats it. So if I were to assign a variable to the fully completed composition after those formatting functions were applied, I might call it getAndFormatCustomerName.

In (4), (5), and (6), the same specialization progression from "get the customer name" to "get the customer name and then format it" happens, but it happens via array concatenation rather than through partial application. But it's the same concept in both cases.

Whatever the variable is called, I wouldn't expect so much trouble in recognizing what it's holding: a partially-applied function, and that the rest of the arguments this function is expecting are further functions to participate in the composition, because I partially applied flow(..) itself.

Perhaps I should have used TypeScript to make the types of the values more clear? Or maybe I should have just stuck to the tried and true foo and bar names. Shrugs.

shuckster commented 2 years ago

@js-choi

The second objection was that both the static-series use case and the dynamic-series use case are easily addressed with a userland function.

I was puzzled about that line of reasoning when we have the group proposal at Stage 3 that could also practically fit into a single line of userland codegolf:

const byGroup = fn => [(acc, item) => ((acc[fn(item)] ??= []).push(item), acc), {}];

const arr = [
  { id: 1, category: "category 1" },
  { id: 2, name: "category 2" },
  { id: 3, name: "category 1" },
  { id: 4, name: "category 3" },
];

arr.reduce(...byGroup(x => x.name || x.category));

Slightly ridiculous, but real prior art is barely over 10 lines.

Perhaps group is much more popular than pipe and so is justified in this way. But it does imply that there's a threshold to be met where the simplicity of a userland implementation will be glossed-over in the face of popularity. It would be helpful to know what this threshold looks like in the eyes of the committee, even if it's just someones hunch.

Apologies if this is slightly off-topic, but since the opening of the issue by @getify seemed to have been prompted by the rejection of the pipe/flow proposal then I thought it was worth dropping these thoughts here.

tabatkins commented 2 years ago

But it does imply that there's a threshold to be met where the simplicity of a userland implementation will be glossed-over in the face of popularity.

Correct, there is a tradeoff between "difficulty of implement in userland" and "expected utility"; if one is higher the other doesn't need to be as high. (But ideally we mostly focus on things that are high in both areas, since our time is finite.)

That said, please take discussion of the pipe() stdlib function over to its repo; it's off-topic here.

getify commented 2 years ago

@tabatkins in all fairness, @js-choi brought up the pipe(..) function here in part as an illustration of why the committee might not see merit to my OP request of syntax... so it does sort of fit within the topic scope here.

tabatkins commented 2 years ago

Yeah, mentioning it obviously isn't off-topic - it's clearly a related subject - but prolonged discussion about its personal merits and why it was or wasn't adopted by the committee is.

getify commented 2 years ago

@tabatkins I agree there's a line. Not sure exactly where it is. But more to @shuckster's point, there's a line, a minimum standard, the committee feels pipe(..) falls below.

If we're postulating that |> ... might also fall below this line, it would be useful to analyze/discuss pipe(..) to see why it fell short, potentially as a way to avoid such a fate if |> ... had any chance of ever gaining traction. That's not the same thing -- and shouldn't be! -- as re-litigating pipe(..). Indeed, I literally proposed this idea because it was clear pipe(..) was a dead horse not worth beating. I don't care to fight for pipe(..) to happen, but I would like to understand more crisply why it failed.

tabatkins commented 2 years ago

That's pretty straightforward. pipe() is short and trivial to do in userland, and allows composing unary functions. The pipe operator, as now defined, does more than that, in a way that can't be done in userland without very hacky-looking code. I think discussion beyond that is best done in the pipe() repo.

Returning to the subject of the thread, tho, the essential objection I still have is that you were able to use ... to treat an array of functions like it was a composed function itself due to the fact that pipe() is a function that takes functions as args, so ... just spreads them into the arglist. It's a syntax hack, not an intended usage; that is, pipe() never intended to take "functions" and "arrays of functions" as inputs, and I suspect your usage isn't idiomatic across pipe() usage generally.

I believe all the examples you gave would be significantly improved in readability if they were actually functions, composed piece-by-piece if necessary, rather than arrays of functions carried around and only at the end spread into pipe(). (This is strongly influenced by the names you chose - giving the arrays names as if they were functions, rather than more idiomatic "array-like" names - which suggest they should be callable, when in fact they are only usable in a very specific location and manner.) There's some perf overhead to that, of course, but whether that overhead is significant or not depends heavily on the precise usage.

getify commented 2 years ago

treat an array of functions like it was a composed function itself due to the fact that pipe() is a function that takes functions as args, so ... just spreads them into the arglist. It's a syntax hack

That may be your perspective, but I don't think it comports with the broader FP thinking, the dao of FP as it were.

An array of (unary) functions that will later (lazily) be composed, is isomorphic to an eagerly composed singular function. They're isomorphic because you could, with the right tools and forethought, design a transform to go from one to the other, or vice versa.

In FP, you choose to use one representation, or another isomorphic one, depending on whichever one is most convenient for the task at hand. An array of functions happens to be a convenient way of dynamically constructing a composition. It's not the only representation, but I think it's arguably the most convenient way available to a JS program.

When you're dealing with isomorphisms, you don't typically concern yourself so much with the naming of something. In fact, in FP, you rarely name things, preferring instead to have expressions flowing into other expressions. Maybe it's because FP developers are tired of arguing over names, I'm not sure. In any case, I only named stuff because I was trying to not mangle underlying concepts under one giant expression of a program. But clearly, we keep coming back to an objection over naming, as if the name of something belies its fundamental design flaws.

Sigh. I definitely regret ever posting any concrete example code here. I probably should have known that whatever that code included would be bikeshed to death, instead of engaging in substantive discussion on the underlying concept (dynamic composition).

You can continue to discount dynamic composition as some novel artifice that I came up with, but I again will assert, I learned it from other FP programmers, and I've subsequently (to this thread) re-verified with more than one of them that it's not some unique invention of mine, but a fairly reasonable application of isomorphism.

Dynamic composition far predates any meager attempts I've made in recent years at learning FP, and indeed exists wholly independent of whether I'm a good messenger of its concept or not. Some FP folks (like me) use it regularly, and at least one person (me) would like it be a bit more ergonomic in JS.

But the nature of the resistance seems to be that -- as has happened a number of times over the years with several other proposals advanced by FP leaning developers -- since it doesn't look like the mainstream JS you're used to seeing, it's somewhat summarily discounted.

That shouldn't be so surprising, I guess, but it's nonetheless a disappointment.

I maintain, however, that the primary reason such a style or code technique is not as popular, is not because it's not useful or interesting, but because it's currently not very ergonomic, so most people end up solving such problems in other (more imperative) ways. If it were made to be more ergonomic, I believe more people would take advantage of it.

getify commented 2 years ago

Since the resistance is clear and unwavering here, I'm resigned to dropping the subject.


But as that means there's nothing left to lose, I'm going to point out one more fact which I almost brought up in my OP, but instead I withheld up until now, fearing that it would both cloud the discussion and, likely, prove a sufficient deathblow in and of itself.

In the deeply divided contest over Hack vs F# style pipe operator, the main proponents of F# were those who do most (or all) of their compositions with unary functions. They were, in the end, told that their style of coding was idiosyncratic or non-mainstream enough -- or, strangely, "too hard for JS engines to optimize" -- and thus Hack-style emerged as the victor. There's still plenty of bitter resentment lingering, however, as it was a tough pill for some to swallow.

But what if that divide could have been healed (mostly)!?!?

The |> ... extension I was proposing here could act as a bridge to bring Hack and F# style pipes back together (almost). That is, the default mode of |> can and would still be Hack, but if an F# leaning developer wanted to, they could simply accomplish something close to their preferred style of composition with a ... and an array literal, separating their composition steps with , commas instead of |> pipes.

// (1) F# style pipe composition:
val |> something |> another(10) |> whatever

// (2) Hack + F#:
val |> ...[ something, another(10), whatever ]

// (3) instead of:
val |> something(^) |> another(10)(^) |> whatever(^)

Of course, (2) would only be a small (but meaningful) consolation prize to the F# proponents. That said, my guess is, if they can't have (1), they'd rather have (2) than (3).

js-choi commented 2 years ago

There’s certainly an ecosystem divide between unary-functional programming and other styles with n-ary function calls and other operations – where the latter includes a large portion of old and new web APIs and other popular APIs. There is also a divide between those other styles and method calls.

My hope remains that, even if unary FP remains at its status quo for now, and even if |> does not help code that primarily uses unary FP…the Hack pipe function would be able to act as a bridge or as glue to integrate unary FP into other styles, with their n-ary functions and other operations.

'--' + input
  |> document.getElementById(^^)
  |> asyncParallelFind(^^, { timeout })
  |> await Promise.all([ ...^^, asyncRunAnotherTask() ])
  // pipe is a one-line userland function, maybe someday standardized:
  |> pipe(^^, ...fnArray)
  // This line is inconvenient to put in pipe’s fnArray because of
  // its complex argument; it is easier with |>:
  |> console.error(`Error: ${^^} is invalid.`);

Here, the pipe(^^, ...fnArray) line sits in the middle of a long chain of n-ary function calls, array/object/template literals, and other expressions that may be inconvenient to write as unary functions inside fnArray. This is what I mean when I say that |> does not much help other styles integrate into unary FP, but it may help unary FP integrate into other styles.

Having said that, if |> becomes used but there still is a demonstrable need for a standard function (or a syntax) that exclusively pipes unary functions, then that could be an add-on proposal. I myself plan to watch carefully for a chance to revive the Function.pipe proposal. But that only would have a chance for several years after |> becomes used anyway.

shuckster commented 2 years ago

@js-choi I noticed your topic-token is ^^ and wondered if I missed something, so I checked the topic-bikeshedding issue and made the discovery that:

I agreed with other Committee members that no single-character tokens were probably appropriate as the topic reference

I checked the README.md and notice it hasn't been updated to reflect this preference, which was apparently agreed to nearly a year ago.

I know we're all very busy here so this isn't a berating. But regardless of how anyone is falling on |> ... perhaps it makes a difference to how this and other discussions would go if we all knew (roughly) what the current topic preference is?

@getify's previous example does look different:

// (1) F# style pipe composition:
val |> something |> another(10) |> whatever

// (2) Hack + F#:
val |> ...[ something, another(10), whatever ]

// (3) instead of:
val |> something(^^) |> another(10)(^^) |> whatever(^^)

Again, apologies once again to @tabatkins for being off-topic, but if the the topic itself is off I figured it was worth bringing into the topic. 😁

getify commented 2 years ago

@js-choi

FWIW, I don't personally see myself using |> much, as it's currently proposed, because I'm going to still need a pipe(..) function (from a library), which means I'm already going to have a lot of other familiar tools/patterns for the adaptations of expression-building into unary function composition. |> will be the new unfamiliar approach, and I don't see that, as is, it will be very compelling in that respect.

For example, |> console.error(`Error: ${^^} is invalid.`) can be done with v => console.error(`Error: ${v} is invalid.`) in a unary composition chain (dynamic or static), and that's not substantively different than the |> form. Moreover, by being able to name the topic (v), I find the latter more readable than the former.

Even with the knowledge that unary function composition patterns are not as eagerly optimized by JS engines, and so I may be paying a perf penalty in doing so, FP patterns -- and especially, lots of familiarity with those existing patterns -- would weigh much more heavily in my mind to keeping the status quo, instead of being an encouragement to start throwing |> into the mix. That doesn't feel like it would improve my code enough.

The real attractiveness of |> to me would be if most or all of my compositions could be done without a pipe(..), not having to put |> and pipe(..) together. But it doesn't seem like such affordances are being seriously considered, at least not in the near term. I hoped this OP might shift that, but it seems like not.

tabatkins commented 2 years ago

or, strangely, "too hard for JS engines to optimize"

Quick response on this, because I've seen it brought up in this dismissive way before:

The objection was not at all that "unary functions are hard to optimize". That would be a ridiculous statement to make on its face. It's that pipe() (and the F# pipe operator) encourages the use of single-shot dynamically created functions (both arrow functions, and the return values of higher-order functions like partial and pluck), which are by definition harder to optimize since they're only executed a single time before being discarded. This usage is encouraged even in "hot" codepaths like inside of loops, where the exact same pipe() might be executed multiple times, freshly creating and discarding a heap of temporary functions that could have been usefully extracted outside of the loop into a multi-shot function that has a chance of being optimized (or left in the original nested function-call stack, or linearized by hand into temporary variables, both of which are also more optimizable).

The browser engineers who leveled this objection are neither stupid nor capricious. Please respect the reasoning of people on the opposite side of the argument from you rather than dismissing them offhand as "strange" and presumably erroneous.

js-choi commented 2 years ago

I checked the README.md and notice it hasn't been updated to reflect this preference, which was apparently agreed to nearly a year ago. I know we're all very busy here so this isn't a berating. But regardless of how anyone is falling on |> ... perhaps it makes a difference to how this and other discussions would go if we all knew (roughly) what the current topic preference is?

@shuckster: As you say, this is a bit off topic, but I can quickly address this. We haven’t updated the explainer and spec yet to ^^ because it’s still up in the air. For more information, see HISTORY.md, the next upcoming pipe bikeshedding incubator call, and the official changes thread (#232). And, for what it’s worth, both the explainer and spec already warn that the topic reference’s token is still tentative and subject to change. (I do plan to make a pull request to change the documents to use ^^ after the next upcoming incubator call if it doesn’t get strong pushback there.)


The real attractiveness of |> to me would be if most or all of my compositions could be done without a pipe(..), not having to put |> and pipe(..) together. But it doesn't seem like such affordances are being seriously considered, at least not in the near term. I hoped this OP might shift that, but it seems like not.

@getify: Yes, as you say: in the near term, the use cases of |> and other “dataflow” affordances are just about set in stone (subject to continued arguing over the role of bind-this/call-this). I wouldn’t say that unary FP affordances were not considered—we have done holistic examinations of the whole dataflow space, including unary FP. But the general attitude of the Committee has been towards a general solution for the general problem of dataflow, with the additional restriction that it have zero runtime cost.

I attempted to advance proposal-function-pipe-flow as a stopgap solution for unary-FP-predominant styles, and I apologize that I was not able to advance it at this time. But, although the Committee considered and rejected unary-FP affordances in the short term, it may reconsider them in over the long term. We can only see—in the meantime, the userland status quo for unary-FP-predominant code will continue on.

tabatkins commented 2 years ago

Back on the topic more directly:

I maintain, however, that the primary reason such a style or code technique is not as popular, is not because it's not useful or interesting, but because it's currently not very ergonomic, so most people end up solving such problems in other (more imperative) ways. If it were made to be more ergonomic, I believe more people would take advantage of it.

This suggestion does not make this coding pattern more ergonomic in a significant way. The existence of an isomorphism between an array of functions and a composed function doesn't make the two equivalent in usability; you still can't do arrayOfFn(arg) or arr.map(arrayOfFn); in both cases you need to actually perform the transformation manually before using it in the "equivalent" way. Even in pipe(), you have to remember that you're dealing with two completely different types of objects; one can be passed in directly, the other needs to be prefixed with ... first. I fundamentally disagree that this is a reasonable primitive to support, versus just composing the functions eagerly so you have a function the whole time and can use it like any other function. Or even lazily, if you really want to - the only benefit of pipe(foo, ...fns, bar) over pipe(foo, Function.compose(fns), bar) is typing length.


I say this regularly, but I'll remind again: I am also an FP proponent. I grew up on Common Lisp and made my own bug-ridden informally-specified version of half of Haskell's Arrow type in it. I've done my time in the point-free mines, happily eating my curry. I still think JS would benefit from formalizing monads. And it is precisely because I've gotten so deep in the guts of this topic that I'm so wary of trying to add too much of it to JS. The affordances just aren't here for it; the syntax itself fights you if you go too hard in certain directions. The features themselves sometimes scare me as well; I don't have a single piece of heavy point-free programming that I can look back at and say "ah yes, this is readable and easy to understand". I regret every flip I've ever written.

So I understand where you're coming from, and have many of the same interests. I'm not an imperative programming partisan here to ruin your day. I just disagree with you.

js-choi commented 2 years ago

Indeed, I have always found it useful to distinguish unary FP from n-ary FP (see https://github.com/tc39/proposal-pipeline-operator/issues/233#issuecomment-929569548).

Unary FP by necessity heavily involves currying, and languages based on unary FP – like Haskell – typically have “auto-currying” function syntaxes.

In contrast, n-ary FP involves currying much less often, and languages based on n-ary FP – like Lisps – typically do not have auto-currying syntaxes. (Indeed, Lisps are based on lists as much as they are based on lambdas; they are fundamentally n-ary.) Because of this, Lisps and other such n-ary FP languages typically focus on “high-level” functional combinators like monadic binding – and less so on “low-level” partial application, currying, and unary functional composition.

Of course, JavaScript is an n-ary FP language, and, in this manner, it is more similar to Lisps than to Haskell; although Lisps and JavaScripts both can accommodate unary FP styles, both arguably lend themselves more readily to n-ary FP styles.

|> as it is now does not accommodate unary FP combinators, but it certainly may accommodate n-ary FP combinators. For example, a monadic bind would appear as … |> mBind(^^, f) |> …. (This reflects how Lisps use forms like (m-bind input f), rather than curried function calls with Haskell’s m x -> (x -> m y) -> m y.) In the future we may accommodate unary FP in the core language too, but for now the Committee has made it clear that it wants that in userland in the near term.

getify commented 2 years ago

It's that pipe() (and the F# pipe operator) encourages the use of single-shot dynamically created functions

I find this assertion the most difficult thing to understand/swallow in this whole thread. It seems outlandish to me that JS added the => arrow function, but not admit (or at least regret) that I would say it's clearly been a giant neon sign to developers saying "please write as many single-shot lambdas as you possibly can, in every promise chain, map() call, etc". The existence of => in JS, and the overwhelming tide that shifted towards using them almost entirely as replacements for function functions, shows that developers use these kinds of inline function expressions about as easily and frequently as any variable (maybe even more so).

If JS engines were worried about having inline => arrow expressions all through programs, and that harming performance optimization capabilities, they should have pushed back on => itself. That ship has long since sailed.

So I give precisely zero credence to a claim now, years after => swept across and became nearly ubiquitous, that JS engines are worried that => functions are going to be used in "too many hot paths" and harm performance.

The general crowd of JS developer has already loudly voted, and they said they like the ergonomics of => mountains more than they care about any places where usage of it is costing tiny fractions of milliseconds in lost JS engine optimizations.

I'll also add that my lack of respect for this claim -- I'm not saying anything about the people claiming it, only the claim itself -- comes from observing that over the years, JS engines (and the people writing them) have shown unbelievable amounts of talent and creativity in creating a whole universe worth of JS engine optimizations that, quite literally, CS professors used to teach students (like me) were never going to be possible.

I have trouble believing there's anything that JS engines cannot optimize, given how much magic they've already demonstrated and proven.

Moreover, a lot of that effort seems to have been poured into increasing the surface area and complexity/capability of class and all its descendant features, while keeping things neatly optimized. If we made a spreadsheet of features that JS has added over the last decade, and that JS engines have thus accepted and then eventually optimized for, and in one column we listed "class oriented" features, and in the other "FP oriented" features, I think the former column would be significantly longer than the latter.

From where I sit, my opinion is, the question is not, "can a JS engine optimized for ", but rather, "is there sufficient motivation for the engine to optimize for ". It's not because FP is fundamentallly less optimizable than class-orientation -- I just don't buy that -- but because, as TC39 decisions have borne out year after year, there's more appetite/motivation to put effort into designing, and optimizing, class-related features.

getify commented 2 years ago

for now the Committee has made it clear that it wants that in userland in the near term.

The intent of this thread was to push back on the edges of that "decision" for some reconsideration. It seems from the tone of response here that the thread has, minimally, accomplished its goal, in that it has elicited a pretty clear "nope we're not ready to look at this further" response. I dunno how representative of broader TC39 this thread's discussion is, but since I'm not on TC39, it's the best forum I have. And I think it's clear what the outcome is. Please feel free to close the issue and let's just move on.

js-choi commented 2 years ago

Understood—my apologies again that, at this time, we’re not able to accommodate your use cases as a language feature. Hopefully, we have the chance to revisit this again in the future.

tabatkins commented 2 years ago

Just to close up the conversation:

The existence of => in JS, and the overwhelming tide that shifted towards using them almost entirely as replacements for function functions, shows that developers use these kinds of inline function expressions about as easily and frequently as any variable (maybe even more so). [...] So I give precisely zero credence to a claim now, years after => swept across and became nearly ubiquitous, that JS engines are worried that => functions are going to be used in "too many hot paths" and harm performance.

Yes, arrow functions are completely ubiquitous in JS and wonderful.

But note that what people don't commonly use arrow functions for today - organizing individual steps of operations. People don't write code like:

for(let val of vals) {
  val = (x=>x + 1)(val);
}

because doing so is pretty obviously ridiculous. The perf argument against this kind of code pattern isn't even relevant, since it's completely unergonomic and unattractive to start with, so nobody is going to write it. But that is precisely the code pattern that pipe() and F#-style pipeline embody with add(1) higher-order functions (or more realistic examples; I'm just using a silly example here). In many instances that's fine, the line will only be executed once anyway so the difference is minimal, but making it ergonomic to do this in hot paths isn't great.

You're right that, given sufficient motivation, we can probably usually optimize away most of the cost. But it's better, if possible, to avoid introducing the cost in the first place. The current pipeline operator is approximately a zero-cost abstraction here, which is nice from this perspective.