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

Added value of hack-style proposal against temporary variables #200

Closed loreanvictor closed 3 years ago

loreanvictor commented 3 years ago

TLDR

The current argument for hack-style proposal against temporary variables is:

In the hack-style proposal, we still have a variable representing the intermediate values. However, we have overcome these two issues simply by giving it a consistent, short and meaningless name, i.e. ^.

This means the argument currently mentioned against temporary variables IS NOT resolved by the pipeline operator itself, it is simply resolved by picking a consistent, short and meaningless placeholder token. In other words, we DO NOT need a new operator (with new syntax support) for resolving these issues, we could simply go the temporary, single-use variables route and just drop the need to pick unique and meaningful names for them, and basically achieve the same thing.

The main other added value that I can think of for hack-style pipelines vs temporary variables is the fact that the whole pipeline is a singular expression resolving to a value, which is not true for a sequence of assignment statements. It is notable that the real gain here is also only in short and simple pipelines.

Explanation

This is the example code mentioned against temporary variables:

const envarKeys = Object.keys(envars)
const envarPairs = envarKeys.map(envar =>
  `${envar}=${envars[envar]}`);
const envarString = envarPairs.join(' ');
const consoleText = `$ ${envarString}`;
const coloredConsoleText = chalk.dim(consoleText, 'node', args.join(' '));
console.log(coloredConsoleText);

Its issues are resolved by the pipeline operator like this:

envars
|> Object.keys(^)
|> ^.map(envar =>
  `${envar}=${envars[envar]}`)
|> ^.join(' ')
|> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log(^);

However, we do not need the operator itself for gaining the benefits here. If we were to agree on using meaningless variable names for intermediate values in such chains, then we could simply rewrite the original code like the following:

let _= envvars
_= Object.keys(_)
_= _.map(envar =>
  `${envar}=${envars[envar]}`)
_= _.join(' ')
_= `$ ${_}`
_= chalk.dim(_, 'node', args.join(' '))
_= console.log(_)

Note that:

But in real life, we tend to use meaningful names for variables

With the hack-style proposal, we are already conceding on that front, as the placeholder variable will be a meaningless variable name. Besides some visual preference and some potential confusion with existing operators, there isn't much difference between using ^ to represent the intermediate value and using _. This means all the issues that would rise from NOT using meaningful names for intermediate variables would also apply to current hack-style proposal, and this cannot be used as an argument for the hack-style proposal against temporary variables.

Statements vs Expressions

One argument that could be made in favor of hack-style pipelines vs temporary variables is that a pipeline expression is an expression, resolving to a singular value. This means you could do things like this:

const x = a |> b(^) |> c(^)
const f = x => x |> h(^) |> g(^)
h(a |> b(^) |> c(^))
(a |> b(^) |> c(^)).someMethod()

The last case can easily be re-written as the following:

a |> b(^) |> c(^) |> ^.someMethod()

The third case is arguably bad practice, as making the flow of execution consistent with reading direction is one of the main purposes of pipelines to begin with, which is violated here. It can also be easily re-written like this:

a |> b(^) |> c(^) |> h(^)

As for the second case, with longer pipelines that have more complex expressions. This means for a typical clean code, each step in the pipeline would occupy its own line and the overall overhead of having a function with a return statement would be minimized. A similar argument can be made for the first case, were the assignment would happen at the end of the sequence instead of at the beginning, without much overhead between the two cases.

None of these arguments hold for short pipelines with simplistic expressions, where there might be a value in writing the whole pipeline in a single line / expression. In these cases though, the overall added value of pipelining is marginalized, and it might not be as clear whether the overhead of adding new syntax to the language would be worth such marginalized gains or not.

haltcase commented 3 years ago

If we were to agree on using meaningless variable names for intermediate values in such chains

Emphasis mine. This is the main issue with your suggestion, as it's highly unlikely a majority of users would agree on this style vs a pipeline operator.

