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

New, optimized version of Pipeline Operator #20

Closed
gilbert opened this issue Dec 9, 2015 · 33 comments
Closed

New, optimized version of Pipeline Operator #20

gilbert opened this issue Dec 9, 2015 · 33 comments
Labels

Comments

@gilbert
Copy link
Collaborator

gilbert commented Dec 9, 2015

After much mulling and consideration, I think I have managed to combine everyone's ideas to create a version of the pipeline operator that is optimal for JavaScript. In short, the rule is:

  • Pipe the left-hand side of the operator into the right-hand side as the last argument.

In other words, x |> f(10, 20) is equivalent to f(10, 20, x).

Why not the first argument like Elixir? A couple reasons:

  • When I read x |> f(10), I expect 10 to be the first argument to f. Elixir's style changes this to f(x, 10).
  • Piping to the last argument plays well with partial-application-friendly functions. This allows code such as let g = f.bind(null, 10); x |> g(20)

Syntactically, this also lets us code really useful flows that still solve the prototype extension problem. Check out the full README of the new proposal version for details.

I would love everyone's feedback on this, both negative and positive. Thanks for reading.

P.S. The pull request for this version is here.

@gilbert gilbert mentioned this issue Dec 9, 2015
@RangerMauve
Copy link

I kinda liked calling the right hand side as a unary function since it was obvious how it was getting called, however the performance implications and the fact that it fits in nicely with partial function application-centric libraries is good enough reason for me.

@seanstrom
Copy link

@mindeavor
What happens when I want to call a function that has been written to be curried?
For example:

let add = x => y => x + y
100 |> add(10)

Here I'm expecting add to be applied to 10 and return the function that would take 100.

@tabatkins
Copy link
Collaborator

Anything with syntactic transformations is unfriendly to patterns like dynamically generating functions, or grabbing the function you want to pipeline beforehand and storing it in a variable. This makes it much less useful. The previous version that was merely an alternate .call syntax was much friendlier to various types of functional programming.

In particular, syntax like:

let loggify = f=>x=>{let r = f(x); console.log(r); return r;};
64 |> loggify(sqrt);

no longer works - it'll try to insert the argument into the loggify() call, rather than the resulting function. This makes it harder to refactor code - you can't start with 64 |> sqrt() and then loggify it later, unless you make a special version of loggify() designed explicitly for pipelining that takes the value as a second arg.

The fact that this plays badly with non-pipelining functional code is not worth the 5-character savings of being able to omit the wrapper arrow function.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom Under the new proposal you can accomplish that by using parenthesis:

let add = x => y => x + y
100 |> (add(10)) //=> add(10)(100)

@tabatkins I think a general log function will solve that issue:

let log = (x) => (console.log(x) || x);

64 |> sqrt(); // Original
64 |> sqrt() |> log(); // Logged version

64
  |> sqrt()
  |> log() // <-- Easy to comment out
;

@seanstrom
Copy link

@mindeavor would @tabatkins log function also work with the defined syntax you used in my example?

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom Certainly:

let log = (x) => (console.log(x) || x);
let add = (x) => (y) => x + y;

100 |> (add(10)) |> log() //=> log( add(10)(100) )

// Or even:
100 |> (add(10)) |> log() |> (add(20)) //=> add(20)( log( add(10)(100) ) )

@seanstrom
Copy link

@mindeavor oh sorry I mean to ask that @tabatkins could wrap the function call in parens too.

let loggify = f => x => {
  let r = f(x)
  console.log(r)
  return r
}
64 |> (loggify(sqrt))

@seanstrom
Copy link

@mindeavor I feel hesitant that users will accept the additional syntax for calling a function inlined, instead of additional syntax to annotate argument list pushing.

@seanstrom
Copy link

Have we considered using a syntax for the placeholder arguments like so:

100 |> _.reduce([1,2,3], &1, fn)

This syntax is borrowed from how I believe Elixir allows for lambda expressions with argument placeholders, just slightly re-purposed.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom I understand where you're coming from, but I do believe non-curried functions are the vast majority of JavaScript.

In fact I think that the main reason there is a small presence of currying in JavaScirpt at all is because of ES5's verbose functions. If arrow functions had always existed, I doubt most anyone would be inclined to manually curry their functions.

