当我们谈Currying时,我们在谈些什么

489 阅读4分钟

在数学中,函数是集合到集合的映射,即集合 A 中的元素,经过处理后,映射到集合 B 中的元素。可以简单表示如下:

f(A)->B

函数式编程,顾名思义,就是用函数的方式去编程。关于函数式编程的起源可以追溯到20世纪初的λ演算。在λ演算中,函数只有一个参数,为了实现多参数,于是出现了函数的柯里化(currying),本质上是单参数函数的语法糖。

所谓柯里化,可以理解为把多参数的函数,转换成单参数的函数链,简单表示如下:

f(X1, X2, ..., Xn)->->g(X1)(X2)...(Xn)

在前端日常开发中柯里化并不常用,但在面试中时常会被问到,个人猜测,最初问这个问题的人,也许是为了考查面试者对函数式编程的理解

这里我们以一道经典的面试题入手:

请实现加法运算函数add(),使其满足:

add(1, 2, 3); // 6
add(1, 2)(3); // 6
add(1)(2)(3); // 6

首先,要实现链式调用,add的返回值毫无疑问也是个函数;其次,因为这里连续调用的都是同样的加法运算,很容易想到调用自身的递归。

先来实现个简单版本的:

let sum = 0;
function add() {
    sum += Array.from(arguments).reduce((x, y) => x + y);
    add.toString = () => sum; // console.log时实际调用的是Function.toString()
    return add;
}

这样写当然没毛病,但是在调用的时候,每次都需要手动将sum归零,否则sum就会被之前的计算污染。

我们很容易想到利用闭包来解决这个问题,实现纯函数版本的add()

function add() {
    // 第一次调用时,就要对参数求和并保存到total中
    let total = Array.from(arguments).reduce((x, y) => x + y);
    // 利用闭包特性,保存total
    const _add = function () { // 注意此处不能写成箭头函数
        total += Array.from(arguments).reduce((x, y) => x + y);
        return _add;
    }
    _add.toString = () => total;
    return _add;
}

这里额外提一句,不要手里拿着锤子就到处找东西锤:

柯里化函数可用于任何支持闭包的编程语言;然而,出于效率原因,通常首选非柯里化函数,因为大多数函数调用可以避免部分应用程序和闭包创建的开销。 —— 维基百科

上面的 add() 函数还不够优雅,你看 reduce 重复调了两次,实际上我们可以把所有参数都先收集起来,最后调用一次就行:

function add() {
    // 把参数保存到一个数组里
    const arr = Array.from(arguments);
    // 利用闭包特性,保存total
    const _add = function () { // 注意此处不能写成箭头函数
        arr.push(...Array.from(arguments));
        return _add;
    }
    _add.toString = () => Array.from(arr).reduce((x, y) => x + y);
    return _add;
}

让我们再抽象一层,把普通函数转换成柯里化函数的函数:

function fnToCurry(fn) {
    const curry = function () {
        // 第一次调用时,就把参数保存到数组里
        const arr = Array.from(arguments);
        // 利用闭包特性,保存total
        const _fn = function () { // 注意此处不能写成箭头函数
            arr.push(...Array.from(arguments));
            return _fn;
        }
        _fn.toString = () => fn(...arr);
        return _fn;
    }
    return curry; // 最后返回柯里化后的函数
}

用法示例:

function f1() {
    return Array.from(arguments).reduce((x, y) => x + y);
}
// 将f1柯里化
const f2 = fnToCurry(f1);
console.log(f2(1, 2, 3)); // 6
console.log(f2(1)(2)(3)); // 6

上面这个柯里化后的函数会在调用时就立即执行,如果我们希望柯里化后的函数不立即执行,比如,只有当我们传入的参数包含 "exec" 的时候才执行,那么我们可以这样写:

function fnToDelayCurry(fn) {
    const curry = function () {
        const arr = Array.from(arguments)
        // 利用闭包特性,保存total
        const _fn = function () { // 注意此处不能写成箭头函数
            // 当输入的参数中有exec标志时,立即执行
            // 主要就是在这里去对传入的参数作判断,来判断是否执行
            // 这里包含exec的参数不会被保存到arr中,若要保存exec,则把else中的push挪到外层即可
            if (Array.from(arguments).indexOf("exec") > -1) {
                _fn.toString = () => fn(...arr);
            } else {
                _fn.toString = Function.toString;
                arr.push(...Array.from(arguments));
            }
            return _fn;
        }
        return _fn;
    }
    return curry;
}

用法示例:

function delayFn() {
    const args = Array.from(arguments);
    return args.join("~");
}
console.log(fnToDelayCurry(delayFn)(1)(2)(3)(4, 5)); // 这里打印的是函数_fn
console.log(fnToDelayCurry(delayFn)(1)(2)(3)(4, 5)("exec")); // 1~2~3~4~5

以上Demo代码都在这里:jsrun.net/ciUKp/edit

参考资料:

  1. 柯里化[wiki]:en.wikipedia.org/wiki/Curryi…
  2. 闭包:developer.mozilla.org/zh-CN/docs/…
  3. λ演算-函数式语言的起源:zhuanlan.zhihu.com/p/164700404

往期文章

现代包管理工具:pnpm

硬核!手撕源码第一弹: UpdateNotifier

前端转后端是一种怎样的体验

当程序员遇到会写代码的产品经理......

手摸手写个webpack plugin

手摸手写个webpack loader

这锅我背了......

ES2021新特性

用魔法打败魔法:前端代码规范化

手摸手教你搭个脚手架

手摸手教你搭建npm私有库

requestAnimationFrame