Much of your argument around the aesthetics of _= also depends on it being formatted as if it were a single token, but many lint rules would add a space around the assignment operator. I do agree that _ is a better placeholder, but unfortunately that ship most likely sank in the harbor (unless the committee is on board with using _ or it despite the fact they're already valid identifiers).

Lastly I'd add that while the "naming things is hard" argument might be the driving one behind Hack-style specifically, it is not the primary benefit of the pipeline operator itself. That would be its more linear and less nested ordering of function calls.

kosich commented 3 years ago

NOTE: a similar issue was discussed previously https://github.com/tc39/proposal-pipeline-operator/issues/173 with some interesting pros and cons, though back then we compared it to both Hack-style and F#-style pipelines.

I agree with the idea that assignment sequence seriously questions added value of Hack-style pipelines in the language.

@citycide, I think the agreement needs be achieved only inside a single codebase. And difference between _= and _ = is negligible. Plus additional linting/formatting rules can fix that if needed. Although, to be fair, this approach is rarely to be seen in real world code bases (argument taken from aforementioned issue).

loreanvictor commented 3 years ago

Emphasis mine. This is the main issue with your suggestion, as it's highly unlikely a majority of users would agree on this style vs a pipeline operator.

If I understand you correctly, this means people would not converge on a single variable name for this, so let's force one upon them. Sure, but that still does not require an operator. You could basically achieve that by having _ (or ^ or whatever token) to hold the value of last assignment statement (or better yet, last expression).

Much of your argument around the aesthetics of _= also depends on it being formatted as if it were a single token, but many lint rules would add a space around the assignment operator.

Isn't agreeing on (and changing) linter rules easier than introducing new syntax to the language itself?

Lastly I'd add that while the "naming things is hard" argument might be the driving one behind Hack-style specifically, it is not the primary benefit of the pipeline operator itself. That would be its more linear and less nested ordering of function calls.

But it is (according to the README of this proposal) the main benefit over temporary variables. In other words, you do get the more linear and less nested ordering of function calls with assignment sequences as well (that is without the pipeline operator).

ljharb commented 3 years ago

It’s definitely still very different from temporary variables. Having to name each step, or having to reuse an identifier, are problems; reusing a syntactic token is not.

loreanvictor commented 3 years ago

Having to name each step

Normally you don't have to. In fact, the hack-style pipeline proposal simply chooses a name for you for resolving that issue and nothing more. We could agree on the such a token without new syntax being added to the language.

Perhaps this example would help me better clarify myself: What if we just got ^, which would be the value of last expression, without the |> operator? Wouldn't that achieve the same thing?

envvars;
Object.keys(^);
^.map(envar =>
  `${envar}=${envars[envar]}`);
^.join(' ');
`$ ${^}`;
chalk.dim(^, 'node', args.join(' '));
console.log(^);

Of course this code is harder to read, because the value of ^ changes without any apparent assignment, so ^ means different things depending on which step you are looking at, but doesn't that also hold true when you add a |> add the beginning of each line?

having to reuse an identifier

I think I am missing something here. How is re-using ^ different than re-using _ in the examples I provided?

ljharb commented 3 years ago

It’s entirely different, because i can’t make a variable named ^. Javascript devs know what valid variable names are.

haltcase commented 3 years ago

These fake-pipeline reassignment chains come with their own overhead, so claiming they are somehow simpler is a dubious argument. @theScottyJam previously made good points on this topic so I will defer to what was said there already.

@loreanvictor:

But it is (according to the README of this proposal) the main benefit over temporary variables. In other words, you do get the more linear and less nested ordering of function calls with assignment sequences as well (that is without the pipeline operator).

It isn't even the first benefit listed there — there are three listed before it that you're skipping over:

@ljharb:

It’s entirely different, because i can’t make a variable named ^. Javascript devs know what valid variable names are.

Agreed, and so do engines, compilers, and linters, some of which could raise errors or similarly guide devs to use a feature properly. Reusing a variable is more complex and error prone in comparison.

loreanvictor commented 3 years ago

It isn't even the first benefit listed there — there are three listed before it that you're skipping over

@citycide I am not. I rather feel I am unable to convey properly what I am talking about. Let me try one more time:

  1. The argument is that pipelines are good, because they provide convenience of method chaining and applicability of expression nesting (as mentioned here)
  2. Temporary variables can also be used to achieve the convenience of method chaining and applicability of expression nesting (this point is raised here). This is NOT questioning these benefits, it is just mentioning that there already are methods in the language to achieve those benefits.
  3. The counter-argument is that picking names is hard and it makes the code too wordy (this section, third paragraph). This is an answer to the question Why shouldn't we just use temporary variables instead of pipeline operators? In this discussion, the primary benefits of pipelines become irrelevant, since we are discussing another alternative that DOES provide the same benefits.
  4. My point is that syntactically hack-pipeline is (or rather, can be) pretty close to use of temporary variables, so the arguments of "wordiness" and "tediousness" mentioned in the aforementioned section are moot (the same number of symbols and tokens). On the proposal, as it stands, I do not see any other benefit listed for pipelines vs temporary variables. Again, in this discussion, the primary benefits of pipelines that you are mentioning do not play a role, because temporary variables do provide them as well.

To put this schematically: we want to achieve A and B, X and Y are two ways of achieving them. The question is now whether we should pick X or Y. Someone mentions that because of C we should pick X. I am pointing out that C is moot in the way we are going about X, are there any other reasons to favor X over Y? Now saying "but you are forgetting about A and B" does not contribute to the discussion, because both X and Y achieve A and B and pointing them out does not help us pick between X and Y.

loreanvictor commented 3 years ago

@theScottyJam previously made good points on this topic so I will defer to what was said there already.

This is indeed a good point (basically, increased coding safety). However, I cannot help but think this is something you could achieve with some linting rules instead of new syntax, similar to how the main power of const is when you add linting rules to enforce it (whenever possible).

ljharb commented 3 years ago

It would be poor language design to abdicate responsibility for safety and push it onto the ecosystem, when it's achievable to avoid doing so.

kaizhu256 commented 3 years ago

i think README.md should be updated with multi-use-temp-variable alternative mentioned at top. the doc seems a bit biased towards the java/haskell school-of-thought that javascript-variables should be "statically-typed" / single-use.

loreanvictor commented 3 years ago

It would be poor language design to abdicate responsibility for safety and push it onto the ecosystem, when it's achievable to avoid doing so.

I agree with the sentiment generally, though I feel in JS space people are super used to picking their own safety features (and thats why Prettier and TypeScript are super popular). I cannot argue one way or another about where the line should be drawn.

Hack-style pipelines are super close to assignment sequences using a token such as _. The reasons for lack of adoption of the latter in practice might (and most probably will) also affect the former. I am not saying safety of JS should be delegated to linters wholesale, I am saying if the main issue with assignment sequences was safety, perhaps we would have some linter rules for that (we already have linter rules to disallow unused arguments except when they are named _, for example). The fact that we don't, makes me be wary of the claim "the issue with assignment sequence is safety and hack-style pipelines fix that".

Regardless, if safety is an important benefit of hack-style pipeline pattern over using temporary variables, I suspect it at least should be mentioned in the README.


P.S. The primary actually used-in-practice pattern referenced for this proposal is jQuery or Mocha style method chaining. These patterns differ from hack-style in that they completely eliminate the need for a placeholder to begin with. I am all for standardizing and further facilitating commonly used design patterns in the language itself, but hack-style pipelining doesn't strike me as such a pattern.

ljharb commented 3 years ago

@kaizhu256 so are most JS styleguides and best practices - variables shouldn't be reassigned.

@loreanvictor even if an identifier was viable (it is not), _ is a nonstarter due to the popularity of underscore and lodash - it's quite commonly used.

loreanvictor commented 3 years ago

@kaizhu256 so are most JS styleguides and best practices - variables shouldn't be reassigned.

@loreanvictor even if an identifier was viable (it is not), _ is a nonstarter due to the popularity of underscore and lodash - it's quite commonly used.

Yeah I am using it as a mere example, not saying that this particular token has any benefits over ^ (though it visually does work better for me at least, and it is not an operator)

kaizhu256 commented 3 years ago

@kaizhu256 so are most JS styleguides and best practices - variables shouldn't be reassigned.

at the macro/ux level of js-programming, temp-variables are reassigned all the time as placeholders for data waiting to be serialized/message-passed.

this proposal feels like an acknowledgement of above reality, but allowing ppl w/ dogmatic views like variables shouldn't be reassigned to break it using reassignable ^ instead.

ljharb commented 3 years ago

@kaizhu256 i hear that that’s your experience, but in mine, in those exact use cases, variables are never reassigned.

loreanvictor commented 3 years ago

@kaizhu256 so are most JS styleguides and best practices - variables shouldn't be reassigned.

at the macro/ux level of js-programming, temp-variables are reassigned all the time as placeholders for data waiting to be serialized/message-passed.

this proposal feels like an acknowledgement of above reality, but allowing ppl w/ dogmatic views like variables shouldn't be reassigned to break it using reassignable ^ instead.

I don't think passing judgement on people participating in the discussion (such as calling their view dogmatic) helps advancing the discussion.

kaizhu256 commented 3 years ago

but i do feel this proposal is political and advanced by ppl with certain dogmas on programming (static-typing and immutability of variables), but want to break that dogma in real-world programming, without having to admit to breaking it.

and increasing the language complexity for the rest of us.

theScottyJam commented 3 years ago

@kaizhu256 - careful - these people have very good reasons to believe the way they do about variable reassignment, just like you feel you have good reasons to believe variable reassignment is a good thing. An important part of language design is trying to figure out what patterns are good, what patterns are bad, and encouraging the good ones. They're currently under the belief (and so am I), that variable reassignment is generally a thing that should be avoided. Of course, these beliefs can be challenged, opposing scenarios can be brought up, and things can change.

But simply calling a particular viewpoint "dogmatic" is just hurtful and unhelpful. It's hurtful because it's implying that everyone who has that viewpoint is uneducated and just following what's currently hip. In reality, these people have very good reasons to believe what they do, and throwing an "uneducated" label on them is just going to cause contention. It's unhelpful because it doesn't actually contribute to an argument against the opposing viewpoint. You're claiming that the only reason they believe a certain way is because it's the hip thing to do, and you're not leaving room for an actual discussion about the pros and cons of variable reassignment. It also just closes off the conversation. Any time someone here's "you're being dogmatic about X viewpoint", what they really here is "I don't see eye to eye with your viewpoint, and I think the only reason you don't believe the same way I do is because you haven't tried to understand me yet. You couldn't possibly give any good arguments to defend your viewpoint, so I feel safe in calling it 'dogmatic'". How could anyone defend a point of view if the opposing side has already decided it's 100% wrong and are unwilling to have a thoughtful discussion about it?

I wish, in the programming community as a whole, we would stop using that term. It never leads to good things.

mAAdhaTTah commented 3 years ago

"Prefer const" is an extremely common best-practice and hardly a minority dogma being imposed by this proposal. By comparison, in all the code & tutorials I've read or written, I have never once seen a single temp variable be reassigned over and over in a sequence of expressions. Reassignment of a variable once a message arrives is one thing, but it's not really what is being discussed here, and trying to conflate the two doesn't advance your argument.

mAAdhaTTah commented 3 years ago

Additionally, treating the placeholder as a "reassigned variable" is a mistake. It's not a general-purpose variable; it's explicitly the result of the LHS of the operator. Its value is narrowly defined, its scope is narrowly defined, and thus its purpose is narrowly defined. The placeholder is thus more akin to block scoping than temp variables, a feature I hope we can all agree is a significant improvement to the language.

theScottyJam commented 3 years ago

@loreanvictor - I think I'm following your argument, and I think what you're basically showing is that the README's argument for "variable naming" being the reason pipes are preferred over reassignment is simply not a great argument and perhaps needs to be updated. What you gave was pretty solid evidence that that argument by itself does not create enough merit to introduce a pipeline operator.

Security has been alternative reasoning that's been discussed around here. I personally view it as a readability thing more than anything - which I tried to explain thoroughly in the post that already got linked to over here. It's really easy to see a pipeline and know at a glance what's going on. It's much harder to look as a bunch of variable reassignments and know what's happening - variable reassignments have a lot more power to them, and can cause a lot of other things to happen that you might not expect. Pipelines, on the other hand, constrain the way you write your logic to follow a specific structure that can be understood at a glance and can't ever be deviated from.

I also believe that an important part of language design is to encourage good practices, and provide ways to make them easy to follow. Structuring code in a pipeline fashion is generally a good practice - code structured that way is generally more readable. For example, a function with a single pipeline is very easy to follow, and there's not a lot of stuff you have to keep in your head to folllow it - you just need to know how the previous step in the pipeline behaves in order to understand the next step. On the other hand, a function full of individual statements and many different variable names is much harder to follow. You have to keep in your mind a much bigger context - what the values of all of those different variables are at different points in time. The dependencies between one line and its predecessor lines are also much more complicated, due to the fact that any line can reference a variable created from any of the previous lines. There's also the fact that it's very, very easy to use side-effect inducing functions outside a pipeline. Inside a pipeline, programmers are naturally going to use pure-er functions, because you're forced to do something with whatever got returned from that function in the next stage of your pipeline. Using a temporary variable puts you into this kind of pattern, but doesn't force anything, and doesn't make it easy to see at a glance that this pattern is being followed.

tabatkins commented 3 years ago

but i do feel this proposal is political and advanced by ppl with certain dogmas on programming (static-typing and immutability of variables), but want to break that dogma in real-world programming, without having to admit to breaking it.

Speculating on motives is rarely productive or appropriate. In this case, it's wrong - I'm a primary champion, and don't feel either of those are particularly important. (But I recognize that it does affect a number of people, so it's not meaningless and should be considered.)

