Java程序员对递归的印象,大概停留在"写个阶乘"、"写个斐波那契"这种教科书示例里。实际项目里很少用------因为递归太容易栈溢出了。
但Scala不一样。Scala是函数式语言,递归是它的核心特性之一。而且Scala有尾调用优化(Tail Call Optimization,TCO)------理论上你可以写无限深度的递归,不会栈溢出。
很多Java程序员第一次写Scala递归的时候,写法还是Java那套:直接递归,然后报StackOverflowError。然后就说"Scala递归也不行啊"。
问题不在递归,在你没写对。
这篇文章不讲"递归是什么"这种入门废话,直接说干货:为什么普通递归会栈溢出?尾调用优化到底优化了什么?怎么写一个不会栈溢出的递归函数?

先从一个栈溢出说起
写一个计算阶乘的函数:
scala
// 普通递归:计算n的阶乘
def factorial(n: Int): Int = {
if (n <= 1) 1
else n * factorial(n - 1)
}
factorial(5) // 120
factorial(1000) // StackOverflowError!
为什么n=1000就炸了?因为每次调用factorial(n - 1),都要在调用栈上压一层------保存当前的n、保存返回地址、然后进入下一层。1000层调用,栈就满了。
这就是Java程序员对递归的固有印象:递归 = 栈溢出。
但你有没有想过:为什么递归会栈溢出?不是因为递归本身有问题,而是因为你写的递归方式有问题。
递归的本质:它就是循环
先说一个反直觉的事实:递归和循环,本质上是同一个东西。
你写一个for循环:
scala
var result = 1
for (i <- 1 to n) {
result = result * i
}
// result就是n!
你写一个递归:
scala
def factorial(n: Int): Int = {
if (n <= 1) 1
else n * factorial(n - 1)
}
两者做的事情一模一样------都是从1乘到n。区别只是:循环用var保存中间状态,递归用调用栈保存中间状态。
循环把状态存在堆上(变量),递归把状态存在栈上。
栈是有限的------默认大小也就几MB。所以当递归深度太大的时候,栈就溢出了。
那有没有办法让递归不占栈?有------尾调用优化。
尾调用优化:它到底优化了什么?
先看什么是尾调用。
尾调用,就是函数的最后一步是调用另一个函数。
scala
// 普通递归:最后一步是 n * factorial(n - 1)
// 不是尾调用------因为乘操作要等factorial(n - 1)返回后才能做
def factorial(n: Int): Int = {
if (n <= 1) 1
else n * factorial(n - 1)
}
// 尾递归:最后一步是调用辅助函数
// 这就是尾调用------因为最后一步就是函数调用,没有其他操作
def factorial(n: Int): Int = {
def loop(acc: Int, n: Int): Int = {
if (n <= 1) acc
else loop(acc * n, n - 1)
}
loop(1, n)
}
看出区别了吗?
普通递归里,n * factorial(n - 1)------factorial(n - 1)返回之后,还要做一个乘法。所以当前这层栈帧不能释放------它要等子调用返回后,继续做乘法。
尾递归里,loop(acc * n, n - 1)------这就是最后一步了。loop返回什么,整个函数就返回什么。当前这层栈帧可以直接释放------因为它不需要等子调用返回后做任何事。
这就是尾调用优化的核心:如果函数的最后一步是调用自身,那就不需要保留当前栈帧,直接跳过去就行。
编译器把这个尾递归编译成什么?------一个循环。
scala
// 你写的尾递归
def factorial(n: Int): Int = {
def loop(acc: Int, n: Int): Int = {
if (n <= 1) acc
else loop(acc * n, n - 1)
}
loop(1, n)
}
// 编译器背后做的:
def factorial(n: Int): Int = {
var acc = 1
var i = n
while (i > 1) {
acc = acc * i
i = i - 1
}
acc
}
看到了吗?尾递归被编译器自动转成了循环。所以它不会栈溢出------因为它本质上就是个循环。
为什么Java没有尾调用优化?
很多人会问:那Java为什么不做尾调用优化?
答案是:JVM规范不允许。
JVM的设计里,调用栈是和异常堆栈绑定的------你抛个异常,堆栈里要有完整的调用链。如果做了尾调用优化,栈帧被释放了,异常堆栈就不完整了------这会破坏Java的调试体验。
所以Java到现在(Java 21了)都没有尾调用优化。你写递归,深度一大就栈溢出。
Scala呢?Scala跑在JVM上,但它有自己的办法------@tailrec注解。
scala
import scala.annotation.tailrec
@tailrec
def factorial(n: Int): Int = {
def loop(acc: Int, n: Int): Int = {
if (n <= 1) acc
else loop(acc * n, n - 1)
}
loop(1, n)
}
这个注解告诉编译器:"这应该是尾递归,如果不是,报错。"
这是一个非常好的设计------它不是偷偷帮你优化,而是让你明确声明"我要尾递归",编译器帮你检查。如果你写的不是尾递归,编译器直接报错,你就知道要改了。
怎么写一个正确的尾递归?
写尾递归有一个固定套路:用一个辅助函数,把中间状态当参数传进去。
这个辅助函数通常叫loop或者go。它有两个部分:
- 累加器(accumulator):保存当前已经算出来的结果
- 当前状态:保存当前递归到哪了
scala
// 例子:计算列表的和
def sum(list: List[Int]): Int = {
@tailrec
def loop(acc: Int, list: List[Int]): Int = {
list match {
case Nil => acc // 递归结束:返回累加器
case head :: tail => loop(acc + head, tail) // 尾递归:把head加到累加器,继续处理tail
}
}
loop(0, list)
}
看这个模式:
acc是累加器------当前已经算出来的和list是当前还没处理的部分- 每次递归,把head加到acc上,把tail传给下一层
这就是尾递归的标准写法。所有的中间状态都通过参数传递------所以不需要栈帧保存状态。
递归 vs 循环:什么时候用?
Java程序员会问:既然尾递归最后都会被编译成循环,那我直接写循环不就行了?为什么还要用递归?
答案是:递归更优雅,更函数式。
循环是命令式的------你要告诉计算机"怎么做":初始化变量、循环、更新变量。
递归是声明式的------你只需要告诉计算机"是什么":阶乘就是n乘以n-1的阶乘。
scala
// 循环写法:命令式
def factorial(n: Int): Int = {
var result = 1
for (i <- 1 to n) result *= i
result
}
// 递归写法:声明式
def factorial(n: Int): Int = {
if (n <= 1) 1 else n * factorial(n - 1)
}
递归的写法更接近数学定义------你看一眼就知道这个函数在算什么。循环的写法你得仔细看才知道它在干嘛。
但递归也有代价:它要写辅助函数、要加累加器、要加@tailrec注解。写起来比循环啰嗦。
所以判断标准是:如果这个问题天然是递归的(树、图、嵌套结构),就用递归;如果就是简单的计数累加,用循环也行。
写在最后
我写这篇文章的时候,特意没罗列递归有多少种写法------因为递归的语法很简单,谁都能写。
真正值得花时间理解的是:为什么Java的递归会栈溢出,而Scala的不会?
不是因为Scala更厉害------而是因为Scala是函数式语言,递归是它的核心特性之一。它从设计之初就考虑了尾调用优化的问题。Java是命令式语言,它的核心是对象和方法,递归只是一个"能用但不推荐"的特性。
这个设计哲学的差别,决定了:
- Java程序员写递归:栈溢出,不敢用
- Scala程序员写递归:尾递归,随便写
递归不是洪水猛兽,栈溢出是因为你没写对。
理解了尾调用优化,你就理解了函数式编程的一个核心能力:你可以用递归的优雅写法,获得循环的性能。
这就是Scala在大数据开发里这么好用的原因之一------Spark、Flink里到处都是递归结构(树状聚合、图遍历),你用尾递归写起来既优雅又不会栈溢出。
尾递归的本质,就是编译器帮你把递归转成循环。你写的是递归,跑的是循环------这就是函数式编程的魔法。