递归函数和普通函数的本质区别是什么?计算机内部会对递归函数(包括直接递归和间接递归)做特别处理吗?本节回答这些问题。
1. 计算机组成
要想了解计算机怎么实现函数调用和递归,就必须了解计算机的组成。下图给出了计算机组成结构示意图:
图1 计算机内部的组成结构示意
我们通常所说的CPU其核心就是一个加法器,可以执行二进制加法以及位运算。有两个加法寄存器负责向它提供数据,一个结果寄存器用来保存加法或者位运算的结果。减法、乘法和除法乃至更高级的乘方等等运算都是通过加法来实现的。
乘法和乘方运算好理解,为什么减法和除法也是用加法实现的?以减法为例,假设计算机能存储和计算n位二进制数。超过n位的自动截断,这意味着 。所以有
。减法也就转化成加法。
又称为b的补码。计算补码很简单,把b的每一位取反再加1即可。比如3的8位二进制补码就是11111101。
计算机的内存主要分为两段:数据段和指令段,分别用来保存数据和内存。除了数据段和指令段之外,内存中还有其他内容,但与我们的讨论无关,这里忽略。指令段中含有一条一条指令。所谓程序就是这些指令的集合。程序运行时,计算机会把指令指针寄存器(IP,Instruction Pointer)所指内存位置处的指令取出并放在指令寄存器(IR,Instruction Register)中,计算机执行该指令完毕后,自动把IP的值加1,以便取下一条指令并放在IR中,如此循环直到程序执行完毕。
指令的种类有很多,有的用来把内存指定位置处的数据转载入寄存器中,本文用伪汇编<reg> = [<address>]表示,其中<reg>表示寄存器的名字,<address>表示一个内存地址。有的正好相反,把寄存器中的数据写入内存,本文用 [<address>] = <reg>表示。有的负责执行加法运算,本文用add指令表示,有的则负责执行指令跳转,本文用goto <address>表示。
一般来说,指令会按顺序执行,但是当遇到跳转指令(比如goto指令)时,计算机会把IP指向goto指令指定的位置a,这样计算机就会从a开始执行指令,从而实现指令的非顺序执行。
2. 返回地址和运行堆栈
从上面的讨论可以看出,计算机内部其实没有递归甚至函数的概念,要想实现函数调用和递归函数,就必须把用高级语言写的程序转换为计算机指令集合,这个过程称为编译。早先,编译是程序员手工进行的,所以程序员不得不在程序纸上打孔。现在的编译可以自动进行了,但我们仍然需要了解编译后的结果即指令集合是如何实现函数和递归的。
当一个函数a()调用另一个函数b()时,最重要的一件事是:当b()调用完毕后IP能够回到函数a()中并指向下一条指令。这就有了返回地址的概念。
图2 返回地址
上图解释了返回地址的概念。假设函数a()、b()、c()的指令集合分别位于内存指令段的100、210和300位置处。函数a()在101和103位置处调用了函数b(),而后者在211位置处时调用了c()函数。高级语言的函数调用都会被编译转换为无条件跳转指令,比如call b()被编译成goto 210。这样IP中的值101会被替换成210,然后计算机就从210处开始执行。
当函数b()执行到214位置处时,遇到了return指令,该指令的含义就是返回最近一次函数调用的地方,并把IP指向下一语句,即102。这里102就是函数调用的返回地址。注意,返回地址不是唯一的,函数a()中有多处调用了b()函数,于是就有两个返回地址102和104。返回地址也不一定在同一个函数中,b()函数在211处调用了c()函数,此时的返回地址就是212。
这里的三个返回地址:102、104和212,它们应该被存放在数据段的什么地方?又该如何存取呢?数据段中有一段专门的空间就是用来存放这些返回地址的,称为运行堆栈(RS,Running Stack)。
堆栈是一个先进后出的数据结构,计算机的指令中有两个指令与RS有关,这里简称为push和pop指令,分别用来把数据压入或者弹出RS。当若干数据按顺序被压入堆栈之后,再执行pop指令,获得的数据正好与它们被压入RS的顺序相反。比如push 12,push 23,push 17之后,再执行三次pop操作得到的数据分别是17、23和12。执行push 12,push 23,pop,push 17,pop,pop得到的数据就是23、17、12。
计算机内部还有一个名为堆栈指针(SP,Stack Pointer)的寄存器(见图1),指向当前RS的栈顶,push操作会把数据存入RS指向的位置并把SP加1,pop会先把SP减1,然后再取SP所指向位置处的值。程序运行前,计算机会自动把SP指向RS的栈底。比如假设计算机内存数据段把1000~2000位置处的空间指定为RS,则SP的初值就是1000。
所以RS只不过是数据段中的一段内存而已,与普通数据段内存相比并无特殊之处。由于RS实际是有上下界限制的,所以,错误执行的push和/或pop操作可能会导致SP指向1000~2000之外的位置,从而导致程序的运行错误。幸好有高级语言,不但避免程序员直接使用push和pop指令,而且还能保证push和pop总是成对地出现在编译结果中。
为什么要用堆栈保存返回地址呢?因为返回地址总是后发现的先使用。在图2所示的函数调用中,第一次函数调用是101位置处的call b(),函数调用被编译为两条语句,一条是push IP+1,一条是goto <被调函数的起始位置>。其中IP+1就是返回地址的位置,所以102被压入RS。接着211位置处call c()同样会被翻译成上述两句话,这样导致212被压入RS。此时RS中有两个数102和212,见下图。
图3 运行堆栈的变化
计算机执行到304位置处时,执行return指令,该指令首先把SP减1,然后获取SP所指位置处的值212,最后把212填入IP,这导致下一步指令会从212处获取。这正是b()调用c()后应该返回的位置。
接下来计算机运行到214位置处再次遇到return指令,于是再次把IP减1,获得102,102被装入IP,计算机下一步将从102位置处开始执行,这正是a()第一次调用b()后应该返回的位置。此时RS中已经没有数据了。
最后计算机运行到103位置处,再次执行push和goto指令,其后RS的变化情况见上图。
3. 参数入栈
除了返回地址以外,还有函数的参数、函数体中的局部变量和临时变量需要存入RS中。这恰恰是函数递归调用所要求的。以求斐波那契数列的代码为例:
def fib(n):
if n <= 1: # 递归边界
return 1
return fib(n - 1) + fib(n - 2)
if __name__ == '__main__':
for n in range(11):
print('fib(%d) = %d' % (n, fib(n)))
假设调用fib(3)时实参3不进入堆栈,而是放在内存中某个固定位置处,则第一次递归调用fib(n-1)时,会把形参n的值设为2,第二次递归调用fib(n-2)会把n的值设为0,而不是我们期望的1。这样程序运行的结果就是错误的。
解决的办法是每次调用fib()函数时,都把实参的值和返回地址一起存入RS,形参n不再表示内存中某个固定位置,而是相对于当前RS栈顶的某个相对位置。下面给出了用伪汇编写出的上述代码的编译结果,其中主程序部分仅仅调用了print(fib(3)):
代码1 伪汇编求解Fibnacci数列
99: if [SP-2]> 1 goto 102
100: ret_reg = 1 # 令返回值寄存器等于1
101: return 2 # 弹出2个元素并按返回地址跳转
102: push [SP-2] – 1 # n-1压入堆栈
103: push 105 # 返回地址105压入堆栈
104: goto 99 # 递归调用
105: add_reg1 = ret_reg # 加法寄存器 1加载返回值
106: push [SP-2] – 2 # n-2压入堆栈
107: push 109 # 返回地址109压入堆栈
108: goto 99 # 递归调用
109: add_reg2 = ret_reg # 加法寄存器2加载返回值
110: add # 执行加法运算
111: ret_reg = add_res # 返回值寄存器加载加法结果
112: return 2 # 弹出2个元素并按返回地址跳转
# 以下主程序,仅调用print(fib(3))
200: if __name__ != '__main__' goto 204
201: push 3 # 实参3压入堆栈
202: push 204 # 返回地址204压入堆栈
203: goto 99 # 函数调用
204: print ret_reg # 打印get_rabbits(3)的
204: stop # 停机
代码中打头的数字和冒号表示内存地址,#号是注释。ret_reg表示返回值寄存器,函数的返回值总是寄存在这里。[SP-2]表示把SP的值减2然后再取该位置处的值。add_reg1和add_reg2表示两个加法寄存器,add_res表示加法结果寄存器。编译的规则是:
- 只要遇到函数调用,就先把返回地址压入堆栈,然后再把参数按照逆序压入堆栈,最后跳转到函数的第一行即可。
- 引用第i个形参时,使用[SP-i-1]即可,i=1, 2, 3, ......。每个参数大小都是1,不考虑内存字对齐。
- 函数调用结束时应该弹出所有参数和返回地址,并按该地址跳转。
以上规则不论函数是不是递归的,不论主程序还是子程序都适用。下图给出了实参和返回地址一起入栈的情况下,调用fib(3)之后RS以及各寄存器的变化情况。可以看到,程序正确输出了结果。
图4 调用fib(3)之后运行堆栈和寄存器的变化(*表示返回地址)
4. 运行记录
代码1的105行是有问题的,因为它把函数的返回结果保存在加法寄存器add_reg1中,这意味着当调用fib(n)时,会把fib(n-1)的结果保存在add_reg1中,但当函数紧接着递归调用fib(n -2)并且返回时,就很有可能会把add_reg1中保存的值覆盖。这种情况之所以在n=3时没有发生,是因为n -2=1,函数直接返回了结果,没有用到add_reg1。
所以正确的做法是把第一次递归调用的结果保存在一个临时变量中,用伪代码表示就是把 105行和110代码改为:
...
105: temp = ret_reg
...
110: add_reg1 = temp
111: add
...
这里的temp就是临时变量,它是由编译器生成的,是高级语言程序员所看不到的。而局部变量是程序员在函数体中显式定义的,两者的可见性虽然不同,但本质相同,两者都应该入栈。这很好理解,因为上例中如果temp不入栈,它就会像add_reg1一样被覆盖。
总结一下,临时变量、局部变量、参数以及返回地址都应该入栈。这四者作为一个整体被称为运行记录(RR, Running Record)。每发生一次函数调用计算机都会往SP中压入一个RR,每遇到一个return语句,都会从SP中弹出一个RR,并根据其中的返回地址跳转。这就是函数调用的本质。
5. 递归和普通函数调用
从RR以及SP等概念可以看出,普通函数调用与递归调用并没有本质的区别。计算机遇到调用时,并不关心它是不是递归的,而是统一地往SP中压入一个RR;遇到return语句时,统一地弹出一个RR,并根据其中的返回地址返回。这中间并没有对递归做特别处理。这种一致性简化了对函数调用的处理。
早期的计算机语言,比如Fortran,是没有递归这个概念的,所以RR就没有必要入栈。早期Fortran的编译器就把函数的参数、局部变量和临时变量一股脑地放在函数指令集之前。因此定位一个参数、局部变量和临时变量是不需要经过SP的,因此存取一个变量或者参数的速度很快。后来,由于电脑运行速度越来越快,Fortran这一点速度上的优势越来越不能抵消不能递归的麻烦,从Fortran77之后,它也支持递归了,因为参数和变量都入栈了。