Skip to content

递归与高阶函数:把变化交给参数 ​

函数成为值之后,我们不仅能传数字,还能传“做什么”。先用简单的高阶函数表达变化,再认识递归与组合,避免一开始就落进看不懂的抽象套娃。

高阶函数接收或返回函数 ​

js
function repeat(times, action) {
  for (let i = 0; i < times; i += 1) action(i);
}
repeat(3, index => console.log(`第 ${index + 1} 次`));

repeat 决定重复规则,action 决定每次动作。它接收函数,所以是高阶函数。回调只是“交给另一个函数,由它决定何时调用”的函数,不自动意味着异步。上面三次打印就在 repeat 返回前完成。

回调契约需要说明调用次数、参数、返回值是否使用、是否同步。下一章的 map 就会传入值、索引和数组;把只想接收一个参数的函数直接传进去,未必安全。

纯函数与副作用分开理解 ​

js
function addMinutes(current, extra) {
  return current + extra;
}
let total = 10;
total = addMinutes(total, 5);
console.log(total); // 15

纯函数在相同输入下给出相同结果,不修改外部可观察状态。读取当前时间、随机数、写日志、修改传入对象,都可能使函数不再纯。纯函数不是“没有局部变量”:内部使用 let 累计,只要不产生外部变化,仍可保持纯粹。

副作用是程序与世界交互所必需的,只是应集中在容易识别的位置。这样计算规则能独立验证,而不是每算一次价格就顺手发一封邮件。

递归必须缩小问题 ​

js
function digitSum(n) {
  if (!Number.isSafeInteger(n) || n < 0) {
    throw new RangeError('需要非负安全整数');
  }
  if (n < 10) return n;
  return n % 10 + digitSum(Math.trunc(n / 10));
}
console.log(digitSum(407)); // 11

throw 表示遇到不满足约定的输入时抛出错误,完整错误处理见后续章节。递归每次把整数去掉末位,直到只剩一位。展开 407 的过程是 7 + digitSum(40),再是 7 + 0 + digitSum(4),最终得到 11。

递归需要终止条件和向终止条件推进的步骤。调用会占用栈空间;复杂树结构适合递归,极深数据需要显式栈或循环。尾调用优化在常用 Node.js 运行环境中不能当作普遍保障,不要因为写了尾递归就声称不会栈溢出。

返回阶段也在做工作,不只是“再调用自己” ​

对 digitSum(407),每次递归都会先留下尚未完成的加法,再进入更小的问题:

text
digitSum(407) 等待 7 + digitSum(40)
  digitSum(40) 等待 0 + digitSum(4)
    digitSum(4) 返回4
  得到0+4,返回4
得到7+4,返回11

“缩小问题”解释进入过程,“把结果交回等待者”解释返回过程。忘记返回递归结果,例如只写 digitSum(next);,下层即使正确算出数字,上层仍可能返回undefined;调用发生过不代表结果自动成为当前函数结果。

递归还可以自然产生嵌套结构。下面不增加新数据结构,只生成配对括号:

js
function nest(depth) {
  if (!Number.isInteger(depth) || depth < 0) {
    throw new RangeError('depth需要非负整数');
  }
  if (depth === 0) return 'x';
  return '(' + nest(depth - 1) + ')';
}
console.log(nest(3)); // (((x)))

当前层先决定左括号,等待内部结果,再接右括号。问题深度减一,零层是终点。输入虽合法但极深时仍可能耗尽调用栈;合法性与资源上限是两个不同约束。固定声称递归比循环慢几倍没有普遍依据,但未完成调用占据栈空间这一点必须考虑。

柯里化和部分应用不是一个名字的两种写法 ​

js
const add = a => b => a + b;
const addTen = add(10);
console.log(addTen(3)); // 13

function format(prefix, text, suffix) {
  return `${prefix}${text}${suffix}`;
}
const bracket = text => format('[', text, ']');
console.log(bracket('函数')); // [函数]

柯里化把多参数调用组织成连续接收参数的函数,例如 add(a)(b)。部分应用提前固定部分参数,剩下的以后再传;上面的 bracket 固定了首尾两个参数。两者都利用函数值和闭包,但输入接口并不相同。

通用柯里化工具若用 fn.length 推断参数个数,会遇到默认参数、rest、可选参数边界。业务中少量明确的包装函数通常比一个“支持所有函数”的工具可靠。

组合的前提是输出能接上输入 ​

js
const trim = text => text.trim();
const upper = text => text.toUpperCase();
const tag = text => `[${text}]`;
const normalize = text => tag(upper(trim(text)));
console.log(normalize(' js ')); // [JS]

每一步都接收字符串并返回字符串,所以可以连接。若某一步只打印而不返回,下一步收到的就会是 undefined。组合不是把任意函数排在一起,而是检查每一条输入输出边。

记忆化把已计算结果保存起来,适合成本较高、同输入同输出的计算。缓存键必须表达完整输入,而且需要考虑清理策略;一个永远增长的缓存会用空间换掉本来省下的时间。本章先建立原则,下一章学完 Map 后可以实现明确范围的缓存。

组合器只是把返回值接到下一步 ​

已有 tag(upper(trim(text))) 可以表达固定三步。若想复用“两步相接”的结构,先写一个有明确方向的小函数:

js
function then(first, second) {
  return value => second(first(value));
}
const clean = text => text.trim();
const addMark = text => `《${text}》`;
const format = then(clean, addMark);
console.log(format(' 闭包 ')); // 《闭包》

调用format时先运行clean,把其返回值交给addMark。这里只接受单输入同步函数;多实参、Promise或需要接收者的函数不能不加说明地放进来。保持接口窄,才能把每一步的含义说清。

如果把clean改成 text => console.log(text.trim()),这一步返回undefined,下一步就格式化成 《undefined》。打印正确不等于数据流正确。这也说明纯函数与组合的关系:纯函数较容易保证输入输出,但有返回值的副作用函数同样能被调用,组合器不会替你判断它是否纯。

若需要记录中间值,可以写“打印后返回原值”的观察函数,并明确它带副作用;不能为方便观察把计算结果悄悄丢掉。到下一章,我们会用同一原则理解数组回调为什么必须返回值。

练习 ​

  1. 写 applyTwice(fn, value),让加 3 的函数对 4 连续执行两次,结果为 10。

  2. 用循环重写上面的 digitSum,解释为什么它不需要随位数增长的递归调用栈。

  3. 给nest(2)画出调用与返回顺序;把组合第一步改成只打印不返回时,为什么下游收到undefined?

查看答案

用心记录每一步成长