-
Notifications
You must be signed in to change notification settings - Fork 107
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
Do "smart pipelines" allow usage of curried functions? #116
Comments
In smart pipelines, it would be expressed thus: const size = x => x |> Iter.map(Fn.const(1))(#) |> Iter.fold(Int.plus)(0)(#) You need to include the lexical topic token (currently |
@mAAdhaTTah What kind of footguns does this avoid? When you're programming in a pointfree style as shown above, you're basically going to be writing There needs to be some way of making reverse application play well with forward function application, otherwise it will be very painful to use. |
@masaeedu Smart Pipelines aren't optimized for pointfree-style, except insofar as From the Smart Pipelines README:
There's more details there. I'll let @js-choi expand on the argument for this though, as this is his proposal. |
Neither of the first two seem like a reasonable interpretation for an operator |
If there is still any time to tweak the proposal, you might consider just interpreting all This means |
Edit: Oh, looks like there’s been some updates since I last loaded the page. I’ll reply to the other messages when I can. Thanks for the question. The answer is that, yes, smart pipelines can accommodate curried functions. But if you create the curried functions inline, then you need to use topic style. This phrase:
…applies only to smart pipelines’ bare style, which is a special convenience syntax for simple, unambiguous unary functions. All other expressions, including function calls on function calls, are supposed to use topic style. const size = x => x |> Iter.map(Fn.const(1))(#) |> Iter.fold(Int.plus)(0)(#) One benefit of this is semantic clarity. To summarize that link, in general, With smart pipelines, As for your second code block, I’m not sure what const downloadAndVerify = input => input
|> checkExists
|> checkExists(#) ? # : download(#)
|> do {
const hash = computeHash(#);
if (checksum === hash) #;
else throw new Error(`Checksum of downloaded file was ${hash}: expected ${checksum}`);
}; Smart pipelines can accommodate curried functions. But if you create the curried functions inline, then you need to use topic style, not bare style. |
@js-choi The
equivalent to monadic bind. You can't just unwrap things as you've done because the Here's a simpler example with observables: inputObservable
|> Obs.map(x => x * 2)
|> Obs.flatMap(x => Obs.delay(1000, x))
|> Obs.filter(x => x < 50)
|> Obs.scan(x => y => x + y) |
Regarding your three reasonable interpretations, I don't think any of those is reasonable except for the last one. |
Elixir, for example, does in fact slot in the pipeline value as the first parameter of the function, so that is certainly a feasible interpretation when coming from another language. I don't think the second is as likely, as JS doesn't have built-in currying, but it's not entirely unreasonable either. In any case, if it's ambiguous at all, forcing the developer to be explicit seems entirely reasonable. That said, it sounds like you'd prefer F# Pipelines overall, as that mirrors more closely your expectations for how the operator will function. None of the current proposals have been "adopted", so there's plenty of time to adjust things as you like. |
One thing I'll mention is that if we go with Smart Pipelines, there is less of a need to use curried functions. You could just have a normal n-ary functions with a placeholder. So it wouldn't require the parens and would look thus: const size = x => x |> Iter.map(Fn.const(1), #) |> Iter.fold(Int.plus, 0, #) |
@mAAdhaTTah That doesn't help you with |
@mAAdhaTTah is correct that Elixir’s pipe operator tacitly inserts its input into first parameters. Clojure supports both tacit first- and last-parameter insertion. The R language’s magrittr library also uses tacit first-parameter insertion. More importantly, there are many existing and idiomatic JavaScript APIs whose functions’ “primary inputs” are first parameters, such as DOM
With regard to “you’re basically going to be writing
This possibility was considered. It was passed over in favor of the current simple covering rule (“you must include Consider To mitigate this uncertainty,
Ah, so this is using Fluture. I’d have to study its API more to give a better translation of the original code block #116 (comment). But if nothing else, that could be accomodated by adding Eventually the smart pipe syntax might be extended with build-in higher-order forms of application like functor mapping, monadic chaining, and Kleisli composition; @tabatkins, @isiahmeadows, and I have discussed this before (http://logs.libuv.org/tc39/2018-03-13, #116 (comment), tc39/proposal-smart-pipelines#24). That idea is out of scope for now, though; I have my hands full enough with the core proposal’s Babel plugin. The same goes for the code block in #116 (comment): inputObservable
|> Obs.map(x => x * 2)(#)
|> Obs.flatMap(x => Obs.delay(1000, x))(#)
|> Obs.filter(x => x < 50)(#)
|> Obs.scan(x => y => x + y)(#) Here again, this is a tradeoff—a disadvantage for a particular API style in return for advantages in several other API styles. (At the very least, an important footgun is avoided: if the writer forgets any ending
As far as I can tell,
First-parameter function calls, last-parameter function calls, autocurrying-function calls, and non-function-call expressions are all used in JavaScript. All of these styles, not just the autocurrying style, exist and are idiomatic JavaScript. (To repeat examples above of JavaScript APIs whose functions’ “primary inputs” are first parameters: DOM Thanks for your patience, @masaeedu. |
That's fine, but this is a different contention from saying someone would get confused into thinking
Yes, it's not true that you're going to append it in strictly 100% of expressions, but most combinators of interest accept a parameter that inform their behavior, so as I said, you're basically going to be writing it every time. You can of course make a temporary variable with
Implicitly appending a Obviously there's a need to exercise good judgement to prevent a needle and haystack situation with really complex expressions, but the need for good judgement is applicable to all language features, and importantly, applies to the smart pipeline feature regardless of whether complex bare expressions are allowed or not.
@mAAdhaTTah was suggesting that the need for curried functions is reduced by the existence of
This is not relevant to what we're discussing. The fact that functions exist where you'd have to explicitly do Overall, appending the
Likewise, @js-choi. Trying to respond to and accommodate all these different opinions must be like herding cats. 😄 |
Just to throw it out there, the flip side of this is we're somewhat struggling with the perception that the pipeline operator as an "FP feature", rather than multi-paradigm. I don't know if / how much it impacts this discussion, but it's something to bear in mind. |
I've been sad because this language feature that I've seen in other languages and really want to have seems so obviously aimed at pure functions, but it seems like there's a movement to try to make it work with object methods, which doesn't make sense to me. I don't go around trying to make class-related proposals seem more function-like, why should the function-related features have to bend themselves towards towards classes :-x |
One other thing is that if the operator is idiomatically spaced (like // F#-style
// This is technically parsed as `x |> (f.g())`, not `(x |> f).g()
x
|> f
.g()
// Smart pipelines
// This could be parsed as either `x |> (f(#).g())` or `(x |> f(#)).g()`,
// as both are semantically equivalent.
x
|> f(#)
.g()
// Method pipelines
// This is technically parsed as `(x::f()).g()`, not `x::(f().g)()
x
::f()
.g() |
The problem isn't trying to make the pipeline operator work with object methods; the problem is the bind operator (working on methods) and the pipeline operator (working on functions) are both fundamentally about pipelining / chaining, and there isn't an appetite for accepting both into the language. If we're going to solve "pipelining" as a use case, it has to be in a single operator. |
Correct, but it's important that, in the current syntax, you know immediately that the expression is in topic-form, so you at least know that you do have to go looking for that
"Reduced" is not "eliminated". F#-style encourages heavy usage of curried functions. Smart pipelines reduce this need substantially. There are still situations where you might want currying, of course. (But that's just a question of how much weight you're willing to tolerate from lambdas, ultimately. |
@tabatkins I don't see a difference between "first check to see whether the
I either curry my functions, or I don't. So long as there's use cases that no other tool but partial application will solve, I need to keep currying my function definitions. As an example, I still need to keep |
The point is that reading code well, especially when there's the possibility of FP-ish shenanigans, requires you to know what the expected types of things are before you can start to interpret them. Otherwise you're just mentally tokenizing, not actually reading the code. There's a huge difference between the top-most expression constructing a function that'll get called for a value, and the top-most expression just evaluating to some value; your understanding of the expression as a whole changes pretty drastically in the two situations.
I think we're talking past each other. F# style encourages currying within the pipeline, so you don't have to write lambdas. Smart pipelines reduce this need. And note that feature PF (the |
When functions are values, which is the mental model for someone performing "FP-ish shenanigans", this is not such a huge difference. It's quite common to treat functions alternately as final results, intermediate values, and as transformers of other values (which again, may be functions), often all within the same expression. Regarding knowing the actual types of things: the change proposed does not require knowing the type of things any more than the existing proposal does; it's a purely lexical transformation. Obviously understanding what values the expressions
We are indeed. I still have to write my functions as lambdas, regardless of what shape smart pipeline assumes. Imagine the following function: const f = x => y => ... , which I'd like to use like this: v |> f(x) The suggestion is that because I can do const f = (x, y) => ... This is wrong. I still need to do Because there's way more places where I use partial application of the form: hof(f(x)) In such places, the pipeline operator and its facilities for convenient partial application are useless to me. I need to pass a partially applied function to another function (a frequent occurrence in functional code), and my options are to either wrap things in a lambda right there, as in |
As a functional programmer myself, I strongly disagree; I have to flip a mental switch to read something as manipulating a function versus just executing something. We might have to leave this as just us having different mental models of things. However, given that most programmers use FP lightly (just passing around functions as callbacks), I think it's reasonable to state that for most programmers, there's a big mental difference when attempting to interpret an expression between something that results in a function that'll take a value and something that just uses that value directly.
Ah, sure, by "using currying" I was referring to actually partially applying a function, not just declaring your functions to allow it. So yeah, we're just using the terms differently. 👍 |
@masaeedu Do you have any examples in that codebase where you use arrow functions within the pipeline? |
@mAAdhaTTah Here's an example of a somewhat large arrow function in the pipeline, here's a shorter one. |
@js-choi Is expanding bare-style in Smart Pipelines to function like F# Pipelines currently do out of the question? |
If I'm not misunderstanding, that second arrow function I linked to would need to be expressed as: export const sequence = A => o =>
pairs(o)
|> Arr.map(([k, v]) => v |> A.map(embed(k))(#))(#)
|> (A.of(empty) |> Arr.foldl(A.lift2(append))(#))(#); It's not the end of the world, but it's just weird syntactic noise trying to match up the |
Correct, but that's because you're intentionally doing point-free stuff, instead of just calling methods on objects. Point-free is supported by this syntax, but not catered to. If you rewrite it to actually use arguments, it should be clearer, but I honestly can't tell what this is doing in the first place to be able to rewrite it. This is not the sort of code that benefits from being even terser; it deserves to be separated into sub-statements and commented, imo. |
I would argue point-free isn't supported at all by Smart Pipeline, as your points need to be made explicit through the |
@babakness @masaeedu Still, seems less of a pain than adding a wrapper function to me... //Given these two functions
const add2 = a => a + 2
const add = a => b => a + b
5 |> add2 // Legal in smart pipelines
5 |> add(2) // Not legal smart pipelines
5 |> add(#)(2) // Legal in smart pipelines |
@aikeru It's probably better, I'm not too good at desugaring the pipeline operator stuff myself. My point is that |
@aikeru Maybe using the const listener = onEvent => dom => fn => dom.addEventListener( onEvent, fn )
const T = x => f => f(x) // Thrush combinator
const onClick = listener('onclick')
selector('.foo')
|> map( onClick )
|> T( animate ) Edit: the original code was missing combinator |
@babakness // Apologies if this isn't the way you'd format it
const map = (func) => (target) => func(target)
const listener = onEvent => dom => fn => dom.addEventListener( onEvent, fn )
const onClick = listener('onclick')
// You still have these options ...
// 1. Use it as-is
selector('.foo')
|> map( onClick )( # )
|> animate
// 2. Use a composed function
const mapToOnClick = map( onClick )
selector('.foo')
|> mapToOnClick
|> animate |
@aikeru The Regarding appending |
@masaeedu I expect chaining the pipeline operator is the most common use case. I expect nesting the pipeline operator would be used sparingly, and with as helpful formatting as possible, just like I see most people treat the ternary operator. Do you think otherwise? |
@aikeru Yes, I certainly do. Outside of trivial examples, you'll very frequently have to put in functions that massage the arguments in various ways. Being able to use pipeline operator in there is important to avoid deeply nested parens. It's very common to see JS code that does |
@masaeedu Okay, but that doesn't require nesting or anything of the sort, does it? Am I misunderstanding here? myArr
|> #.map((x, i) => ...)
|> #.filter(x => ...) Please don't reduce my side of the discussion to absurdity regarding temporary variables :) |
@aikeru No because selector is returning a list, onClick expects a single dom element |
@babakness so your |
@aikeru Yes, I think there's a misunderstanding. myArr |> map((x, i) => ...)(#) |> filter(x => ...)(#) Now if I end up using myArr |> map((x, i) => ...)(#) |> filter(x => x |> isUsernameValid(#) |> isPasswordValid(#))(#) this in itself is a rather trivial example, and things only get uglier and more ambiguous from here on. |
In short, |
@masaeedu That is somewhat clearer, but not entirely. It would help if you fleshed out these other little functions a bit more. |
@aikeru I just pulled those names out of a hat, but imagine each is |
My other reservation remains: when you're creating a long pipeline, it's very useful to have a name somewhere in the pipeline. Even if you just reuse the same name as in an earlier function, having a name in of itself that's descriptive is helpful. |
@isiahmeadows solid point. FWIW, As I think about it I feel split. Having topics style is really great. Using it with TypeScript you'd get type checks. Currently, TypeScript doesn't do a good job with point-free functions. On the other hand, it really needs its own operator. You are either using Maybe the issue is in bundling them together. Having two clean proposal is the way forward. I understand that it is difficult for people to understand it all. The logistics of it is something I can appreciate. |
TypeScript does fine with point-free functions. It's the automatic currying of some libraries that it struggles with. Writing this is clear: const add = a => b => a + b This is harder: const add = R.curry((a, b) => a + b)
This is basically what Smart Pipelines do now, but without needing two related but slight different operators. We're unlikely to get multiple variants of the idea of pipelining through committee. This is part of the reason the bind operator stalled; pipeline operator handles the bind's pipelining features. |
Ref: microsoft/TypeScript#5453 Just to elaborate on that: TypeScript sucks with variadic functions in general. Currying is merely a subcase of that particular meta-issue. (For one, they can't even fully type JS's existing builtins that have existed since ES3, much less more complex stuff like currying...) |
What @isiahmeadows said. const add = ( a: number ) => ( b: number ) => a + b
pipeline(
'orange juice',
add(1),
) No complaints I've even tried typed functional libraries like The Monad issue will still be there but at least composing will be a lot better. |
@babakness We're all saying the same thing. This is point-free and correctly errors: const nums = ['1', '2', '3']
const add = (a: number) => (b: number) => a + b
nums.map(add(1)) The automatic currying I mentioned is a specific case of variadic functions (and an area I ran into issues myself using Flowtype). My point was just that |
BTW, where I'm reworking my lifted pipeline proposal, smart vs normal pipelines are no longer a concern for me in that area. I just need to know which syntax is chosen, so I know which syntax to tailor it to. (I'm still partial to either the F# or this-binding variants.) |
Sorry for the delay in responding to the comments over the past two weeks. I’ve been busy out of town, and I’m unfortunately still busy with a major transition, so I’ve had to disengage for the past weeks. A lot of discussion has been happening here. That’s a good thing, and I am thankful, but it’s also overwhelming, so my apologies if I can’t respond to everything here, at least for now. I want to focus on developing the Babel plugin instead during my free time. I do want to address this question, though:
Nothing in the smart-pipelines proposal is out of the question. I think it is premature to rule completely in favor one way or another way. I do indeed have reservations about distinguishing bare style and topic style purely by the presence/absence of the topic reference—which would make garden-path expressions, requiring indefinite lookahead, more likely. I came to this conclusion in March after rewriting a lot of real-world codebases using pipelines, playing around with various possibilities. But no one cannot confidently claim that one or another thing is better until we are able to test it hands on, ideally with a Babel plugin. I still plan to develop such a Babel plugin together with @mAAdhaTTah, despite my reduced free time. And I do plan to implement many possible variations of smart pipelines, so that all of them may be tried by switching configuration options. The plugin development will take a while, and it would be understandable for anyone to be frustrated by such a slow pace. But all of us – @mAAdhaTTah, I, @littledan, TC39, and everyone commenting here – are volunteers in this process. And until that Babel plugin is written, or until someone else tests the different pipelines on a corpus of actual real-world code, all of this discussion is theoretical. I, @mAAdhaTTah, @littledan, and Yulia Startsev of Mozilla have also been discussing running usability studies on JavaScript developers or real-world code in many coding styles, though those too would not occur soon. As an aside, many of the concerns expressed above, regarding the readability of nested pipelines, are mitigated by simply avoiding nested pipelines. Just because you can do something does not mean you should. Smart pipelines, variables/constants, and functions all should be used as necessary. Actually writing the example in #116 (comment) ( In general, any feature can be used to write unreadable code. (Avoiding footguns is still important. In fact, the current smart-pipeline proposal’s early-error rules guarantee that, if that It is true that many of the real-world examples in the readme do use nested pipelines. But all of the examples using them are formatted and indented clearly, and the early-error rules guarantee to the reader that. None of them are like the example in #116 (comment) – at least intentionally. I certainly could rewrite the real-world examples in the readme to deemphasize nested pipelines. I do not want to mislead people that using as many nested pipelines as possible would be “best practice”. There is also the idea to forbid nested pipelines at all, as Clojure does with its But this is just an aside. Until that Babel plugin is written, or until someone else tests the different pipelines on a corpus of actual real-world code, all of this discussion is theoretical. Thanks to everyone for your patience. |
@js-choi Maybe I'm misunderstanding, but I think |
Oh, whoops, I forgot to finish rewriting that code. I meant, of course, “You should just use |
I have the following code:
Both
map
andfold
are curried functions of multiple arguments (2 and 3 respectively).The TC39 proposal has a slide that says of the "smart pipeline" proposal:
Does this mean the code above would be illegal? How would I express this instead?
Here is a more extreme example of the same concept: pipelining through multiple partially applied 2-ary functions.
The text was updated successfully, but these errors were encountered: