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

Tacit programming / point-free style and pipes #206

Open
treybrisbane opened this issue Sep 13, 2021 · 58 comments
Open

Tacit programming / point-free style and pipes #206

treybrisbane opened this issue Sep 13, 2021 · 58 comments
Labels
documentation Improvements or additions to documentation question Further information is requested

Comments

@treybrisbane
Copy link

As I mentioned in this comment, it's starting to seem like there are some more fundamental points of debate in this space than just the surface semantics of this proposal. One such point is the idea of point-free programming, and whether or not it should be enabled or encouraged within JS.

So, accordingly, I'd like to ask the question: Should enabling point-free programming/APIs be a goal of the Pipeline Operator?

@treybrisbane
Copy link
Author

treybrisbane commented Sep 13, 2021

Incidentally, I personally think it should. General linearization of code is a reasonable goal IMO, but enabling point-free programming within the language gives developers a new way in which to express solutions and/or structure APIs. I do recognize that there are downsides to point-free programming, but I would personally rather have the option. 🙂

(Also, yes I upvoted my own issue, because I suspect people are going to "vote" with upvotes and downvotes, so I figured I might as well start! 😅)

@kiprasmel
Copy link

Yes.

This is exactly what Unix scripting does with Pipes:

find . -type f | grep -E '.jsx$' | xargs sed -ibp 's/var/let/g'

Without point-free/tacit/"curried" programming, the Pipeline Operators lose most of their value.

I argue and provide more examples on this in part "1. Function composition" at #205.

@mAAdhaTTah
Copy link
Collaborator

Point-free programming is not a goal in and of itself. It's a tool for achieving other goals in your code. The pipe operator is a tool for code linearization. If point-free programming is the tool to achieve that, fine, but that's not the point of the operator.

@jderochervlk
Copy link

Point-free programming is not a goal in and of itself. It's a tool for achieving other goals in your code. The pipe operator is a tool for code linearization. If point-free programming is the tool to achieve that, fine, but that's not the point of the operator.

But there is no reason for the operator to block point free?

Yes.

This is exactly what Unix scripting does with Pipes:

find . -type f | grep -E '.jsx$' | xargs sed -ibp 's/var/let/g'

Without point-free/tacit/"curried" programming, the Pipeline Operators lose most of their value.

I argue and provide more examples on this in part "1. Function composition" at #205.

100% this. Pipes are a well known and understood concept. Do thing a, pass the results to the next step, and so on.

@mAAdhaTTah
Copy link
Collaborator

But there is no reason for the operator to block point free?

Changing the behavior based on the presence of the token makes the operator harder to reason about because you have to find the token before you can determine whether it's function application or an expression. It makes the behavior more confusing. This was the original Smart-Mix proposal, which had a limited "bare style" for function application.

@jderochervlk
Copy link

But there is no reason for the operator to block point free?

Changing the behavior based on the presence of the token makes the operator harder to reason about because you have to find the token before you can determine whether it's function application or an expression. It makes the behavior more confusing. This was the original Smart-Mix proposal, which had a limited "bare style" for function application.

But this is already true for an anonymous function used in .map on an array.

You can do foo.map(add(1)) or foo.map(n => add(1, n)) and get the same results. Why would this existing syntax not work for the |>?

@mAAdhaTTah
Copy link
Collaborator

If by "the operator", you mean the Hack pipe, which "blocks" point-free by requiring a topic token, then those two examples aren't equivalent. The "token" that indicates this is an arrow function occurs much earlier, and in a consistent location. The token that indicates whether something is a function application or expression can appear anywhere, which could introduce a refactoring hazard:

x |> f(1)
// desugars to f(1)(x)
// but
x |> f(1) + ^
// desugars to f(1) + x

This is a footgun, as either adding or removing a topic token during refactoring could shift between modes unexpectedly.

@Avaq
Copy link

Avaq commented Sep 13, 2021

Correct me if I'm wrong here, but I think @mAAdhaTTah and @jderochervlk might be arguing for two different things, perhaps over a small misunderstanding.

When @jderochervlk said:

But there is no reason for the operator to block point free?

I think they meant "But there is no reason to choose Hack over F# specifically on the premise that F# enables point free programming?", or something along those lines. Am I interpreting that correctly @jderochervlk ?

But then @mAAdhaTTah interpreted it as meaning: "But why can't the operator allow tacit style as well as topic style?", and proceeded to make arguments against the "Smart Mix" proposal. Smart Mix, which attempts to combine tacit and topic styles into a single operator, loses benefits for both styles while introducing footguns and other problems.

EDIT: After reading the conclusion of #205 (comment), I'm starting to doubt whether I interpreted @jderochervlk's remark correctly 😅

@runarberg
Copy link

@mAAdhaTTah

