Skip to content

递归:函数式中的“循环替代品”

——为什么尾递归在 JS 中受限?如何用 trampoline 避免栈溢出?

“循环是命令式的‘goto’,
递归是函数式的‘数学定义’。”

一、为什么函数式编程偏爱递归?

在函数式语言中:

  • 没有 forwhile(避免可变状态)
  • 只有表达式函数调用

所以,递归是唯一能实现重复逻辑的方式

示例:计算阶乘

命令式(循环 + 变量)

js
function factorial(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i; // 突变!
  }
  return result;
}

依赖变量 iresult突变

函数式(递归)

js
const factorial = n =>
  n <= 1 ? 1 : n * factorial(n - 1);

没有变量,没有突变,只有数学定义的直接翻译

二、递归的问题:栈溢出(Stack Overflow)

问题根源:调用栈无限增长

js
factorial(5)
// 展开为:
5 * factorial(4)
5 * (4 * factorial(3))
5 * (4 * (3 * factorial(2)))
5 * (4 * (3 * (2 * factorial(1))))
5 * (4 * (3 * (2 * 1))) // 开始回弹

每层递归都占用栈帧,直到最深层才开始计算。

n=10000,调用栈深度为 10000 → 栈溢出

三、优化方案 1:尾递归(Tail Recursion)

什么是尾递归?

递归调用是函数的最后一个操作,无需保留当前栈帧。

js
const factorial = (n, acc = 1) =>
  n <= 1 ? acc : factorial(n - 1, acc * n);

执行过程:

factorial(5, 1)
factorial(4, 5)
factorial(3, 20)
factorial(2, 60)
factorial(1, 120) → 120

理想情况下,编译器可以将其优化为循环,O(1) 栈空间

为什么 JS 中尾递归受限?

尽管 ES6 规范要求支持尾调用优化(TCO),但:

引擎TCO 支持情况
V8 (Chrome)已放弃实现(性能权衡)
SpiderMonkey (Firefox)仅在严格模式下部分支持
JavaScriptCore (Safari)曾支持,但后续版本不稳定

现实:JS 引擎普遍不支持 TCO,因为:

  • 调试困难(栈被重写)
  • 性能收益有限
  • try/catch 等机制干扰优化

所以,即使你写了尾递归,依然可能栈溢出

四、解决方案:Trampoline(蹦床函数)

核心思想:

不让函数直接递归调用自己,而是返回一个“继续执行”的函数(thunk)
由一个外部循环(trampoline)来逐个执行这些 thunk。

实现 trampoline

js
function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result(); // 执行 thunk,直到返回最终值
    }
    return result;
  };
}

改造尾递归为蹦床

js
function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return () => factorial(n - 1, acc * n); // 返回 thunk
}

const safeFactorial = trampoline(factorial);

safeFactorial(10000); // 不会栈溢出

执行流程:

text
trampoline(factorial)(5)
→ factorial(5,1) → () => factorial(4,5)
→ trampoline 执行该函数 → factorial(4,5) → () => factorial(3,20)
→ 继续... 直到返回 120

所有调用都在 trampoline 的 while 循环内完成,栈深度始终为 1

五、更复杂的递归:树遍历

问题:非尾递归无法直接蹦床

js
const sumTree = node =>
  !node ? 0 :
  node.value + sumTree(node.left) + sumTree(node.right);

这不是尾递归,因为有两个递归调用,且需相加。

解法:显式使用栈(模拟递归)

js
function sumTreeIterative(root) {
  const stack = [root];
  let total = 0;

  while (stack.length) {
    const node = stack.pop();
    if (node) {
      total += node.value;
      stack.push(node.right, node.left); // 后进先出
    }
  }

  return total;
}

将递归逻辑转化为迭代 + 显式栈,完全避免调用栈溢出。

六、现代替代方案

1. 使用生成器(Generator)处理大列表

js
function* range(n) {
  for (let i = 0; i < n; i++) yield i;
}

[...range(10000)].reduce((a,b) => a+b, 0); // 惰性生成

2. 使用 async/await + 队列(适用于异步递归)

js
async function processQueue(queue) {
  while (queue.length) {
    const item = queue.shift();
    await process(item);
  }
}

七、工程实践建议

推荐使用递归当:

  • 数据结构天然递归(树、图)
  • 逻辑清晰,且输入规模小
  • 用于教学或 DSL 设计

避免递归当:

  • 输入可能很大(如用户提供的 n)
  • 性能关键路径
  • 团队不熟悉函数式

安全策略:

  • 对大输入使用 trampoline
  • 或改写为 显式栈的迭代版本
  • 使用 惰性求值(如 generator)分批处理

结语:递归是“问题的自然表达”,但需工程化保护

它让你用数学定义代替控制流,但 JS 的运行时限制了它的自由

尾递归本应是完美的优化,
但现实引擎的支持缺失,
迫使我们用 trampoline显式栈 来“手动优化”。

这提醒我们: 函数式编程不仅是风格,更是对运行时环境的深刻理解

当你写下 f(n) = n * f(n-1)
你是在定义数学;
当你用 trampoline 保护它,
你是在驾驭现实。