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

Question about ^ when a "child pipeline" is present. #208

Closed benlesh closed 3 years ago

benlesh commented 3 years ago

RxJS plans to move toward whatever pipeline operator lands. It's very frequent in RxJS that we have child observables that have been set up with pipes of their own. The pattern below is very common:

clicks$.pipe(
  concatMap(() => getData$.pipe(
    catchError(err => {
       console.error(err);
       return EMPTY;
    });
  )),
  map(result => result.someData),
)
.subscribe(console.log);

I'm not sure how to cleanly handle this with Hack pipeline:

clicks$
  |> concatMap(() => getData$
    |> catchError(err => {
        console.error(err);
        return EMPTY;
    })(^) // <-- in particular, I'm not sure what will go here?
  )(^)
  |> ^.subscribe(console.log)

It seems like the presence of a |> in a child function would give new meaning to the ^ on its RHS until the end of the function's context?

I've seen in other examples that ^ can be used within closures. Which is why I have this question.

mAAdhaTTah commented 3 years ago

It seems like the presence of a |> in a child function would give new meaning to the ^ on its RHS until the end of the function's context?

This is correct: the |> operator introduces a new expression scope, so the (^) on line 6 refers to what you expect it to.

benlesh commented 3 years ago

Thanks, that's what I expected. It's a little confusing to read in the example above, but that's a real example. It's a shame the inner ^ can't be named differently.

benlesh commented 3 years ago

Actually, I'll leave this open. As it's probably worth discussing with the committee how to handle this confusion? Are there any possible ways to rename this? Any ideas around how to make this more readable? @tabatkins

ljharb commented 3 years ago

I think it's the same readability concerns whenever any chains get "too much stuff" shoved into it - the solution in my experience tends to be "de-inline stuff as needed". In this case:

const mapper = () => getData$
    |> catchError(err => {
        console.error(err);
        return EMPTY;
    })(^);

clicks$
  |> concatMap(mapper)(^)
  |> ^.subscribe(console.log)
tabatkins commented 3 years ago

Yeah, the general solution is "don't chain that much"; you're over-nesting in an operator meant to reduce nesting. ^_^

That said, if this is necessary, do-expressions will solve the problem; you can assign the outer pipe's topic to a variable inside the do-expr and then access that var in the inner pipe. I do expect do-exprs to eventually land (we've been circling them for years, but the champions are doing good work at cleaning up the remaining issues), so I don't think pipeline has to particularly worry about this issue on its own.

tabatkins commented 3 years ago

That said, looking back at the example in the OP, I think it's fine? It doesn't use an arrow func to capture the piped topic at any point, so it's still relying on implicit topic-passing, and thus is directly convertable into Hack-style with the naive transformation.

// original
clicks$.pipe(
  concatMap(() => getData$.pipe(
    catchError(err => {
       console.error(err);
       return EMPTY;
    }),
  )),
  map(result => result.someData),
)
.subscribe(console.log);

// to...
clicks$ 
    |> concatMap(()=>getData$ 
        |> catchError(err => {
            console.error(err);
            return EMPTY;
        })(^) // <-- close catchError, pass getData$
    )(^) // <-- close concatMap, pass clicks$
    |> map(result => result.someData)(^) // <-- concatMap() result
    |> ^.subscribe(console.log);

The extra parens aren't great to read, I'll grant you, but if the functions are written to take their data as an argument it's a lot better:

clicks$ 
|> concatMap(^, ()=>getData$ 
    |> catchError(^, err => {
        console.error(err);
        return EMPTY;
    }))
|> map(^, result => result.someData)
|> ^.subscribe(console.log);

And yeah, as @ljharb says, if you get serious with "reduce nesting" this reads fairly cleanly even with pure "pass topic as separate call":

function logDataError() {
    return getData$
    |> catchError(err=> {
        console.error(err);
        return EMPTY;
    })(^);
}

clicks$
|> concatMap(logDataError)(^)
|> map(result=>result.someData)(^)
|> ^.subscribe(console.log);

