-
Notifications
You must be signed in to change notification settings - Fork 108
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
Comments
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! 😅) |
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. |
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?
100% this. Pipes are a well known and understood concept. Do thing a, pass the results to the next step, and so on. |
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 You can do |
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. |
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:
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 😅 |
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. |
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. |
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. |
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 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 Compare with Hack-style, where the library author just writes a 2-arg function as taking two args ( 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. |
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 |
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 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# 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. |
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. |
@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? |
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. |
I agree; this is an important distinction. But it is also true that tacit programming is already enabled…in userland, with userland functions like 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.) |
The question was: Should enabling point-free programming/APIs be a goal of the Pipeline Operator?
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. |
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. |
We're already somewhat there with higher-order functions. It's very common to see 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. |
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
You're free to believe that, and I doubt I'll convince you otherwise. |
@tabatkins (I’m sorry, I’m going off topic here, this debate belongs in #204 ).
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. |
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 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. |
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".
If you define the function as
Yes, that'd be either ugly or require additional/automagical syntax. Not as concise as Hack, but also not impossible. |
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.
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. ^_^ |
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.
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. |
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. |
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 |
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. |
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. |
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. |
As long as Hack-style pipeline-operator, you are right. #206 (comment)
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.
In terms of point-free style, this is also basically Math. and a way to write in FP. |
I have a question to the TC39 members:
I will pull something out of a hat now to demonstrate what I mean: Proposal: Scoped MethodsSimilar 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 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? |
I don't think it's particularly obvious, tho. Outside of the context of this conversation, I'd assume 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 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 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 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. |
@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 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"] 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' |
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 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). |
Sorry to go a bit offtopic, but since the Writing a) can handle being called as a standalone function or if it needs to be called through b) takes the right amount of arguments and not a second or third optional argument that would get incorrectly set to index or 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, † provided that excess arguments would not be passed through. |
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. |
I think perhaps @baetheus is onto something here. Since the typical fp library's 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 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:
|
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 |
@runarberg same proposal was discussed in #190 (the topic was locked), some reasoning was outlined here. |
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?
The text was updated successfully, but these errors were encountered: