tc39 / proposal-partial-application

Proposal to add partial application to ECMAScript
https://tc39.es/proposal-partial-application/
BSD 3-Clause "New" or "Revised" License
1.02k stars 25 forks source link

Discussion of Related Work (Similar features in Other Languages) #44

Open peey opened 3 years ago

peey commented 3 years ago

Clojure(Script) has a feature (docs) through which instead of writing:

(fn [x] (+ 6 x))

you may write

#(+ 6 %)

Additionally, there can be a qualifying number after the mark to support multiple arguments (including rest arguments)

(fn [x y & zs] (println x y zs))
// can be instead written as
#(println %1 %2 %&)

I think that a "Related Work" section would be a great addition to the README document and could benefit the overall discussion

As an example, looking at how clojure does it, we might resolve https://github.com/tc39/proposal-partial-application/issues/5 and get an alternate design point for parsing. Clojure begins this syntactic sugar with a special token # which makes it easier on the parser. Does this proposal face the similar challenges in parsing? If so, does clojure's design help us alleviate it? These are some things which I believe should be a part of the discussion.

I've discussed clojure, and it'd also be good to know how other languages implement similar features, and if we can take inspiration from their design / learn from challenges they ran into.

aminnairi commented 3 years ago

As discussed in #45 , Haskell and Elm for instance have taken a "simpler" path in the sense that partial application is applied naturally, meaning in the function arguments order.

This means that if we want to call a function defined by

subtract :: Int -> Int -> Int
subtract a b = a - b

To create a new function called decrementOneBy, we would do something like:

decrementOneBy = subtract 1

It can then later be used like

decrementOneBy 2 -- -1

But the more real-world use-case for that would be to do the inverse, meaning decrement something by one for instance. There is a function called flip which will take a function from a -> b -> c and turn it into a function from b -> a -> c (it "flips" the first and second arguments).

decrement = (flip subtract) 1
decrement 2 -- 1

But this means that the responsibility for doing that kind of work is passed to the caller. Haskell and Elm's functions are designed so that the data is always at the end whenever possible and the settings at the beginning so this flip call is rarely needed.

Unfortunately, in JavaScript, there is some examples when the design used for the function signature does not allow a clean partial application and thus will probably require this special ? operator anyway although I'm not in favor of using it. For instance, with the Math.pow method which needs the flip call to work correctly without having a placeholder for partial application.

const powBy = (power, target) => target ** power;

const powBy4 = powBy(4);

console.log(powBy4(5));
console.log(powBy(4, 5));

This is where the proposal's operator is useful.

const powBy4 = Math.pow(?, 4);

console.log(powBy4(5));

This works great, but I'm afraid the implementation behind this special syntax will be too much complexity added to the runtime interpreter and there must be some kind of similar reason why these languages do not allow this out of the box (only with the use of another function like flip).

Also, languages like Haskell and Elm have proven no real use of partially applying arguments backward, this is why I would highly be in favor of a syntax that only allows partial application from right to left. Even if that means writing down some of the already known helpers like Math.pow to powBy.

A flip helper could be included in the ECMA specification for the already existing functions that needs to be flipped.

Here is an hypothetical polyfill for those two helpers if it were to be added like that in the standard.

Function.flip = function(callback) {
  if (typeof callback !== "function") {
    throw new Error("callback is not a function in Function.flip(callback)");
  }

  if (callback.length !== 2) {
    throw new Error("callback does not take exactly two arguments in Function.flip(callback)");
  }

  return (a, b) => {
    return callback(b, a);
  };
};

Function.partial = function(callback, ...initialArguments) {
  if (typeof callback !== "function") {
    throw new Error("callback is not a function in Function.partial(callback)");
  }

  return (...additionalArguments) => {
    const allArguments = [...initialArguments, ...additionalArguments];

    if (allArguments.length >= callback.length) {
      return callback(...allArguments);
    }

    return Function.partial(callback, ...allArguments);
  };
};

const powBy = Function.partial(Function.flip(Math.pow));
const powBy4 = powBy(4);

console.log(powBy(4, 2)); // 16
console.log(powBy4(2)); // 16

It is worth noting that partially applied functions should not have any variadic parameters because it simply does not make sense to partially apply variadic functions.

ljharb commented 3 years ago

imo such a "flip" function couldn't ever be standardized, because JS functions aren't guaranteed to have exactly two arguments (nor any specific number)

aminnairi commented 3 years ago

Flipping a function that has more than two arguments makes little to no sense in my opinion (hence why there is only a flip function in Haskell and not flip2, flip3, flip4, ...).

This would help writing less code for legacy functions that have this kind of argument design in the current standard but it is pretty easy to flip in any direction using a callback if the function were to have more than two arguments.

const badlyDesignedReduce = (items, reducer, initialValue) => { /* TODO: implementation */ };

const fold = Function.partial((reducer, initialValue, items) => {
  return badlyDesignedReduce(items, reducer, initialValue)
});

In this case, there is no need for the use of the Function.flip polyfill provided above. This can be done quickly using an arrow function, even using a one-liner.

The previous comment has been edited to add runtime type checking and a more obvious explanation of what the errors could be at runtime for the Function.flip method polyfill.

noppa commented 3 years ago

There are many, many JavaScript functions out there that take data first and optional argument or variable amount of arguments after that, and tons of people are merrily using them as we speak. Calling all these functions as "legacy" or "bad" just because they aren't designed in the same way that functions in Haskell or Elm are, sounds a bit... dogmatic.

As far as I understand, the goal of this proposal is to narrow the gap between these existing functions and functional-style code, not make it wider.