But going that far isn't necessary if prefer the complexity in the first step.

Avaq commented 3 years ago

Also note that with the Hack pipelines, if you have a single step, it's probably nicer just to inline the input instead:

-getData$ |> catchError(^, err => {
+catchError(getData$, err => {
   console.error(err);
   return EMPTY;
 })

I guess the same is true for when the RHS is curried:

-getData$ |> catchError(err => {
+catchError(err => {
   console.error(err);
   return EMPTY;
-})(^)
+})(getData$)

That makes this specific example a bit less cluttered:

clicks$
  |> concatMap(() => 
    catchError(err => {
      console.error(err);
      return EMPTY;
    })(getData$)
  )(^)
  |> ^.subscribe(console.log)
tabatkins commented 3 years ago

Yeah, I thought about that, but for the sake of the discussion didn't want to change the structure of the example.

ken-okabe commented 3 years ago

In fact, this is a good little example.

The history of software development is fight against complexity, and various attempts have been done. Object Oriented Programming is the one and failed against the complexity. The fundamental reason to fail is that people invent something with assumptions that can only adopt shallow level structures.

OOP class is the one. React once employed OOP class, and they admitted the failure and had to discard OOP class from their framework. React or VirtualDOM framework has essentially nested structures, and OOP class has been designed with assumptions of non-nesting structures, that is why they failed.

RxJS is a relatively clean framework, and mostly because they employ clean Functional Programming, and enjoyed the robustness of the simplicity. Now, RxJS is told to be

Yeah, the general solution is "don't chain that much"; you're over-nesting in an operator meant to reduce nesting. ^_^

This is an unfortunate mention to express the fact Hack-style pipeline-operator is not robust against this shallow level of complexity.

OOP class fails to prove robustness in React framework. Hack pipeline-operator fails to prove robustness in RxJS.

I expect RxJS will be a very hard framework to code in future. I don't think users can follow this confusion.

I've been a FP programmer for years, and F# style pipeline-operator helped me to write code so much easier and lot of freedom obtained. https://github.com/stken2050/io-next/blob/master/code/src/io-next.ts

F# is based on math, and the pipeline-operator is also pure math operator which is essentially robust against complexity. Operator is a term of algebra, and as long as we stick to Math/algebra, we can safely avoid the problem of complexity of software development unlike artificial OOP class design failure.

That said, if this is necessary, do-expressions will solve the problem;

So, after inventing a new artificial mechanism that is not essentially math-operator, the new problem has emerged, and in order to cover up the problem we need to introduce new do...

Please note that do is also not a math-operator. It's not an expression but a statement. Outside of the math structure another artificial mess emerges.

It seems like the presence of a |> in a child function would give new meaning to the ^ on its RHS until the end of the function's context?

This is correct: the |> operator introduces a new expression scope, so the (^) on line 6 refers to what you expect it to.

Yes.

https://github.com/microsoft/TypeScript/pull/43617#issuecomment-816850211

Personal opinion: The hack-style pipeline operator is bad because it introduces a new context variable, like this, in the form of #. These don't nest in intuitive ways. In addition, the operator doesn't actually save much space for many usages. You could rewrite....

this is a product of OOP, and the artificial design failed to sustain robustness against software complexity. We the programmers-human can't control the full of context variables, and now again, Hack-style-pipeline operator provide us nesting context.

FP or Functional operator or Math algebra operator or F# style operator never has context. Context is the fundamental sin of the complexity that we the programmers-human never be able to handle. Bug reason.

IF we smartly have a function-composition-operator such as . in addition to FP style |>

a |> f |> g equals a |> (f . g)

I can teach FP beginners in my book based on Math/algebra. This is essentially independent of language specification. You can learn the concept from Haskell,Scala, or F# tutorial book because Math/algebra is common.

In Hack, a |> f(^) |> g(^) equals a |> (f . g)(^)

This is not math, and lose simplicity and robustness against the complexity.

What is ^ ? What about context? How can I teach to beginners? Will they adopt? I don't think so.

tomByrer commented 3 years ago

In Hack, a |> f(^) |> g(^) equals a |> (f . g)(^)

I don't understand why this is bad? So what if programmers can mix & match method chaining & Hack-pipelines? Some codebases mix together OO & Functional Programming. I agree mix & matching styles isn't ideal, but happens.

Perhaps you could write an ESLint rule to warn against mixing chaining & pipes? This would be helpful for beginners & experts who are transcribing into pipes.

ken-okabe commented 3 years ago

I don't understand why this is bad? So what if programmers can mix & match method chaining & Hack-pipelines? Some codebases mix together OO & Functional Programming. I agree mix & matching styles isn't ideal, but happens.

Why are you talking about OO & FP mix on the topic of "Operator"?

I think the most of the Hack advocators here don't understand that pipeline-operator is an operator of Binary operation.

1 + 2 is an binary operation and + is a binary operator. 2 x 3 is an binary operation and x is a binary operator. a |> f is an binary operation and |> is a binary operator. Ok?

Alright, let me talk about OOP a little.

[1,2,3].map(f) This is a OOP code, however, fundamentally this means: [1,2,3] map f is an binary operation and map is a binary operator.

We can go reverse: 1 + 2 + 3 is a sequence of binary operation, but we can rewrite this 1.plus(2).plus(3) method chain You actually can extend Number Prototype plus method, realworld code)

a |> f |> g 1 + 2 + 3 These are pure math expression. Binary operator. Since this binary operator is pure math, they are free from OOP. We just do math, Ok? No more OOP method chain etc.

Remember we all learn Algebra from elementary school teacher. We know how to write 1 + 2 + 3 and know how this expression behaves. None of us want to write the same expression as ugly 1.plus(2).plus(3) because this is an artificial design of OOP that has already failed and not clean Algebra that will never fail. OOP is bad because it's fundamentally unnecessary invention for programmers. Just use algebra. 1 + 2 + 3.

Here some people want to write algebra expression 1 + 2 + 3 to 1 + 2(^) + 3(^) called "HackStyle" and "programmers can mix & match method chaining, not ideal, but happens.."

What's the point of this??

pipleline-operator is an operator of binary operation in Algebra. Pure math. If we prefer to mess up |> pure math binary operator to be a random OOP mixed something, I don't know how the other programming community(especially FP guys) react, or not we, bad decision of TC39. Thanks.

nicolo-ribaudo commented 3 years ago

I guess most of the people trying to guess what other don't understand don't understand that |> for Hack pipes is a pure math binary operator. Pure math.

It takes one JavaScript value and one expression, and produces a JavaScript value. Pure math. We can define |> : V × E → V, where V is the set of JS values and E is the set of JS expressions. Pure math!

I guess you are complaining that it's not an internal binary operator in the form V × V → V? Like +, that you used as an example? Well I have some bad news to share: F#-style pipeline isn't an internal binary operator either. Its domain is F × V, where F is the subset of V containing all the functions.

If what you want is "an operator which is pure math", I'm happy to have convinced you that Hack pipes are ok :smile:


I'm now hiding my comment as off-topic since it's not the topic discussed here. @stken2050 I encourage you to do the same.

ken-okabe commented 3 years ago

Ok, the significant fact here is

The hack-style pipeline operator is bad because it introduces a new context variable, like this, in the form of #.

I think the hack-style is dangerous, but no hack-style advocators have responded to this matter. Probably we should start new issue.


I have some opinions in terms of @nicolo-ribaudo post, and as our discussion is obviously off-topic here, maybe we should start in a separate issue. I think it's substantial.

ljharb commented 3 years ago

The problems with this aren't "that it's a context variable" - that's totally fine (it's the confusion around when it's set and to what). super and new.target work exactly the same way as a pipeline placeholder would wrt being a "context variable", and nobody has a problem with those.

js-choi commented 3 years ago

Also importantly (but this is all off topic), a topic reference would have a lexical binding, while this has a dynamic binding. Static, lexical bindings are much easier to reason about (both for compilers and for humans).

I think we should add a section about this to the explainer. I foresaw this being a question and should have added it in the first place, and that was my mistake.

But, yeah, this is off topic, so I’m marking these all as such for another issue. :)

