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

Syntactic marker for partial application #48

Closed rbuckton closed 2 years ago

rbuckton commented 2 years ago

History

One of the original goals for partial application was to dovetail with F#-style pipelines using minimal syntax. For example:

[1, 2, 3] |> map(?, x => x + 1)

I have long favored the terseness of the above approach, but partial application has long been blocked on concerns about "The Garden Path", that there was no indication that f(a, b, c, /* ... */ ?) was a partial call until you encountered the placeholder.

Recently, TC39 advanced Hack-style pipelines to Stage 2. This is, in a way, a blessing for partial application. Having partial application divorced from the pipeline syntax means there's less impetus behind pushing for an extremely terse syntax. As a result, I am far more comfortable with requiring a syntactic marker to indicate a partially-applied invocation.

Proposal

Thus, I propose the following syntax for partial application: fn~(a, ?, b)

While we can bikeshed the specific marker, there are a number of reasons why I'm proposing f~(?) over alternatives like +> f(?):

By placing the marker coterminous with Arguments, we can clearly indicate that what is affected is the argument list, rather than an arbitrary expression. For example, in an earlier investigation into a syntactic marker we considered using a prefix ^ before the expression: ^f(?). Given the intended eager semantics of partial application, such a prefix can become confusing. Given ^a.b().c(?), should this have been a syntax error? If not, how would you have differentiated between which invocation was partial? Would we have required parenthesis around the non-partial left-hand-side (i.e., ^(a.b()).c(?))?

Instead, we've chosen to include the marker as part of Arguments to make the locality of the partial application more explicit. The expression a.b().c~(?) does not require parenthesis to disambiguate, and as a result can be easily chained: a.b~(?, ?).apply~(null, ?).

Examples

Here are some examples of this proposed syntax change to provide context:

NOTE: "approx equiv" is an approximately equivalent arrow function that is lazily evaluated.
NOTE: "actual equiv" is a more complex transformation illustrating eager evaluation semantics that are closer to the actual runtime behavior.

argument binding


const add = (a, b) => a + b;

const addOne = add~(1, ?);

// approx equiv: 
const addOne = _b => add(1, _b);

// actual equiv: 
const addOne = (() => {
  const _add = add;
  const _1 = 1;
  return _b => _add(_1, _b);
})();

[1, 2, 3].map(addOne).forEach(console.log);
// prints:
// 2 0 2,3,4
// 3 1 2,3,4
// 4 2 2,3,4

[1, 2, 3].map(addOne).forEach(console.log~(?));
// prints:
// 2
// 3
// 4

chaining

const f = (a, b, c) => [a, b, c];

const g = f~(?, 1, ?).apply~(null, ?);

// approx equiv: 
const g = _args => ((_a, _c) => f(_a, 1, _c)).apply(null, _args)

// actual equiv: 
const g = (() => {
  const _temp = (() => {
    const _f = f;
    const _1 = 1;
    return (_a, _c) => _f(_a, _1, _c);
  })();
  const _apply = _temp.apply;
  const _null = null;
  return _args => _apply.call(_temp, _null, _args);
})();

console.log(g([4, 5, 6]));
// prints:
// 4,1,5

Uncurrying this

