Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How about overloading | instead? #190

Closed
lightmare opened this issue May 29, 2021 · 31 comments
Closed

How about overloading | instead? #190

lightmare opened this issue May 29, 2021 · 31 comments

Comments

@lightmare
Copy link

Have you considered overloading an existing operator instead of introducing a new one? I looked around, and only found some mentions of possible future user-defined operator overloading, like C++ or Python have. That's not quite what I mean. I was thinking about overloading just | for functions, similar to how + is overloaded for strings.

Currently if typeof Foo === 'function' then (x | Foo) === (x | NaN) === (x | 0).
Even though this operation is well-defined, I couldn't come up with a sensible use case where you'd have a calculation relying on the coercion function ~> NaN on the right-hand-side of |.

Pipe operator overload

I experimented a bit with AST explorer, rewriting a | b into:

    lhs = a,
    rhs = b.valueOf(),
    typeof rhs === 'function'
        ? rhs(lhs)
        : lhs | rhs

An upside of overloading an existing operator is that there'd be no changes to grammar. Everything would parse exactly as it parses now. Which could also be seen as a downside, in that it's essentially stripped-down minimal F# semantics without special rules for await/yield.

Another downside is not-immediately-obvious interaction with arrow function expressions:

    res = input
        | x => foo(x, 1)
        | y => bar(2, y)   // still inside x =>
        ;

    // gets parsed as
    res = input | (x => foo(x, 1) | y => bar(2, y));

    // resulting in:
    t1 = input, res = (t2 = foo(t1, 1), bar(2, t2));

It may be surprising to some, that the second arrow function is inside the first, meaning it has access to x. Others might consider that useful, I'm not sure. If such nesting is undesired, it can be prevented by parenthesizing each arrow function:

    res = input
        | (x => foo(x, 1))
        | (y => bar(2, y))
        ;

    // gets parsed as:
    res = input | (x => foo(x, 1)) | (y => bar(2, y));

    // resulting in:
    t1 = input, t2 = foo(t1, 1), res = bar(2, t2);

