函数组合

函数组合是函数式编程中最关键的实践方法,是核心中的核心。reduce() 之所以能成为函数式编程的"万金油",本质上就是因为它映射了函数组合的思想。

面向对象的核心在于继承,函数式编程的核心在于组合。

一、高阶函数(HOF)

满足下面任一条件的函数就是高阶函数:接收函数作为参数,或者返回一个函数。

它的价值在于把"变化的部分"抽离成参数:

const add1     = num => num + 1;
const mult3    = num => num * 3;
const divide2  = num => num / 2;

// 遍历、收集结果这些是"不变"的;具体怎么算是"变"的,以函数形式传入
function arrCompute(arr, compute) {
    const newArr = [];
    for (let i = 0; i < arr.length; i++) {
        newArr.push(compute(arr[i]));
    }
    return newArr;
}

arrCompute([1, 2, 3], add1);    // [2, 3, 4]
arrCompute([1, 2, 3], mult3);   // [3, 6, 9]
arrCompute([2, 4, 6], divide2); // [1, 2, 3]

arrCompute 其实就是手写的 map。JS 内置的 map / filter / reduce / sort / some / every 全是高阶函数,setTimeout、addEventListener、Promise.then 也都是。

二、链式调用

链式调用的本质是在方法中返回对象自身(或同类型的新对象),从而能连续调用其原型上的方法。

const arr = [1, 2, 3, 4, 5];

const biggerThan2 = num => num > 2;
const multi2      = num => num * 2;
const add         = (a, b) => a + b;

const sum = arr.filter(biggerThan2).map(multi2).reduce(add, 0);   // 24

链式调用的代价:多次遍历

filter().map().reduce() 会遍历数组 3 次,并产生 2 个中间数组。数据量小时可读性远比这点开销重要;数据量大(十万级以上)时可以:

  • 用一次 reduce 合并所有步骤;
  • 或者用惰性求值的迭代器链,逐元素流过,不产生中间数组。

reduce 是最通用的那一个——map 和 filter 都能用它表达:

const map    = (arr, fn) => arr.reduce((acc, cur) => [...acc, fn(cur)], []);
const filter = (arr, fn) => arr.reduce((acc, cur) => (fn(cur) ? [...acc, cur] : acc), []);

注意上面这种写法每次迭代都展开一个新数组,复杂度是 O(n²)。作为演示可以,实际用 acc.push(...) 再 return acc。

三、管道 pipe & compose

链式调用依赖对象的原型方法,只能串联该类型自带的方法。要串联任意的独立函数,就要靠 pipe / compose。

// pipe:从左到右依次执行
function pipe(...funcs) {
    return function (param) {
        return funcs.reduce((input, func) => func(input), param);
    };
}

// compose:从右到左执行(数学上的 f(g(x)) 顺序)
function compose(...funcs) {
    return function (param) {
        return funcs.reduceRight((input, func) => func(input), param);
    };
}

const add4      = num => num + 4;
const multiply3 = num => num * 3;
const divide2   = num => num / 2;

pipe(add4, multiply3, divide2)(1);      // (1+4)*3/2 = 7.5
compose(divide2, multiply3, add4)(1);   // 同上,顺序相反

支持异步的 pipe

真实业务里每一步常常是异步的,把 reduce 换成 Promise 链即可:

const pipeAsync = (...funcs) => param =>
    funcs.reduce((chain, func) => chain.then(func), Promise.resolve(param));

工程中的对应物:Redux 的 compose(组合中间件)、RxJS 的 pipe(map(), filter())、Koa 的中间件串联(职责链)、构建工具的 loader/plugin 链。

四、柯里化与偏函数

pipe 有个硬性限制:每一步只能接收上一步的一个返回值,也就是只能串联一元函数。一旦某步需要两个参数:

function multiply(x, y) { return x * y; }
pipe(add4, multiply)(1);   // ❌ y 是 undefined,结果 NaN

解决办法就是把多元函数降维成一元函数。

柯里化(Currying)

把一个 n 元函数改造成 n 个相互嵌套的一元函数:fn(a, b, c) → fn(a)(b)(c)。

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

function curry(fn) {
    return function addA(a) {
        return function addB(b) {
            return function addC(c) {
                return fn(a, b, c);   // 闭包"记住"了 a 和 b
            };
        };
    };
}

curry(addThreeNum)(1)(2)(3);   // 6

偏函数(Partial Application)

把一个 n 元函数变成一个 m(m < n)元函数——固定住部分参数。

两者的区别:

柯里化偏函数
函数的元n 元 → n 个一元n 元 → 一个 m 元(m < n)
函数的数量1 个变成 n 个始终是 1 个
调用方式f(a)(b)(c)g = partial(f, a) 然后 g(b, c)
function partial(fn, ...presetArgs) {
    return function (...laterArgs) {
        return fn(...presetArgs, ...laterArgs);
    };
}

const multiply = (x, y) => x * y;
const multiply3 = partial(multiply, 3);
multiply3(2);   // 6

Function.prototype.bind 本身就是一个偏函数工具:multiply.bind(null, 3) 效果相同(区别是 bind 还会绑定 this)。

通用柯里化

手写嵌套显然不现实。借助 Function.length(形参个数)可以写出通用版本:

