You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

21加深对栈的理解实现尾递归和尾调用优化

你好,我是宫文学。

前面几节课,我们在实现生成本地代码的过程中,对汇编语言、栈和栈桢有关的知识点都进行了比较深入的了解。通过这些学习,你应该对程序的运行机制有了更加透彻的理解。

那么今天这节课,作为第一部分起步篇的结尾,我们就来检验一下自己的学习成果吧!具体一点,我们就是要运用我们前面已经学过的知识点,特别是关于栈和栈桢的知识点,来实现两个有用的优化功能,也就是尾递归和尾调用的优化。

这两个优化有助于我们更好地利用栈里的内存空间,也能够提高程序的性能,对于我们后面实现函数式编程特性也具有很重要的意义。另外,这样的练习,也会加深我们对栈和栈桢、对程序的控制流,还有对程序的运行机制的理解。

好了,我们先从尾递归入手吧,说说尾递归是怎么回事,还有它有怎样的运行特点,看看我们为什么需要去优化它。

递归函数和尾递归

学习编程的同学都应该知道,递归是一种重要的思维方式。我们现实世界的很多事物,用递归来表达是非常自然的。在递归的思维里,解决整体的问题和解决局部问题的思路是相同的。

在我们这个课程里,我们学习的语法分析的方法,也采用了递归的思维:我们给一个大程序做语法分析,会分解成给每一个函数、每一条语句做语法分析。不管在哪个颗粒度上,算法的思路都是相同的。

递归思想的一个更具体的使用方式,就是递归函数。当前的各种高级语言,都会支持递归函数,也就是允许在一个函数内部调用自身。

你可以看看下面这个例子,这个例子是用来实现阶乘的计算的。

function factorial (n:number):number{
  if (n < 1)
    return 1;
  else
    return n * factorial(n-1); 
}

在这里n的阶乘f(n)就等于n * f(n-1)。这是典型的递归思维解决一个整体问题f(n)能够被转化为解决其局部问题f(n-1)。n的值变化了但解决问题的思路是一致的。

最近几年,函数式编程的思想又重新流行起来。在一些纯函数式的编程语言中,递归是其核心编程机制,被大量使用。

**不过,递归函数的大量使用,对程序的运行时机制是一个挑战。**因为我们已经知道在标准的程序运行模式下每一次函数调用都要为这个函数创建一个栈桢。如果递归的层次很深那么栈桢的数量就会非常多最终引起“stack overflow”也就是栈溢出的错误这是我们在使用栈的时候最怕遇到的问题。

另外,我们还知道,当我们在进行函数调用的时候,还会产生比较大的性能开销。这些开销包括:设置参数、设置返回地址、移动栈顶指针、保护相关的寄存器,等等。特别是,在这个过程中,一般都会产生内存读写的动作,这会对性能产生比较大的影响。

所以说,虽然递归函数很有用,但你在学习编程的时候,可能你的老师会告诉你,如果对性能和内存占用有较高的要求,那么我们尽量不用递归算法实现,而是把递归算法改成等价的非递归算法。

不过现代编译器也在努力帮助解决这个问题。比如在上一节课中我们就已经见到了C语言编译器的一个功能它在编译斐波那契数列的过程中能够把其中一半的递归调用转变成一个循环语句从而减少了递归调用导致的开销。

但在这一节课呢,我们不会试图一下子就实现这么复杂的编译优化功能,而是先针对递归调用中的一个特殊情况而进行优化,这个特殊情况就是尾递归

那什么是尾递归呢?尾递归就是在return语句中return后面只跟了一个递归调用的情况。在上面的例子中你会看到return后面跟着的是n * factorial(n-1),这种情况不是尾递归。不过,我们可以把示例程序改写成尾递归的情形,我写在了下面:

function factorial(n:number, total:number):number{
  if (n <= 1)
    return total;
  else
    return factorial(n-1, n*total); 
}

这个新的阶乘函数使用了两个参数其中第二个参数保存的是阶乘的累积值。如果要计算10的阶乘那么我们需要函数factorial(10, 1)。你可以仔细看一下factorial函数的两个不同的版本它们确实是等价的。但第二个版本中的第二个return语句呢就是一个标准的尾递归调用。

我们为什么要谈论尾递归呢?这是因为尾递归在栈桢的使用上有其独特的特点,使得我们可以用很简单的方法就能实现优化。

那么接下来,我们就分析一下递归函数在栈的使用上的特点,这有利于我们制定优化策略。

递归函数对栈的使用

你可以用我们上一节课的PlayScript版本使用make example_fact命令来生成上面示例程序的汇编代码和可执行文件。

这个汇编文件是没有做尾递归优化的,你可以看一下它的内容,看看它的栈桢是什么结构。