My counter-argument to this thread is simply: if people could do this today and use it to simplify their code in a meaningful way, why aren't they doing it? As far as I'm aware, this style of repeated re-assignment does not show up in any meaningful amount in the wild. Instead, they write difficult to read, heavily-nested code, or use uber-objects, or pipe(), etc.

There are a few possible conclusion to be drawn from that:


There's also a practical issue - re-assignment like this means that each pipeline step is seeing the exact same variable, so any closures over that variable will see the updated version from the end of the "pipeline", rather than the version that existed during their step. This is the same sort of footgun that used to plague callbacks created in for loops, and was solved by for-of doing some complex binding shenanigans to instead give the intuitive result.

<!DOCTYPE html>
<script>
var _ = 1;
_= (setTimeout(()=>console.log(_), 1), _),
_= 2*_,
_
// logs 2
</script>

The only way around this with named variables is to do what the example code in the README does, and use a unique name at each step. But that means you either have long, meaningful names, or short meaningless names that all look very similar, and this invokes the other issue with constantly naming steps - it makes it hard to determine at a glance what the scope of each variable is. Each one could be used in any of the succeeding steps; there's no guarantee that they're single-use in just the next line.


Summarizing my points:

ljharb commented 3 years ago

I think the closure limitation is the clincher, really, and avoids the subjective arguments entirely.