function curry(func, arity = func.length) {
    return function generateCurried(prevArgs) {
        return function curried(...nextArgs) {
            const args = [...prevArgs, ...nextArgs];
            // 参数够了就调用,不够就继续返回新的收集函数
            return args.length >= arity
                ? func(...args)
                : generateCurried(args);
        };
    }([]);
}

const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);

curriedAdd(1)(2)(3);    // 6
curriedAdd(1, 2)(3);    // 6  ← 支持一次传多个
curriedAdd(1)(2, 3);    // 6
curriedAdd(1, 2, 3);    // 6

Function.length 不可靠的三种情况

((a, b = 1) => 0).length;   // 1  ← 默认值参数及其之后的都不计入
((a, ...rest) => 0).length; // 1  ← rest 参数不计入
(({ a, b }) => 0).length;   // 1  ← 解构算一个形参

遇到这些函数时,必须手动传入第二个参数指定元数:curry(fn, 3)。

把它们用起来

const add      = (a, b) => a + b;
const multiply = (a, b, c) => a * b * c;
const divide   = (a, b) => a / b;

const curriedAdd      = curry(add);
const curriedMultiply = curry(multiply);
const curriedDivide   = curry(divide);

// 每一步都被降维成一元函数,可以放进 pipe
const compute = pipe(
    curriedAdd(1),           // x => 1 + x
    curriedMultiply(2)(3),   // x => 2 * 3 * x
    curriedDivide(300),      // x => 300 / x
);

compute(1);   // (1+1)*6 = 12 → 300/12 = 25

柯里化不是免费的

每层嵌套都创建一个闭包,高频调用路径上(比如动画每帧、大数组的逐元素处理)会带来可观的开销。柯里化的价值在于表达力和复用,不在于性能——热路径上请直接写普通函数。

五、Point-free 风格

不显式提及要处理的数据,只描述"做什么变换":

// 有参数(point-ful)
const getUpperNames = users => users.map(user => user.name.toUpperCase());

// point-free:把数据流藏起来,只留下变换本身
const prop      = curry((key, obj) => obj[key]);
const toUpper   = str => str.toUpperCase();
const map       = curry((fn, arr) => arr.map(fn));

const getNames  = map(prop('name'));
const upperAll  = map(toUpper);
const getUpperNames2 = pipe(getNames, upperAll);

适可而止

Point-free 能让代码变得极其简洁,但过度使用会严重损害可读性——一串没有名字的变换,别人(和三个月后的你)很难看懂。工程实践中的平衡点是:给每个中间组合起一个有业务含义的名字,而不是写出一整行匿名的组合。

六、记忆化(Memoize)

纯函数"相同输入 → 相同输出"的特性,让结果可以被安全地缓存:

function memoize(fn, resolver = (...args) => JSON.stringify(args)) {
    const cache = new Map();
    return function (...args) {
        const key = resolver(...args);
        if (cache.has(key)) return cache.get(key);
        const result = fn.apply(this, args);
        cache.set(key, result);
        return result;
    };
}

// 斐波那契:从指数级降到线性
const fib = memoize(n => (n <= 1 ? n : fib(n - 1) + fib(n - 2)));
fib(40);   // 瞬间返回;不加 memoize 需要约 2^40 次调用

注意事项与缓存代理完全一致:key 的生成方式、缓存淘汰(避免内存泄漏)、异步失败要清除缓存。

React 的 useMemo / useCallback / React.memo、Vue 的 computed,本质上都是记忆化。

七、其他常用高阶函数

// 防抖:连续触发时只在停止 wait 毫秒后执行最后一次
const debounce = (fn, wait) => {
    let timer;
    return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), wait);
    };
};

// 节流:无论触发多频繁,每 wait 毫秒最多执行一次
const throttle = (fn, wait) => {
    let last = 0;
    return function (...args) {
        const now = Date.now();
        if (now - last >= wait) { last = now; fn.apply(this, args); }
    };
};

// 只执行一次
const once = fn => {
    let called = false, result;
    return function (...args) {
        if (!called) { called = true; result = fn.apply(this, args); }
        return result;
    };
};

这三个都是装饰器模式在 JS 中的形态。写它们时同样要守住那三条:用 function 保住 this、透传参数、返回原函数的返回值。

八、递归与尾调用

FP 倾向用递归代替循环(循环需要可变的计数器):

const sum = arr => (arr.length === 0 ? 0 : arr[0] + sum(arr.slice(1)));

JS 实际上没有尾调用优化

ES6 规范定义了尾调用优化(TCO),但除了 Safari,主流引擎(V8 / SpiderMonkey)都没有实现。因此:

  • 递归深度受调用栈限制,Node 默认约一万多层就会 RangeError: Maximum call stack size exceeded;
  • 处理大数组请老老实实用循环,或者把递归改写成显式栈的迭代形式;
  • 需要深递归时可以用 trampoline(蹦床函数)把递归转成循环:
const trampoline = fn => (...args) => {
    let result = fn(...args);
    while (typeof result === 'function') result = result();   // 逐次求值,栈深度恒为 1
    return result;
};
const sumBelow = trampoline(function rec(n, acc = 0) {
    return n === 0 ? acc : () => rec(n - 1, acc + n);          // 返回函数而不是直接递归
});
sumBelow(100000);   // 不会栈溢出

下一步 👉 数据不可变性

上次更新:
贡献者: Joe