Point-free programming is not a goal in and of itself. It's a tool for achieving other goals in your code.

I would argue that this is a false statement. Library authors quite often have the API style of the library as the main selling point. When I personally pick a library I search for the one that provides the API which I prefer (which is the primary reason I like AVA > Jest). Having the option of point-free programming gives library authors a chance to write a library which provides this API over the other. That makes it a goal of the library author.

@mAAdhaTTah
Copy link
Collaborator

I would argue that this is a false statement.

Even in your examples, point-free is servicing the goals of the library consumer, which have larger stylistic goals that point-free enables. The goal is concise, readable code, ease-of-composition, etc. Point-free is a tool for enabling those things, but the goal is not to write point-free code; it's to accomplish those things with point-free code.

Writing point-free code for its own sake is rarely, if ever, the primary goal of an author of a given piece of code. Even the library author in your example chose point-free because the consumer needs the library in that form to accomplish the consumer's goals.

@runarberg
Copy link

runarberg commented Sep 13, 2021

We might be arguing semantics, but even if you are right and the end goal is better code (as opposed to point free code), I still think that enabling point free programming a worthy goal in and of it self for this reason. That is to allow library authors more diverse options in providing APIs for their consumers. By enabling easy point free programming, we are giving library authors that choice regardless of what their end goal is.

@tabatkins
Copy link
Collaborator

I have my own strong opinions on HOF/point-free programming, based on my extensive experience in and love of functional languages, but they're neither here nor there.

As I argued in my essay, the two syntaxes are exactly equivalent in power (assuming F#-style has a special syntax for await/yield), so which to choose is a numbers game: which syntax makes the most code the easiest to read/write/understand, weighting for overall usage. I think it's a pretty easy call that unary functions are a lot less common overall than "everything that's not an unary function" (even if we cut out the things that a partial-application proposal could cover).

More directly germane to this particular discussion, tho, is the fact that F# (and most languages which have chosen that particular style of pipeline syntax) is auto-curried; when you define a function, it knows its own arity, and if you call it with less arguments than it expects, it automatically returns a function that'll take the remaining args. JS doesn't have this (and is unlikely to ever gain it).

The upshot of this is that, in F#, there's no difference between "pipeline that invokes an unary function RHS with the LHS as its argument" and "pipeline that expects a function-call on the RHS, and inserts the topic as the final argument"; that is, you can consider the desugaring to be:

val |> foo(a,b)
// desugars to
foo(a,b)(val)
// or!
foo(a,b,val)

Both are correct! Importantly, this means that in F# you can just write your three-arg function as a three-arg function, and then the user of your library can either call it with three args in normal code, or call it with two args in pipeline code, whichever suits their purposes at the time.

But because JS is not auto-curried, you don't get this equivalency. Unless you pay the additional cost of manually implementing auto-currying or invoking a library that can do it for you, both of which come with runtime and maintainability costs, you have to make a decision up-front on how you intend for your function to be called: in a pipeline, or as a normal function. If the library user ends up wanting to call your function in a different way than what you intended, they have to adapt it; this isn't hard to do, in either direction, but it's still extra work.

That is, if you wrote your function intending it to be pipelined, like const add = a=>b=>a+b, then if the user wants to call it normally they have to write add(1)(2), which is foreign to JS and quite weird. If you wrote your function intending it to be called normally, like const add = (a,b)=>a+b, then if the user wants to use it in a pipeline they have to write 1 |> x=>add(x,2). Either way, this is an inconvenience to the user, and requires an extra up-front decision for the library author that they previously didn't need to think about. (Authors of libraries that want to interoperate with HOF libraries do have to make this exact decision today, but the vast majority of library authors don't.)

Compare with Hack-style, where the library author just writes a 2-arg function as taking two args (const add = (a,b)=>a+b;), and then the library user can either call it normally in the idiomatic way add(1,2) or put it in a pipeline in a straightforward way 1 |> add(^,2) that looks the exact same as the normal call. The author doesn't have to make any new decisions on how to write their function, and the user has the exact same function-calling experience in either style; the pipeline, if they choose to use it, is solely letting them pull a complex argument out in front, is all.


Whoops, this ended up longer than I wanted, but I hadn't put this reasoning into words in this repo yet, so that's probably valuable.

In short: encouraging point-free style is awkward in JS because JS isn't auto-curried.

@jderochervlk
Copy link

@Avaq

But there is no reason for the operator to block point free?

I meant to suggest that it is a negative for the hack style pipe to force a function to have a defined parameter. The F# style does not force you to have point free functions (you can use a => a if you want), the hack style does not allow point free functions, which to me will make it not really feasible to adopt since it will just make things more verbose or force me to write my code differently.

@js-choi
Copy link
Collaborator

js-choi commented Sep 13, 2021