ken-okabe commented 3 years ago

Ok, here off-topic, but we need to discuss in other place.

Having admitting that,

super and new.target work exactly the same way as a pipeline placeholder would wrt being a "context variable", and nobody has a problem with those.

"nobody has a problem with those" This is not true, Object-Oriented Programming — The Trillion Dollar Disaster

How can you justify to tolerate famous OOP issues(problems) when we take advantage of Algebra cleanness?

Also importantly (but this is all off topic), a topic reference would have a lexical binding, while this has a dynamic binding.

This is true, thanks for supplementing, and there is no lexical binding in F#style. Therefore,

  1. F# style pipeline-operator [Safe]
  2. Hack style pipeline-operator [Not Safe ( lexical bindings)]
  3. this [Not Safe (dynamic bindings)]

"much easier" perhaps, but not safe, and in fact a question like this emerges.

Note: I am not insisting lexical binding itself is dangerous. The mechanism is required for FP. The point is Hack style pipeline-operator produces extra variable that has lexical bindings that human have to manage which should be avoided.

js-choi commented 3 years ago

This is true, thanks for supplementing, and there is no lexical binding in F#style.

This is not true. F# has lexical bindings. Lexical bindings are closures. Lexical bindings and closures are a fundamental part of functional programming. But please open a new issue.

