深入理解Scala递归函数:从栈溢出到尾调用优化

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。它有两个部分:

  1. 累加器(accumulator):保存当前已经算出来的结果
  2. 当前状态:保存当前递归到哪了
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里到处都是递归结构(树状聚合、图遍历),你用尾递归写起来既优雅又不会栈溢出。

尾递归的本质,就是编译器帮你把递归转成循环。你写的是递归,跑的是循环------这就是函数式编程的魔法。

相关推荐
三*一23 分钟前
Mapbox‑GL JS 多边形折点编辑工具:实现原理、功能拆解与踩坑总结
开发语言·javascript·ecmascript
YSL07012435 分钟前
C++基础
开发语言·c++
若亦_42 分钟前
Java中关于this的三种用法
java·开发语言·intellij-idea
2601_968793141 小时前
Python Requests 轻量自动化:无需浏览器,实现真人级账号浏览模拟
开发语言·python·自动化
eggwyw2 小时前
关于C++为什么不加入垃圾回收机制解析
开发语言·c++
来让爷抱一个13 小时前
Python 异步里混用同步库,事件循环被卡住的全过程
开发语言·python
心祭丫丫15 小时前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
露天赏雪16 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
yeccheng18 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试