_factorial:
    .cfi_startproc
## bb.0
    pushq   %rbp
    movq    %rsp, %rbp
    cmpl    $1, %edi                #  cmpl $1, var0
    jg  LBB0_2
## bb.1
    movl    %esi, %eax              #  movl var1, returnSlot
    jmp LBB0_3
LBB0_2:
    movl    %edi, %r10d             #  movl var0, var2
    subl    $1, %r10d               #  subl $1, var2
    movl    %edi, %r11d             #  movl var0, var3
    imull   %esi, %r11d             #  imull    var1, var3
    movl    %r10d, %edi
    movl    %r11d, %esi
    callq   _factorial
    movl    %eax, %edx              #  movl returnSlot, var4
    movl    %edx, %eax              #  movl var4, returnSlot
LBB0_3:
    popq    %rbp
    retq
    .cfi_endproc

从汇编代码中你能看出来这个函数的栈桢特别简单。在factorial函数中n和total两个参数是保存在寄存器中的在栈桢里只保存了rbp寄存器的旧值和返回地址。如果递归函数复杂一点或者我们采用的是比较简单的寄存器分配算法那么栈桢里可能会保存一些其他信息比如溢出的变量等。

图片

然后每次的递归调用都会创建一个栈桢。递归嵌套多少层就需要建立多少级的栈桢。如果说我们要计算10000的阶乘那就需要10000个栈桢。

这样说的话,到目前为止,我们的编译器所生成的程序,是完全合乎规则的,似乎没啥问题啊?

不过,如果你细看一下,就会发现一个现象:在每一级调用结束之后我们都会返回到上一级函数。而且上一级函数也会立即结束继续返回上一级。这个过程会一直持续下去直到退出最上面一级的factorial函数。

图片

在逐级返回的过程中其实程序再也没有访问n和total这两个本地变量只有%eax寄存器是有用的因为这里面保存了返回值。

这样看起来这些一层层的上级栈桢都是没有什么用的这里的内存空间完全是浪费的。也就是说在计算10000的阶乘的时候前9999个栈桢其实都是没有用的。另外寄存器里保存的变量的值也已经结束了生存期。所以我们在调用下一级函数的时候根本没有必要去保护它们原来的值。

既然如此,那我们能不能做一些优化,提高程序的性能呢?

这就需要用到尾递归优化的技术了。

尾递归优化

这要怎么优化呢?总的思路是这样的:既然旧函数的栈桢没有用,那么我们就没有必要为递归调用产生一个新的栈桢,而是复用当前栈桢就行了

那怎么实现这一点呢我先直接说答案吧。我们的解决思路就是把call语句改成一个jmp指令跳到程序的开头重新执行。

这个时候参数1和参数2的值已经分别被更新成了n-1和n * total。所以当我们跳到程序开头再一次执行的时候就相当于调用factorial(n-1, n * total),从而取得了和尾递归调用相同的计算结果,你可以看看下面这个截图。

图片

这里我稍微岔开一个小话题在上面的图中你会看到我还把callq后面的两条指令打了叉去掉了。这个原因也很简单这两条语句其实是做了无用功。第一条指令把eax中的值拷贝到edx第二条又从edx拷贝回了eax作为返回值的eax其实并没有变化。

你还记不记得,在上一节课的思考题中,我让你去检查,我们目前生成的汇编代码还有哪些地方需要优化,这里就是其中一个例子。不过,具体如何去完善这些代码,我们以后再说。

我们还是回到尾递归的话题上来。对着上面的汇编代码你可能会发现这不就是一个循环语句的结构吗每次循环就把n减1。也就是说上面的汇编代码其实相当于下面的高级语言代码

function factorial(n:number, total:number):number{
  for (; n>=1; n--){
    if (n<=1)
      return total;
    else
      total = n*total;
  }
}

没错!尾递归调用是一定能够转化成一个循环语句的。也正因为如此,所以我们没有必要为每次调用都生成一个新的栈桢,这样就能既避免了栈溢出的风险,又提升了性能。

那么我们应该如何升级PlayScript来实现尾递归的优化呢

首先我们必须能够分析出来什么样的函数调用属于尾递归。这个判断起来也比较简单如果一个return语句中的表达式只有一个递归调用那么这个递归调用就是尾递归。

我们看看它的AST有什么特点。你用node play example_fact.ts -v 命令可以打印出程序的AST信息。你会发现ReturnStatement节点只有一个FunctionCall子节点并且FunctionCall所调用的函数正是factorial自身是一个递归调用。

图片

掌握了AST的这个特点后我们很容易就可以写一个分析程序识别出哪个函数调用是尾递归从而改变后面生成汇编代码的逻辑这里具体代码你可以参考代码库里的TailAnalyzer