aminnairi commented 3 years ago

As far as I understand, the goal of this proposal is to narrow the gap between these existing functions and functional-style code, not make it wider.

Interesting, could you elaborate more on what makes you think that this solution widens the gap between the existing functions?

As far as I understand the goal of this article, it's interesting to compare the proposal to other programming languages and the way they address the kind of problem that this proposal is attempting to solve without the need for a new operator. There also has been some great solutions provided by the community here that does not involve the use of a new operator such as a new keyword behind function definition that makes it more obvious like in #40

I'm really curious to have your input on what makes you think that a function, in a functional-style code, instead of a "new" operator (?) makes the gap wider, especially when this operator has already been used for things like ternary and optional chaining.

noppa commented 3 years ago

Let's say I want to call JSON.stringify, with its optional formatting arguments, to items in a list. Today, I'd do that with

items.map(_ => JSON.stringify(_, null, 2))

This works fine, but the need to wrap my simple mapping function in a lambda introduces a small amount of
extra boilerplate (_ =>) that adds no real value to the reader. It's not a big deal in this example, but if you have many steps one after another (maybe you want to call .filter on the list too, or .sort, or something), the boilerplate can add up.

With this proposal, I could instead write

items.map(JSON.stringify(?, null, 2))

I find that slightly easier to read and more pleasant to write, although I also understand that beauty is in the eye of the beholder.

This use case and the way to invoke these functions was perhaps not at the top of the usage examples when designing these functions, and that's OK because JavaScript is and is supposed to be a versatile multi-paradigm language that caters to lots of developers' widely different coding styles. With the help of the proposal, however, I believe even functions designed in this way - data first, variable number of optional arguments after - can be pleasant to use for developers who'd perhaps prefer a bit different kind of signature for them.

I call this "narrowing the gap" because it makes the use of these existing functions more ergonomic in this particular manner and thus makes it more likely for developers of different backgrounds to use these existing functions without being compelled to create replacements or wrappers for them.

On the other corner, we have currying and/or partial application functions that partially apply arguments from left to right. As discussed above, these solutions are kind of ineffective for functions designed with a signature like that of JSON.stringify, because it's the first argument to the function that you actually don't want to specify beforehand. Then we turn to the flip-function for help. At least in the case of JSON.stringify, the function's length property is 3, so maybe you even could define a flip-function that manages to help us here, but that's not always the case. As you and @ljharb already discussed, defining a flip function that operates on functions with arity != 2 can get a bit messy.

So, if you can't just

items.map(Function.partial(Function.flip(JSON.stringify), 2, null)

what can one do? Well, as you suggested with the reduce-example, they could create a new wrapper function that takes its formatting arguments in a different order, or no formatting arguments at all.

I call this "widening the gap" because it discourages direct use of perfectly good existing functions in favor of writing one-liner wrappers.

Some years back, when Promises were new and shiny and great but the builtin APIs in Node.js were not quite caught up yet, a common start for any of my Node script files was something like

const fs = require('fs')
const readFile = (path, options) => new Promise((resolve, reject) => fs.readFile(path, options, (err, res) => {
  if (err) reject(err)
  else resolve(res)
})

Perhaps with Promises the paradigm shift from callbacks was big enough that this migration pain was warranted, but nonetheless, the pain was real. I don't long for writing these ugly little wrappers, not one bit.
We later got util.promisify, which helped, but I don't think this issue was "fixed" until fs/promises was baked in.

I find it unlikely that we'd start getting unary/data-last versions of these functions built in, so in this case there's no fs/promises that would come and save the day.

Also as a sidenote, I find the that the readability of these alternatives goes (from most readable to the least)

items.map(JSON.stringify(?, null, 2))
items.map(_ => JSON.stringify(_, null, 2))
items.map(Function.partial(Function.flip(JSON.stringify), 2, null)

but I don't want to overemphasize that point since I know people will have different opinions about this.


it's interesting to compare the proposal to other programming languages

Yes it is interesting, and I didn't mean to discourage good healthy discussion. But I just want to remind that JS is not the same language as Clojure or Haskell, and a notion that the aftermentioned languages are somehow objectively better or obvious directions where JS language design should be headed, is a very opinionated idea that is not shared by everyone.

Calling (items, reducer, initialValue) => a "badlyDesignedReduce" and implying that functions with this kind of signature would be replaced over time with a more haskell-like signatures is a highly opinionated stance and personally I find it unlikely that all or even most JS developers would get behind it. I could be proven wrong, of course.

especially when this operator has already been used for things like ternary and optional chaining

The actual sigil to use is up to discussion in #21. There's also a lot of discussion in other issues here about the benefits of an operator versus a function, like readability and runtime performance. An operator can also do things a function simply can't. Namely, it can bind the this of the created function to the context object.

pepkin88 commented 2 years ago

I'd like to chime in with LiveScript: https://livescript.net/#functions-partial

They have it similar, as the proposal, that is obj.method(_, 1, _) where _ is the placeholder. Use cases such as flip could be done like this: -> fn(&1, &0), where -> is a function definition and & is just a sugar for arguments, so &1 is the same as arguments[1]. They also have partial applications for operators, like: (2 +), (- 1), (*), (.a.b = c), (obj.). I mentioned them in https://github.com/tc39/proposal-partial-application/issues/46#issuecomment-939450017.

The partialized functions are also autocurried, so if not every missing argument was passed, it returns another function awaiting remaining arguments:

minus = (-)
oneMinus = minus(1)
oneMinusTwo = oneMinus(2) # -1
oneMinusTwo2 = minus(1, 2) # also -1