tabatkins commented 3 years ago

I'm going to skip over the significant digression about math; that's not directly relevant to this topic. But:

This is an unfortunate mention to express the fact Hack-style pipeline-operator is not robust against this shallow level of complexity.

As I said in the comment this was responding to, the point of the pipeline operator is to linearize code and reduce nesting. It is not meant as a general-purpose code-flow operator meant to chain together large amounts of code. It can be misused as such, sure, but it being somewhat painful to do is an accidental benefit; JS has many good ways to organize code in a more readable fashion, and when those are used it's easy to write pipelined code without issues.

That said, it is also still true that Hack-style and F#-style pipelines are precisely identical in power (ignoring await/etc issues); if one wants to write a nested expression that uses more pipelines, and mix mention of the outer topic and inner topic together, one can do so with an arrow function, identically in either syntax. You just have to immediately invoke it in Hack-style, whereas it's implicitly invoked in F#-style (the "put a (^) at the end" thing again).

ken-okabe commented 3 years ago

This is true, thanks for supplementing, and there is no lexical binding in F#style.

This is not true. F# has lexical bindings. Lexical bindings are closures. Lexical bindings and closures are a fundamental part of functional programming. But please open a new issue.

I know that and I expected you would say that so, I added the Note:

Note: I am not insisting lexical binding itself is dangerous. The mechanism is required for FP. The point is Hack style pipeline-operator produces extra variable that has lexical bindings that human have to manage which should be avoided.

ken-okabe commented 3 years ago

the point of the pipeline operator is to linearize code and reduce nesting. It is not meant as a general-purpose code-flow operator meant to chain together large amounts of code.

Ok, this is great. Thank you. I would like to point your misunderstanding and misleading here. This is substantial.

First, as I mentioned earlier(off-topic someone says), the pipeline operator is not something artificially designed by human. This is a binary operator that is identical to f(x).

In this 48 hours or so, I've read your comments repeatedly insisting misleading "purpose" will. You are wrong in this point.

pipeline-operator is a binary operator, and it's defined as equivalent to f(x),

f(x) === x |> f

It's defined as mathematical structure, and fundamentally there should be no space for human invention, design, or purpose. How can you design something on the existing math structure??

It is not meant as a general-purpose code-flow operator meant to chain together large amounts of code.

When we talk about f(x), function application, in algebra, no Mathematics textbook suggests "meant as general purpose" or "large amount" etc. It's just a math structure, Ok?