kosich commented 3 years ago

With all due respect: Maybe, us not seeing such pattern used much in the wild, means that people don't really need this kind of chaining? While we do see a lot of pipe( ) implementations, which means we have hard evidence that people need it. Why do we invent stuff for hypothetical situations while we have hard evidence of devs using other approaches?

P.S: I wish we could see videos and/or notes from the September meeting, before things moved so fast in this repo. The lack of communication of the reasoning behind Hack over F# decision (before closing issues and updating readme & specs) doesn't let us fully and properly participate in the discussions.

tabatkins commented 3 years ago

Your argument in this thread was not over pipeline syntaxes, but over the need for a pipe operator at all. It applies equally well to any pipeline syntax.

loreanvictor commented 3 years ago

Your argument in this thread was not over pipeline syntaxes, but over the need for a pipe operator at all. It applies equally well to any pipeline syntax.

I actually have to disagree. the argument I made was specifically around the hack-style pipeline. the F#-style pipeline doesn't have a token representing the preceding value, so this argument doesn't apply to it.

loreanvictor commented 3 years ago

I feel my original question was "being too wordy doesn't seem like enough of an argument against temporary variables since we can make it equally wordy, are there any other reasons to prefer hack-style pipelines over temporary variables?" and we already have answers for that (basically code safety by avoiding variable reassignment since ^ is not a reassignable variable). I would recommend on formulating a real world-ish example highlighting that code safety difference, creating a PR to include this additional reasoning in the README as well, and then if there are arguments against those additional reasons, we can discuss them in separate issues afterwards (basically after they are well formulated and added as proper reasons for hack-style pipelines over temporary variables).