TailAnalyzer的运行结果是一个TailAnalysisResult对象。这个对象里保存了所有尾递归的FunctionCall节点。这样在生成Asm的时候我们就可以针对尾递归专门生成不同的代码了。

接下来我们再使用这节课的PlayScript版本再次执行make example_fact就会生成针对尾递归优化后的汇编代码和可执行程序。我们看一下新版本的汇编代码

图片

在新版本生成的汇编代码里你会看到我们新加了一个基本块作为跳转指令的目标。而原来的callq指令则变成跳转到这个基本块的一个jmp指令。

你可以运行一下新版本的程序,你会发现两个版本的运行结果是相同的,也就是说它们的功能是完全等价的。如果你有兴趣的话还可以做一个测试程序,测一下优化前后的性能差异到底有多大。

你可以把阶乘的例子变成一个累加的例子来测试也就是f(n) = n+f(n-1)。为什么呢因为阶乘的值增加得太快了很快就会超过整数的范围比如16的阶乘就超出了一个32位整数的表达能力。

好了,现在我们已经完成了尾递归的优化。但在这节课的开头,我们还提出了另一个概念,就是尾调用。那么尾调用又是什么呢?我们把尾递归和尾调用放在一起介绍,是否意味着我们也可以采用尾递归优化的思路来优化尾调用呢?我们接着往下分析。

尾调用优化

什么是尾调用呢?尾调用就是在return语句后面直接跟一个函数调用的情况。比如对于下面两个函数foo和barfoo中有一个return语句直接调用了bar这就是一个尾调用的情况。

function foo(p1:number, p2:number):number{
  ...
  return bar();
}
funtion bar():number{
  ...
}

如果你利用我们分析尾递归所获得的知识去分析尾调用,就会发现它们是有相似点的。也就是,在做尾调用的时候,调用者的栈桢和寄存器已经没有用了,所以被调用者完全没有必要新建立一个栈桢,而是复用调用者的栈桢就可以了

那么这具体要怎么进行优化呢其实跟尾递归一样我们都是使用jmp指令来代替callq指令就行了。只不过这一次jmp跳转目标是_bar也就是bar函数的入口位置。

我们之前就说过在汇编语言里函数名称也只不过是一个标签而已所以它们可以作为跳转指令的目标。而原本callq指令也只不过是相当于两条指令

pushq 返回地址
jmp 函数标签

但在尾调用的情形下我们没必要修改返回地址。因为bar返回foo以后接着就会返回foo的调用者。所以我们这里只用一个jmp指令就可以了这样也能减少保存返回地址导致的性能开销。

具体实现你可以通过make example_tail命令去编译example_tail.ts示例代码,并研究一下它所生成的汇编代码。

课程小结

好了,到这里我们今天这节课就讲完了。这节课,我们借助尾递归和尾调用优化的话题,加深了对栈、栈桢和程序运行机制的了解。我希望你记住并产生以下这几个认知:

首先对于任何程序来说当它在执行return语句的时候所有的变量的生存期其实都已经结束了所以栈桢和变量所占据的寄存器就都没用了。而尾递归和尾调用的优化就是借助了这一特点复用了调用者的栈桢从而达到了节省内存和提高性能的目的。

第二,我已经多次提到一个观点,就是计算机语言的设计者,其实拥有很大的自由度来决定如何使用栈桢。这节课的内容也能够再次印证我这个观点,我们对栈和栈桢的使用不是僵化的、一成不变的,而是针对不同的语言特性,我们可以做不同的使用。比如,在实现协程机制的时候,我们使用栈的方式跟传统的函数调用方式也有不同。如果你想进一步了解这方面的知识,你可以参考一下《编译原理实战课》的第34节

最后,这节课也是我们第一次接触编译器中的优化技术。对于编译器来说,第一项任务当然是把高级语言的代码翻译成目标代码,而第二项重要的任务就是在翻译的过程中,做各种优化,尽量保证生成的是最高效的机器码。而优化采用的技术也有很多,并且会发生在编译过程的各个阶段。优化工作其实也是编译器中工作量最大、难度最高的工作。在后面的课程中,我们也会接触到更多的优化技术。

思考题

你会发现,在我们example_tail.ts这个尾调用的例子中只有一个程序的出口也就是只有一个return语句所以我们做指令的改写还是比较容易的。那如果存在两个或两个以上的return语句并改写其中的一个尾调用语句让它变成jmp指令这样生成的汇编代码又会有什么不同呢

欢迎你研究一下,并在留言区分享你的发现,这个研究会帮助你加深对程序控制流的理解。我是宫文学,我们下节课见。

资源链接

这节课的示例代码在这里!