In category theory , Pipeline Operator is an endoFunctor, and at the same time, Monad. Generally speaking, when someone defines Functor or Monad, no one would say "this is not designed to deal with too much nesting structure".

In fact, this is the same fixed phrase I've heard from OOP community. OOP code easily break in nesting structure, and to cover up they invented "design pattern" etc. that also failed.

f(x) does not introduce any extra variables, but since Hack pipe-operator is no longer identical to f(x), broken the equivalency that is extremely miserable, Hack pipe-operator produces extra context variables.

In fact, today, I was thinking I will check if hack operator still satisfies the monad-laws because I'm afraid this hack broken the algebraic structure. probably I will report later.

I also observed you insist

Hack-style and F#-style pipelines are precisely identical

Why only the hack produces context variables?

If you want to "design" a |> f(^,#) stuff, please do so after releasing the algebra operator that is mathematically identical to f(x) which means you never have to insist or misleading "This is not designed for nesting structure, be careful to use, it's out of purpose".

tabatkins commented 3 years ago

the pipeline operator is not something artificially designed by human. This is a binary operator that is identical to f(x).

No, it's not. There are multiple variations on the pipeline operator across several languages. "F#-style" and "Hack-style" are two of the 4+ currently existing variations on the idea. It can be equivalent to function invocation, but that's not a truth brought down from the heavens; it's a language-design choice, and several languages have chosen differently.

(Further discussion on the nature of creation vs discovery of mathematical truths is out-of-scope for this repository.)

In fact, today, I was thinking I will check if hack operator still satisfies the monad-laws because I'm afraid this hack broken the algebraic structure. probably I will report later.

I'm afraid you're going to be disappointed, because the monad laws define/restrict the behavior of the monadic bind/return operators. Function invocation isn't either of those.

You are probably thinking of the Function monad, whose functor action (not monadic) is function composition. However, the pipe operator is neither a Function nor the fmap operator; JS does not yet have syntax-level support for the functor hierarchy and its actions, and the F#-syntax pipe operator doesn't change that.

ken-okabe commented 3 years ago

However, the pipe operator is neither a Function nor the fmap operator; JS does not yet have syntax-level support for the functor hierarchy and its actions, and the F#-syntax pipe operator doesn't change that.

This is not true.

Pipeline operator and function composition operator is a very basic endofunctor and monad. I've checked by myself a long time ago, here is the code using F#-style Pipeline Operator . https://github.com/microsoft/TypeScript/pull/38305 @Pokute implementation for TypeScript

(Well, you don't have to investigate if you don't want to, but an evidence that your idea is wrong )

const identity = a => a;

const customOperator = op => f => set =>
  Object.defineProperty(set, op, {
    value: function (a) {
      return f(a)(this);
    }
  });//returns new set/object

Function.prototype |>
  customOperator('.')
    (g => f => x => x |> f |> g);
// function composition operator is '.'

const log = console.log;

"associativity------"|> log;

const f1 = a => a * 2;
const f2 = a => a + 1;

1|>f1|>f2
  |> log; //3

1|>(x => x|> f1|> f2)
  |> log; //3

1|>(f1['.'](f2))
  |> log; //3

"idientiry left-right-- m * f ----"|>log;

const e = identity;
const f = a => a * 2;

{
  // cleaner Monad laws
  // Left/Right identity
  "e.f = f = f.e" |> log;

  //left   e.f = f
  const left = 1 |>
    (e)['.'](f);

  //center  f
  const center = 1 |>
    f;

  //right f = f.e
  const right = 1 |>
    (f)['.'](e);

  left |> log; //2
  center |> log; //2
  right |>log; //2
}
{
  // typical Monad laws shown in Haskell

  // Left identity
  "e(x) |> f == f(x)" |> log;
  const x = 1;
  e(x) |> f
    |>log; //2
  f(x)
    |>log; //2

  // Right identity
  "m |> e == m" |> log;
  const m = 2
  m |> e
    |>log; //2

  m |> log; //2

}

image

Well, surely F# |> well satisfies monad laws.

Now, I have to write Hack-style pipleline operator for examination that should be nightmare to me...


There are multiple variations on the pipeline operator across several languages. "F#-style" and "Hack-style" are two of the 4+ currently existing variations on the idea. It can be equivalent to function invocation, but that's not a truth brought down from the heavens; it's a language-design choice, and several languages have chosen differently.

Functional Programmers in JavaScript share the same concept from Haskell, Scala. F#, and I never noticed "Hack", never heard FP community in JS except here. Why do you want to push such a thing to us?

HKalbasi commented 3 years ago

Perhaps we need to uncurry functions and libraries after hack pipe?

So OP example would become:

clicks$
  |> concatMap(^,() => getData$
    |> catchError(^, err => {
        console.error(err);
        return EMPTY;
    })
  )
  |> ^.subscribe(console.log)

I think it is now clear? Uncurring will be create a temporary turmoil in functional js ecosystem, but after some years we will probably adopt it.

tabatkins commented 3 years ago

@HKalbasi Right, I gave that as one of the options in https://github.com/tc39/proposal-pipeline-operator/issues/208#issuecomment-918678683. And note that the majority of the JS ecosystem is already "uncurried". ^_^

@stken2050 I'm sorry, but you're wrong. The monad laws cover how the bind/return monadic operators must work. The F#-style pipe operator can be thought of as the map operator of the Function functor. It is absolutely not the monadic bind operation, over the Function monad or any other.

Regardless, this is a great digression from the topic of this thread, which was covering nested pipes and the effect on the ^ placeholder.

ken-okabe commented 3 years ago

@stken2050 I'm sorry, but you're wrong. The monad laws cover how the bind/return monadic operators must work. The F#-style pipe operator can be thought of as the map operator of the Function functor. It is absolutely not the monadic bind operation, over the Function monad or any other.

Well, it's surprising you don't admit you are wrong after seeing the working code.

The monad laws cover how the bind/return monadic operators must work.

Correct, so I have illustrated that

Assciativity and Left&Right identiry laws are satisfied.

The F#-style pipe operator can be thought of as the map operator of the Function functor.

So what? If you can think so, why you think it's not "monadic operation"?

I've been doing this for years, and sorry probably you don't know much about Monad Laws stuff...

ken-okabe commented 3 years ago

I think it is now clear? Uncurring will be create a temporary turmoil in functional js ecosystem, but after some years we will probably adopt it.

This is what we are afraid of... Never. Currying is the natural consequence of functional programming. Look at Haskell. There are solid reasons we use curried unary functions, and the reason stands on not "js specification" but mathematics. This will destroy functional js ecosystem forever not temporal, and we will never be able to adopt it.

lightmare commented 3 years ago

Assciativity and Left&Right identiry laws are satisfied.

No, the F# pipe operator transplanted into JS is not associative, because rearranging parentheses changes meaning.

(x |> f) |> g /* desugars to */ g(f(x))
x |> (f |> g) /* desugars to */ (g(f))(x)
thesoftwarephilosopher commented 3 years ago
clicks$.pipe(
  concatMap(() => getData$.pipe(
    catchError(err => {
       console.error(err);
       return EMPTY;
    });
  )),
  map(result => result.someData),
)
.subscribe(console.log);

I really can't help but think maybe there's another kind of pattern library that's waiting to be born from this kind of thing. I've felt that way every time I've seen RxJS code or tried to use it myself, and especially feel that way using this example. It intuitively feels to me like Hack Pipes will make such a new library a lot more realistic than ever before. I don't have any specific examples right now, it's just, on the tip of my mind so to speak.

tabatkins commented 3 years ago

I'm going to go ahead and close this issue; it's gotten way off track.

As far as I can tell, the original issue raised by @benlesh has been addressed as well: do-exprs, when they mature, will allow easy renaming of an outer topic so it doesn't clash with nested pipelines. While it's slightly less convenient syntax-wise, an IIFE also does the job, at the cost of preventing async/yield/etc in the inner pipe. Finally, there is discussion focused on the possibility of topic-renaming going on in #203.