tabatkins commented 3 years ago

Hack-style and F#-style pipelines are (with some caveats) equivalent in power; anything you can do with one you can do with the other. Temp-var reassignment is an argument against the need for a pipe operator at all; it happens to resemble Hack-style pipelines more closely, but the argument for/against it is has zero details specific to Hack-style pipelines. The presence or absence of a placeholder token isn't significant; F#-pipeline absolutely does as well when you're using arrow functions (the arrow-func's argument). As far as I can tell, the argument for temp-var reassignment doesn't hinge on the alternative being point-free or not, either.

It's possible I'm missing something - can you elaborate on why you think temp-var reassignment is an argument against one pipe operator syntax in particular?

theScottyJam commented 3 years ago

Good point @tabatkins

I guess in other words, these are all equivalent:

const data = { x: 1, y: 2 }

const modifiedData = data
  |> Object.entries(^)
  |> ^.map(([k, v]) => [k, v + 1])
  |> Object.fromEntries(^)

const modifiedData = data
  |> _=> Object.entries(_)
  |> _=> _.map(([k, v]) => [k, v + 1])
  |> _=> Object.fromEntries(_)

let _ = data
_ = Object.entries(_)
_ = _.map(([k, v]) => [k, v + 1])
_ = Object.fromEntries(_)
const modifiedData = _

Given a properly structured temp-var pipeline, it's easy enough to do a direct conversion into either hack-style or F#-style. If hack-style pipes are seen as unecessary, because a temp var pipeline is a good enough replacement, then F#-style pipes should also be seen as unnecessary, as the differences between hack and F#-style are really quite minor (in F#, you use a function literal when you need topic-like behavior, in hack, you use a "(^)" when you're just wanting to use a single-param function - that's about it)

tabatkins commented 3 years ago

The first two are equivalent; the last is equivalent because none of the steps close over the temp variable, but would be different (in a worse way) if they did.

But other than that, yes, your argument is correct.

theScottyJam commented 3 years ago

So, maybe we just need to start doing pipelines like this - and have lint rules and such help out 🙃️

let _ = data
_ = (_=> Object.entries(_))(_)
_ = (_=> _.map(([k, v]) => [k, v + 1]))(_)
_ = (_=> Object.fromEntries(_))(_)
const modifiedData = _
kaizhu256 commented 3 years ago

no, in practice the choice of variable-name is usually whatever is the final value to be returned/serialize/message-passed:

let modifiedData = ...
modifiedData = ...
modifiedData = ...
...
postMessage(modifiedData);

so in the readme example, it would be:

let coloredConsoleText = Object.keys(envars)
coloredConsoleText = envarKeys.map(envar => `${envar}=${envars[envar]}`);
coloredConsoleText = envarPairs.join(' ');
coloredConsoleText = `$ ${envarString}`;
coloredConsoleText = chalk.dim(consoleText, 'node', args.join(' '));
console.log(coloredConsoleText);
loreanvictor commented 3 years ago

Hack-style and F#-style pipelines are (with some caveats) equivalent in power; anything you can do with one you can do with the other. Temp-var reassignment is an argument against the need for a pipe operator at all; it happens to resemble Hack-style pipelines more closely, but the argument for/against it is has zero details specific to Hack-style pipelines. The presence or absence of a placeholder token isn't significant; F#-pipeline absolutely does as well when you're using arrow functions (the arrow-func's argument). As far as I can tell, the argument for temp-var reassignment doesn't hinge on the alternative being point-free or not, either.

It's possible I'm missing something - can you elaborate on why you think temp-var reassignment is an argument against one pipe operator syntax in particular?

it actually is, since it indicates presence / absence of one important token per step of the pipeline. it makes some pipelines (in which every expression resolves to a unary function) much shorter and other pipelines (where you need to turn an expression into a unary lambda function to be able to express where in the final expression the preceding value should appear) longer. without that intermediate token, pipelines have a linearly scaling token benefit over assignment sequences, and fall short multiple important tokens behind where the expression doesn't resolve into a unary function by default.

in other words, it's the difference between constant overhead and a linearly scaling overhead.

tabatkins commented 3 years ago

without that intermediate token, pipelines have a linearly scaling token benefit over assignment sequences, and fall short multiple important tokens behind where the expression doesn't resolve into a unary function by default.

I apologize for still being confused. It sounds like you're arguing here that temp-var reassignment additionally has a several-token advantage over F#-pipes, if some of the pipeline steps aren't unary functions? Doesn't this mean that temp-var reassignment is better than F#-pipes (assuming we're judging purely by token length)?

tabatkins commented 3 years ago

no, in practice the choice of variable-name is usually whatever is the final value to be returned/serialize/message-passed:

Hm, that code looks pretty foreign to me - I haven't seen code where a semantically meaningful name is given very early in a function, before the name is remotely meaningful, and then repeatedly reassigned like this. Are your experiences different?

kaizhu256 commented 3 years ago

yes, the basic template of a macro-level javascript-program in my experience is:

function messagePassingProgram(webInput, configOptions) {
    let serializedOutput = webInput;
    serializedOutput = ...
    serializedOutput = ...
    ...
    serializedOutput = ...
    messagePass(serializedOutput);
}

after fleshing-out/rewriting the program several times, you organically figure out a better/more-semantic name for generic temp-variable serializedOutput (e.g. responseText, coverageResult, astTree, sqlQuery, etc.).

loreanvictor commented 3 years ago

without that intermediate token, pipelines have a linearly scaling token benefit over assignment sequences, and fall short multiple important tokens behind where the expression doesn't resolve into a unary function by default.

I apologize for still being confused. It sounds like you're arguing here that temp-var reassignment additionally has a several-token advantage over F#-pipes, if some of the pipeline steps aren't unary functions? Doesn't this mean that temp-var reassignment is better than F#-pipes (assuming we're judging purely by token length)?

it does, where several steps in the pipeline do not resolve to unary functions, since in those cases you would need to include 'x =>' whatever expression you have, which you can assume as two (or generally, a constant number of) additional tokens per step (assuming the F#-style pipelines and not the hack-style pipelines, which will always have that linear overhead similar to temp-var reassignment sequences).

tabatkins commented 3 years ago

Right. So it sounds like, if temp-var reassignment is an argument against Hack-style pipes (because it can do the same thing for similar syntax weight), it's an even stronger argument against F#-style pipes, right?

Jopie64 commented 3 years ago

I guess in other words, these are all equivalent:

const data = { x: 1, y: 2 }

const modifiedData = data
  |> Object.entries(^)
  |> ^.map(([k, v]) => [k, v + 1])
  |> Object.fromEntries(^)

const modifiedData = data
  |> _=> Object.entries(_)
  |> _=> _.map(([k, v]) => [k, v + 1])
  |> _=> Object.fromEntries(_)

let _ = data
_ = Object.entries(_)
_ = _.map(([k, v]) => [k, v + 1])
_ = Object.fromEntries(_)
const modifiedData = _

Given a properly structured temp-var pipeline, it's easy enough to do a direct conversion into either hack-style or F#-style. If hack-style pipes are seen as unecessary, because a temp var pipeline is a good enough replacement, then F#-style pipes should also be seen as unnecessary, as the differences between hack and F#-style are really quite minor (in F#, you use a function literal when you need topic-like behavior, in hack, you use a "(^)" when you're just wanting to use a single-param function - that's about it)

This is indeed true, if you want to have topic-like behavior. But when you don't want this topic-like behavior, then the benefits of F# over hack or temp-vars become apparent.

Consider a generator library that has operators for lazy lists.

Hack:

const sumOf100Primes = range(0, Infinity)
  |> Lazy.filter(isPrime)(^)
  |> Lazy.take(100)(^)
  |> Lazy.reduce(add)(^);

Temp-var:

let _= range(0, Infinity)
  _= Lazy.filter(isPrime)(_)
  _= Lazy.take(100)(_)
  _= Lazy.reduce(add)(_);
const sumOf100Primes = _;

In my view, both of the above have the same cognitive overload of the token. But with F#, there is no such cognitive overload:

const sumOf100Primes = range(0, Infinity)
  |> Lazy.filter(isPrime)
  |> Lazy.take(100)
  |> Lazy.reduce(add);

Although I think it is a good point that one benefit of Hack over Temp-var is that closures don't close over a changing variable, I strongly doubt that that is the reason why you currently don't see the Temp-var pattern used in the wild. I think the main reason is because of the token. And here it shows a clear benefit of F# over the the other 2 approaches.

Also as @kosich pointed out, we do see F#-like pipe constructs being used in the wild already. When used, this is almost always without a token. The pattern is mostly used to execute a list of operations on data, and the operations are existing functions of a library most of the time that you can preconfigure with arguments. (See Lazy.xxx operator functions above.) But currently it has to depend on a pipe() function that has to be imported and it has a different cognitive overload. |> barely used as a simple application operator (like in F#) would make these kinds of code so much cleaner and less dependent.

theScottyJam commented 3 years ago

In other words, it sounds like this argument is "hack is only marginally better than temp vars - they both suffer from the same issue - they both refer to some sort of variable/token at each step", and "F# is better than both, because F# works great for curried and single-var functions"?

Does that about sum it up?

Jopie64 commented 3 years ago

F# is better than both, because F# works great for curried and single-var functions

I'd put it more like this: F# works great for the list-of-operators pattern. |> can serve the same purpose as . on instances, when you use it 'fluent style'. The drawback of . is that it is hard to extend and that the data where the operators act on need to be class-like. A simple application operator like |> would solve both issues.


Another more philosophical thing is: as an architect, I want code to be as simple as possible. Paradoxically, most of the time it is easy to make things complex, and it is hard to make things simple. The beauty of |> as an application operator, is that it's semantics are very simple. It applies a function with an argument, just like (), but with the argument before it. This simple semantic gives devs a powerfull way to make some code simple and easy to mentally parse. I'm of the strong opinion that the main reason to justify adding a language feature is to be able to write code that is simpler and eventually easier to read. Code is a way to communicate with a machine, but importantly also to the human reader.

theScottyJam commented 3 years ago

Well, there's already been a whole ton of discussion around the benefits of both hack-style and F#-style - I guess we need to wait for the meeting notes to know why they chose hack-style. A good portion of this debate has happened over here, a lot of this kind of discussion has already been hashed out over there.

I do agree that the F#-style proposal is certainly simpler semantic-wise. I also know that one of the big things functional languages promote is point-free style programming - F#-style naturally makes this easier to do, and penalizes you if you try to go against this. This is a good thing if you find point-free programming to be a good virtue to pursue. Point-free programming is what naturally brings about the kind of "list-of-operators" pattern you're talking about, among with many other benefits.

The question is - should point-free programming be taken as a fundamental virtue that we want to encourage in Javascript itself? Of course, all of the deep functional fans will be shouting "YES!" to this, but I'm not so sure.

I suspect that the committee has simply decided that this is not a virtue they want to push or encourage in the Javascript ecosystem. If this is the case, then one of the biggest arguments for F#-style falls away, and hack-style starts looking a lot more attractive.

For some people, this has turned the pipeline operator into something near useless, because now we've re-introduced that dreaded point - the "^". They would rather use a pipe function that have that point in their pipeline. That's fine. But most Javascript users today are not trying to do point-free programming and the constraints that F# pushes would just become an annoying hassle to them. I do think the hack-style pipe fits more people's use cases.

This does circle us back to the original point of this discussion. If point-free pipelines isn't the end goal, then what is? I think many people have laid out a number of reasons as to why the pipeline operator is still useful, even if you're required to use that dreaded point. And, for some people, hack-style will really be a let-down, because their favorite piece will be missing. It's too bad that we're not able to find a middle ground that makes both parties happy, but I do think there's good reason to not pursue point-free programming as a virtue that we incorporate into the syntax level.

Jopie64 commented 3 years ago

… F#-style naturally makes this easier to do, and penalizes you if you try to go against this.

How does it penalize you I wonder?

I don’t consider F# pipe as a way to do point-free programming. It’s just that you have the function and the argument reversed. You still ‘name’ the argument, but just before the function.

The question is - should point-free programming be taken as a fundamental virtue that we want to encourage in Javascript itself?

No, it should be completely the other way around. In the Netherlands we have a saying, rules are made for people, people are not made for the rules. One should not use point-free programming, nor any other style, only because of principal. The reason, most of all, should be to help the reader understand your intent.

... Javascript's functions are not curried by default. You have to basically use a framework/library thing like Rambda to actually use point-free programming in Javascript effectively.

Not at all!

const myOperator = (configuration, arguments) => data => {
   // Do something with the data using configuration arguments
   return data;
}

Just vanilla javascript. Note that this is more or less curried, but not point-free cause you still name all arguments including ‘data’ and now you can use it as operator in F# pipes.

… others find that the variable names (the points) provide a lot of information that you normally wouldn't have.

What information is carried by the name of the hack token?

But most Javascript users today are not trying to do point-free programming and the constraints that F# pushes would just become an annoying hassle to them.

How does F# constraint push point-free style? That is really unclear to me. I might have used point-free style in this example:

range(0, Infinite) |> Lazy.filter(isPrime)

But I could have written it like this all the same:

range(0, Infinite) |> Lazy.filter(nr => isPrime(nr))

Nothing has anything to do with the pipe operator used. I could write it point-free when using good ol’ . style operators:

[1,2,3,4].filter(isPrime)

If point-free pipelines isn't the end goal, then what is?

Well, it isn’t the end-goal. So…

… but I do think there's good reason to not pursue point-free programming as a virtue that we incorporate into the syntax level.

I agree.

runarberg commented 3 years ago

The question is - should point-free programming be taken as a fundamental virtue that we want to encourage in Javascript itself? Of course, all of the deep functional fans will be shouting "YES!" to this, but I'm not so sure.

One of the points made in #167 was that the functional programming paradigm has bad support in the language relative to the other popular paradigms. The language of the web should be more accommodating towards popular paradigms, hence it is not a bad thing to ask that a widely sought after operator would favor this paradigm (and accommodate it’s supporters) over the others.

Point-free programming relies on curried functions, and Javascript's functions are not curried by default.

This was also voiced in #167 what I gathered was that the partial application proposal would solve this shortcoming.

I too am waiting to see the meeting notes, perhaps they did some usability testing, some controled interviews, and gathered data to justify their decision. But while the notes are unreleased and we don’t have evidence, I’m not in favor of making claims such as “most Javascript users today are not trying to do point-free programming” and what most users will get/want/benefit etc.

loreanvictor commented 3 years ago

Right. So it sounds like, if temp-var reassignment is an argument against Hack-style pipes (because it can do the same thing for similar syntax weight), it's an even stronger argument against F#-style pipes, right?

@tabatkins not necessarily. In terms of token count (taken as a proxy for wordiness and hence developer convenience) Hack-style pipes would be marginally better than temp-var assignment in all domains of applicability. As for F#-style pipes, I feel you should look at the different domains (i.e. different types of potential pipelines):

Combining the results of two domains can be tricky. It seems like the first domain is limited to a specific type of expression while the second is not, hence the latter should have more weight in the combination and in the decisions we make based on that.

However, I personally feel that does not provide a proper assessment, and we should look at actual use cases. The two really prominent examples I can think of are RxJS operators and jQuery methods, in both cases each pipeline step resolves into an operator:

pipe(
  source,
  transformA,             // --> pure operator
  transformB(param),      // --> constructed operator
)

$('stuff')
  .methodA()              // --> the `.method()` bit can be considered a function taking a unary `this` argument
  .methodB(param)

For measuring the actual use cases for the second type of pipelines, I suspect it is best to look at prevalence of sequences like this:

const src = something;
const srcStr = src.toString();
const srcSplit = srcStr.split('\n');
...

So to summarize: perhaps if you want to apply it in all cases (depending on weight of various cases), not in cases where it is suitable for application (so it is more of a "effectiveness vs applicability" trade-off).

mAAdhaTTah commented 3 years ago

This is still "point-free":

range(0, Infinite) |> Lazy.filter(nr => isPrime(nr))

in that the point that's passed to function returned by Lazy.filter isn't named. By comparison:

// Hack
range(0, Infinite) |> Lazy.filter(nr => isPrime(nr))(^)

// F#
range(0, Infinite) |> x => Lazy.filter(nr => isPrime(nr))(x)

is now pointed because the data you're operating on is explicit. In the second example, F# would allow you remove both of those points; Hack would require the tailing point (^). Because F# encourages these sort of unary functions, it encourages that style of point-free composition.

The myOperator example is "point-free" in a similar manner, in that F# encourages writing functions in the way you've written (function-returning functions), which is not idiomatic in JavaScript, and encourages you to call them with the first two args, but allow the 3rd arg to be passed point-free.

kosich commented 3 years ago

A small imho on measuring use cases for hack-style pipes from @loreanvictor 's example:

const src = something;
const srcStr = src.toString();
const srcSplit = srcStr.split('\n');
...

I'm not sure such code snippet out of the wild can be seen as hack-pipe equal, since variable names might matter. As sometimes we want to name the intermediate results to improve readability & better transmit the intention. Maybe, we should search particularly for the temporary variable assignment w/ the same name (like in original _= examples). Yet again, then the need of the new language feature for such cases, that are already handled with simple _= chain, would be doubtful. Looks like catch-22 for hack-style pipes.

While the F# style comparison is fair, although crawling for data would require looking for different pipe analogues in many libs.

Sorry for going a bit offtopic.

Jopie64 commented 3 years ago

This is still "point-free":

range(0, Infinite) |> Lazy.filter(nr => isPrime(nr))

in that the point that's passed to function returned by Lazy.filter isn't named.

I still argue it has the same point-level as normal function calls as in that you name the same things except you put the argument in front instead of after.

range(0, Infinite) |> Lazy.filter(nr => isPrime(nr))

vs

 Lazy.filter(nr => isPrime(nr))(range(0, Infinite))

Hack introduces an extra 'point' intentionally carrying no information.

The myOperator example is "point-free" in a similar manner, in that F# encourages writing functions in the way you've written (function-returning functions).

I think you are confusing point-free with currying. When you write functions like this it allows users to call them 'partially' (partial application). When you want to write them point-free you don't name all arguments. But in that example I named them all.

which is not idiomatic in JavaScript

Please state why this is not idiomatic? It sometimes is very useful for a function to return other functions. Also, as you can see, in JavaScript it requires very little syntax to do so.

You should not write point-free/currying style merely out of principle. There should be a good reason behind it. But the same counts the other way around as well: you should not avoid point-free/currying style just out of principle (e.g. only because you think this is idiomatic).

mAAdhaTTah commented 3 years ago

Hack introduces an extra 'point' intentionally carrying no information.

Yes, exactly, the F# version is "free" of that "point", hence "point-free".

When you want to write them point-free you don't name all arguments. But in that example I named them all.

I'm not confusing point-free with currying. You're confusing arguments with parameters.

Please state why this is not idiomatic?

Currying & point-free are closely related concepts. What I'm referring to when I say it's not idiomatic isn't simply "point-free" but the overall unary-function-returning style that F# encourages. Currying is not idiomatic in JavaScript outside of the subset of functional JavaScript, and the fact that "functional JavaScript" is its own subtype is its own argument for it not being idiomatic broadly.

I used Ramda extensively at one point in my career and have since stopped because no one on my team understands or is familiar with those patterns. That's what I mean by not idiomatic.


Edit: Here's another example:

const add = a => b => a + b;
[1, 2, 3].map(add(1));

By your argument, line 2 isn't "point-free" because we named a & b in add.