java中的尾递归

1、基本概念

1)尾调用:

在计算机学里,尾调用是指一个函数里的最后一个动作是返回一个函数的调用结果的情形,即最后一步新调用的返回值直接被当前函数的返回结果。此时,该尾部调用位置被称为尾位置。尾调用中有一种重要而特殊的情形叫做尾递归。经过适当处理,尾递归形式的函数的运行效率可以被极大地优化。尾调用原则上都可以通过简化函数调用栈的结构而获得性能优化(称为“尾调用消除”),但是优化尾调用是否方便可行取决于运行环境对此类优化的支持程度如何。

2)尾递归:

若函数在尾位置调用自身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。对尾递归的优化也是关注尾调用的主要原因。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

尾递归在普通尾调用的基础上,多出了2个特征:

  1. 在尾部调用的是函数自身 (Self-called);
  2. 可通过优化,使得计算仅占用常量栈空间 (Stack Space)。
3)尾递归优化:

尾递归和一般的递归的不同点在对内存的占用,普通递归每次递归调用时回创建新的stack,从而stack膨胀,随着递归的结束而后收缩。而尾递归只会占用恒量的内存(和迭代一样)。看一个直观的例子:计算从1到n的累加(python)

def recsum(x):
  if x == 1:
    return x
  else:
    return x + recsum(x - 1)

当调用recsum(5),Python调试器中发生如下状况:

recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
5 + (4 + (3 + 3))
5 + (4 + 6)
5 + 10
15

可以看到,随着递归调用的进行,从左到右达到顶峰,再从右到左收缩。而我们通常不希望这样的事情发生(当调用栈达到一定深度就会报statck overflow错),通常使用以下方法优化:

a.迭代:只占据常量stack space(更新这个栈!而非扩展他)。

for i in range(6):
  sum += i

b.尾递归优化:

def tailrecsum(x, running_total=0):
  if x == 0:
    return running_total
  else:
    return tailrecsum(x - 1, running_total + x)

虽然也是递归调用,但是这种写法可以被编译器优化,变成:

tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15

观察到,tailrecsum(x, y)中形式变量y的实际变量值是不断更新的,对比普通递归就很清楚,后者每个recsum()调用中y值不变,仅在层级上加深。所以,尾递归是把变化的参数传递给递归函数的变量了。

注:上面的第二种仅为了说明尾递归,实际上python的编译器时不支持尾递归优化的。

4)怎么写尾递归?

形式上只要最后一个return语句是单纯函数就可以。如:

return tailrec(x+1);

return tailrec(x+1) + x;

则不可以。因为无法更新tailrec()函数内的实际变量,只是新建一个栈。

5)尾递归优化原理:

传统模式的编译器对于尾调用的处理方式就像处理其他普通函数调用一样,总会在调用时创建一个新的栈帧(stack frame)并将其推入调用栈顶部,用于表示该次函数调用。

当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。

2、java为什么不支持尾递归优化

Python,Java,Pascal等编译器没有实现尾递归优化(Tail Call Optimization, TCO),所以采用了for, while, goto等特殊结构代替recursive的表述。像C这类编译器,在语言层变一旦写成尾递归形式,就可以进行尾递归优化。

这里要强调一下:不是“Java没有尾递归优化”,而是“Java编译器没有尾递归优化”。这样更准确!

1)Java编译器本身应该是不太可能直接支持尾递归优化的:

Project Loom已经箭在弦上。Loom官方是这样介绍自己的:

Project Loom is to intended to explore, incubate and deliver Java VM features ... This is accomplished by the addition of the following constructs:

  • Virtual threads
  • Delimited continuations
  • Tail-call elimination

虽然协程的优先级更高,但既然选择了在JVM层面来做尾递归的路子,就不太可能在编译器层面再来一次。

2)尾递归优化会更改调用栈,对于程序员debug来说非常不方便:

以Kotlin为例,下面这段有bug的代码是没有开启尾递归优化的:

fun triangle(n: Int) {
  if (n > 0) {
    println("*".repeat(n))
    triangle(n-1)
  } else {
    println(1/0)    // <--- 在这里的递归边界刻意埋了一个bug
  }
}
fun main() {
    triangle(5)
}

报错信息:

Exception in thread "main" java.lang.ArithmeticException: / by zero
        at MainKt.triangle(Main.kt:6)
        at MainKt.triangle(Main.kt:4)
        at MainKt.triangle(Main.kt:4)
        at MainKt.triangle(Main.kt:4)
        at MainKt.triangle(Main.kt:4)
        at MainKt.triangle(Main.kt:4)
        at MainKt.main(Main.kt:11)
        at MainKt.main(Main.kt)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:566)
        at org.jetbrains.kotlin.runner.AbstractRunner.run(runners.kt:64)
        at org.jetbrains.kotlin.runner.Main.run(Main.kt:176)
        at org.jetbrains.kotlin.runner.Main.main(Main.kt:186)

可以非常清晰地看到函数调用的情况,直接对应到程序员写的代码。

如果加上tailrec开启尾递归优化,代码倒是变动不大(添加tailrec关键字):

tailrec fun triangle(n: Int) {
  if (n > 0) {
    println("*".repeat(n))
    triangle(n-1)
  } else {
    println(1/0)
  }
}
fun main() {
    triangle(5)
}

错误信息变成了

Exception in thread "main" java.lang.ArithmeticException: / by zero
        at MainKt.triangle(Main.kt:6)
        at MainKt.main(Main.kt:11)
        at MainKt.main(Main.kt)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:566)
        at org.jetbrains.kotlin.runner.AbstractRunner.run(runners.kt:64)
        at org.jetbrains.kotlin.runner.Main.run(Main.kt:176)
        at org.jetbrains.kotlin.runner.Main.main(Main.kt:186)

可以看到,triangle只有一层调用。这是因为编译器做了尾递归优化,把递归调用展开成了循环。运行效率虽然提高了,但是程序员调试的时候很容易一头雾水。这还是最简单的递归。假如A尾调用B、B尾调用A呢?理论上是可以优化的,但出了bug程序员简直要抓瞎。而Java作为一个相对高级的语言,尤其是有了未来JVM的支持,基本没必要在编译器层面自己给自己找麻烦。

3)JVM尚未支持尾递归优化,更主要是历史遗留问题

Brian Goetz(Java语言首席架构师)曾经在一个演讲中解释过。

简而言之,JDK里面过去有一些诡异的安全机制,会去数当前调用栈究竟有多少个帧。如果帧数不对,就会报错。然而尾递归优化就是要减少栈帧好么!这不没事找事吗?这种安全机制显然是一种dirty hack,然而屎山一旦留下就覆水难收,直接导致这么多年压根没法在JVM层面搞尾递归优化。Brian并没有明确指出究竟是哪块代码,不过我找到了一篇貌似相关的论文,感兴趣的可以研究一下。当然,现如今这块屎山已经移除了,所以JVM的尾递归优化应该是指日可待了。

为什么Java编译器没有实现尾递归优化? - 知乎

示例:看一个累加计算的例子:

 

private static int sum(int n) {
	if (n == 1) {
		return 1;
	}
	return n + sum(n-1);
}

当n=19000的时候就会出现栈溢出。接下来,进行尾递归优化:

private static int sum2(int n, int sumRes) {
	if (n == 0) {
		return sumRes;
	}
	return sum2(n-1, n + sumRes);
}

当n=19000的时候继续执行,发现仍然会出现栈溢出。所以可以证明,虽然形式上写成了尾递归,但是java编译器并没有对其进行优化。

  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

赶路人儿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值