Re placeholders: This has been discussed in #4. The consensus is adding additional syntax for 10 |> f(&1, 20) is not worth it when you can use an arrow function instead.

@eplawless
Copy link

@mindeavor I'm less enthusiastic about this version, it seems much more implicit than the previous proposal and I find it more difficult to read. You've got to keep in your head that f(x,y) isn't really f(x,y), it's actually f(x,y,z), which is more cognitive overhead.

@seanstrom
Copy link

I agree n-ary are more popular than unary functions at the moment, but piping composes very well with unary functions by default. It's only when a community is already used to n-ary functions that we have to design a shorthand around what can be seen as building a curried function that will eventually call the n-ary function. That's why I suggested place holder arguments since they seem like a good way of modeling that.

@seanstrom
Copy link

Arrow functions over Placeholder Arguments but
Special syntax for normal function application seems unrewarding.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@eplawless Yes, that is a valid downside to this version. On the other hand, you could make a similar argument against x |> f(10) – in this situation, f(10) isn't really f(10), it's actually f(10)(x). Either way, invoking the |> operator does change some semantics.

@seanstrom I think we're going to have to agree to disagree on this point. As much as I love FP, I don't see the JS community ever moving towards curried functions by default.

As for "un-rewarding", to me the motivating examples speak otherwise :)

@seanstrom
Copy link

@mindeavor I feel like you're missing the point. I'm not saying currying is the only way or the best way.
I'm pointing out that the design hinders users who apply functional techniques and everyone else who already knows how the function application syntax.

I'm suggesting that when we add support for multi-parameter functions, we do so by building on top of the syntax instead of overloading syntax if possible.

Again |> by default works with unary functions, there shouldn't be any unnecessary pain around the syntax for that. Meaning we wouldn't want to do:

val |> (partialFn(otherVal)) // partial application
val |> unaryFn() // unary function

@pygy
Copy link
Contributor

pygy commented Dec 10, 2015

I liked your initial proposal better, for its simplicity. It mixes very well with the new x => foo(x, 3) syntax.

@eplawless
Copy link

@mindeavor with the old version, there was a way to reduce the cognitive load in targeted cases where the higher order functions weren't obvious, using arrow functions:

x |> x => f(10)(x)

We lose that in the new proposal. It also allowed very clear "placeholder" syntax.

x |> _ => f(1, _, 3)

The new proposal is a behavior of this operator that we haven't seen before, and I think it has some real downsides. I'd urge you to really consider whether this is the right direction for it.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom I think I understand what you're saying, but the only hinderance I can see is the usage of curried functions. All other functions seem to be perfectly fluent.

Here's my stance on the issue. I love FP, and often curry my own functions. However, there are tradeoffs to be made. The new pipeline version effectively suffers the need to type curry-parenthesis, but gains the ability to integrate with prototype methods while still being useful for normal functions. This is an important point; you don't want to switch to a different style of chaining and not be able to go back.

Of course, it very well may be that the original is better. Which I would have no problem with; I would just like to exhaust all practical possibilities before a decision is made.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@eplawless That's a really nice placeholder syntax! And don't worry, "considering" is all I am doing at the moment :)

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

Before I forget, there is one issue with the original proposal: associativity, especially with arrow functions.

arg |> x => f(x, 10) |> y => g(y, 20)

// Parsed as:
( arg |> x => f(x, 10) ) |> y => g(y, 20)
// or:
arg |> x => ( f(x, 10) |> y => g(y, 20) )
// ?

Both of the above constructs are useful. I've seen people make comments with an example like this:

array.map( x => x |> f |> g )

...which would either work or break depending on operator precedence. This new proposal avoids this by requiring parenthesis around arrow functions. Should the same be required in the original proposal?

@seanstrom
Copy link

@mindeavor I don't understand the trade-off of prototype methods and curry-parens. By that I mean to say I understand why you'd want to be able to access prototype methods but not how this design meets that goal better.

I'm assuming by the example you linked you're referring to:

val
|> Lazy() // Lazy(val)
.map( p => p.name )
.take(5)

In my mind this would work the same as:

let collection = val |> Lazy
collection
.map( p => p.name )
.take(5)

Or with an extra rule for newlines

val
|> Lazy
.map( p => p.name )
.take(5)

which would be equivalent to

(val |> Lazy)
.map( p => p.name )
.take(5)

