聊尾递归之前,我得先承认一件事:真正让我把 Continuation 想明白的,是一次排查问题里连续三次看到 RangeError: Maximum call stack size exceeded。当时同事写了一段递归遍历树形数据的代码,数据量一大就爆栈。我提醒他“这块应该改成尾递归”,他反问我:尾递归和递归到底差在哪?改成尾递归浏览器就一定不炸了吗?要是碰上必须在节点返回后还要拿结果继续算的场景,又怎么办?
这些问题正是我想在这篇文章里聊透的。尾递归不是“多一个累加参数”这么简单,它是函数调用的控制流语义问题;Continuation 也不是函数式编程里唬人的玄学术语,它本质上就是把“接下来要做的事”变成可传递的一等值。把这两件事放在一起看,很多关于性能、栈溢出、异步回调、协程的困惑都能一下串起来。
1. 先回答最实在的问题:尾递归凭什么不会爆栈
1.1 你看到的那棵调用树,其实是函数的“旅行账本”
写递归的时候,我们脑子里通常是一棵“不断分叉的树”。但实际上每个函数调用都会在调用栈上压入一条记录:局部变量、返回地址、执行到哪一行,这些东西都是当前一帧的“旅行账本”。只有子调用返回后,账本才会被清掉,外层函数才能继续往下走。
就拿最典型的求和递归来说:
javascript复制function sum(n) {
if (n === 0) return 0;
return n + sum(n - 1);
}
当 sum(100000) 执行时,最后一行的 n + sum(n - 1) 不是在调用完 sum(n - 1) 后立刻结束的。它需要等子调用把结果算回来,再把这个结果加上当前的 n,然后才能返回。也就是说,外层函数的局部变量 n 和返回地址必须一直待在栈上等着。递归层数一多,这些等待状态的栈帧就会一层压一层。
调用栈的容量不是无限的。V8 里大多数默认配置下,大概几千到上万层就会开始报警;Python 的默认递归深度只有 1000 左右;Java 直接抛 StackOverflowError。这就是“递归炸栈”的最直接原因:不是递归本身有罪,而是每一帧都在等子调用回来,栈被无意义地占住了。
1.2 尾调用为什么能“先记账、再出发”
“尾调用”的定义简单到让人忽略:一个函数里,某个调用出现在尾部位置,也就是这个函数在计算出调用结果后直接返回,不再做任何加工。比如:
javascript复制function foo() {
return bar();
}
末尾的 return bar() 就是尾调用。注意,return 1 + bar()
