前端知库

怎样手写函数柯里化和 compose?

柯里化把一个多参数函数改成可以分几次传参的函数,参数凑够原函数的个数才真正执行,常用来预先固定一部分参数。compose 把多个单参数函数串成一个,数据从右往左依次经过每个函数。

参考答案

简答

柯里化就是把 add(1, 2, 3) 变成可以写成 add(1)(2)(3),也可以写成 add(1, 2)(3)。每次调用先把参数存起来,凑够原函数需要的个数,再一次性调用原函数。原函数需要几个参数,看的是它的 length。

实现靠闭包。curry 返回一个函数,这个函数记住已经收到的参数;参数不够就再返回一个函数继续收,够了就调用原函数。

它的用处是预先固定一部分参数,得到一个更具体的函数。比如 const log = curry(logger)('订单'),之后每次调用 log 都自动带上“订单”这个前缀。compose 是另一种组合方式,compose(f, g)(x) 等于 f(g(x)),把几个处理步骤串成一个函数,手写一般用 reduceRight。

要注意,length 不计算默认参数及其后面的参数,也不计算剩余参数。所以对写了默认值或 ...args 的函数,柯里化可能凑不够个数或者提前执行,这时要手动传入参数个数。

手写 curry

下面的 curry 接收一个函数,返回柯里化以后的版本。可以把要凑够的参数个数当作第二个参数传进来,不传就用 fn.length。

JavaScript
function curry(fn, arity = fn.length) {
  return function curried(...args) {
    if (args.length >= arity) {
      return fn.apply(this, args);
    }
    return function (...rest) {
      return curried.apply(this, [...args, ...rest]);
    };
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

const addTen = curriedAdd(10);
console.log(addTen(1)(2)); // 13
console.log(addTen(5, 5)); // 20

curriedAdd(1) 为什么没有报错,而是返回了一个函数?因为 add.length 是 3,现在只收到 1 个参数,不够,所以返回一个新函数继续等。这个新函数被调用时,会把之前的 [1] 和新收到的参数拼起来,再交给 curried 判断一次。

之前收到的参数存在哪里?存在闭包里。每个返回的函数都记住了它创建时的 args。所以 addTen 可以反复用,addTen(1)(2) 和 addTen(5, 5) 都从 [10] 开始拼,互不影响。

fn.apply(this, args) 里传 this,是为了柯里化以后的函数作为对象方法调用时,this 还能传给原函数。

为什么有时要手动传参数个数

JavaScript
function greet(name, greeting = '你好') {
  return `${greeting},${name}`;
}
console.log(greet.length); // 1

greet 明明写了两个参数,length 却是 1。因为 length 只数第一个默认参数之前的参数,剩余参数 ...args 也不算。所以 curry(greet)('小明') 收到 1 个参数就执行了,第二个参数根本没机会传。

这时就用第二个参数告诉 curry 要凑几个:curry(greet, 2)('小明')('早上好')。

手写 compose

处理数据时经常要经过好几步,比如先去空格、再转小写、再加前缀。写成 addPrefix(toLower(trim(input))) 层层嵌套不好读,compose 可以把它们串起来。

JavaScript
function compose(...fns) {
  if (fns.length === 0) return (value) => value;
  return function (value) {
    return fns.reduceRight((result, fn) => fn(result), value);
  };
}

function pipe(...fns) {
  return function (value) {
    return fns.reduce((result, fn) => fn(result), value);
  };
}

const trim = (s) => s.trim();
const toLower = (s) => s.toLowerCase();
const addPrefix = (s) => `user:${s}`;

console.log(compose(addPrefix, toLower, trim)('  Alice ')); // user:alice
console.log(pipe(trim, toLower, addPrefix)('  Alice ')); // user:alice
console.log(compose()(5)); // 5

为什么 compose 用 reduceRight?因为 compose(f, g, h)(x) 的意思是 f(g(h(x))),最右边的 h 先执行。reduceRight 从数组末尾往前遍历,正好是这个顺序。pipe 则是从左往右,读起来和执行顺序一致,很多人觉得更直观。

没有传任何函数时返回原值,这样 compose() 也能安全使用。

常见追问

柯里化和 bind 预设参数有什么区别?

bind 只能预设一次参数,返回的函数再调用时就直接执行了。柯里化可以分任意多次传,直到凑够个数才执行。另外 bind 会固定 this,柯里化不会。

Redux 的中间件为什么也用到 compose?

Redux 的 applyMiddleware 用 compose 把多个中间件串成一条链,每个中间件包住下一个,请求从外往里传。Koa 的洋葱模型思路类似,不过 Koa 处理的是异步函数,实现上用的是递归调用 next。

实际项目里经常手写柯里化吗?

不常手写通用的 curry,更常见的是直接写一个返回函数的函数,比如 const createLogger = (prefix) => (msg) => console.log(prefix, msg)。它的道理和柯里化一样,都是用闭包记住先传进来的参数。