微博上看到有人在讨论尾递归,想起之前曾看过老赵写的一篇相关的博客,介绍的比较详细了,相信不少人都看过,我也在下面留了言,但挑了个刺,表示文章在关键点上一带而过了,老赵天然是懂的,但看的人若是不深刻思考,未必真正的明白,下面我说说个人理解。python
什么是尾递归呢?(tail recursion), 顾名思议,就是一种“不同的”递归,说到它的不同,就得先说说通常的递归。对于通常的递归,好比下面的求阶乘,教科书上会告诉咱们,若是这个函数调用的深度太深,很容易会有爆栈的危险。c#
// 先不考虑溢出问题
int func(int n) { if (n <= 1) return 1; return (n * func(n-1)); }
缘由不少人的都知道,让咱们先回顾一下函数调用的大概过程:函数
1)调用开始前,调用方(或函数自己)会往栈上压相关的数据,参数,返回地址,局部变量等。优化
2)执行函数。spa
3)清理栈上相关的数据,返回。code
所以,在函数 A 执行的时候,若是在第二步中,它又调用了另外一个函数 B,B 又调用 C.... 栈就会不断地增加不断地装入数据,当这个调用链很深的时候,栈很容易就满 了,这就是通常递归函数所容易面临的大问题。blog
而尾递归在某些语言的实现上,能避免上述所说的问题,注意是某些语言上,尾递归自己并不能消除函数调用栈过长的问题,那什么是尾递归呢?在上面写的通常递归函数 func() 中,咱们能够看到,func(n) 是依赖于 func(n-1) 的,func(n) 只有在获得 func(n-1) 的结果以后,才能计算它本身的返回值,所以理论上,在 func(n-1) 返回以前,func(n),不能结束返回。所以func(n)就必须保留它在栈上的数据,直到func(n-1)先返回,而尾递归的实现则能够在编译器的帮助下,消除这个限制:递归
// 先不考虑溢出
int tail_func(int n, int res) { if (n <= 1) return res; return tail_func(n - 1, n * res); } // 像下面这样调用
tail_func(10000000000, 1);
从上能够看到尾递归把返回结果放到了调用的参数里。这个细小的变化致使,tail_func(n, res)没必要像之前同样,非要等到拿到了tail_func(n-1, n*res)的返回值,才能计算它本身的返回结果 -- 它彻底就等于tail_func(n-1, n*res)的返回值。所以理论上:tail_func(n)在调用tail_func(n-1)前,彻底就能够先销毁本身放在栈上的东西。get
这就是为何尾递归若是在获得编译器的帮助下,是彻底能够避免爆栈的缘由:每个函数在调用下一个函数以前,都能作到先把当前本身占用的栈给先释放了,尾递归的调用链上能够作到只有一个函数在使用栈,所以能够无限地调用!编译器
尾递归的调用栈优化特性
相信读者都注意到了,我一直在强调,尾递归的实现依赖于编译器的帮助(或者说语言的规定),为何这样说呢?先看下面的程序:
1 #include <stdio.h>
2
3 int tail_func(int n, int res) 4 { 5 if (n <= 1) return res; 6
7 return tail_func(n - 1, n * res); 8 } 9
10
11 int main() 12 { 13 int dummy[1024*1024]; // 尽量占用栈。
14
15 tail_func(2048*2048, 1); 16
17 return 1; 18 }
上面这个程序在开了编译优化和没开编译优化的状况下编出来的结果是不同的,若是不开启优化,直接 gcc -o tr func_tail.c 编译而后运行的话,程序会爆栈崩溃,但若是开优化的话:gcc -o tr -O2 func_tail.c,上面的程序最后就能正常运行。
这里面的缘由就在于,尾递归的写法只是具有了使当前函数在调用下一个函数前把当前占有的栈销毁,可是会不会真的这样作,是要具体看编译器是否最终这样作,若是在语言层面上,没有规定要优化这种尾调用,那编译器就能够有本身的选择来作不一样的实现,在这种状况下,尾递归就不必定能解决通常递归的问题。
咱们能够先看看上面的例子在开优化与没开优化的状况下,编译出来的汇编代码有什么不一样,首先是没开优化编译出来的汇编tail_func:
1 .LFB3:
2 pushq %rbp 3 .LCFI3:
4 movq %rsp, %rbp 5 .LCFI4:
6 subq $16, %rsp 7 .LCFI5:
8 movl %edi, -4(%rbp) 9 movl %esi, -8(%rbp) 10 cmpl $1, -4(%rbp) 11 jg .L4 12 movl -8(%rbp), %eax 13 movl %eax, -12(%rbp) 14 jmp .L3 15 .L4:
16 movl -8(%rbp), %eax 17 movl %eax, %esi 18 imull -4(%rbp), %esi 19 movl -4(%rbp), %edi 20 decl %edi 21 call tail_func 22 movl %eax, -12(%rbp) 23 .L3:
24 movl -12(%rbp), %eax 25 leave
26 ret
注意上面标红色的一条语句,call 指令就是直接进行了函数调用,它会先压栈,而后再 jmp 去 tail_func,而当前的栈还在用!就是说,尾递归的做用没有发挥。
再看看开了优化获得的汇编:
1 tail_func:
2 .LFB13:
3 cmpl $1, %edi 4 jle .L8 5 .p2align 4,,7
6 .L9:
7 imull %edi, %esi 8 decl %edi 9 cmpl $1, %edi 10 jg .L9 11 .L8:
12 movl %esi, %eax 13 ret
注意第7,第10行,尤为是第10行!tail_func() 里面没有函数调用!它只是把当前函数的第二个参数改了一下,直接就又跳到函数开始的地方。此处的实现本质其实就是:下一个函数调用继续延用了当前函数的栈!
这就是尾递归所能带来的效果: 控制栈的增加,且减小压栈,程序运行的效率也可能更高!
上面所写的是 c 的实现,正如前面所说的,这并非全部语言都摆支持,有些语言,好比说 python, 尾递归的写法在 python 上就没有任何做用,该爆的时候仍是会爆。
def func(n, res): if (n <= 1): return res return func(n-1, n*res) if __name__ =='__main__': print func(4096, 1)
不只仅是 python,听说 C# 也不支持,我在网上搜到了这个连接:https://connect.microsoft.com/VisualStudio/feedback/details/166013/c-compiler-should-optimize-tail-calls,微软的人在上面回答说,实现这个优化有些问题须要处理,并非想像中那么容易,所以暂时没有实现,可是这个回答是在2007年的时候了,到如今岁月变迁,不知支持了没?我看老赵写的尾递归博客是在2009年,用 c# 做的例子,估计如今 c# 是支持这个优化的了(待考).
前面的讨论一直都集中在尾递归上,这其实有些狭隘,尾递归的优化属于尾调用优化这个大范畴,所谓尾调用,形式它与尾递归很像,都是一个函数内最后一个动做是调用下一个函数,不一样的只是调用的是谁,显然尾递归只是尾调用的一个特例。
int func1(int a) { static int b = 3; return a + b; } int func2(int c) { static int b = 2; return func1(c+b); }
上面例子中,func2在调用func1以前显然也是能够彻底丢掉本身占有的栈空间的,缘由与尾递归同样,所以理论上也是能够进行优化的,而事实上这种优化也一直是程序编译优化里的一个常见选项,甚至不少的语言在标准里就直接要求要对尾调用进行优化,缘由很明显,尾调用在程序里是常常出现的,优化它不只能减小栈空间使用,一般也能给程序运行效率带来比较大的提高。