and if you piped again it would be

((val |> Lazy).map(p => p.name).take(5)) |> otherFn

Was that what you were referring to? In general I appreciate the balancing between to the two styles that you're trying to go for. I hope what I'm doing is at least voicing these concerns properly.

@seanstrom
Copy link

@mindeavor good point on the arrows functions. I assumed users would need to wrap their functions in parens in order to separate the expressions in a single line. Else the first functions body would be the rest of the operations. At least that's how the syntax reads to me.

@billyjanitsch
Copy link

Another -1 here.

On the other hand, you could make a similar argument against x |> f(10) – in this situation, f(10) isn't really f(10), it's actually f(10)(x). Either way, invoking the |> operator does change some semantics.

I disagree; here f(10) is still f(10). It's only the result of the function call that's being modified by the operator (similar to decorator factories that take arguments @likeThis(...). I find it very difficult to parse an operator that actually modifies the invocation of the function.

let add = x => y => x + y
100 |> (add(10)) //=> add(10)(100)

This similarly feels un-Javascript-y. I have no reason to expect (add(10)) to be different from add(10).

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom Yup, that's what I'm referring to. One advantage to piping to the first invocation is that you can start chaining right away, and even pipe to something else afterwards without hinderance.

JS isn't whitespace sensitive in general, so a newline rule would probably not be a good idea.

I assumed users would need to wrap their functions in parens in order to separate the expressions in a single line.

Can you provide an example for illustration?

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

This similarly feels un-Javascript-y. I have no reason to expect (add(10)) to be different from add(10).

This is a fair point :)

@seanstrom
Copy link

@mindeavor yeah I agree say that whitespace rules wouldn't be much better.
The only fallback I have is still "placeholder-args". I know it's beaten to death at this point, but it's the only I can think of modeling the function application. For example:

val
|> Lazy(&1).map( p => p.name ).take(5)

// equivalent to

val
|> x => Lazy(x).map( p => p.name ).take(5)

Which is just more of the same JS we know, but we add the sugar syntax for convenience. Again I know you're very aware of this, just explaining my line of thinking.

Here's what I meant for wrapping the arrow functions:

arg |> x => f(x, 10) |> y => g(y, 20)

// could be two expressions
arg |> (x => f(x, 10)) |> (y => g(y, 20))

// or one expression with piping in its definition
arg |> (x => f(x, 10) |> y => g(y, 20))

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom In an ideal world we would have placeholder args, but I don't think it will happen taking responses like this into consideration.

I just realized my concerns associativity may be unfounded. From what I can see – and someone correct me if I'm wrong – the following two lines are actually equivalent:

(arg |> x => f(x)) |> y => g(y)
arg |> x => (f(x) |> y => g(y))

@ghost
Copy link

ghost commented Dec 10, 2015

This new proposal makes the javascript syntax ambiguous, a function call stops being a function call if next to the pipe operator. The parenthesis around the function call to bring back the normal behavior also looks like trying to solve a problem that should not be there, and adds even more ambiguity.

If currying functionality is something desirable as part of the syntax it should be a separate proposal.

@seanstrom
Copy link

@mindeavor just finished reading the critique to this whole proposal and the comment you specifically linked. I apologize for being too pushy, you've already gone through enough stress. It's moments after reading that kind of push back that makes me want to use at least SweetJS.

Also, I believe the associativity you showed checks out. This of course assumes f and g are pure and stuff. I would also mention that if users didn't wrap their arrow functions, they may have done so to intentionally create a closure.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 10, 2015

@seanstrom It's no problem, thank you for being considerate.

When you "assume f and g are pure", can you think of a case where non-pure would make a difference? Both lines of that example I wrote translate to the same AST, so I think in either case using non-pure functions would retain the same behavior.

@seanstrom
Copy link

@mindeavor you're absolutely right about the purity not mattering. I meant that strictly for the example being associative in the law sense. But the way functions will eventually pipe to each other is equivalent as you've shown it.

@gilbert
Copy link
Collaborator Author

gilbert commented Dec 11, 2015

It seems the consensus is that the original proposal is much better, due to its simplicity and understandability. With regard to the original proposal, there is one more thing to discuss: operator precedence (#23)

Closing now :)

@github-actions
Copy link

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

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

No branches or pull requests

7 participants