函数组合
函数组合是函数式编程中最关键的实践方法,是核心中的核心。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); // 不会栈溢出
下一步 👉 数据不可变性