I appreciate this issue because it gets to the heart of a lot of the disputation. Kudos here to @treybrisbane for doing so. We should have addressed this in the explainer in the first place, and it was our bad for not doing so.

One thing I particularly appreciate about this issue is that it does not conflate “JavaScript functional programming” with “JavaScript tacit programming / point-free style”. The two are different. Tacit programming might be a subset, but it is certainly not equivalent to functional programming. Plenty of functional programming happens in a point-free way (see @getify’s #205 (comment) for a personal example).

In fact, non-tacit higher-level-function programming is the purpose of many functional languages’ built-in syntaxes, like the do notation of Haskell or the computation expressions of F#. From what I recall, the question of when to do tacit vs. non-tacit programming is in fact controversial in many functional programming languages’ communities.

I think F#’s documentation itself brings a lot of wonderful wisdom about tacit programming. It’s important enough that I’ll reproduce it here:

F# supports partial application, and thus, various ways to program in a point-free style. This can be beneficial for code reuse within a module or the implementation of something, but it is not something to expose publicly. In general, point-free programming is not a virtue in and of itself, and can add a significant cognitive barrier for people who are not immersed in the style.

Do not use partial application and currying in public APIs

With little exception, the use of partial application in public APIs can be confusing for consumers. Usually, let-bound values in F# code are values, not function values. Mixing together values and function values can result in saving a few lines of code in exchange for quite a bit of cognitive overhead, especially if combined with operators such as >> to compose functions.

Consider the tooling implications for point-free programming

Curried functions do not label their arguments. This has tooling implications. […] At the call site, tooltips in tooling such as Visual Studio will give you the type signature, but since there are no names defined, it won't display names. Names are critical to good API design because they help callers better understanding the meaning behind the API. Using point-free code in the public API can make it harder for callers to understand.

If you encounter point-free code like funcWithApplication that is publicly consumable, it is recommended to do a full η-expansion so that tooling can pick up on meaningful names for arguments.

Furthermore, debugging point-free code can be challenging, if not impossible. Debugging tools rely on values bound to names (for example, let bindings) so that you can inspect intermediate values midway through execution. When your code has no values to inspect, there is nothing to debug. In the future, debugging tools may evolve to synthesize these values based on previously executed paths, but it's not a good idea to hedge your bets on potential debugging functionality.

Consider partial application as a technique to reduce internal boilerplate

In contrast to the previous point, partial application is a wonderful tool for reducing boilerplate inside of an application or the deeper internals of an API. It can be helpful for unit testing the implementation of more complicated APIs, where boilerplate is often a pain to deal with. For example, the following code shows how you can accomplish what most mocking frameworks give you without taking an external dependency on such a framework and having to learn a related bespoke API. […]

Don't apply this technique universally to your entire codebase, but it is a good way to reduce boilerplate for complicated internals and unit testing those internals.

F# is a wonderful language, and its design and conventions have a lot of wisdom. I think F#’s conventions about point-free style are excellent: Point-free style is wonderful but should be used judiciously.

My own belief is that the pipe operator should encourage functional programming, but it should not necessarily encourage tacit/point-free programming. The latter is a subset of the former.

I understand that some people have been strongly wishing for tacit programming to be built into the language syntax for a long time, and it feels like something has been taken away. I understand your frustration. Having said that, F# itself recommends that tacit programming be only judiciously used in internal code, for good reason (cognitive overhead, tooling, etc.). We’re trying to focus on things like Web Platform APIs (the most common JavaScript APIs in the world) over something that F# itself recommends should only be occasionally used in internal code. But I know that many people have different priorities, and I apologize that they feel that they are not being addressed.


Kudos again here to @treybrisbane for cutting to the meat of the matter. The pipe operator should encourage functional programming (we think that Hack pipes would do so), but that doesn’t mean the pipe operator should encourage tacit programming, when pointful functional programming is generally super common.

We should have addressed this in the explainer in the first place, and it was our mistake for not doing so. We should add a section addressing this to the explainer later.

@samhh
Copy link

samhh commented Sep 13, 2021

It doesn't need to encourage tacit programming, merely enable it. The F# proposal achieves this by leaving open the opportunity to open up a lambda.

@runarberg
Copy link

runarberg commented Sep 13, 2021

@tabatkins So what you are saying is that it is the purpose of this committee to dictate which API library authors should choose based on what you believe is better for the general user of the language?

@tabatkins
Copy link
Collaborator

It is the purpose of TC39 to evolve and shepherd the JS language, which does involve making value judgements about how the language is to evolve, yes.

@js-choi
Copy link
Collaborator

js-choi commented Sep 13, 2021

It doesn't need to encourage tacit programming, merely enable it. The F# proposal achieves this by leaving open the opportunity to open up a lambda.

I agree; this is an important distinction. But it is also true that tacit programming is already enabled…in userland, with userland functions like rx.pipe.

We’re talking about whether to strongly encourage it by baking it into the language syntax itself: whether to enable it at the language-syntax level, not just at the userland level.

To give a parallel example: monoids, monads, applicative functors, etc. are already enabled in JavaScript…with userland functions, even if not with language syntax. (Although I love monoids/monads/applicatives, and in fact I am in the midst of writing a proposal for monadic comprehensions, using F# computation expressions. It’s very incomplete, though.)

@lightmare
Copy link

@mAAdhaTTah

Point-free programming is not a goal in and of itself.

The question was: Should enabling point-free programming/APIs be a goal of the Pipeline Operator?
(I didn't even add emphasis, it's in the OP).

@tabatkins

Whoops, this ended up longer than I wanted, but I hadn't put this reasoning into words in this repo yet, so that's probably valuable.

In short: encouraging point-free style is awkward in JS because JS isn't auto-curried.

It sure is valuable, but your rationale and conclusion is specific to F# style (arg-last), while the question was generic. Elixir style (arg-first) doesn't have a problem with JS not being auto-curried.

@runarberg
Copy link

runarberg commented Sep 13, 2021

@tabatkins

which does involve making value judgements about how the language is to evolve

And I believe you have not done a good enough job at that, or at least not been convincing enough. I created #204 asking for data for how you’ve reached the conclusion that Hack is better suited over F#. From what I gathered in the issue threads is that your methodology of gathering the evidence needed to make a valued judgement is flawed. Therefore I have reasons to believe that the valued judgement you’ve reached in this instance is insufficient and is providing a sub-optimal stewardship.

@samhh
Copy link

samhh commented Sep 13, 2021

I agree; this is an important distinction. But it is also true that tacit programming is already enabled…in userland, with userland functions like rx.pipe.

We’re talking about whether to strongly encourage it by baking it into the language syntax itself: whether to enable it at the language-syntax level, not just at the userland label.

We're already somewhat there with higher-order functions. It's very common to see xs.map(f) instead of xs.map(x => f(x)), and ironically the usual refrain for doing so is that variadic functions mightn't behave as you'd expect at first glance. It should likewise be second nature to anyone who's used pipes in a terminal.

I'm unfortunately (in terms of subjective preference) aware that lots of developers actively dislike tacit programming. In the case that this is the committee's primary reason for rejecting F# it might help to communicate that as clearly as possible; I know a lot of people myself included were excited about a functional pipeline operator making it into JS, and the knowledge that something very fundamental to FP - being really rather core to readable function composition - is disliked by committee would help to temper expectations in the future.

@tabatkins
Copy link
Collaborator

It sure is valuable, but your rationale and conclusion is specific to F# style (arg-last), while the question was generic. Elixir style (arg-first) doesn't have a problem with JS not being auto-curried.

Sure, and I rather like Elixir-style; it's definitely more compatible with JS-as-she-is-written, imo (where the most important arg is usually written first, and functions have rest args or optional args). I didn't mention it because it has nothing to do with point-free invocations. ^_^

The reason I didn't pursue Elixir-style is that it requires the same special-case syntax for await/yield that F#-style does, but afaict is even worse for non-function-calling expressions; without more syntax special-cases, I'm not sure how you'd, say, add one to the topic value, without just writing function versions of every JS operator.


And I believe you have not done a good enough job at that, or at least not been convincing enough.

You're free to believe that, and I doubt I'll convince you otherwise.

@runarberg
Copy link

@tabatkins (I’m sorry, I’m going off topic here, this debate belongs in #204 ).

You're free to believe that, and I doubt I'll convince you otherwise.

I doubt so too. But there are ways to make your arguments more convincing by doing more research rather then relying on speculation. And being a figure of authority I would expect you to do so. You’ve previously made claims which many people have reason to doubt. If you’d have done the research and backed your arguments up with data from various studies you would have been more convincing. Perhaps not enough to convince me, but maybe enough for me to stay silent.

@Jopie64
Copy link

Jopie64 commented Sep 13, 2021

I think F#’s conventions about point-free style are excellent: Point-free style is wonderful but should be used judiciously.

My own belief is that the pipe operator should encourage functional programming, but it should not necessarily encourage tacit/point-free programming.

Yet the F# pipe proposal actually comes from... F#. Where it is extensively used in use code. So.... 🤔

@js-choi
Copy link
Collaborator

js-choi commented Sep 13, 2021

Yet the F# pipe proposal actually comes from... F#. Where it is extensively used in use code. So.... 🤔

Yes. F# itself gives several reasons to avoid point-free style (except judiciously in internal code). These reasons given by F# (cognitive overload and tooling) are general and would apply to other languages like JavaScript. F# does have point-free style baked into language syntax (e.g., its |>), but that is because the designers of F# (whom I find inspiring and respect very much) happened to decide that it remained a good fit for the F# language despite those reasons to avoid point-free style.

This does not mean that transplanting F# semantics (which fit with language auto-currying) is a good fit for JavaScript the language (which can never become auto-curried due to backwards compatibility). The upsides of point-free style in F# are weaker in JavaScript due to the fundamental auto-currying difference. And, at the same time, F#’s reasons to avoid point-free style (except judiciously in internal code) are still valid.

This does not mean that using point-free style is terrible and invalid. (F#’s advice is wise. Point-free style should be avoided in general, but point-free style can be wonderful when used sparingly and in internal code. Point-free style is not the same as functional programming; in fact, most functional programming arguably should be pointful, as F# itself recommends.)

Nor does it mean that point-free style is impossible in JavaScript. Point-free style is still already possible in JavaScript with userland libraries. What we are currently doing is deciding that syntactic tacit programming is out of scope of this proposal.

Tacit programming is already possible with userland libraries, and tacit programming should be generally avoided anyway (except judiciously in internal code—as recommended by F# itself, for good reason). I respect F# a lot, and I find its design inspiring and reasonable (after all, I’m planning to adapt F# computation expressions into a TC39 proposal!). F#’s conscientiousness about point-free style is merely one reason why I respect its design.


Again, though, I really do appreciate @treybrisbane’s issue cutting to the meat of the matter, and we should have addressed this directly in the explainer the first place.

@lightmare
Copy link

@tabatkins

I didn't mention it because it has nothing to do with point-free invocations. ^_^

Sorry for nagging you, but I feel like I'm either missing, or failing to convey something. Here's an excerpt from your reasoning that I presume lead to the conclusion that "encouraging point-free style is awkward in JS because JS isn't auto-curried".

That is, if you wrote your function intending it to be pipelined, like const add = a=>b=>a+b, then if the user wants to call it normally they have to write add(1)(2), which is foreign to JS and quite weird. If you wrote your function intending it to be called normally, like const add = (a,b)=>a+b, then if the user wants to use it in a pipeline they have to write 1 |> x=>add(x,2). Either way, this is an inconvenience to the user, ...

If you define the function as const add = (a, b) => a + b, then there's no inconvenience with Elixir pipeline, they can call it like add(1, 2) or in pipeline 1 |> add(2). Hence I think the conclusion does not follow.

without more syntax special-cases, I'm not sure how you'd, say, add one to the topic value,

Yes, that'd be either ugly or require additional/automagical syntax. Not as concise as Hack, but also not impossible.

@tabatkins
Copy link
Collaborator

If you define the function as const add = (a, b) => a + b, then there's no inconvenience with Elixir pipeline, they can call it like add(1, 2) or in pipeline 1 |> add(2). Hence I think the conclusion does not follow.

Right, Elixir-style doesn't suffer from these problems. I wasn't writing my comment as an argument against Elixir-style, I was writing it in support of "encouraging point-free style is awkward in JS because JS isn't auto-curried". Elixir-style isn't point-free, and thus the argument is irrelevant for discussions about Elixir-style.

Yes, that'd be either ugly or require additional/automagical syntax. Not as concise as Hack, but also not impossible.

Yeah, I presume that if we'd wanted to pursue Elixir-style, then "arrow functions are magically called" would be the way to go. Unsure if that'd get thru committee, so the IIFE style might have ended up being it instead, which I agree is ugh. ^_^

@Jopie64
Copy link

Jopie64 commented Sep 14, 2021

This does not mean that transplanting F# semantics (which fit with language auto-currying) is a good fit for JavaScript the language (which can never become auto-curried due to backwards compatibility).

Nor does it have to be. Pipable libs just have to be data last and only the last argument must be curried like how it's done in RxJS. Very easy to do in JS, even easier than declaring functor prototype members like how it's currently done. But the latter is not extensible, and the former is.

The upsides of point-free style in F# are weaker in JavaScript due to the fundamental auto-currying difference. And, at the same time, F#’s reasons to avoid point-free style (except judiciously in internal code) are still valid.

I still think the discussion about point-freeness is rather technical than semantic. At operator design time you can (and usually will) still name all arguments explicitly. So not point free. The only time where you could argue that it is point free is at usage time. Because you technically create a function without the last argument, and the pipe operator then immediately calls it, just like in F#. But I argue this is only technical, but semantically you supply the last argument immediately, just before the function instead of after.
The F# docs reason for avoiding point-free is about readability. And here semantics count, not technicalities. Semantics at usage time are the same in F# and JS. And at design time, it doesn't have to be designed point-free.

@runarberg
Copy link

Method chaining used to be a popular way of providing an API that was point free until we started worrying about bundle sizes, and it remains a popular option where bundle sizes don’t matter (e.g. in assertion libraries like Chai). However in production code that is delivered on the world wide web I want the same freedom as a library author to provide similar APIs without my users having to have to worry about their bundle sizes suffering.

@samhh
Copy link

samhh commented Sep 15, 2021

It should be noted that even fp-ts started out with a prototypal, "fluent" method-chaining API in 1.x. The primary limitation it exposed to me as an end user is that inserting your own functions into the pipeline is never very ergonomic. At best it's something like this:

x.map(f).pipe(g).pipe(h) // hope you didn't need to cross type-boundaries!

Whereas now that same code would be expressed as follows:

pipe(x, map(f), g, h) // pipeline application
flow(map(f), g, h) // function composition

@mAAdhaTTah
Copy link
Collaborator

You're both missing the point: none of those fluent method libraries used point-free method chaining because they wanted to write point-free code. They wrote them that way because they wanted write linear, unnested code. A point-free approach is a tool for writing code in that way. With a pipe operator, it becomes feasible to unnest & linearize a sequence of function calls, not just methods, which is possible regardless of the pointed-ness of the functions at play here.

@voronoipotato
Copy link

voronoipotato commented Sep 15, 2021

We didn't ask for that though. The original thing people were excited about were function pipes. I don't want expression pipes. If you think function pipes are bad for the language, don't add function pipes, but don't use function pipes as a way to slide through expression pipes when what we asked for is function pipes. I don't trust that they will be as safe or as easy as you say they will. I trust and use function pipes, I don't know what the implications for expression pipes even are. I think there is a very good chance that I will have to tell newcomers to avoid expression pipes because there are dangerous and confusing edgecases.

@samhh
Copy link

samhh commented Sep 15, 2021

Functions are already a powerful unit of expression.

In any F#-style pipeline I can take any lambda, name it, and then reference back to it without modifying the value itself. I can likewise do the inverse. The function can contain whatever arbitrary expressions I'd like without the cost of additional, specialised syntax. This is really helpful for refactoring. I don't think Hack can compete here; functions are the unit of code reuse at this granular level because they already capture the essence of data input and output in a more generalised way.

With regards point-free, referencing functions as values without wrapping them in new lambdas is something that in my experience is already widespread outside of functional circles, and as I've said previously ironically the counter-idiom not to do this only exists because the functions might not be unary.

@ken-okabe
Copy link

ken-okabe commented Sep 15, 2021

I think there is a very good chance that I will have to tell newcomers to avoid expression pipes because there are dangerous and confusing edgecases.

As long as Hack-style pipeline-operator, you are right.
Since we already know Hack-style pipeline-operator introduces new context variables like OOP this which is dangerous and confusing, no question about that.

#206 (comment)
#208
microsoft/TypeScript#43617 (comment)

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....

On the other hand, F# style pipleline-operator is proven to be robust because it's simply a binary operator of Binary operation in Algebra.

I think the most of the Hack advocators here don't understand that pipeline-operator is for binary operation because in this 48 hours reading through here I have read "pipeline-operator is syntax sugar.... easy to read the nest" etc.

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?
F# style (pure math style) Safe.
Hack-style (I don't know what this is) Dangerous.

#208 (comment)


In terms of point-free style, this is also basically Math. and a way to write in FP.
As long as we stick to Math/Algebra or F# operator style, point-free style is automatically possible because it's the same league of Math/Algebra, so not to be a goal but as a matter of course.
However, Hack-Style, since this is something other than math, any intricate concept of Math cannot be achieved.

@runarberg
Copy link

runarberg commented Sep 15, 2021

I have a question to the TC39 members:

  1. If you really don’t want people to write point free with the pipeline operators,
  2. and if you could be convinced that library authors still deserve the freedom to provide a point-free API,
  3. but you still believe that authors should not be exporting functions that return a unary function,
  4. would you then be open to new proposals which grants us that freedom again without the downsides of the current way of extending the prototype.

I will pull something out of a hat now to demonstrate what I mean:

Proposal: Scoped Methods

Similar to Operator overloading, building on prior art from rust, and reviving the dreaded with statement we could temporarily extend the prototype of any object by doing something like:

import { foo, bar, baz } from "./string-methods.js";
with { foo, bar } on String.prototype;

// Now this will work but only inside this module
"my string".foo();
"my string".bar();
 
{
  // This will only be applied inside this scope.
  with { baz } on String.prototype;

  "my string".baz();
}

"my string".baz();
// => Type error: String.prototype.baz is not a function.

If I wanted to write an operator library for iterators I could write my operators as functions that operate on this like this:

export function* map(fn) {
  for (const item of this) {
    yield fn(item);
  }
}

export function* filter(p) {
  for (const item of this) {
    if (p(item)) {
      yield fn(item);
    }
  }
}

And instruct my users to use it like this:

import { Iterator, range, map, filter } from "my-iter-lib";
with { map, filter } on Iterator.prototype;

range(0, 10)
  .filter(isPrime)
  .map((n) => n * 2)

Now I’m not saying this is a good idea, scoped prototype extension is only something I pulled out of a hat in order to demonstrate what I mean. So to summarize my question: Would the committee be open to something like this, or are you likely to block any proposals which would allow us to write libraries which encourages point free operations?

@tabatkins
Copy link
Collaborator

@Jopie64

Here it is quite obvious that the operator lib should be used this way. For the designer and for the user. The same way as that it is very clear that you should use Array.prototype.map on an array with a ..

I don't think it's particularly obvious, tho. Outside of the context of this conversation, I'd assume Lazy.sum() is a function that is meant to take something (a lazy collection, presumably?) and sum it, as in Lazy.sum(collection). Instead, in this example it has be invoked as Lazy.sum()(collection), which is strange in JS.

Obviously this is meant to be invoked in a pipeline, but that's my exact complaint - pipeline now becomes a third calling convention that must be adhered to, or else it's awkward to call the function. And even in this case, whether it's collection |> Lazy.sum() or collection |> Lazy.sum isn't clear to me - if it's not taking any arguments, why does it need an invocation rather than just being a stored function?

This ties into my overall thesis - languages designed for point-free application have syntax and mental models that support it. They're usually auto-currying, so Lazy.sum and Lazy.sum() are the same thing (in langs like Haskell you can't even write them differently, due to how function invocation is written), and Lazy.sum(collection) is also possible from the exact same function, without the function author having to do anything special. The library user can invoke the code in any of these ways, as makes the most sense for their particular situation.

But JS doesn't have those features, and likely never will, so this all gets awkward, with more rules for library users to memorize that will not be consistent from library to library. In contrast, in Hack-style you just invoke the function normally, exactly the same inside the pipe as outside the pipe. Library users just have to deal with the same memorization of argument order they already have to deal with when writing any code from any library, and then they get to use the same well-known calling syntax everywhere.

If point-free code was the only code that would benefit from this sort of linearization operator, tho, those objections might still be overcome by the benefit of a baked-in invocation operator that would at least ensure every library in this style worked the same way. But it's not - over-nesting of functions and expressions is a scourge in people's code today, in all library styles and function definition practices. And the vast majority of JS code is not written in point-free style, including the largest, most widely-used library in the world - the web platform. If reducing nesting is valuable for point-free code, it's valuable for any other code as well, and it's easy to argue that the balance of benefits leans toward "the web platform and most libraries" over "the relatively small number of HOF-oriented libraries".

This is further supported by the fact, stated slightly upthread by @mAAdhaTTah, that a number of the libraries currently written in a HOF-oriented style, such as RxJS, are not written that way because they enjoy the benefits of HOFP. They're written that way because they had a very large library of "methods", they wanted them to be easy to use to repeatedly transform a value (like how method-chaining works), and they wanted them to be tree-shakeable (that is, they want tooling to be able to easily detect which functions are unused and remove them from the customized code bundle actually sent over the wire). Given the current syntax and design of JS, a pipe() function that takes unary functions is simply the best solution to all those constraints. Had they been designed after the introduction of a Hack-style pipe operator, however, they could be written just as easily to use the more common JS argument-ordering, taking all their arguments in one invocation and putting the more important arguments at the front. That would satisfy all their constraints and offer additional benefits, such as the functions being more easily usable outside of a pipeline as well.

Some people do use HOFP for its own merits, and as an FP-lover myself, good for them. But the choice was between optimizing this operator for HOFP and making it less convenient for all other use-cases, or optimizing it for all other use-cases and making it less convenient for HOFP. Neither use-case is crippled either way (again, see my essay on the matter), just slightly suboptimal, so the choice was clear to me: no, promoting point-free programmings/APIs was not a goal of the pipe operator.

@runarberg
Copy link

runarberg commented Sep 15, 2021

@tabatkins This does not retract from your overall point—and I’m sorry if I’m being overly pedantic—but there are a few methods in the JavaScript language (or coming to the language) which are designed to be called point free, mostly as being passed into Array.prototype.sort. Examples:

Intl.Collator#compare

console.log(['Z', 'a', 'z', 'ä'].sort(new Intl.Collator('de').compare));
// expected output: ["a", "ä", "z", "Z"]

console.log(['Z', 'a', 'z', 'ä'].sort(new Intl.Collator('sv').compare));
// expected output: ["a", "z", "Z", "ä"]

console.log(['Z', 'a', 'z', 'ä'].sort(new Intl.Collator('de', { caseFirst: 'upper' } ).compare));
// expected output: ["a", "ä", "Z", "z"]

Temporal.Instant.compare

one = Temporal.Instant.fromEpochSeconds(1.0e9);
two = Temporal.Instant.fromEpochSeconds(1.1e9);
three = Temporal.Instant.fromEpochSeconds(1.2e9);
sorted = [three, one, two].sort(Temporal.Instant.compare);
sorted.join(' ');
// => '2001-09-09T01:46:40Z 2004-11-09T11:33:20Z 2008-01-10T21:20:00Z'

@tabatkins
Copy link
Collaborator

No, you're right. Those are specialized contexts where that makes sense. I've never argued that point-free in the small is bad; nothing wrong with arr.map(fn) either. Forcing people to eta-expand when the already-defined function is sitting right there ready to take arguments in the correct order would be silly.

I'll note, tho, that those comparison functions are not designed for composition; they're special-purpose for this one context (being passed directly to a sorting function).

@noppa
Copy link
Contributor

noppa commented Sep 15, 2021

Sorry to go a bit offtopic, but since the map(fn) example came up, I want to mention that I was in the F# camp not really because of the merits of F# pipelines, but because I was really rooting for the partial application proposal.

Writing arr.map(foo.fn) today some nasty gotchas. You need to consider if fn

a) can handle being called as a standalone function or if it needs to be called through foo to keep this working correctly

b) takes the right amount of arguments and not a second or third optional argument that would get incorrectly set to index or arr here. I.e. the notorious map(parseInt) foot gun

c) if b) is ok, can it be reasonably assumed to stay that way, or is it likely that someone will later come and add new optional arguments to it

Because of this, I'd almost always just rather wrap it in a lambda to be safe, arr.map(_ => foo.fn(_)). That works, but it's not ideal. For a while there, I was excited that the partial application proposal would let me write arr.map(foo.fn(?)) instead†.

provided that excess arguments would not be passed through.

@tabatkins
Copy link
Collaborator

Yup, I'd love to have something that makes it easier to safely pass functions/methods like that. PFA, or something like it, would be great for fixing those persistent annoyances.

It is indeed separate from the pipe operator, tho, and the pipe operator, regardless of style, doesn't block or otherwise hamper it.

@ZebulanStanphill
Copy link

I think perhaps @baetheus is onto something here. Since the typical fp library's pipe function already provides a very terse way to do point-free pipelines:

const foo = pipe(
	bar,
	fn1,
	fn2,
	fn3
)
// Is very close to the F# style:
const foo =
	bar
	|> fn1
	|> fn2

Perhaps there's no need for an F# pipeline operator? For longer chains, the pipe function actually uses fewer characters than an F# pipeline operator, while accomplishing the same thing. And you can still use PFA with the pipe function.

An equivalent to the Hack pipeline operator, on the other hand, doesn't really exist in current JS as far as I can tell, unless you want to use variable reassignment, which would go against the trend towards static typing in JS/TS code, or else use a ton of arrow functions.

I was initially in favor of F# pipelines, but I now believe Hack pipelines would add more to the language. In short:

  • JS already has noticeably terse point-free piping, so why add an operator for it?
  • A lot of the unchangeable parts of JS and the majority of JS libraries do not play well with point-free style, as demonstrated by @tabatkins here.
  • In contrast, expression piping seems to be improved dramatically with the Hack operator, and it immediately works with the entirety of functions in the JS ecosystem.

@runarberg
Copy link

runarberg commented Sep 17, 2021

I’m wondering if we can simply (ab)use operator overloading to get our point free pipe instead:

const PipeOps = Operators(
  {},
  {
    right: Function,
    "|"({ value }, fn) { return new Pipe(fn(value)); },
  },
);

class Pipe extends PipeOps {
  value;
  constructor(value) {
    super();
    this.value = value;
  }
}

with operators from Pipe;

const double = (n) => n * 2;
const addTwo = (n) => n + 2;

const { value } = new Pipe(20) | double | addTwo
console.log(value);
// => 42

@js-choi js-choi changed the title Should enabling point-free programming/APIs be a goal of the Pipeline Operator? Tacit programming / point-free style and Hack pipes Sep 18, 2021
@js-choi js-choi changed the title Tacit programming / point-free style and Hack pipes Tacit programming / point-free style and pipes Sep 18, 2021
@js-choi js-choi added documentation Improvements or additions to documentation question Further information is requested labels Sep 19, 2021
@dy
Copy link

dy commented Sep 28, 2021

@runarberg same proposal was discussed in #190 (the topic was locked), some reasoning was outlined here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
documentation Improvements or additions to documentation question Further information is requested
Projects
None yet
Development

No branches or pull requests