This might not even be a big issue, because regardless of whether the functions are nested (as in the first example where the line breaks are deceptive), or individually parenthesized (as in the second example), the order of operations and the result will be the same (as long as the inner function doesn't reach for x, of course). Since the pipe operator is left-associative, arrow function gobbling up subsequent | terms doesn't alter the sequence of calls:

    (a | x => (f(x) | g))  ==  (x => (f(x) | g))(a)  ==  (f(a) | g)  ==  g(f(a))
    (a | (x => f(x)) | g)  ==  ((x => f(x))(a) | g)  ==  (f(a) | g)  ==  g(f(a))

AST explorer snippet

Here's the experimental transform adding compile-time and run-time | overloads in AST explorer:

https://astexplorer.net/#/gist/1d15ff515bc1dfab22e64820ea6b4a18/latest

It also overloads shift-right operators, which I'm not really suggesting. I was just trying different stuff, figured one could use a secondary operator to resolve promises before calling the right-hand-side, and | doesn't have a same-precedence "twin" for this purpose.

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented May 29, 2021

It's not always NaN, for example:

const Nums = Object.fromEntries(
  ["zero", "one", "two", "three", "four", "five"].map((name, val) =>
    [
      name,
      Object.assign(x => x * val, { valueOf: () => val })
    ]
  )
);

console.log(typeof Nums.two); // "function"

console.log(Nums.one + Nums.two); // 3
console.log(Nums.two(Nums.three)); // "take two 3s", 6
console.log(Nums.five - Nums.three); // 2
console.log(Nums.five / 10); // 0.5
console.log(4 + Nums.three); // 7

// And this:
console.log(4 | Nums.two); // 6

Also, the | operator has worked like this for 20 years so it's really likely that many websites are relying on every possible strange behavior.

@lightmare
Copy link
Author

Thanks for the input. I forgot to update the typeof test in the description, but in the overload-simulating code I did take the .valueOf first, so that particular example would still resolve as bitwise or.

@dy
Copy link

dy commented Aug 27, 2021

Going to express radical opinion.

|> operator is ugly.
| operator is naturally disposed for organizing pipes.
As bitwise OR in JS it is underused (asm.js is not a thing already), but occupies so significant syntax piece.

Things like

whitenoise() | lpf(440, 100) | compress() | speaker(.75)

<a>{ t`hello` | doubleSay | capitalize | exclaim }</a>

are possible in JS[X] already.

Looking more at jsPipe - less it seems a joke. I can imagine similar quality package done seriously, mb using BigInts. Somebody like RxJS:

// current API
fromEvent(document, 'click')
  .pipe(
    throttleTime(1000),
    map(event => event.clientX),
    scan((count, clientX) => count + clientX, 0)
  )
  .subscribe(count => console.log(count));
  
// pipe API
let clicks = fromEvent(document, 'click') | throttleTime(1000) | map(event => event.clientX)
subscribe(clicks, count => console.log(count))

So pipe syntax can be done organically via |, and operator overloading would allow reliable implementation, if promoted. So 🤞🏼 this proposal will get obsolete.

@tabatkins
Copy link
Collaborator

Reusing | is quite impossible - | already has a meaning and (reasonably) wide usage. All of the code you've written is already valid JS.

|> is used across a number of languages; the spelling of the operator isn't the problem here.

@dy
Copy link

dy commented Aug 27, 2021

Reusing | is quite impossible

Practically much possible. That's even defined programming task.

| already has a meaning and (reasonably) wide usage

Yes, as pipe operator as well. Every operator has a meaning and wide usage, nonetheless there's overloading proposal and .valueOf, .toString, Symbol.toPrimitive.

the spelling of the operator isn't the problem here.

Direction is present in code itself, what's the point to underline it with >?
sine(440) | curve(1.08) | speaker(.75) is js-independent unix style - is there any confusion?
sine(440) |> curve(1.08) |> speaker(.75) is local F#/Hack community style.
How many know what is F#/Hack? When did F#/Hack people become an authority for JS folk?

@jonatjano
Copy link

you can't overload the bitwise OR | with pipeline meaning because the two meaning are not compatible

const falsy = () => false

if (true | falsy) {
    console.log("true")
} else {
    console.log("false")
}

using the bitwise operator this logs true because true | false is 1 | 0 which is 1 which is truthy

but if | is used as a pipe, then the condition becomes falsy(true) which is false

@lightmare
Copy link
Author

@jonatjano You're just rewording what has been acknowledged in the OP. Show me six real-world examples of a function on the right-hand side of |. Useless hypotheticals don't really explain why something can't be done.

@jonatjano
Copy link

jonatjano commented Sep 2, 2021

most minifier I know tend to change to order of conditions to put variable values behind constant values :

using UglifyJs 3 this code :

const someFunction = () => Math.random() < 0.5 // create a boolean from other values

if (someFunction() | true) {
    console.log("true")
}

becomes

const someFunction=()=>Math.random()<.5;!0|someFunction()&&console.log("true");

as you can clearly see, the function is now after the bitwise OR, meaning most code minified with UglifyJs 3 using at least one bitwise operator is potentially broken by your proposal, which is more than 6 I suppose

EDIT : I just looked throught Google's result page scripts with the regex [^|][|][a-zA-Z0-9-_]+[(] and found 5 results, meaning that proposal is breaking one of the most used page of the world

@lightmare
Copy link
Author

as you can clearly see, the function is now after the bitwise OR

No, it's not. You have a function call on the right-hand side. The function gets called before the OR is evaluated.

This would only break code where typeof RHS === "function".

@jonatjano
Copy link

jonatjano commented Sep 2, 2021

huh, well I did something dumb by adding these parenthesis, but if you remove the parenthesis the result stay the same, minifiers move the variable identifier to the right hand side of the operator, it doesn't treat functions differently than any other type of variable

@dy
Copy link

dy commented Sep 2, 2021

@jonatjano result of overloaded pipe/any operator now (via valueOf, like here) doesn't make much sense anyways and has to be unconverted.
But the overloading proposal makes any arbitrary result possible, so the meaning of true | falsy can be redefined.
As for result type incompatibility - it's problem of the overloading proposal, and is already present with BigInts.

@lightmare
Copy link
Author

... if you remove the parenthesis the result stay the same, minifiers move the variable identifier to the right hand side of the operator, it doesn't treat functions differently than any other type of variable

So what you're saying is that it would also break code where typeof LHS === "function", because minifiers assume commutativity and freely move it to the RHS. Great observation. However for that to break, it still requires the original code to use a function object on either side of |.

@dy
Copy link

dy commented Sep 2, 2021

Minifiers assume safely assumption of input, preserving workable code (what's the point of minifier otherwise?).
If there's no commutativity in input, eg. when operator is overloaded or order matters, minifier saves the order:

const v1 = {valueOf: () => 1}
const v2 = {valueOf: () => 2}

if (v2 | v1 | true) {
    console.log("true")
}

gives

const v1={valueOf:()=>1},v2={valueOf:()=>2};v2|v1|!0&&console.log("true");

@tabatkins
Copy link
Collaborator

We still don't break existing code if there's no good reason to, and ambiguous parses are generally a strong negative.

The |> digraph has a strong history across multiple languages now. It's fine to hold a "radical opinion" that it's an ugly operator, but the weight of precedence suggests that most people disagree, and |> is actually perfectly fine.

(My coding font even has a ligature for it, turning |> into a triangle. It's pretty nice.)

@dy
Copy link

dy commented Sep 2, 2021

Not sure what breaking code and parsing ambiguity you mean. From the precedence point (Go, Julia, Perl et al.), introducing |> is ambiguous and redundant, not overloading |. But fair enough, |> is more popular.

Overloading proposal enables | as pipe, regardless of |> and opinions. Even now it is practically possible for some limited set of tasks.

One problem of | is unintuitive operators order:

a | b + c | d 
// is
a | (b + c) | d 
// and not
(a | b) + (c | d)
// as one may think from chaining logic
a.pipe(b) + c.pipe(d)

// what's intuition for this?
a |> b + c |> d

@dy
Copy link

dy commented Sep 3, 2021

Another example of overloading pipe https://github.com/jussi-kalliokoski/babel-plugin-operator-overload

@tabatkins
Copy link
Collaborator

breaking code

There are literally trillions of HTML pages in the wild. It's generally reasonable to assume that any behavior that is legal has been used on at least a small % of those pages. If we change the behavior of | in this way, code currently using | might break.

Is it a large risk? Probably not. But taking such risks still needs to give a sufficiently worthwhile benefit, and "|> operator is ugly" isn't a very compelling one; the prevalence of the operator across multiple languages suggests you're in a minority with that opinion.

parsing ambiguity

You end up citing the issue just below - the precedence of | isn't great for what the pipe operator is meant to be used for. It binds more loosely than a number of other operators that we'd like to allow in pipe bodies, meaning that authors would have to wrap pipe bodies in parens to get them to parse as they intended.


I think the problems with reusing | are fairly killer, so I'm going to go ahead and close this issue.

@dy
Copy link

dy commented Sep 9, 2021

change the behavior of |

Not sure where you got this, the proposition is not to change the | behavior, but to proceed with operator overloading proposal first, adopting which would bring another factor into play for this proposal, maybe reveal not obvious usage patterns and provide feedback.

That's unfortunate that you see "|> operator is ugly" as an argument, but ignore precedence facts (from more well-acknowledged languages than F#), standard programming task, brevity and DRY arguments.

I hoped language design is not opinion challenge.

@ljharb
Copy link
Member

ljharb commented Sep 9, 2021

Operator overloading isn’t a thing yet. It’s not appropriate to block a proposal on another that hasn’t advanced past it.

@dy
Copy link

dy commented Sep 9, 2021

Isn't that how proposals get declined, left hanging, become obsolete or get revisioned, due to superseding by (indirect? more general?) competitor?

I'm sorry, are pipes a thing already? Do you mean Stage 1 a couple of days ago?

Also - is that appropriate to ignore alternatives based on opinion? The discussion doesn't seem fair - there's only criticism against |, but no comparison with |>.

@ljharb
Copy link
Member

ljharb commented Sep 9, 2021

Pipes are stage 2 as of last week - but it’s not really appropriate for a stage 1 proposal to be blocked by another, in the general case.

@tabatkins
Copy link
Collaborator

Yeah, layering proposals is sometimes appropriate - we want to make sure we have a coherent language if possible, and sometimes that means waiting for another proposal that we can build off of rather than creating a one-off solution - but not always. Especially in this case, operator overloading is still an immature proposal with little consensus (unfortunately), so it would be a significant delay for this proposal.

As was said in earlier comments, tho, the precedence issues with | mean that it's not a very appropriate operator for pipelines even if we did wait for operator overloading.

Finally, using an overloaded | operator (where it expects the RHS to be a function) would just be a differently-spelled "minimal F#-style" pipeline (that is, without any special cases to let it handle async functions). Several committee members have explicitly stated their rejection of the "minimal F#" syntax (as in "we'll block advancement if that's the proposal") because it's incompatible with async code; that's why the "F#-style + await" proposal was the one that was being seriously advanced instead. (And now that the proposal has reached Stage 2 with the Hack-style syntax, the question is even more moot.)

@dy
Copy link

dy commented Sep 10, 2021

The pipe operator’s precedence is the same as:
the function arrow =>;
the assignment operators =, +=, etc.;
the generator operators yield and yield *;

As was said in earlier comments, tho, the precedence issues with | mean that it's not a very appropriate operator for pipelines

|> has the same precedence as |!

a |> b(^) + c |> d(^) 
// is
a |> (b(^) + c) |> d(^) 
// and not
(a |> b(^)) + (c |> d(^))

// as one may think from chaining logic
a.pipe(b) + c.pipe(d)

JS could be that:

a|b + c|d

Not that:

(a|>b(^)) + (c|>d(^)) 

@ljharb
Copy link
Member

ljharb commented Sep 10, 2021

JS already is that first one; it has a meaning that can’t change.

@dy
Copy link

dy commented Sep 10, 2021

So far the only argument outlined against | is:

minimal F#" syntax is incompatible with async code;

Interesting to know what's implied by that, this?

src | async src => await Promise.resolve(src)

In both proposals, the syntax tax per taxed expression is small (both (^) and x=> are only three characters).

Just want to clarify what tax are we talking about, this?

(1)                           (2)
a |> b(^)              vs     a | b
a |> b(^, ...args)     vs     a | x=> b(x, ...args)

I only wonder if proper research of | line was done, or there's bias.

It is alarming that JS turns into Hack:

// now:
b(a) + d(c)

// proposal:
(a |> b(^)) + (c |> d(^))

@tabatkins
Copy link
Collaborator

In

src | async src => await Promise.resolve(src)

The value of the pipe is now a promise. The next step of the pipeline can't get at the value inside the promise in a reasonable way:

src | async src => await Promise.resolve(src) |> ??doSomethingWithSrcInPromise??

The only way around that is to abandon the pipe operator and switch into .then()-based chaining, which also requires you to wrap your pipeline in parens, harming the readability/writeability benefits that pipeline was bringing in the first place:

(src | async src => await Promise.resolve(src)).then(doSomethingWithSrcInPromise)

Promise-returning functions are already common, and will only become more so. JS has tools to make dealing with promises easier - the await keyword in particular - but you can't use await in the "minimal F#" pipeline. Adding a new code-organization tool like the pipe operator, but having it simply not work with await, is unacceptable to a number of committee members (me included).

Just want to clarify what tax are we talking about, this?

The README goes into detail about this.

It is alarming that JS turns into Hack:

I'm not sure why one would rewrite their expression to use pipes there - the function calls already seem perfectly reasonable. If they're not reasonable, because they're actually representing some heavily-nested stack of code, then the original expression isn't reasonable or readable either - you should be storing each expression in a variable and then adding those variables together.

Pointing out that it's possible to write code badly is not, generally, an argument against a feature. You'd have to show that the new bad code can be expected to become common.

@dy
Copy link

dy commented Sep 11, 2021

Since some current libraries (RxJS, gulp, pull-stream, flyd, observable etc.) aren't going to win from Hack pipes even refactored (syntax tax is high, keyboard and visual noise), there's a room for an alternative. Also there's discontentment with Hack pipes anyways.

You'd have to show that the new bad code can be expected to become common.

Cases of implicit/same-type argument from readme are common (current pipe / stream / chain libs ). RxJS pattern would be more widespread if there was pipe operator, now chaining is just simpler.

// jQuery / dom, pipe
$('#el') |> fadeIn(^, 100) |> css(^, {border: '2px solid red'}) |> on(^, 'click', ()=>{...})

// vs |
$('#el') | fadeIn(100) | css({border: '2px solide red'}) | on('click', () => {...})
// RxJS (#167) pipe
someObservable
  |> map(^, x => x + x)
  |> filter(^, x => x % 2 === 0)
  |> concatMap(^, n => interval(n)
  |> take(^, 3))
  |> subscribe(^, console.log)
  
// vs |
someObservable
  | map(x => x + x)
  | filter(x => x % 2 === 0)
  | concatMap(n => interval(n) |> take(^, 3))
  | subscribe(console.log)
// gulp pipe
src('src/*.js') |> uglify(^) |> rename(^, { extname: '.min.js' }) |> dest(^, 'output/');  
 
// vs |
src('src/*.js') | uglify() | rename({ extname: '.min.js' }) | dest('output/');  
// web-audio / tuna pipe
input('./src.wav') |> gain(^, .6) |> chorus(^, { rate: 1.5, feedback: 0.2, delay: 0.0045, bypass: 0 }) | output(^)

// vs |
input('./src.wav') | gain(.6) | chorus({ rate: 1.5, feedback: 0.2, delay: 0.0045, bypass: 0 }) | output()

Tax from |>, ^ is obvious from length of lines (multiply by project size), whereas | doesn't have tax, compared to chaining.

Hack syntax has a bit different purpose than existing pipe solutions. It tries to glue code from different areas, as shown in readme (object → array → string):

// js way
let keyVals = Object.keys(envars).map(envar => `${envar}=${envars[envar]}`);
console.log(chalk.dim(`$ ${keyVals.join(' ')}`, 'node', args.join(' ')));

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

Not clear why optimizing that, already well-solved in js way problem. Also anticipate breakage for static analyzers, bundlers and transforms (stuff like import(^)).

Many libraries are working on same-type objects, like stream, audio, array, file, animation, date, element etc., and misuse chaining, target.pipe and other contraptions. And Hack proposal does not actually solve that problematic concept in language: pipes.

@dy
Copy link

dy commented Sep 12, 2021

Also facebook/react#22295 😄

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Sep 12, 2021

@dy It feels like you are mixing two unrelated discussions. One is F# vs Hack pipe operator, and the other one is which token to use.

Hack F#
|> source |> map(^, x => x + 1) source |> map(x => x + 1)
| source | map(^, x => x + 1) source | map(x => x + 1)

@dy
Copy link

dy commented Sep 12, 2021

@nicolo-ribaudo to illustrate tax for some common scenarios, these concerns come together. Also | implies minimal F#, so | vs |> automatically means Hack vs F# (there's no point in | Hack).

If there's a chance overriding |, I am not sure if that's worthy introducing |> operator at all in any style, since pipes problem will be perfectly solved for existing cases without breaking anything (the only thing that would change is result type from a | b operation), and other scenarios are covered well without any new syntax.

@tabatkins
Copy link
Collaborator

I'm going to go ahead and lock this issue. The pipe operator is not going to switch to overloading |; there is zero chance of it getting thru the committee even if any of the champions wished to pursue it. We've laid out the reasons clearly for not pursuing this.

@tc39 tc39 locked as resolved and limited conversation to collaborators Sep 13, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants