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

Viability of achieving this in userland libraries #45

Open peey opened 3 years ago

peey commented 3 years ago

If we treat the pipelines proposal as a separate proposal and disregard the pipelines-related examples, I'm wondering if this proposal can entirely be achieved through libraries (and if not, then what what are the key issues that prevent it)

e.g. it should be possible to write a library that allows you to write

import {_, partial} from "a-partial-application-library" // _ is a symbol, partial is like Function.call but that recognizes the symbol _ and performs partial application
const addTen = partial(add, _, 10) // instead of add(?, 10)
const another = partial(f, _, 10, "hi", _, "yo") // instead of f(?, 10, "hi", ?, "yo")

it may even be possible to support https://github.com/tc39/proposal-partial-application/issues/5

// design 1

import {_0, _1, partial} from "a-partial-application-library" // _0 might be an alias for _
const another = partial(f, _1, 10, "hi", _0, "yo") // instead of f(?1, 10, "hi", ?0, "yo")

// design 2
import {_, partial} from "a-partial-application-library"
const another = partial(f, _[1], 10, "hi", _[0], "yo") // instead of f(?1, 10, "hi", ?0, "yo")

and to support https://github.com/tc39/proposal-partial-application/issues/7

function add({x,y}) {
    return x + y;
}

// design 1 - dumb implementation
import {_, partial} from "a-partial-application-library" 

const plus2 = partial(add, {y : 2, x: _}) // instead of add({y:2, ...});
// the above is dumb because it'll cause the implementation in "partial" to do a nested object walk through all arguments  to find the key that's equal to `_`

// design 2 - slightly smarter
import {_, partial} from "a-partial-application-library"  // this time _ is callable

const plus2 = partial(add, _({y : 2})) // _ wraps the optional arg and marks first positional argument to add as having been partially applied
console.log(plus2({x : 3})) // and when {x: 3} is passed, partial performs Object.assign({}, {y:2}, {x: 3}) and passes that as an arg to add

// design 3 - perhaps clearer?

// we can use a different name than _ for these "args which have been supplied but incompletely" (avoiding the word "partial" because that's being used for "args which have not been supplied at all")
import {_, incomplete, partial} from "a-partial-application-library"  // this time _ is callable

const plus2 = partial(add, incomplete({y : 2})) 
console.log(plus2({x : 3}))

const other = partial(someFunc, _, 3, incomplete({x : 2}), _, "hi")

// we can also support fixed-length arrays by using 

incomplete(_, 1) // to denote first element of array is missing, when user passes [0] this arg will resolve to [0, 1] before being sent to the function
incomplete(1, _, 3) // to denote middle element is missing, user passes [2] and this arg resolves to [1, 2, 3] before being sent to the function
incomplete(1, _, 3, _, 5) // when user passes [2, 4], function gets [1, 2, 3, 4, 5] 
incomplete(1, _, 3, _, 5, _rest) // when user passes [2, 4] function gets [1,2, 3, 4, 5] and when user passes [2, 4, 6, 7] the function gets [1,2,3,4,5,6,7] 

// nesting can be achieved, though you might prefer the shorter name _ over "incomplete" here
incomplete(1, _, incomplete(_, "y", _)) // user passes [2, ["x", "z"]] and function gets [1, 2, ["x", "y", "z"]
incomplete(1, _, incomplete({'key1': 'v1'})) // user passes [2, {"key2": "v2"}] and function gets [1, 2, {"key1": "v1", "key2": "v2"}

Through these examples, I'm not saying that this proposal is not needed. Indeed some of the syntax clunkiness in a userland library would go away if this were a language feature. I'm just hoping that this leads to a discussion of he core language limitations that prevent this proposal from happening as a userland library, and to discuss if this proposal addresses those limitations well.

A discussion of how this can (even partially) be achieved in a userland library may also support development of this feature in transpilers (babel, typescript) and allow it to reach users faster.

noppa commented 3 years ago

Lodash's _.partial works pretty much like that (without some of the more advanced features you envision in the last example).

const f = (a, b, c) => { console.log(a, b, c) }
const incomplete = _.partial(f, 1, _, 3)
incomplete(2)
// logs 1, 2, 3

there are also similar functions in other popular utility libraries.

Indeed some of the syntax clunkiness in a userland library would go away

IMO this is a big benefit of the operator.
Ease of use is a "break it or make it" quality of a utility feature like this.

I practically never use the partial helper functions because at the end of the day, writing

import {_, partial} from 'a-partial-application-library'
const plus2 = partial(plus, _, 2)

is more cumbersome and arguably even less readable than your everyday

const plus2 = _ => plus(_, 2)

Also their static typing with TS/Flow often leaves much to be desired.

aminnairi commented 3 years ago

Here is my proposal polyfill. It only supports partial application from left to right, just like in a functional programming language like Haskell or Elm. One could also write a flip function if there is a need to do partial application backward.

/**
 * @description Partial polyfill
 * @author Amin NAIRI <https://github.com/aminnairi/>
 * @version 1.0.0
 * @example https://replit.com/@amin_nairi/LividEffectiveConversions#index.js
 */
const partial = (callback, ...initialArguments) => {
  return (...additionalArguments) => {
    const allArguments = [...initialArguments, ...additionalArguments];

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

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

const add = partial((first, second) => {
  return first + second;
});

const range = partial((from, to) => {
  return [...Array(to - from + 1)].map((_, index) => {
    return index + from;
  });
});

const fromFiveTo = range(5);
const increment = add(1);
const decrement = add(-1);

console.log(fromFiveTo(10)); // [5, 6, 7, 8, 9, 10]
console.log(range(1, 5)); // [1, 2, 3, 4, 5]
console.log(add(2, 3)); // 5
console.log(increment(4)); // 5
console.log(decrement(0)); // -1

Not sure if this is really necessary to do partial application backward for an argument a in a function f(a, b) like f(?, b). I find it really odd and never had any problem doing so. Haskell does a great job defining function argument order so that it is rarely needed to use flip on a function. The data comes last and the settings come first.

import {storageGet} from "./library/api/storage-get.mjs";
import {storageSet} from "./library/api/storage-set.mjs";

storageSet(localStorage, "search", "");
storageGet(localStorage, "search").whenOk(searchValue => console.log(searchValue));
storageGet(localStorage, "items").whenOk(storageSet(localStorage, "savedItems"));
lazarljubenovic commented 3 years ago

I'm by no means an expert on this, but I assume another huge benefit of this being part of the language is performance. Engines would be able to optimize partial application and/or pipes as if they were regular function calls, and not create the mess of intermediate functions.

aminnairi commented 3 years ago

True, Rambda has a placeholder for doing that with R.__ but of course, this adds some runtime overhead (just like the solution I proposed above) and thus slows down the interpretation of the script.

Though this won't add too much overhead for most of the Web applications (to not say practically invisible for a non-power user) out there that do not have performance in mind, this can be critical for other range of domains like Gaming or Geolocation and such.

In the end, achieving this in a userland library should be viable if there is no need for performance and we can achieve a pretty slick API with partial application and function composition.

import {compose} from "partials/compose.mjs";
import {map, mapWithIndex} from "partials/map.mjs";
import {reduce} from "partials/map.mjs";
import {isModulo} from "partials/utils.mjs";
import {or} from "partials/logical.mjs";

const isValidFrenchCompanyIdentifier = compose([
  map(compose([Number, or(0)])),
  mapWithIndex(doubleIfOddIndex),
  map(sumDigits),
  reduce(sum),
  isModulo(10)
]);

isValidFrenchCompanyIdentifier("732829320"); // true
isValidFrenchCompanyIdentifier("732829321"); // false
fabiosantoscode commented 3 years ago

@lazarljubenovic yes indeed!

Terser could also know how to inline bound functions, since it can't know for sure that .bind() wasn't modified in the function prototype.

When combining with pipelining, I think the performance cost of ? would disappear for most pipelining cases. As well as end the debate over on that proposal regarding first-argument vs curried.

make-github-pseudonymous-again commented 2 years ago

Terser could also know how to inline bound functions, since it can't know for sure that .bind() wasn't modified in the function prototype.

I don't know of how much of a niche this is, but the current proposal indeed would allow some light version of static metaprogramming without the need of a tool such as babel. Although one could argue that this is already achieved by using arrow functions to reorder/fix parameters (minus this).