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.52k stars 108 forks source link

Effect of Hack proposal on code readability #225

Closed arendjr closed 2 years ago

arendjr commented 2 years ago

Over the past weekend, I did some soul searching on where I stand between F# and Hack, which lead me into a deep-dive that anyone interested can read here: https://arendjr.nl/2021/09/js-pipe-proposal-battle-of-perspectives.html

During this journey, something unexpected happened: my pragmatism had lead me to believe Hack was the more practical solution (even if it didn't quite feel right), but as I dug into the proposal's examples, I started to dislike the Hack proposal to the point where I think we're better off not having any pipe operator, than to have Hack's.

And I think I managed to articulate why this happened: as the proposal's motivation states, an important benefit of using pipes is that it allows you to omit naming of intermediate results. The F# proposal allows you to do this without sacrificing readability, because piping into named functions is still self-documenting as to what you're doing (this assumes you don't pipe into complex lambdas, which I don't like regardless of which proposal). The Hack proposal's "advantage" however is that it allows arbitrary expressions on the right-hand side of the operator, which has the potential to sacrifice any readability advantages that were to be had. Indeed, I find most of the examples given for this proposal to be less readable than the status quo, not more. Objectively, the proposal adds complexity to the language, but it seems the advantages are subjective and questionable at best.

I'm still sympathetic towards F# because its scope is limited, but Hack's defining "advantage" is more of a liability than an asset to me. And if I have to choose between a language without any pipe operator or one with Hack, I'd rather don't have any.

So my main issue I would like to raise is, is there any objective evidence on the impact to readability of the Hack proposal?

shuckster commented 2 years ago
// :Hack
value 
  |: extractBusinessData(^) 
  |: await mergeWithWebServiceData(^) 
  |: processResults(^) 
  |: printResult(^);

Just wanted to see what it looked like. 👀

arendjr commented 2 years ago
// Hack
value 
  |> extractBusinessData(^) 
  |> await mergeWithWebServiceData(^) 
  |> processResults(^) 
  |> printResult(^);

But above we still have a good idea what's going on without naming the temp variables right? @aadamsx

Yes, and if everybody wrote their pipelines like that I would welcome it. That’s also what I like about the Minimal/F# proposal: it basically limits you to this and with an even cleaner syntax. But Hack encourages you to put arbitrary expressions on the RHS, which is where my concerns lie. So far, the examples I’ve seen (including those that are supposed to sell me on the proposal) are nearly-universally off-putting to me.

ljharb commented 2 years ago

@aadamsx sure!

let $ = doSomethingFirst();
$ = doSomethingAsync(() => /* meaning, the result of doSomethingFirst */ $);
$ = doSomethingElse($);
$ = doSomethingMore($);

now, $ is the result of doSomethingMore, but later, when the doSomethingAsync callback runs, it will return the result of doSomethingMore instead of the expected/intended result of doSomethingFirst.

This also happens a lot using for loops, whenever a function is created in the loop - http://web.archive.org/web/20161209211206/www.mennovanslooten.nl/blog/post/62/ is a post about it, and https://eslint.org/docs/rules/no-loop-func is a common linting rule designed to prevent that common mistake.

shuckster commented 2 years ago

This also happens a lot using for loops, whenever a function is created in the loop

"I don't always create a closure. But when I do, I do it millions of times inside a for-loop." 🍺 🧔‍♂️

Anyway, after being reminded of this particular language footgun I do wonder just how over-stressed runaway F# closures are. 🤔

mmkal commented 2 years ago

A small readability note to piggy-back on https://github.com/tc39/proposal-pipeline-operator/issues/225#issuecomment-929817963. I've seen it said a lot that Hack with unary functions is just a three-character tax, and while it's obviously three-characters per step, that fact is kind of glossed over.

A pipeline of length one, x |> foo(^) is just plain worse than foo(x). Even at two, x |> foo(^) |> bar(^) will probably be more often written as bar(foo(x)). So the tax in the vast majority of non-silly pipelines will be a minimum of nine. And the average will be something like 12/15/18/21?

At that point it has a pretty bad effect on readability and writability - and will incentivise more buggy/untyped ad-hoc pipe functions, or further siloing/schism-forming of the community. @js-choi touched on this in https://github.com/tc39/proposal-pipeline-operator/issues/202#issuecomment-919374937 - incidentally, @lozandier, you might want to read that issue, as it's pretty close to what you're proposing, but with the operators swapped. I prefer your idea personally but would support whatever is more realistic.

I like the idea of changing the Hack operator to |: to make room for the potential future more-traditional F# usage of |>.

nocksock commented 2 years ago

Sorry this got way longer than I intended 😅 - not sure if I should create another issue, but it's quite on the topic of this one - and I'm reference a lot that's been said here.

So... Hey 👋

I have been working with JS for more than a decade, but I'm rather new to FP so I don't know about all those mathematical implications etc. However, having done a lot of coaching, mentoring and teaching JS concepts to juniors, colleagues etc in that time, I thought I might chime in and give my 2 cents on the readability and by extension usability aspects of the proposal.

A bit of stage setting with this piece of very basic javascript, that a lot of javascript beginners will face:

const makeClickHandler = s => _event => console.log(s);
element.addEventListener('click', makeClickHandler('click!')); 

This small piece teaches a lot of fundamental things about how javascript is written and works. Like passing functions as arguments, that appending any variable with () will call it as a function, and the concept of higher order functions and its implications.

Hack-Pipes however kinda break with that concept, at the cost of parse-ability, more editing work and eventually defeating the actual intent of this proposal, and I don't think we'd actually get any actual benefits.

But first, about readability. Taking @aadamsx example:

value 
   |> extractBusinessData(^) 
   |> await mergeWithWebServiceData(^) 
   |> processResults(^) 
   |> printResult(^);

It's easy to argue "well we can immediately see where the result of the previous function is used". I admit that it was my reaction too, but it's a weak and superficial argument.

When reading that code, it'll intuitively read to me like extractBusinessData is called with the value ^ and will probably return a function - BEFORE the hack pipe/composition is evaluated. Because that's just how it works everywhere else. Like in addEventListener('click', console.log('welp...'))

So I argue that Hack Pipes will make it harder to parse code efficiently with confidence. Granted, just a bit, but given there's an alternative that I believe doesn't have this issue, I think that bit matters a lot.

Here's an example I feel a lot of beginners would infer from the concept of hack pipes, and I couldn't blame them for it:

const AlertButton = ({children}) => {
    const handleClick = (event) => {
        alert('clicked');
    }

    return <button onClick={handleClick(^)}>{children}</button>
}

It seems contrived, but I'd be willing to bet money that this would happen an awful lot. Hack Pipes create an exception where we'd break a simple, fundamental rule "() calls a function" and instead have something like "except when passed an ^ but only if preceded by an |>".

Now to why I think Hack Pipes fail their intended purpose and won't actually me help during development.

Also I feel that the pro/cons examples on the proposal's readme seem skewed and weirdly written in favour of the Hack Pipes proposal and based on subjective reasoning.

This is the claim:

// -- Hack Pipe Pro
value |> foo(1, ^)
value |> ^.foo() // which is essentially value.foo()
value |> {foo: ^} 

// -- F# Pipe Con
value |> x => foo(1, x)
value |> x => x.foo()
value |> x => ({foo: x})

At first glance I thought "Oh, sure Hack Pipes looks SO much better! ". But when playing around with code scenarios, it kinda fell flat and became annoying to work with fast. From my personal experience a lot of code writing is: prototyping, rewriting, maintaining, debugging and (hopefully) testing.

// -- Hack Pipe
value |> foo(1, ^) |> someThingElse(^)
// let's extract foo to be able to test or re-use it.
export const paFoo = v => foo(1, v); 
value |> paFoo(^) |> someThingElse(^) 
// consider all the adjustments it took to get there. Or would this be valid code?
// export const paFoo = ^ => foo(1, ^);
// I seriously hope not.

// -- F#
value |> x => foo(1, x) |> someThingElse
// equivalent refactor
export const paFoo = x => foo(1, x) // suprise, body is literally just copy paste!
value |> paFoo |> someThingElse // same function behaviour like everywhere else.

Also the inverse is true for the F# scenario. I can just replace any named function in a composition with an arrow function. Copy, paste, done. So the F# Syntax is actually helping me as a developer doing my work. And with Hack-Pipes there's actually more writing and editing involved, when writing production code. And any argument in the direction of "This refactor could be done by LSP" holds true for F# as well.

As mentioned earlier, I'm rather new to FP in general, but isn't getEntry(id) |> ^.name considered unsafe? It's usually when introduced to Container Types like Maybe. So why is this proposal enabling a pattern that is collectively discouraged and labelling it as a pro?

Concerning debugging, I think the readme example for that is detached from real life.

value |> (console.log(^), ^) // sure, that's nice. 

// but usually we're already in a FP setting 
// so we usually have an inspect or logger always ready at hand
value |> tapLog

// Besides. What about the F# version?
value |> s => (console.log(s), s)

And at last, there's pipe functions that already exist.

const result = value 
  |> extractBusinessData(^) 
  |> await mergeWithWebServiceData(^) 
  |> processResults(^) 
  |> printResult(^);

// How it can be written today:
const someComposition = pipeWithPromise(
  extractBusinessData,
  mergeWithWebServiceData,
  processResults,
  printResult
)

const result = someComposition(value) 

// For completeness' sake, F#:
const result = value 
  |> extractBusinessData
  |> await mergeWithWebServiceData
  |> processResults
  |> printResult

So, from what I understood so far, I fail to see what benefits hack pipes bring in my daily work compared to what I already can do and lot of the examples feel like marketing to make Hack Pipes look better on the surface level and its benefits are more based on the implications of partial application without third a party tool but as mentioned, that's a whole different proposal.

// F# with Ramda
const result = value
    |> replace('{name}', __, 'Hello, {name}!') 

// Hack Pipe
const result = value
    |> doStuff(options, ^, 'foo')

// F# with existing PFA-? proposal
const result = value
    |> doStuff(options, ?, 'foo')

Isn't the last example something both sides would agree on? Especially the hack pipers? I would be REALLY surprised if not.

With syntax we can put a breakpoint in between individual steps in the pipe, similar to how you can put a breakpoint after the arrow in a one-line arrow function. Including an explicit placeholder means I can hover it like a variable & get its type in VSCode.

Which both applies to F# too? I also could hover the function name and get its return value and VSCode could easily be aware of pipes and show inputs/outputs in the pipe context. This is a benefit of the pipe operator, not the hack pipe proposal.

Also value |> x => {debugger}?

Or value |> someFunction + 1 is valid syntax with F#, but not with Hack Pipe - selectively neglecting the existence of aforementioned ts-server and also ignoring that (value |> someFunction) + 1 is more explicit anyway (since hack pipes are argued to be more explicit, that felt important to point out).

All of the built-in types, all of the Web apis, none of them are designed around unary functions. Even tools as basic as setTimeout are going to require you to wrap it in a lambda in order to use it in F# pipes.

And honestly... Yes. Good.

This is exactly how we currently work today. And we will still have to do that everywhere else. In fact I'd argue this is a strong argument against hack: We will have to be aware of the context we're writing/using our functions in. The only other area in JS where we have to do something of that level is inside of with, and we all know how great that worked out.

Dealing with n-ary functions is the job of PFA, not pipes. Right?

On the other hand there's arguing that the F#-Syntax has some neat math-y implications that I honestly don't understand but which apparently might make JS more interesting to more people in academic areas. That sounds quite amazing to me! Especially given that JS already has gained traction in the DataScience parts.

But on the other side I've seen advocates of hack pipes arguing that they're easier to understand for beginners (because lambdas are hard?), again selectively ignoring that arrow functions will definitely have been taught at the point of pipes already. With F# you can immediately start chaining arrow functions. No new concepts have to be explained.

And that's beginner friendly.

Hack pipes however will have to introduce the concept of partial function application, so likely hack pipes will be pushed down even further in courses and books. And as many people pointed out, PFA are an entirely different - and existing - proposal.

I don't intend to sound harsh, I'm definitely open to be convinced in favour of Hack Pipes, when the arguments are based on examples that reflect how we actually write and teach javascript and take the tools and patterns we already have into account. Which imho so far none of the arguments I've seen actually do.

My current impression is, we'd be in fact better off without hack pipes. In germany we would call it a "Verschlimmbesserung" - making a thing worse by trying to make it better.

Again sorry this is such a wall of text. I was reading into this while writing and it has gotten out of hand and it took so long, that it'd feel wasted to not post I have 0 intentions to offend and I hope it doesn't come off as such. Unfortunately I won't be able to respond for the next ~9 hours - gotta sleep. :)

I honestly believe everyone here is genuinely trying to make JS better and the discussion is super interesting and inspiring

ljharb commented 2 years ago

I very much doubt a lot of javascript beginners will make a function factory (like makeEventHandler) - javascript beginners most often struggle with the idea of functions as first-class values at all, let alone grasping the concept of a function that returns a function. Your "simple example" there does indeed concisely convey those concepts, but i'm not sure how easy it would actually succeed at that to a beginner unfamiliar with first-class functions.

nocksock commented 2 years ago

I very much doubt a lot of javascript beginners will make a function factory (like makeEventHandler) - javascript beginners most often struggle with the idea of functions as first-class values at all, let alone grasping the concept of a function that returns a function

Fair. The example was a bit longer and i trimmed it down. Yet it's a concept that is definitely used early on in disguise:

const clickHandler = s => console.log(s);
element.addEventListener('click', () => clickHandler('click!')); 
Pokute commented 2 years ago

We will have to be aware of the context we're writing/using our functions in. The only other area in JS where we have to do something of that level is inside of with, and we all know how great that worked out.

You actually missed one of the biggest existing bouncing contexts in JS: statements vs. expressions. if (const a = getValue('foo')) { setValue(a + 1); } doesn't work and requires quite deep knowledge of why it doesn't work and where it doesn't work.

As mentioned earlier, I'm rather new to FP in general, but isn't getEntry(id) |> ^.name considered unsafe?

It's as unsafe as getEntry(id).name and depends on the implementation of getEntry. For unknown code we can be concisely defensive with getEntry(id)?.name ?? 'fallback' for example.

mikesherov commented 2 years ago

Ok, so because I'm a complete simpleton... I will ask this question completely naively, and excuse me if it's covered elsewhere. I assume it has to be because it so obvious...

why can't we use the Hack semantics, but if the ^ character isn't present on the RHS, assume a (^) is intended? e.g: 'a' |> toUpper is the same as 'a' |> toUpper(^).

aadamsx commented 2 years ago

Ok, so because I'm a complete simpleton... I will ask this question completely naively, and excuse me if it's covered elsewhere. I assume it has to be because it so obvious...

why can't we use the Hack semantics, but if the ^ character isn't present on the RHS, assume a (^) is intended? e.g: 'a' |> toUpper is the same as 'a' |> toUpper(^). @mikesherov

Check out https://github.com/tc39/proposal-pipeline-operator/issues/234, it's called Smart Mix. Inside you'll see the explanation as to why it is not acceptable.

If you don't care to read it all, here's a quote that captures the gist I think:

"...Smart Mix syntax that failed to gain traction, and got a good amount of unofficial but strong pushback from committee members. Committee members were really against the syntax having multiple interpretation modes, complaining that it was a source of confusion and likely bugs."

My take:

-- Like function composition, we JS devs can't be trusted to use it without footgun. --

mikesherov commented 2 years ago

I read #234 and then #100

The conclusions of both threads are essentially "smart mix has gotten too complicated because of all the syntax restrictions we think we need, and so we're going to put together a new proposal"

It was a lot of talk of folks getting confused by things like x |> new Foo() not meaning x |> new Foo(x) and instead meaning (new Foo())(x) and supposing we need early syntactic errors for that.

234 points to the pushback being unofficial but strong.

I'm still left wondering if we dropped the early syntax errors and put assume (^) if left off on top of Hack if that could be successful, or if the same concerns about footguns would "officially" block the proposal from advancing.

of course, I'm not personally willing to do the work, and it's easy for me to suggest this from the sidelines.

mAAdhaTTah commented 2 years ago

Trying to respond to as much as I can here. Re-reading before I post, most of this is responding to @lozandier. I've included a few responses to others at the top here first, although this isn't necessarily in order that comments were made.


@arendjr First, I'll be totally honest, all of the examples in this comment seem like improvements to me. All of these temp variable names are superfluous, trying to name subsections of a sequence that don't really rise to the level of needing them. The phrase newLines appears 4 times in 3 lines in the first original; result & item appear in several permutations in the second; most of this repetition doesn't communicate more information than the function names themselves do (e.g. findIndex of the highlightedResult is the highlightedResultIndex... does the variable communicate that better than the expression already does?). This also reminds me of the dozens of times I've seen similar code with the word value or data suffixed after some operation, only because the result needs to be assigned to something before being passed into the next operation. The overall result is to make things busier without communicating any additional information.

Maybe that makes me weird, or maybe I've been staring at this for too long, but the functions and expressions communicate to me what's happening at each step fully without the need for a variable name. Further, I think the readability of those is improved swapping ^ for % as the placeholder, and the overall result is cleaner without needing to reread it.

And this was my original concern as well, which I still don't see adequately addressed despite the almost 200 comments in this thread. And sure, Hack might fix this footgun over the strawman we don't want: it doesn't address the readability concerns over the code we don't want.

Instead, the best we got is some people saying they actually do prefer code looking like that, at which point it becomes a game of he said/she said.

Because readability is significantly correlated with familiarity, "he said, she said" is the only way this can shake out. There is no objective standard for "readable"; the definition I use and come back to is "closest to idiomatic for the language" but even that is shaped by the style of code we write. Point-free composition is idiomatic in the functional community but not necessarily elsewhere. Idioms can even be specific to a codebase, so it's not really possible to provide an objective answer.

This is also why I object below to the idea that there is a single, "orthodox" style of pipelining.


@shuckster

Temporary variables have a chance of being renamed on refactor. Actually, has refactoring been considered at all?

Yes, two comments on this:

  1. Refactoring from nested expressions to Hack pipe is straightforward: select the expression to extract; cut it; type the placeholder; paste the expression on the line above; write the pipe operator; save and format; lather, rinse, repeat. It's an easy transformation because there is no intermediate function to extract to or wrap the expression in an arrow. These are also steps that can be aided by an editor with an "Extract expression to pipe" or something similar, which I also fully expect to be useful to F# developers.
  2. Adding an intermediate variable is probably going to require editor aid, but VSCode can currently select an expression an extract to variable. I don't expect either pipe to make that more or less difficult.

JS Choi also pointed me to paredit, which is for lisps & s-expression but has some neat tooling that may be applicable here.


@lozandier

The simplest way most languages represent a series of logic blocks/tasks/subroutines that can simultaneously take in a result of a previous series of logic blocks/tasks/subroutines as starting input is a unary function.

I don't think this is quite true. While yes, a pipeline is a single input and a single output, the functions that go into that pipeline aren't necessarily, strictly speaking, unary functions, but are instead composed into unary functions. If we look at one of your examples from earlier:

x |> multiply(2) |> add(10); // (A)
x |> double |> add10; // (B)

Of the two examples above, you're generally far more likely to see (A) than (B), because most functions aren't unary by default but need to have their arguments partially applied to become unary. This doesn't just apply to basic math functions like the above, but the entire world of ADTs in JavaScript need to be composed into unary functions to operate on them in in a pipeline. If (A) is actually more ubiquitous, then you're actually mostly dealing with a world of n>1-ary functions, and providing a layer of tooling on top of them to make them more ergonomic to compose into unary functions in JavaScript.

I mention ADTs specifically because despite the value of the Maybe & Either/Result types, it's incredibly difficult to integrate & use those types in otherwise non-functional contexts due to the baggage of currying & HOFs needed to make them useful. They're also not unary functions, because you have to partially apply things like map & fold so that you can compose them in a pipeline. Enabling Railway Oriented Programming via these constructs would be a great boon for JavaScript writ large and I've had great success building up pipelines of side effects this way but I can't bring that to non-functional contexts without a ton of baggage.

This ubiquitous understanding of the pipeline paradigm is why an overwhelming amount of userland JS libraries designed to facilitate better dev UX working with functions have conformed to this paradigm with their abstractions insinuating association with pipelining.

I don't think this is necessarily true either. Being able to pipe is the thing that was desired. Most of the other languages that have pipe (including F# itself) are auto-curried languages, so the process of composing into a unary function is an affordance of the language itself. If programmers in JavaScript wanted to adopt pipelining because it's an incredibly useful tool for solving a wide variety of problems, they needed to design tools that made composing into unary functions ergonomic, so they adopted tools like curry to mirror the auto-currying of the languages that have that built in. But if you change the affordances of the language, the tools you need to pipeline change.

Looking back at the Hack version:

x |> multiply(2, ^) |> add(10, ^)

Having this affordance built into the language means we no longer need an entire class of tool to opt functions into composition. Any function is now composable and can be added to a step in your data pipeline without modification. A whole world of functions & packages are now available to be pipeline-able directly.

In the curried point-free F# version, you're dealing with the mental overhead not only of higher order functions, but a 2-step process that includes two different call syntaxes which do different things – first, () which creates a new function and then |> which applies the LHS to the function on the RHS returned by ().

We're diving into this in more in #233, so I leave a deeper dive to that thread.


Note on Julia: I was looking up the behavior of |> in Julia to see how it works. Julia is the only language with a pipe that I've seen that doesn't have autocurrying (maybe Bash is another exception, unless piping to stdin counts as point-free 😄 ). The first result I got for "Julia pipeline operator" is this result, which, after explaining the base functionality (which matches F#), describes the pipe thusly:

Pipes in Base are very limited, in the sense that support only functions with one argument and only a single function at a time.

Conversely, the Pipe package together with the @pipe macro overrides the |> operator allowing you to use functions with multiple arguments (and there you can use the underscore character "_" as placeholder for the value on the LHS) and multiple functions, e.g.:

addX(a,x) = a+x; divY(a,y) = a/y
@pipe a |> addX(_,6) + divY(4,_) |> println # 10.0

That package links to several other related packages. This undermines the claim that this sort of placeholder pipelining is "unorthodox", as there are several userland libraries in Julia that enable this style of placeholder pipelining. Hack-style is derived from Hack the language; we didn't invent this approach whole cloth.


An overwhelming amount of people in these discussions don’t mind the aim or utility of Hack-style–I actually welcome it for the benefits you just outlined that's very accessible to beginners of solving problems in a pipeline-oriented way; its usage is like training wheels for those that don't normally functionally compose by doing () => <expression> potentially and (x) => <expression with x being available via ^>; for those familiar with the ubiquitous purpose of pipelining to tacitly communicate first-class functional composition, it's a casual means of expressing what you would express within a body of a unary function pipeline fundamentally leverages for its casual computational representation that's very teachable

This has been and continues to be the major advantage of Hack pipe, and I appreciate that you agree that Hack pipe is more intuitive to beginners. It is this direct translation from current syntax that makes it teachable. That f(x) translates to x |> f(^) is incredibly intuitive for beginners because they don't have to learn that |> is another function calling syntax, or that add(2) doesn't evaluate the function directly but instead returns a function that is then evaluated.

That said, if we accept that as true, many of the downstream claims are weakened significantly.

First of all, beginners who find this more intuitive are not going to be showing up in a GitHub repo about language design. They might not even know TC39 exists, let alone that they can participate in the conversation or feel comfortable offering their opinion. The expressed views on this GitHub shouldn't be taken as necessarily the majority's perspective. In fact, the limited data we have suggests people prefer the placeholder version, but make fewer mistakes w/ F# (see #216 and HISTORY.md; also note said study was done on Smart Mix (not Split Mix) rather than Hack), although the differences in both cases were very small, implying at minimum that preferences are split, rather than F# being the clear majority preference.

Second, given that beginners may not have well-formed opinions, the majority view isn't necessarily the most important thing to determining the value & utility of a given proposal. Somewhat related to the above, but determining what is most popular is often quite difficult to ascertain, especially amongst a community as large & widespread as JavaScript (see #222). Learnability/teachability are both important to consider for new syntax, as well how it integrates with the rest of the language, and on both of those counts, Hack style is the better option; the former based on the above comment; the latter based the lack of special casing required to integrate with await and other operators.

Lastly, while I don't really mind swapping |> for |:, I would stress that an F# style pipe still faces an uphill battle in committee. If Hack-style pipes were accepted as |:, we should do so accepting there's a high likelihood that this will be the only pipe operator JavaScript gets. Given the struggle PFA has gone through to advance, I'd be cautious about tying decisions of a given proposal to the advancement of another.


Finally, it would be completely unintuitive to ban placeholder topics with |> further if TC39 decides to go ahead with Hack-style (|>>/|:)

I emphatically disagree with this, especially given the initial confusion in this thread about the behavior of the two different operators. Having two syntaxes, both containing placeholders, that behave in similar but slightly different ways, is far more confusing than the clear distinction that would result from one of them allowing placeholders while the other didn't. Because the distinction between them is subtle and only expressed at runtime, banning placeholders in F# style provides much clearer developer feedback with an early SyntaxError should someone try to use a placeholder where they're not supposed to.

Rereading this thread, there's some significant confusion right off the bat about the various behaviors of the placeholder and how they relate to the two operators that is likely to doom this version of the Split Mix proposal. The RHS being polymorphic to the type in the |> (calling functions if a function, inserting values if not) seems like a major footgun and is confusing even to people on this thread who were excited about the Split Mix approach. I also disagree strongly with considering those footguns "self-inflicted"; the term itself implies shooting yourself in the foot, so the fact that it's self-inflicted isn't relevant to the fact that the gun has a hair trigger that makes shooting yourself in the foot really easy (to stretch the metaphor).

To directly address @aadamsx: the issue isn't that you "can't be trusted"; even good programmers make mistakes, and good language design minimizes the ways those mistakes can be made. While we each have different intuitions about which approach is more footgun-y, we cannot dismiss the existence of footguns as irrelevant to the design of a given language feature.


I believe that addresses many of the substantive arguments presented here throughout this past week. If there are others that you believe are worth addressing, I'm happy to take them.

Hope everyone is having a great week!

lozandier commented 2 years ago

@mAAdhaTTah Thanks for the thorough response; excited to respond when I next optimally can a couple of days/weeks from now. Yes, piping to stdin counts as point-free towards point-free functional composition being ubiquitously available to programmers for some time! 😄

Have a great weekend yourself.

aadamsx commented 2 years ago

@mAAdhaTTah I know reading through all these posts is a lenghty process. I think we all greatly appriciate your effort to respond to the concerns of the community!

Again, I think Hack will be a great addition, but please try to again to persuade the TC39 members to add the unary function exception to Hack?

For example, below for unary functions, and only for unary functions, the compiler will assume the placeholder if none is given:

// Hack with alternative pipe operator and placeholder |: & :


value 
  |: extractBusinessData  // no placeholder, compiler assumes unary
  |: await mergeWithWebServiceData(:) // unary, but still explicit placeholder added
  |: processResults(:, 1, 2) // n-ary function, must have placeholder
  |: printResult; // no placeholder, compiler assumes unary

This seems such a simple and resonable ask that won't do a lot of damage, doesn't seem too confusing, shouldn't cause too many footguns, yet will reduce much consternation!

shuckster commented 2 years ago

Appreciate the response @mAAdhaTTah . Will look at it full, but on the subject of readability I tried putting one of @arendjr 's examples you linked into F#:

const room2Ref = rooms.get(getId(portal.room))
    |> x => ({
        description: "",
        flags: x.flags,
        name: "",
        portals: [],
        position: portal.room2.position,
    })
    |> x => sendApiCall(`object-create room ${JSON.stringify(x)}`)
    |> await

Since I'm still unfamiliar with looking at |> I find the x part of the anons very "leading" for my own eye. I suppose the reason for this this is obvious, being already very familiar with functions, there's no extra syntax within this block that surprises me. I'd call that a readability win for this particular example.

Also, the first pipe could be refactored thus:

    |> ({ flags }) => ({
        description: "",
        flags,
        name: "",
        portals: [],
        position: portal.room2.position,
    })

I find this even clearer, and since it's "just args" that's another readability/refactor win. And if we decide to lift this anonymous function out into a named one, well, there's nothing to change in the function body.

I'm also liking the await on its own line, although I can't articulate why just yet.

mAAdhaTTah commented 2 years ago

This seems such a simple and resonable ask that won't do a lot of damage, doesn't seem too confusing, shouldn't cause too many footguns, yet will reduce much consternation!

This is the Smart Mix we pushed for a while and failed to make traction on. The symbols were slightly different but the basic proposal was what you've sketched here.


I'm also liking the await on its own line, although I can't articulate why just yet.

This to me was always a weakness of F#, like it had to be shoehorned in. await sendApiCall(arg) is generally more idiomatic than await on a line separate from where the promise is created. Nothing you couldn't get used to ("It's like passing a promise to an await function!") but definitely different from how await is typically used.

SRachamim commented 2 years ago

I wish we'll stop designing the language for "beginners". Those beginners are beginners for a very limited time. Very early in their career, there'll come a point in which they'll feel the Hack pipeline is more limiting than they originally felt. Feeling comfortable with lambdas is one of the first skills every developer acquires. Is it right to sacrifice their DX for most of their career as JS developers, just to make it (arguably) friendlier for their first week as developers?

|> double
// If you want it terse 
|> _ => multiply(_, 5)

// If you want it descriptive
|> size => multiply(size, 5)

Very common scenarios can't even be implemented with Hack style:

// Minimal
|> as => as.map(a =>
      a
      |> i => as[i]
    )

// Hack
|> ^.map(a =>
      a
      |> ^[^] // which `^` is which?
    )

(That's a minimal example, imagine it inside a bit longer pipe). A nested pipe is something that happens a lot even in the most modular codebase.

I'd say forcing a ^ is way more limiting than a lambda.

If you want to use a promise, then do it the imperative way outside a pipe (with await) or the declarative way (with then). No reason to mix an imperative await inside a pipe. The whole idea of await was to make it easier to name it and work with promises the imperative style. It's absurd that we are doing flip-flops to try to force them into a declarative feature. If you really want to mix, that's another proposal: p |>> f === p.then(f). Don't mix features - Split them and make them a minimal syntactic sugar.

Lambda is the most powerful feature in programming. Embrace it. Use it. Utilise it. Lambdas are here for us. All modern programming languages are built around them. And we're lucky JavaScript has them as first class citizens since day one.

For me, the minimal proposal is the only way to go. a |> f === f(a). Easy, simple, familiar, friendly. Just pick a symbol and move it to Stage 2.

No reason to block the minimal proposal, since it's just a syntactic sugar for reverse function application: a |> f === f(a). Nothing new here. No new concept invented. Barely need a spec.

tabatkins commented 2 years ago

@SRachamim I've hidden your last comment. You have been getting increasingly aggressive thruout this thread, and the preceding comment absolutely crosses the line of TC39's code of conduct. Further violations will result in escalation.

tabatkins commented 2 years ago

@mikesherov

I'm still left wondering if we dropped the early syntax errors and put assume (^) if left off on top of Hack if that could be successful, or if the same concerns about footguns would "officially" block the proposal from advancing.

It would; that still hits the "garden-path errors" objection that Smart Mix so carefully designed itself to avoid. You have to look at the entire expression to know if ^ is present or not, and thus you can't tell how to interpret the expression (is it invoking a function, or defining a function?) until you know that.

(Note that partial-application is planning to move to using a syntactic marker to, in part, avoid this garden-path issue as well, because it was likely to provoke blocking objections.)


@ multiple people

It has been asserted by a number of people, directly and indirectly, that "function application" is the traditional/common/well-understand meaning of pipe, and thus it's weird that we're fighting against it with this strange placeholder-based pipe.

This is, speaking simply, untrue. Function-application pipes are common in languages that promote higher-order functional programming and have language features that support it well; auto-currying, in particular, is a unifying feature across these sorts of languages that makes this style of pipe pretty convenient to use.

Clojure supports multiple styles of pipeline. Elixir uses "topic inserted as first argument" syntax. Piping on the command line definitely isn't remotely equivalent to F#-style as you don't invoke programs that return programs that accept one positional command-line argument; given the role of stdin as a sort of special privileged input, it can probably best be thought of as analogous to bind-operator piping.

My point is that any argument from "naturalness" is based on false assumptions. This problem space has been approached in many different ways, and the solutions any given language chooses are dependent on features of that specific language.

tabatkins commented 2 years ago

@SRachamim If you can't tell what part of your most recent comment (and several preceding comments) exceeded or toed the line of the TC39 Code of Conduct, then I recommend simply erring as strongly as possible on the side of "respectful, friendly and patient, inclusive, considerate, and careful in the words that you choose", as the CoC dictates.

aadamsx commented 2 years ago

@SRachamim I've hidden your last comment. You have been getting increasingly aggressive thruout this thread, and the preceding comment absolutely crosses the line of TC39's code of conduct. Further violations will result in escalation. @tabatkins

While @SRachamim's tone may have a lot to be desired, I still think it's only marganily so, and further his post does have some value and deserves to be responded to I think.

My point is that any argument from "naturalness" is based on false assumptions. This problem space has been approached in many different ways, and the solutions any given language chooses are dependent on features of that specific language. @tabatkins

I think the finer point here is, while you're correct that there might not be ONE right way, there is at least one Proven/Traditional way to compose software, using Function Composition. This proposal was all about this way originally. I think the worry is, if Function Composition is to ever be implemented in the language at some point, will it be awkward when the operator assoticated with has already been used for Hack?


Lambda is the most powerful feature in programming. Embrace it. Use it. Utilise it. Lambdas are here for us. All modern programming languages are built around them. And we're lucky JavaScript has them as first class citizens since day one. @SRachamim

While I agree with this, what you want these champions to do is unrealistic. Bottom line, they've already gone to TC39 and got rejected with Lambda composition & Lambda first approach from what I understand. They went back and got Hack approved to Stage 2. Now you're asking them to scrap that and go back to Function composition only route? That's not going to work for the time being of course. We have to be more pragmatic. We have to work within the lines. We can try to convince them that one day Function composition will become more widely understood & used -- and therefore we should reserve the |> for eventually getting it in. Also, I think Smart Mix-LIKE syntax for unary functions only is a fine bridge that would placate all parties (I talk more about that in a post below this).


This is the Smart Mix we pushed for a while and failed to make traction on. The symbols were slightly different but the basic proposal was what you've sketched here. @mAAdhaTTah

Yes, I think at least @tabatkins was actually on board with Smart Mix-LIKE but was rebutted by TC39 members when it was offered as an approach. My question here is, can you give a deeper understanding (along with coding examples) of TC39's thinking here? For example, how would one footgun on code like the following? In code please, why is dropping the placeholder for unary functions so bug causing and confusing?

Please note, I'm not wanting Smart Mix back. What I'm suggesting is the compiler treat the call to the unary function as if there is a placeholder present on that line, even if it's not there. Like a short-hand or short-cut for unary functions only. Yes it looks similar to Smart Mix, but I don't want the mode change.

// All Hack (no modes) + optional placeholder with unary functions. Also with alternative pipe operator and placeholder |: & :

value 
  |: extractBusinessData  // implicit placeholder, compiler assumes unary, treats as if a placeholder present
  |: await mergeWithWebServiceData(:) // explicit placeholder
  |: processResults(:, 1, 2) // n-ary function, must have placeholder
  |: printResult; // implicit placeholder, compiler assumes unary, treats as if a placeholder present

As a side, I'd like to thank @tabatkins, @mAAdhaTTah, & @js-choi for taking the time to answer all our questions! They are volunteers doing this on their own time becasue they care. We appriciate you all!

tabatkins commented 2 years ago

Smart Mix was rejected, not quite on footgun reasons, but on complexity reasons - it offered multiple "modes" for the syntax, and the additional complexity of the modes wasn't seen as worth the benefit.

(You keep saying "no modes, all Hack", but it's definitely different modes; the syntax works completely differently. val |> foo.bar and val |> ^.bar mean completely different things in Smart Mix, despite looking extremely similar.)

And, because of the effort Smart Mix went thru to make the two modes as simple to tell apart as possible (only allowing bare or dotted idents for "bare" mode, nothing more complex), it meant that Smart Mix wasn't enough anyway. For example, it wouldn't allow for RxJS to be naively used; obs |> map(x=>x+1) is too complex to be in bare mode, but lacks a placeholder so it's an error in placeholder mode.

The only way to satisfy reasonable use-cases like RxJS is either to drop the restrictions on "bare" mode and allow any expression without a placeholder to be used (which runs into the "garden path" objection, where you have to read the entire expression before you can tell how to interpret the expression, and the footgun objection of forgetting or refactoring causing the code to change modes accidentally), or to drop placeholder entirely and just make it F#-style (which runs into the already-mentioned issues explained in #221 and the History document linked therein).

aadamsx commented 2 years ago

(You keep saying "no modes, all Hack", but it's definitely different modes; the syntax works completely differently. val |> foo.bar and val |> ^.bar mean completely different things in Smart Mix, despite looking extremely similar.) @tabatkins

I'm not looking to get smart mix back in the hands of TC39 memebers. I'm not really talking about smart mix here (but it seems like I am because the syntax might match up). What I'm proposing is for only one circumstance, unary functions, the compiler should treat functions as it would as if there was a placeholder present -- even if i's not.

// All Hack (no modes) + optional placeholder with unary functions. Also with alternative pipe operator and placeholder |: & :

What I'm suggesting is the compiler treat the call to the unary function extractBusinessData as the same call -- the output of the compiler should be the exact same for this line of code. So no smart mix here, just a short-hand for placeholder only for unary functions. And of course it's option.

value 
  |: extractBusinessData  // implicit placeholder, compiler assumes unary, treats as if a placeholder present
  |: printResult(:); // explicit placeholder
value 
  |: extractBusinessData(:)  // explicit placeholder
  |: printResult(:); // explicit placeholder

So it's Hack, just with a short cut option for unary functions

SRachamim commented 2 years ago

Lambda is the most powerful feature in programming. Embrace it. Use it. Utilise it. Lambdas are here for us. All modern programming languages are built around them. And we're lucky JavaScript has them as first class citizens since day one. @SRachamim

While I agree with this, what you want these champions to do is unrealistic. Bottom line, they've already gone to TC39 and got rejected with Lambda composition & Lambda first approach from what I understand. They went back and got Hack approved to Stage 2. Now you're asking them to scrap that and go back to Function composition only route? That's not going to work for the time being of course. We have to be more pragmatic. We have to work within the lines. We can try to convince them that one day Function composition will become more widely understood & used -- and therefore we should reserve the |> for eventually getting it in. Also, I think Smart Mix-LIKE syntax for unary functions only is a fine bridge that would placate all parties (I talk more about that in a post below this).

If I understood correctly, the minimal proposal was rejected mainly because it has no support for await. I think it's not a good reason to reject it, and in my comment I tried to emphasise on another attitude entirely toward this operator. I wish to see it published or reappeared, as it has a lot of legitimate points that wern't discussed before (and a possible solution).

arendjr commented 2 years ago

@mAAdhaTTah Thanks for the elaborate reply! It’s appreciated!

Naturally, I still have some reservations. I won’t rehash the examples any further, as it’s evident we disagree there. Instead, I would just like to respond to some of the more high-level concerns.

You wrote:

Because readability is significantly correlated with familiarity, "he said, she said" is the only way this can shake out. There is no objective standard for "readable"; the definition I use and come back to is "closest to idiomatic for the language" but even that is shaped by the style of code we write.

I agree readability is largely correlated with familiarity, but it’s not the only aspect of it. While I cannot argue against the fact named temporaries may introduce some verbosity or redundancy (I believe you called it “superfluous”), I will argue for the value of it. Just as when making an oral or written argument there is value in repetition. Why? Because people have limited short-term memory, and reminders are helpful. Hack, it’s for good reason most television shows start with a recap of previous episodes. Sure, to someone binging the show those recaps are superfluous. And to an audience that already knows where your argument is going, reiteration of points is superfluous. Generally, the experts and those intricately familiar are not helped by “superfluous” reiteration. But just as you mentioned how only a tiny fraction of the community might even be aware of these discussions and even fewer would dare to jump in, I fear the people in this thread are mostly experts who might overlook the value of named temporaries to beginners and those less familiar. Pipelines take away a mental aid to box temporary results into something you don’t have to worry about now, as there’s another line of code that worries about it. Pipelines force you to grok the entire thing at once, because every step is tightly coupled, mentally.

So where to draw the line? When is a pipeline worth it, and when is a temporary variable worthwhile? Regardless of familiarity, I expect different people to have a different value judgement there. But it’s a judgement we currently don’t even need to make, which is one less worry today, and one less source of friction today. And as we know from current coding practices, people who are writing it tend to overestimate their own readability. Things that appear obvious when you write them can appear magic when you look back later. By taking away temporaries, my fear is that we compromise to the writer at the expense of the reader. And especially towards the expert writer, at the expense of the novice reader.

I have no doubts I can learn to become more familiar with the Hack pipelines. But if the only value I get out of doing so is to omit things I currently consider valuable, that’s not a great sell. To me, that’s a step backwards.

Even so, it’s good you mentioned familiarity. Because ultimately, we have an entire community familiar with the status quo. If this proposal is accepted (regardless of which version), its proponents will now have a “new best way” of writing code that goes against the best practices of the entire community that is familiar with how we do things today.

The results should be predictable: you will get backlash. Probably doubly so because the people who wanted a pipeline appear to have mostly wished for F#/Minimal, while those that didn’t want one will get one anyway. You will surely win some souls as well, and the whole thing might blow over with people happily using Hack after all. But right now we don’t know how it will pen out. What we do know is asking the entire community to update their best practices is a huge ask and there will be pushback.

In my opinion, for such a huge ask to be justified we need objective benefits. And a game of he said/she said, with some proponents claiming this to be to the “benefit of everyone”, which you rightly say should not be assumed to represent a majority perspective, should not be sufficient justification for accepting this proposal.

arendjr commented 2 years ago

@shuckster While I appreciate what you were trying to do with the example I posted, once you start using lambda's for every step, what is even the benefit over the original without pipeline? :)

lightmare commented 2 years ago

@aadamsx

What I'm suggesting is the compiler treat the call to the unary function extractBusinessData as the same call -- the output of the compiler should be the exact same for this line of code. So no smart mix here, just a short-hand for placeholder only for unary functions.

Please read the archived Smart Mix proposal. What you're suggesting is exactly smart mix without seatbelt. It had restrictions (the seatbelt) on what kinds of right-hand-side expressions were allowed without placeholder, specifically to avoid hunting for placeholder (to figure out the mode) in stuff like:

value 
  |: extractBusinessData("with", { some: ["stupid", "long"], bag: "of options" })["do we have a placeholder yet?"]
mAAdhaTTah commented 2 years ago

@arendjr

Because people have limited short-term memory, and reminders are helpful.

While I don't entirely disagree with your argument, I think this is my main point of pushback. Reading the same long variable name multiple times is also mental overhead. This is part of why people nest expressions generally; there is overhead in assigning to a variable as well both on the reader & writer's side.

To pick on one of your examples:

index = resultItems.indexOf(highlightedResultItem) + delta;

If I were to write this myself, I'd probably start by inlining the findResultItem call into indexOf because findResultItem is a well-written function. Without needing to dive into the implementation, it's clear what it does from the function name & params passed to it. I'll also point out that you didn't assign a variable for highlightedResultItemIndex and then add that to delta because you recognize that "boxing up" into a variable there is not valuable.

If I were to inline the variable:

index = resultItems.indexOf(findResultItem(resultItems, highlightedResult.id)) + delta;

This can no longer be read left-to-right. The first thing that happens is in the middle. This is why resultItems.indexOf isn't useful as a variable; it wouldn't change the left-to-right reading flow. Inlining findResultItem does.

All of is to say that boxing up into variables isn't just about boxing up the result of a function so we don't have to worry about it downstream. It's also about maintaining top-to-bottom, left-to-right reading flow. That also reduces our mental overhead, so our eyes aren't jumping around the expression in order to understand what it means. This is (perhaps obviously) the benefit pipes bring as well, without the overhead of needing to read the same redundant variable name.

What's more, given the amount of times I've seen terrible variable names (data is my biggest pet peeve, cuz everything is "data", but even like ... result :trollface: or value are common, or using these as meaningless suffixes), it's not universal that boxing up the result into a variable reduces the mental overhead. Variables are not a zero-cost abstraction; writing good variable names is an art unto itself. The pipe allows you to skip this step, both as a reader & a writer, and focus on the "meat" of what is happening (the flow of expressions into each other). If, instead of highlightedResultItem, you just wrote item, not only is that duplicative, but it's actually worse than just piping the previous line into the next expression. It gives you less information than the expression itself.

This is a long-winded way of saying I don't think it's clear-cut that temporary variables are universally more readable than the pipe version. Some cases are going to benefit from temp variables; some cases are going to be worse. Some of this will differ between people & teams. I mostly write React application, so Hack pipes aren't going to be all over the place, but there are a handful of places like the example above where it's clear they'd improve the code in these simpler expression sequences. I suspect heavier functional codebases will find longer sequences of expressions more common & readable, and other teams & codebases won't use them at all.

It's not really a suggestion to update all best practices, just as it isn't a best practice to extract every sub-expression into a temp variable (e.g. indexOf). It's a tool that will make code some more readable, but I do think it's objectively true that there is code that will be improved by the pipe.

aadamsx commented 2 years ago

Please read the archived Smart Mix proposal. What you're suggesting is exactly smart mix without seatbelt. @lightmare

value 
  |: extractBusinessData("with", { some: ["stupid", "long"], bag: "of options" })["do we have a placeholder yet?"])

Isn't what you've written here a n-ary function?

What I'm proposing is for only one circumstance, unary functions, the compiler should treat functions as it would as if there was a placeholder present -- even if i's not. @aadamsx

lightmare commented 2 years ago

What I'm proposing is for only one circumstance, unary functions, the compiler should treat functions as it would as if there was a placeholder present -- even if i's not.

I'm not sure what you mean by unary functions, then. The compiler cannot know at parse-time whether extractBusinessData will be a unary function, or n-ary, or not a function at all. Especially if it's imported from another module, it could change at any time.

Isn't what you've written here a n-ary function?

Depends on what extractBusinessData returns. It could return an object such as this:

{ ["do we have a placeholder yet?"]: x => `voila, unary function, ${x}` }
Pokute commented 2 years ago

I do think temporary variables are used to make code more readable. There's less need for it if the code is already readable enough. a(b(c(d(e(f))))) into const found = e(f); a(b(c(found))); helps since it slices the difficult-to-parse inside-to-outside reading order into two mentally more easily manageable chunks. The pipeline operator also achieves the result of simplifying reading code by changing the reading order to left->right. Therefore I argue that the threshold on when the code becomes too complex so that it needs to be sliced up into smaller portions to maintain readability is higher for code that eases the complexity with other techniques, like pipeline operator.

Of course, pipeline operators are not necessary, but neither is using any temporal variables. People can write unreadable code and I see great benefit in making more tools available that make code understandable. It's very possible that coders will go overboard with pipelines, but people can also go overboard with temporary variables. Temporary variables work better with some code while pipelines work better with other.

mAAdhaTTah commented 2 years ago

@lightmare To clarify, I believe @aadamsx is suggesting bare identifiers be allowed and treated as unary functions with the placeholder implied. This is a slightly more restrictive version of the "bare style" of the original Smart Mix, which also allowed a.b as the identifier (and I feel like there was a 3rd case I'm forgetting).

That said, I'm not sure there's a ton of value in enabling this, given the added complexity, because bare identifiers aren't (imo) going to be the most common usage. I think what Tab said in #234 still applies even to this slightly more restrictive version.

boonyasukd commented 2 years ago

Here's another example why using temporary variables won't necessarily help with code readability; this sample snippet teaches beginners how to read a file in utf-8 encoding and print its content through console:

const file = await Deno.open('intro.txt')
const decoder = new TextDecoder('utf-8')
const text = decoder.decode(await Deno.readAll(file))

console.log(text)

This is how the same logic looks using pipeline operator:

'intro.txt'
  |> await Deno.open(^)
  |> await Deno.readAll(^)
  |> new TextDecoder('utf-8').decode(^)
  |> console.log(^)

What's interesting to me in this example is that, though both snippets are straightforward, the pipeline code reveals the intention of the code better. By not declaring any (unnecessary) variable whatsoever, you stay single-minded throughout the pipeline, focused on how transformation happens from one line to the next. This is what I consider to be "less is more".

By contrast, in the original code, despite employing meaningful variable names (file, decoder, text), such naming becomes a distraction: since every line is now "meaningful" (being assigned to a variable and all), you end up having to hold these variables in mind while scanning for their presences in the rest of the code, thereby increasing cognitive load. And even if you understand how the original snippet works, its code linearization might still be missed entirely, since at a glance it's not readily obvious that Deno.open() and Deno.readAll() are invoked in succession. In cases like these, the use of pipeline operator definitely helps "straighten" the code out, and thus its readability is markedly improved.

mikesherov commented 2 years ago

(You keep saying "no modes, all Hack", but it's definitely different modes; the syntax works completely differently. val |> foo.bar and val |> ^.bar mean completely different things in Smart Mix, despite looking extremely similar.)

I know these arguments have been potentially made before. But I wonder if the speculation on what will trip folks up, and at what point in their JS journey they would have encountered pipelines, has been backed up by more than gut. Consultation with educators? Teachers of FP libraries in the community? Polling and or usability studies? I'm asking this honestly.

benlesh commented 2 years ago

What's interesting to me in this example is that, though both snippets are straightforward, the pipeline code reveals the intention of the code better.

@boonyasukd That's mostly because the first example was nested in one spot to make it more difficult to read. And then the new TextDecoder bit was inlined in the second example to make it more terse. An apples-to-apples comparison looks more like this, and I honestly don't see a difference.

let file = await Deno.open('intro.txt')
let data = await Deno.readAll(file)
let text = new TextDecoder('utf-8').decode(data)
console.log(text)
tabatkins commented 2 years ago

@arendjr

I agree readability is largely correlated with familiarity, but it’s not the only aspect of it. While I cannot argue against the fact named temporaries may introduce some verbosity or redundancy (I believe you called it “superfluous”), I will argue for the value of it.

The problem with any argument about theory on these lines is that practice shows the opposite. People do long chains of methods and don't generally confuse themselves; existing pipe() users seem to mostly use predefined or unary-returning functions and thus don't name their topic variable either.

I agree that it can be useful to name a value when it represents a significant semantic concept in your program, but you can do that by assigning to a variable and then continuing in the next statement; that's what people do today using the similar tools. People can overuse chaining or pipe() and make their code harder to read than it needs to be, but most of the time the code people naturally write seems to be pretty reasonable, and usable for them in practice.

So, unless you've got a good explanation for why nearly all existing method-chaining and pipe() users are writing bad code, I think we can close the book on discussions about mandatory naming of pipeline steps.

@mikesherov

I'm not sure if any programmer would consider val |> foo.bar to be equivalent to val |> ^.bar.

What I mean is the RHS expressions are identical in both cases - they're both accessing the .bar property of an object, and differ only in the name of the object being accessed. However, the suggested behavior ("Smart Mix") is that the former then calls the result with val as its argument, while the latter just resolves to the result; the engine decides which behavior to do by whether there's a ^ somewhere in the expression or not.

Thus, two modes.

The teachability of "missing ^ just means (^) at the end" really shouldn't be understated either. I don't think we're giving enough credit to language learners to apply this rule. Functions as value is a thing you need to learn if you're doing JS.

Indeed, functions as values are a vital and very common part of JS. The problem isn't with the concept, it's with the fact that seemingly-identical situations either cause this behavior or don't, depending on the name of one of the values in the expression. This sort of behavior differentation has no precedence in JS.

If we embraced full "missing ^ just means (^) at the end" (which is more than "Smart Mix" did), then we have situations like val |> foo(1, 2, ^) and val |> foo(1, 2, 3) being completely different, for instance - one desugars to foo(1, 2, val), the other desugars to foo(1, 2, 3)(val). But all they did was change the placeholder out for a constant value, something that could very plausibly happen in the middle of debugging, for instance. Literally everywhere else in the entirely of JS, this sort of transformation is safe to do; you can always replace a variable with the value of the variable as a constant, and have the code execute the same save for the changed value. This would be the first and only place where that transformation becomes only sometimes safe, depending on what you're replacing.

(This proposal does make it an error to not use the placeholder, so val |> foo(1, 2, 3) would be an early error, so technically the current proposal also violates this substitution principle. But "early syntax error telling you exactly what's wrong in the console" is a vastly different beast than "silent behavior change that might not be noticed until sometime much later in your program". The proposed behavior is just a nice-to-have to avoid confusion, in any case; it's not necessary for the meaning of the operator and could potentially be dropped.)

(This exact same issue is one of the reasons partial-application has had trouble advancing, and why the plan is now to add a syntactic marker indicating that a particular function call is being partially-applied rather than invoked normally.)

@runarberg

No need to wonder. The committee is pretty explicit in their believe that there is no need to do studies (see e.g. #204 (comment)). Appealing to the authority of experts is how TC39 does things,

This is off-topic, but no, that's not what any of us have said. Please do not intentionally misinterpret our words.

What we actually said (in #216 and elsewhere) is that usability studies are expensive, difficult, and historically rare in language design, both in JS and in other languages; this was in response to a number of people arguing that anything other than F#-style needed to be justified by user studies. (Not to mention, we did in fact perform a usability study on one aspect; it didn't give any strong conclusions.)

boonyasukd commented 2 years ago

@benlesh There's no need for an "adjusted" apple-to-apple comparison to be made here, though. I didn't invent that example to make a point: I simply took a Deno sample code I found on the internet, converting it to use pipeline operator, then made an (impartial) observation. This code is just about as valid as any other existing code we have brought up so far, whether you like it or not. And whipping up a new code snippet isn't going to take away any point I made on that existing code, I'm afraid.

mikesherov commented 2 years ago

you can always replace a variable with the value of the variable as a constant

but this supposes we teach ^ as a variable, which it isn't. ^ is not a valid variable name. You need to teach it as something special.

If we embraced full "missing ^ just means (^) at the end" (which is more than "Smart Mix" did),

yes because I'm not proposing Smart mix.

then we have situations like val |> foo(1, 2, ^) and val |> foo(1, 2, 3) being completely different, for instance - one desugars to foo(1, 2, val), the other desugars to foo(1, 2, 3)(val). But all they did was change the placeholder out for a constant value, something that could very plausibly happen in the middle of debugging, for instance. Literally everywhere else in the entirely of JS, this sort of transformation is safe to do; you can always replace a variable with the value of the variable as a constant, and have the code execute the same save for the changed value.

Except this isn't true in hack either. You can't replace the placeholder with a constant in hack. It errors. It just errors in a different way. Whether this errors in a "early error way" behavior is better than errors in a "foo is not a function way" is a tradeoff to be considered.

tabatkins commented 2 years ago

^ is a variable in every meaningful sense. It's lexically restricted to within pipeline bodies, but in that context it is usable in exactly the same ways that any other variable can be, and not usable in any way that a variable couldn't be. If it's not a variable, it's "a thing that is exactly like a variable in every single way except that it has a funny name".

Except this isn't true in hack either. You can't replace the placeholder with a constant in hack. It errors. It just errors in a different way. Whether this errors in a "early error way" behavior is better than errors in a "foo is not a function way" is a tradeoff to be considered.

Yes, I said exactly this in my comment. The two behaviors are extremely different in both intent and effect. Also, the committee has already rejected the simple form of inference that Smart-Mix represented, and the rejection reason was "two implicitly-switched modes are too complex"; making the implicit switching harder to syntactically distinguish just runs into that same complaint but worse.

mikesherov commented 2 years ago

^ is a variable in every meaningful sense. It's lexically restricted to within pipeline bodies, but in that context it is usable in exactly the same ways that any other variable can be, and not usable in any way that a variable couldn't be.

Except for the fact that it has a magic name and is wired to a thing on LHS, can't be used twice on RHS, can't be replaced with a constant, can't exist outside placeholder syntax... it's not going to be taught to folks as any old variable.

tabatkins commented 2 years ago

I didn't say it would be taught as "any old variable". I said that it's a variable. It has some special conditions, but it is not a fundamentally new concept.

mikesherov commented 2 years ago

My point is that you claimed that everywhere else you can replace a variable with a constant and it works. My point is: it's not a variable and even if it was, with neither hack nor F# allow you to replace w a constant.

runarberg commented 2 years ago

@tabatkins

This is off-topic, but no, that's not what any of us have said. Please do not intentionally misinterpret our words.

Sorry about that. After I hastily posted, I reread the comments and realized that. So I deleted my post. I should have been more careful before posting and I do apologize.

ljharb commented 2 years ago

@mikesherov you can use ^ as many times as you want in a pipe segment, it’s not limited to just once.

mikesherov commented 2 years ago

@mikesherov you can use ^ as many times as you want in a pipe segment, it’s not limited to just once.

interesting, but still doesn't really change the point that both Hack and F# have special treatment for the placeholder. Neither allow you to accidentally exclude the character safely during refactoring or debugging. In Hack, it's kind of the point. In F#, the accidental omission most likely leads to foo is not a function.

The garden path objection that you need to read to see the missing placeholder I don't quite understand. How is that issue alleviated in Hack?

arendjr commented 2 years ago

@tabatkins

So, unless you've got a good explanation for why nearly all existing method-chaining and pipe() users are writing bad code, I think we can close the book on discussions about mandatory naming of pipeline steps.

I’m sorry, but you’re misrepresenting my argument. I never said people chaining methods or using pipe() today are writing bad code, nor did I say variable names are or should be mandatory. In fact, I am somewhat offended by your tone, because it reflects an appearance that you want to be rid of the argument for a lack of understanding. We have a fundamental disagreement on values. I understand that might be uncomfortable, because my values fundamentally undermine the value of your proposal, but wishing it away is not how you should handle feedback. It’s your prerogative to not care about my values, but if that’s your position please have the curtesy to say so, so I don’t need to waste my time here.

The problem with any argument about theory on these lines is that practice shows the opposite. People do long chains of methods and don't generally confuse themselves; existing pipe() users seem to mostly use predefined or unary-returning functions and thus don't name their topic variable either.

The misrepresentation of my argument starts right here. Let me reiterate from my very first post that started this thread:

as the proposal's motivation states, an important benefit of using pipes is that it allows you to omit naming of intermediate results. The F# proposal allows you to do this without sacrificing readability, because piping into named functions is still self-documenting as to what you're doing (this assumes you don't pipe into complex lambdas, which I don't like regardless of which proposal). The Hack proposal's "advantage" however is that it allows arbitrary expressions on the right-hand side of the operator, which has the potential to sacrifice any readability advantages that were to be had.

My argument is that there is value in naming things. Those can be temporary variables if necessary, but they don’t have to be. Good function/method names will achieve the same value: self-documenting code that is easy to understand.

It’s the Hack proposal’s ability to put arbitrary expressions on the RHS that I take issue with from a readability perspective.

arendjr commented 2 years ago

@mAAdhaTTah

I don't think it's clear-cut that temporary variables are universally more readable than the pipe version. Some cases are going to benefit from temp variables; some cases are going to be worse. Some of this will differ between people & teams. I mostly write React application, so Hack pipes aren't going to be all over the place, but there are a handful of places like the example above where it's clear they'd improve the code in these simpler expression sequences. I suspect heavier functional codebases will find longer sequences of expressions more common & readable, and other teams & codebases won't use them at all.

It's not really a suggestion to update all best practices, just as it isn't a best practice to extract every sub-expression into a temp variable (e.g. indexOf). It's a tool that will make code some more readable, but I do think it's objectively true that there is code that will be improved by the pipe.

This is a fair position, and I would agree there is certainly some code that will be improved. I also readily admitted to liking the |> Object.keys(^) notation, for instance.

But I don’t think that should be the standard for acceptance. My overall impression of the examples championed by the proposal is readability regression rather than an improvement.

You’re totally right saying the level of acceptance will differ between people and teams. But in doing so it creates a prime opportunity for bike-shedding and opposing style guides. People will end up on one side of the argument or the other and then become familiar with the style they prefer. But then whenever people need to deal with code by other people with an opposing style guide, they will have a harder time to read the other’s code. This is a schism that will be created by this proposal and I don’t think it should be applauded.

Compare it to the situation with the semi-colon. We have style guides advocating for and against and it’s a totally useless waste of time and effort. The same will happen with a pipe operator, and the arguments will be even more heated, because it’s already controversial. Some people might enjoy the operator and use it responsibly, while some may use it to ridiculous extremes. But most I expect will end up bound by their team’s style guide that either mandates or restricts it.

In the end we just end up with more complexity and arguments, while the overall readability of code regresses between everybody’s differing preferences.

ljharb commented 2 years ago

The schism you’re talking about is created by literally any addition to the language - F# would cause the same thing.

arendjr commented 2 years ago

@ljharb To some extent that is true, and it is why additions should be sufficiently motivated to be worth the cost. If F# would be nearly universally loved it wouldn’t be an issue, but since it isn’t I agree it is a totally valid reason to reject F# as well.

That puts the pipeline operators in a tough spot generally. They don’t enable anything new we cannot do today. Between named variables and sensible use of nesting, we can already express everything we want in a readable way. Adding a third way of expressing the same things may be considered even more readable by some, but worse by others. Even the proposal itself acknowledges its readability advantages are arguable. That’s just not a great value proposition.

I read in one of the comments you don’t take popularity into account in the decision, but I think if you could show (let’s say) over 80% of JS developers would welcome the proposal, that would certainly give it validity. But if that percentage would be only 20% that significantly weakens it. Even if the percentage is 50% in favor I believe that should not be enough if the other 50% feels strongly opposed, because that’s a recipe for friction. But well, we don’t know any numbers, so we’re just guessing…