The ~( syntactic marker does allow you to uncurry the this argument of a function:

function whoAmI() {
  console.log(`I'm ${this.name}`);
}

const f = whoAmI.call~(?);
f({ name: "Alice" }); // prints: I'm Alice
f({ name: "Bob" }); // prints: I'm Bob

Caveat - No placeholder for Callee

The ~( syntactic marker does not address the possibility of having the callee itself be a placeholder. That is a corner case that is outside the scope of the proposal and can be easily solved in userland either using arrow functions or simple definitions like invoke, below:

const person = { sayHello(name) { console.log(`Hello, ${name}!`); } };
const dog = { sayHello(name) { console.log(name === "Bob" ? "Grr!" : "Woof!"); } };

const invoke = (o, name, ...args) => o[name](...args);

// can't do `?.sayHello~(?)`, but can do this:
const f = invoke~(?, "sayHello", ?);

f(person, "Alice"); // prints: Hello, Alice!
f(dog, "Alice"); // prints: Woof!
f(dog, "Bob"); // prints: Grr!

Re-introduce ... placeholder

Finally, as part of introducing ~(, we intend to also re-introduce ... as the "remaining arguments" placeholder (see #18) so that we can cover all of the following cases:

Given const f = (...args) => console.log(...args);:

The ... operator always means "spread any remaining unbound arguments into this argument position".

Relation to .bind and the "bind" operator (::)

Reintroducing ... would mean that o.m~(...) would be a syntactic shorthand for o.m.bind(o):

o.m.bind(o);
o.m~(...);

o.m.bind(o, 1, 2, 3);
o.m~(1, 2, 3, ...);

It also means that o.m~(...) could serve as a replacement for the proposed prefix-bind operator (i.e., ::o.m), though that does not necessarily mean that prefix-bind should not be considered.

Neither partial application nor its use of ... are able or intended to replace the proposed infix-bind operator (i.e., o::m).

Related Issues

See the following related issues for additional historical context:

ljharb commented 2 years ago

Why about optional partial application? In other words, i assume x~(?) will be a type error when x is nullish, but what if i want the semantics that the function doesn’t invoke when x is nullish, like x?.()?

jridgewell commented 2 years ago

The chaining example makes my head hurt. Can you explain what it is without using apply in the original?

mAAdhaTTah commented 2 years ago

@jridgewell I believe it indicates that f~(?, 1, ?) is partially applied first, then .apply is partially applied, bound to the partially applied f (same that 5 times fast!).

jridgewell commented 2 years ago

There are two issues that confuse me:

  1. How can you have two partially applied functions, but only one call to get a result?
  2. _apply.call(...) is like a 3rd order function, and that melts my mind.
ljharb commented 2 years ago

@jridgewell i think it's more like, a.b() is involving two things, a receiver a and a function a.b - so, partial application of a.b would thus need to eagerly cache both the receiver and the function.

rbuckton commented 2 years ago

The point of showing how method chaining works isn't to show its a good idea, but that it is consistent with the language. f~() returns a function, so all you can really do is invoke it or access function members like call, apply, bind, etc.


I'm not yet sure whether optional partial application should be permitted. It wouldn't be the only calling convention that doesn't support optional chaining. If it was supported, it might look like this:

o?.m~() // if o is nullish
o.m?.~() // if o.m is nullish

In either case, the result would return undefined if the callee was nullish, rather than a function that may eventually return undefined (which would be consistent with optional chain evaluation).

tabatkins commented 2 years ago

This change would make me so happy, and remove all of my blocking concerns with PFA:

I'm a big supporter of all this.

Pokute commented 2 years ago

What should f~; do? Would import be partially applicable? Overall, I like!

tabatkins commented 2 years ago

What should f~; do?

Syntax error - the ~ is part of the call operator, so the operator is actually ~(), similar to how optional-call is ?.()

Would import be partially applicable?

The import() function, yeah. It's just a normal function that returns a promise; it can be PFA'd like anything else. (I'm pretty sure import() doesn't get figured into the module dependency tree, right?) The import statement, no.

ljharb commented 2 years ago

import() is not a function, normal or otherwise, it's syntax, so unless the proposal explicitly carved out additional syntax for import~(), it would not work.

rbuckton commented 2 years ago

I explicitly don't allow super~(), which is similar, syntactically, to import(). Partial import() seems marginally useful with import assertions, but doesn't seem like it's worth the added syntax.

tabatkins commented 2 years ago

Yeah, my bad for thinking import() was a normal function. Any of the "magic" functions (that can't just be squirreled away in a closure for later) shouldn't be PFA-able.

Pokute commented 2 years ago

I made a TypeScript PR implementing some parts of this variant. Playground link

I'm thinking of dropping support for old-form f(?) partial application soon. This is also Missing ... -support.