Tail Recursion in C [一]
March 27th, 2009 :: ph4nut
看了《写出正确的尾递归代码》一文,对尾递归的作用有了表面上的理解,但是关于编译器如何优化尾递归为一条跳转指令以及对递归堆栈的处理还不是很清楚。所以有了这篇小文,有什么错误的地方还请各位看官指出。
先来看一下在C语言里一个尾递归的例子:
- #include <stdio.h>
- void add( int n)
- {
- printf( " %d\n",n);
- add(n + 1);
- }
- int main()
- {
- add(0);
- return 0;
- }
编译这段代码:gcc -o add.exe add.c
如果运行这个程序的话,当n增加到一定值的话,程序就会推出,因为栈空间不够。
再来用gcc的O2选项(这个选项增加了对尾递归的优化)来优化这段代码并运行:gcc -o add.exe add.c -O2
如果你运行这个程序,你会发现一直打印递增的n,而没有停止。
接下来我们来比较一下没有优化尾递归和优化后的汇编代码。
未优化尾递归的汇编代码:gcc -S add.c
- .file "add.c"
- .section .rdata,"dr"
- LC0:
- .ascii " %d\12"
- .text
- .globl _add
- .def _add; .scl 2; .type 32; .endef
- _add:
- pushl %ebp
- movl %esp, %ebp
- subl $8, %esp
- movl 8(%ebp), %eax
- movl %eax, 4(%esp)
- movl $LC0, (%esp)
- call _printf
- movl 8(%ebp), %eax
- incl %eax
- movl %eax, (%esp)
- call _add ;注意这里,add函数的调用
- leave
- ret
- .def ___main; .scl 2; .type 32; .endef
- .globl _main
- .def _main; .scl 2; .type 32; .endef
- _main:
- pushl %ebp
- movl %esp, %ebp
- subl $8, %esp
- andl $-16, %esp
- movl $0, %eax
- addl $15, %eax
- addl $15, %eax
- shrl $4, %eax
- sall $4, %eax
- movl %eax, -4(%ebp)
- movl -4(%ebp), %eax
- call __alloca
- call ___main
- movl $0, (%esp)
- call _add
- movl $0, %eax
- leave
- ret
- .def _printf; .scl 3; .type 32; .endef
优化了尾递归的汇编代码:gcc -O2 add.c
- .file "add.c"
- .section .rdata,"dr"
- LC0:
- .ascii " %d\12 "
- .text
- .p2align 4,,15
- .globl _add
- .def _add; .scl 2; .type 32; .endef
- _add:
- pushl %ebp
- movl %esp, %ebp
- pushl %ebx
- subl $20, %esp
- movl 8(%ebp), %ebx
- .p2align 4,,15
- L2:
- movl %ebx, 4(%esp)
- incl %ebx
- movl $LC0, (%esp)
- call _printf
- jmp L2 ;注意这里,尾递归被优化成了一条跳转指令。
- .def ___main; .scl 2; .type 32; .endef
- .p2align 4,,15
- .globl _main
- .def _main; .scl 2; .type 32; .endef
- _main:
- pushl %ebp
- movl $16, %eax
- movl %esp, %ebp
- subl $8, %esp
- andl $-16, %esp
- call __alloca
- call ___main
- movl $0, (%esp)
- call _add
- leave
- xorl %eax, %eax
- ret
- .def _printf; .scl 3; .type 32; .endef
从上面两段代码看来,add函数的确有差别,但是重点在于,优化后对堆栈的处理。还是在来看优化后的代码片段(跟上面的一样,只不过我摘取了重点)
- _add:
- pushl %ebp ;标记1
- movl %esp, %ebp
- pushl %ebx
- subl $20, %esp
- movl 8(%ebp), %ebx
- .p2align 4,,15 ;标记2
- L2:
- movl %ebx, 4(%esp)
- incl %ebx
- movl $LC0, (%esp)
- call _printf
- jmp L2
- .def ___main; .scl 2; .type 32; .endef
- .p2align 4,,15
从标记1 – 标记2,这段代码在add函数递归的过程只执行了一遍,这就说明了,优化后的代码,在堆栈上分配参数的时候,只分配了一次,也就是在第一次调用add函数的时候,现在我们终于明白了尾递归为什么不消耗栈的原因了。同样的,如果在add函数里定义了一些变量(当然要在递归调用的前面),变量在堆栈上也只是分配了一次。
到此,尾递归这个问题也应该告一段落了,但是还有一个问题值得一提,上面的add函数是个无限的递归,永不返回,如果再加一个递归终结的条件,当add函数(也就是最深一层的add函数)返回的时候,它不是向上一层递归地返回,而是直接跳到第一个add调用者(在这个例子应该是main函数)的下一个语句。这个下一篇文章在详细解释吧。
很有益处的文章。不仅让人知道“尾递归”是什么,还讲明白了它具体是怎么做到的。反过来,对“尾递归”是什么又再有了更深一层的了解。
@jackyz
尾递归确实需要深一层的了解,特别是在FP领域;刚学Erlang不久,希望能有所收获。
大部分系统都是用jump语句代替call, 同时清除当前的函数调用堆栈,lua是专门一条op_code来做这个事情的。
,