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

Syntactic marker for partial application #48

Closed
rbuckton opened this issue Sep 23, 2021 · 13 comments · Fixed by #49
Closed

Syntactic marker for partial application #48

rbuckton opened this issue Sep 23, 2021 · 13 comments · Fixed by #49

Comments

@rbuckton
Copy link
Collaborator

rbuckton commented Sep 23, 2021

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(?):

  • f~() indicates a separate calling convention from f(), not unlike f?.() (but without the need for the . disambiguator)
  • Makes it very clear which calls are partial vs. which are not.
  • Partial Application behaves like a syntactic version of the .bind() operation:
    • The callee is eagerly evaluated
    • Callee references are maintained (i.e., a is preserved as this for a.b~())
    • All supplied arguments are eagerly evaluated
    • Unbound arguments (provided via ?) are mapped directly to arguments in the new function
  • Since all evaluation is eager and placeholders are mapped directly to individual arguments, the prefix must be coterminous with Arguments to indicate the call is partial.
  • It becomes possible to have a partial application that forwards no arguments: f~()
  • It becomes possible to support partial application of new with a clear meaning: In const f = new C~(arg, ?);, f can be called since the new is part of the partial application.
  • All of the +> variants (as proposed for "smart-mix" pipelines and as an addendum to Hack-style pipelines) are lazily evaluated, essentially acting like an arrow function.

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);:

  • f~() - Partially apply f with fixed arguments and no placeholders:
    const g1 = f~();
    g1(1, 2, 3); // prints:
    
    const g2 = f~("a");
    g2(1, 2, 3); // prints: a
  • f~(?) - Partially apply f with fixed arguments and one or more placeholders:
    const g1 = f~(?);
    g1(1, 2, 3); // prints: 1
    
    const g2 = f~("a", ?);
    g2(1, 2, 3); // prints: a 1
    
    const g3 = f~(?, "a");
    g3(1, 2, 3); // prints: 1 a
  • f~(...) - Partially apply f with fixed arguments and a remaining arguments placeholder:
    const g1 = f~(...);
    g1(1, 2, 3); // prints: 1 2 3
    
    const g2 = f~("a", ...);
    g2(1, 2, 3); // prints: a 1 2 3
    
    const g3 = f~(..., "a");
    g3(1, 2, 3); // prints: 1 2 3 a
  • f~(?, ...) or f~(..., ?) - Partially apply f with fixed arguments, one or more placeholders, and a remaining arguments placeholder:
    const g1 = f~(?, ...);
    g1(1, 2, 3); // prints: 1 2 3
    
    const g2 = f~(..., ?);
    g2(1, 2, 3); // prints: 2 3 1
    
    const g3 = f~("a", ?, ...);
    g3(1, 2, 3); // prints: a 1 2 3
    
    const g4 = f~("a", ..., ?);
    g4(1, 2, 3); // prints: a 2 3 1
    
    const g5 = f~(?, "a", ...);
    g5(1, 2, 3); // prints: 1 a 2 3

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
Copy link
Member

ljharb commented Sep 23, 2021

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
Copy link
Member

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

@mAAdhaTTah
Copy link

@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
Copy link
Member

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
Copy link
Member

ljharb commented Sep 23, 2021

@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
Copy link
Collaborator Author

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
Copy link

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

  • garden-path is gone, since you now know very early in the syntax that something is a PFA (and thus will evaluate to a function) rather than a normal invocation
  • editting hazard is gone (wrapping one of the placeholders in another function, like a logger, thus changing the top-level function back into a normal invocation), since ? in a non-PFA-signaled function is an error
  • the other editting hazard is gone (changing the placeholder to a constant value, thus turning it into a regular invocation), since the PFA-ness is still signaled no matter if there's placeholders or not
  • no-arg PFA (just binding the receiver) can now happen (along with "pass all args" PFA due to ... placeholder)

I'm a big supporter of all this.

@Pokute
Copy link

Pokute commented Oct 1, 2021

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

@tabatkins
Copy link

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
Copy link
Member

ljharb commented Oct 1, 2021

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
Copy link
Collaborator Author

rbuckton commented Oct 1, 2021

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
Copy link

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
Copy link

Pokute commented Oct 4, 2021

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants