引子

在加密系列里突然出这么一个问题,确实有点怪。我犹豫了一下,还是先写了再说。

这个问题的提起,是公司老大kay提出一个问题。在反转链表的时候,如果有环怎么办。然后我们就现场写各种解环算法。这个问题本身不难,我们先看一下原始问题和解,还有带环反转。

Scheme反转链表

想了想,还是先写Scheme版本。这个版本实现简单概念清晰,个人觉得非常典型。而且对后面理解Python版本很有帮助。如果看不懂的话,可以直接去看Python版本。Python版本看不懂了再回本章来,去看Scheme入门教材。

Scheme的正解版本只有一个,就是带复制TCO版本。

(define (reverse-tco ll rslt)
  (if (eq? ll '())
	  rslt
	  (reverse-tco (cdr ll) (cons (car ll) rslt))))

这个版本体现了Scheme的几个特点。

  1. 递归比迭代好使。
  2. 强制要求TCO。
  3. cons操作起来非常方便。

Python递归反转链表

def reverse_tco(ll, rslt):
	if not ll:
		return rslt
	return reverse_tco(ll[1], [ll[0], rslt])

这是链表最简单的问题,也是递归论最基本的问题。注意这里采用list来模拟了lisp中的cons,不用tuple主要是后面还有环,需要修改数据。如果你不明白,可以先看Scheme版本的代码。

递归转迭代

写到这个水平,其实有一半的被面人就挂了(好水)。但是这个水平其实也就是入门水平。最低限度,也要知道上面这个递归是可转写为迭代的。更进一步的,要知道这个递归是个尾递归。下面我们把这个代码转写为迭代形态。

def reverse_iterate(ll):
	rslt = None
	while ll:
		rslt, ll = [ll[0], rslt], ll[1]
	return rslt

或者非生成节点版本。

def reverse_iterate(ll):
	rslt = None
	while ll:
		rslt, ll[1], ll = ll, rslt, ll[1]
	return rslt

这里的区别,其实是SICP第24页所说的“递归计算过程”和“递归过程”的区别。具体请直接参考原书,我就不打字摘抄了。

另外,尾递归的构成条件也很简单。最后一个函数调用的结果紧接着被return给上级函数,中间没有其他操作。满足上述条件的递归,我们称为尾递归。尾递归是可以优化的。更加广义的说,所有最后一步是调用其他函数并直接返回给上级的函数,都是可优化的。具体来说,这个调用的栈帧可以被抽取掉。当然,抽取栈帧的行为将导致调试信息不正确,从而导致调试困难。

Python非TCO版本

这个问题还有个解法是非TCO的。

def reverse_recursion(ll):
	if not ll[1]:
		return ll
	rslt = reverse_recursion(ll[1])
	ll[1][1], ll[1] = ll, None
	return rslt

这个版本做不到尾递归,是因为返回后还有操作。

大家可以考虑一下,这个版本能转写为迭代么?如果迭代里不使用类似栈的结构的话。(或者用list/array来模拟stack操作)

带环问题

这个问题其实很简单,把上面的测试数据换一下就行。

t = [5, None]
ll = [1, [2, [3, [4, t]]]]
t[1] = ll[1][1]

在这里,尾部的None被换为了[3, …]。如果直接使用原始解法,三个解法都会出问题。TCO和非TCO版本的会爆栈,迭代版本则是变成了12354。

Python解带环反转

这个版本要用到一点小技巧——placeholder。主旨思想是在解除node的时候,将原本的cons中的cdr替换为placeholder。这样当遍历到环尾时,就可以识别出来了。之所以placeholder不能用None,是因为这样会在无环情况下和倒数第二个cons的情况搞混。

def reverse_loop_tco(ll, rslt):
	if not ll or ll[1] == PLACEHOLDER:
		return rslt
	ll[1], rslt, ll = PLACEHOLDER, [ll[0], rslt], ll[1]
	return reverse_loop_tco(ll, rslt)

转写为迭代,也不算困难。

def reverse_loop_iterate(ll):
	rslt = None
	while ll and ll[1] != PLACEHOLDER:
		ll[1], rslt, ll = PLACEHOLDER, [ll[0], rslt], ll[1]
	return rslt

注意这里没有非生成节点版本。因为如果复用旧节点的话,cdr就会被指向下一跳,不能填充placeholder进去。

非TCO版本,稍微改改就行。

def reverse_loop_recursion(ll):
	if not ll[1] or ll[1][1] == PLACEHOLDER:
		return ll
	t, ll[1] = ll[1], PLACEHOLDER
	rslt = reverse_loop_recursion(t)
	t[1], ll[1] = ll, None
	return rslt

争议

关键是在带环的Non-TCO版本上,我和kay产生了一点分歧。我认为,这个算法的空间消耗从N变成了2N(虽然同属O(n)级别)。kay认为,最优版本的解法是不增加空间消耗的。于是我要证明这个版本算法的最优情况下,空间消耗还是会增加。关键是,我能想到的都未必是最优版本。那咋办呢?

于是我就想出了个大杀器——gcc。结果一研究,发现一个更不得了的事情。

先从gcc开始说起吧。gcc内部有很多优化算法,会将代码优化到最优。而一个算法到底是推一个数到栈上去,还是两个,这是可以一眼看出来的。如果在gcc的优化下,代码依然增加推了一个对象上去。那么我就可以比较有信心的说,空间消耗有增加。

具体成果请看最后的“附录B: 用C解链表反向问题的六种解法源码”。这里不是要说这个代码转写为C的问题(因为这没有任何难度)。问题是,这个代码编译后的情况不大对头。为此我写了个最简代码,从头开始说起。

编译/反汇编/调试/参数调用的基础知识

  1. 要用O2编译,这个会启动优化。O1编译以下都不带这个优化。
  2. 使用-g输出调试符号,方便gdb调试。
  3. 不要strip,会干掉符号表,导致反汇编难度增大。
  4. 使用objdump -d反汇编。
  5. 使用gdb调试。

传统函数调用时,会设定参数,然后用call指令调用目标函数。call指令会把当前地址压到栈上去,然后将rip改为目标地址。目标函数接受参数,执行,再执行ret返回。ret会将stack顶元素pop出来设定到rip上。简单来说,这就是函数调用过程。

如果加入细节。首先函数调用前,调用者需要依次push参数。顺序是按照C里面声明的顺序,左到右(pascal式)或右到左(C式)。最后调用者使用call指令。接收者首先要push rbp,因为下面要使用这个寄存器,又不能破坏原始值。随后mov rsp, rbp; sub xxx, rsp;,前者是将rbp固定在当前位置,后者则是对stack留空一定字节。因为后续rsp还可能随着进一步的调用而增减,所以需要固定rbp。

在完成这步之后,rsp就可以随意增减了。rbp-xx是rsp预留出来的空间,一般是各种函数局部变量。rbp+xx则是rbp原本值,ret地址,然后是各种参数。缓冲区溢出攻击的核心就是直接使用rbp-xx的某个byte array,写入过多数据导致覆盖到了ret地址。

至于参数,如果是pascal式,rbp+3N(N是CPU字长)应当是右边第一个参数,rbp+4N是右边第二个。而C式,rbp+3N是左边第一个,rbp+4N是左边第二个。这就引申出一个区别。C语言的调用允许你在调用时,参数表右边写入原则上无限多个参数。如果是pascal式,你上来就接收了一个不明其意的参数,而且你也不知道会传入多少个。而如果是C式,你上来会接收左边第一个参数,这个参数里可以为你指明后续有多少个参数,意义分别是什么。例如printf中的第一个参数。因此C式调用允许动态传参,pascal则基本不行。

但是C式也是有缺陷的。C式由于被调用者很难知道自己被传入了多少个参数(其实可以从首参数中解析,但是清理是编译器工作,解析参数是业务逻辑,这个做法需要函数逻辑侵入编译器工作),因此只能用ret返回,返回后由调用者来清理栈(调用者一定知道有多少个参数)。而pascal被调用者很清楚有多少个参数,因此可以由被调用者清理。关于这个问题的更进一步资料,可以看这里

还有一点细节技巧要讲。汇编语言的参数传递,和我当年学的时候有很大区别。我建议大家直接看wiki上的资料:calling conventions。我们这是System V AMD64 ABI,所以参数依次是RDI, RSI, RDX, RCX, R8, R9, XMM0–7。

C代码,O2优化及反汇编

我们先从不是那么复杂的非带环版本开始说起。引用附录B里的代码。

struct node* reverse_tco(struct node *n, struct node *result)
{
	struct node *t;
	if (n == NULL) {
		return result;
	}
	t = n->next;
	n->next = result;
	return reverse_tco(t, n);
}

我们先看O1反汇编

000000000000078b <reverse_tco>:
 78b:	48 89 f0             	mov    %rsi,%rax
 78e:	48 85 ff             	test   %rdi,%rdi
 791:	74 18                	je     7ab <reverse_tco+0x20>
 793:	48 83 ec 08          	sub    $0x8,%rsp
 797:	48 89 fe             	mov    %rdi,%rsi
 79a:	48 8b 7f 08          	mov    0x8(%rdi),%rdi
 79e:	48 89 46 08          	mov    %rax,0x8(%rsi)
 7a2:	e8 e4 ff ff ff       	callq  78b <reverse_tco>
 7a7:	48 83 c4 08          	add    $0x8,%rsp
 7ab:	f3 c3                	repz retq 

可以清晰的看出,这是一个递归代码。上来78b和78e乱序执行,将rsi(result)放到rax上去准备返回,再检测rdi(n)是否为NULL。是的话跳去7ab返回,否的话rsp留出8bytes空间,将n作为result,将n->next(rdi+8)作为n准备递归。最后79e执行n->next = result;,7a2递归。

总的来说,这是个翻译的很明白的汇编代码。

下面我们查看O2编译反汇编。

0000000000000850 <reverse_tco>:
 850:	48 85 ff             	test   %rdi,%rdi
 853:	74 20                	je     875 <reverse_tco+0x25>
 855:	48 89 f8             	mov    %rdi,%rax
 858:	eb 09                	jmp    863 <reverse_tco+0x13>
 85a:	66 0f 1f 44 00 00    	nopw   0x0(%rax,%rax,1)
 860:	48 89 d0             	mov    %rdx,%rax
 863:	48 8b 50 08          	mov    0x8(%rax),%rdx
 867:	48 89 70 08          	mov    %rsi,0x8(%rax)
 86b:	48 89 c6             	mov    %rax,%rsi
 86e:	48 85 d2             	test   %rdx,%rdx
 871:	75 ed                	jne    860 <reverse_tco+0x10>
 873:	f3 c3                	repz retq 
 875:	48 89 f0             	mov    %rsi,%rax
 878:	c3                   	retq
 879:	0f 1f 80 00 00 00 00 	nopl   0x0(%rax)

这段也不难懂,上来判断rdi(n)是否为0,为0把rsi(result)设定为返回值并退出。不为0把n转移到eax(返回值)上,跳去863执行。863-86e里,n在eax上,t在rdx上,result始终在rsi上。最后一个t赋给n是跳去860做的。

关键是这段汇编说明了什么?这段代码根本不调用自身,所以根本不是递归代码。有趣的是,我们可以对比它的手工转迭代形态,reverse_iterate函数。

struct node* reverse_iterate(struct node *n)
{
	struct node *t;
	struct node *result = NULL;
	while (n != NULL) {
		t = n;
		n = n->next;
		t->next = result;
		result = t;
	}
	return result;
}

废话不说,O2反汇编。

00000000000008c0 <reverse_iterate>:
 8c0:	48 85 ff             	test   %rdi,%rdi
 8c3:	74 20                	je     8e5 <reverse_iterate+0x25>
 8c5:	31 c9                	xor    %ecx,%ecx
 8c7:	48 89 f8             	mov    %rdi,%rax
 8ca:	eb 07                	jmp    8d3 <reverse_iterate+0x13>
 8cc:	0f 1f 40 00          	nopl   0x0(%rax)
 8d0:	48 89 d0             	mov    %rdx,%rax
 8d3:	48 8b 50 08          	mov    0x8(%rax),%rdx
 8d7:	48 89 48 08          	mov    %rcx,0x8(%rax)
 8db:	48 89 c1             	mov    %rax,%rcx
 8de:	48 85 d2             	test   %rdx,%rdx
 8e1:	75 ed                	jne    8d0 <reverse_iterate+0x10>
 8e3:	f3 c3                	repz retq 
 8e5:	31 c0                	xor    %eax,%eax
 8e7:	c3                   	retq
 8e8:	0f 1f 84 00 00 00 00 	nopl   0x0(%rax,%rax,1)
 8ef:	00 

代码结构极其类似。但是由于寄存器染色的问题,result放在了ecs而非rsi上。所以8c5多了一行xor初始化,8d7是mov rcx。除此以外代码一模一样。

gdb对O2的调试问题

当你用gdb去调试这种优化过的代码,会出现非常有趣的情况。例如调试reverse_loop_tco为例,代码会在几个位置跳来跳去,就是不正常递归。n和result全都不显示,显示就是。如果你用disassemble reverse_loop_tcosi来调试的话,会发现,这其实是在汇编流上正常执行,然后地址被映射回了C源码,行为就显得诡异了。总的来说,O2不是一个用来调试的好版本。

C反转链表,TCO版本,带环

C代码在这里。

struct node* reverse_loop_tco(struct node *n, struct node *result)
{
	struct node *t;
	struct node *n1;
	if (n == NULL || n->next == &node_nil) {
		return result;
	}
	n1 = (struct node*)malloc(sizeof (struct node));
	n1->i = n->i;
	n1->next = result;
	t = n->next;
	n->next = &node_nil;
	return reverse_loop_tco(t, n1);
}

这里我们不需要反汇编,结论也是很明显的——应当转为了迭代。这个解法的核心是malloc。因为在Python版本里我们就说过了,如果复用旧节点的话,cdr就会被指向下一跳,不能填充placeholder进去。

所以,请大家考虑一个问题。这个例子里是用C写的,使用malloc分配内存,没有回收释放过。如果这个代码使用GC语言编写,同时young gc算法是引用计数法,释放到分配有缓存队列(Python就是这个情况)。这个算法的空间复杂度是什么量级。如果young gc使用copy算法(Java就是这个情况),那么算法的空间复杂度又是什么情况?这个问题在文章的后面会分析。

C反转链表,非TCO版本

我们先看不带loop的版本,源码长这个样子。

struct node* reverse_recursion(struct node *n)
{
	struct node *result = NULL;
	if (n == NULL) {
		return NULL;
	}
	if (n->next == NULL) {
		return n;
	}
	/* result can be defined just in here */
	result = reverse_recursion(n->next);
	n->next->next = n;
	n->next = NULL;
	return result;
}

O2反汇编后这样子:

0000000000000880 <reverse_recursion>:
 880:	48 85 ff             	test   %rdi,%rdi
 883:	74 2b                	je     8b0 <reverse_recursion+0x30>
 885:	53                   	push   %rbx
 886:	48 89 fb             	mov    %rdi,%rbx
 889:	48 8b 7f 08          	mov    0x8(%rdi),%rdi
 88d:	48 89 d8             	mov    %rbx,%rax
 890:	48 85 ff             	test   %rdi,%rdi
 893:	74 15                	je     8aa <reverse_recursion+0x2a>
 895:	e8 e6 ff ff ff       	callq  880 <reverse_recursion>
 89a:	48 8b 53 08          	mov    0x8(%rbx),%rdx
 89e:	48 89 5a 08          	mov    %rbx,0x8(%rdx)
 8a2:	48 c7 43 08 00 00 00 	movq   $0x0,0x8(%rbx)
 8a9:	00 
 8aa:	5b                   	pop    %rbx
 8ab:	c3                   	retq   
 8ac:	0f 1f 40 00          	nopl   0x0(%rax)
 8b0:	31 c0                	xor    %eax,%eax
 8b2:	c3                   	retq   
 8b3:	0f 1f 00             	nopl   (%rax)
 8b6:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
 8bd:	00 00 00 

同样,代码解读并不难。但是意义就很有趣。这段代码确实调用了callq,所以是真递归,不满足TCO优化条件(或gcc无法优化)。但是并没有sub rsp,所以在递归时并没有预留本地空间。栈上除了rbx和ret地址外,应该什么都没有。在这段代码里,rbx代表n。所以这个算法会在每个栈帧里留下n,这也符合我们的推测——这个算法的空间消耗是N(准确说是2N,还有ret地址开销)。

无法优化的原因很简单。指针改变的顺序是从尾向头走,将每一层的节点指针压栈,我们才方便追踪每次改变时的尾节点。否则这个算法的复杂度将达到O(n^2)。

下面带loop版本的代码。

struct node* reverse_loop_recursion(struct node *n)
{
	struct node *t;
	struct node *result = NULL;
	if (n == NULL) {
		return NULL;
	}
	if (n->next == NULL || n->next->next == &node_nil) {
		return n;
	}
	/* we had to keep t in stack. */
	t = n->next;
	n->next = &node_nil;
	result = reverse_loop_recursion(t);
	t->next = n;
	n->next = NULL;
	return result;
}

这段代码和最上面相比,非常类似。但是这段代码是有额外开销的。我们看O2反汇编。

0000000000000970 <reverse_loop_recursion>:
 970:	48 85 ff             	test   %rdi,%rdi
 973:	74 53                	je     9c8 <reverse_loop_recursion+0x58>
 975:	55                   	push   %rbp
 976:	53                   	push   %rbx
 977:	48 83 ec 08          	sub    $0x8,%rsp
 97b:	48 8b 6f 08          	mov    0x8(%rdi),%rbp
 97f:	48 85 ed             	test   %rbp,%rbp
 982:	74 3c                	je     9c0 <reverse_loop_recursion+0x50>
 984:	48 8d 15 b5 06 20 00 	lea    0x2006b5(%rip),%rdx        # 201040 <node_nil>
 98b:	48 39 55 08          	cmp    %rdx,0x8(%rbp)
 98f:	48 89 f8             	mov    %rdi,%rax
 992:	74 1b                	je     9af <reverse_loop_recursion+0x3f>
 994:	48 89 fb             	mov    %rdi,%rbx
 997:	48 89 57 08          	mov    %rdx,0x8(%rdi)
 99b:	48 89 ef             	mov    %rbp,%rdi
 99e:	e8 cd ff ff ff       	callq  970 <reverse_loop_recursion>
 9a3:	48 89 5d 08          	mov    %rbx,0x8(%rbp)
 9a7:	48 c7 43 08 00 00 00 	movq   $0x0,0x8(%rbx)
 9ae:	00 
 9af:	48 83 c4 08          	add    $0x8,%rsp
 9b3:	5b                   	pop    %rbx
 9b4:	5d                   	pop    %rbp
 9b5:	c3                   	retq   
 9b6:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
 9bd:	00 00 00 
 9c0:	48 89 f8             	mov    %rdi,%rax
 9c3:	eb ea                	jmp    9af <reverse_loop_recursion+0x3f>
 9c5:	0f 1f 00             	nopl   (%rax)
 9c8:	31 c0                	xor    %eax,%eax
 9ca:	c3                   	retq   
 9cb:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

这段代码里面有两个特征,975/976两行push,977行出现了sub rsp,97e行出现了callq。这是尾递归不可优化,栈上额外保留空间的特征。我们随后可以分析栈上都出现了什么。

975和976是两条push,rbp用来保存n->next(97b),rbx用来保存n(994),也就是一个保存源码里的n,一个保存源码里的t。至于那个sub rsp,直到后面add rsp我都没看到引用过rsp。貌似就是缓冲空间,没有别的作用。

我们分析是否能将n和t消除掉呢?结果是不行的。后续组链的核心就是t->next = n,这里t和n都是必须的。而通过n保存t呢?由于n节点不能新分配,因此需要原始复用。而原始复用中,需要借用next指针。因此这也是不可行的。因此,这里的额外空间花销是不可消除的。通过比较这段算法和原始的无环形态,我们可以看出,这个算法的空间复杂度还是O(n),但是系数是2。

和TCO以及迭代版本的带环算法相比,TCO版本的空间复杂度貌似也是O(n),系数为2n。因为新生成了n个对象,每个对象是一个node,有两个指针(一个指针一个数据空间)。所以总空间使用量为2n。但是如果没有外界干扰,系统采用引用计数gc的话。在上一轮循环结束时,原始node就是失去所有引用。虽然它还引用了node_nil,但是引用计数归零,所以直接释放回queue。下一轮循环的new过程中,又被复用。这个分析对于所有节点几乎都成立,只有一个节点除外——交叉节点。因此,总内存分配量其实也就是两个node。

而如果系统采用复制gc的话,那么情况就不一样了。由于不能保证运行过程中触发young gc,因此只能假定新生成n个对象,总空间使用量为2n。这样就和当前算法空间复杂度一致了。

java实现fib

java版本很简单

public static int fib (int n) {
    if (n <= 0) {
        return 1;
    }
    return fib(n-1) + fib(n-2);
}

javap -v输出byte code decompiler,结果如下:

     0: iload_0
     1: ifgt          6
     4: iconst_1
     5: ireturn
     6: iload_0
     7: iconst_1
     8: isub
     9: invokestatic  #2                  // Method fib:(I)I
    12: iload_0
    13: iconst_2
    14: isub
    15: invokestatic  #2                  // Method fib:(I)I
    18: iadd
    19: ireturn

从字节码来分析,Java版本是没有做recursion转iterate优化的。我对JVM不熟,不能确定JVM或JIT时会不会进行优化。

gcc对fib的优化

gcc让我觉得更加惊人的是对fib的优化。源码很简单,请直接看“附录D: 用C解fib的三种解法源码”,我们关键是看O2优化。

int fib0(int n)
{
	if (n <= 0) {
		return 1;
	} else {
		return fib0(n-1) + fib0(n-2);
	}
}

O2如下。

0000000000000720 <fib>:
 720:	85 ff                	test   %edi,%edi
 722:	7e 27                	jle    74b <fib+0x2b>
 724:	55                   	push   %rbp
 725:	53                   	push   %rbx
 726:	31 ed                	xor    %ebp,%ebp
 728:	89 fb                	mov    %edi,%ebx
 72a:	48 83 ec 08          	sub    $0x8,%rsp
 72e:	66 90                	xchg   %ax,%ax
 730:	8d 7b ff             	lea    -0x1(%rbx),%edi
 733:	83 eb 02             	sub    $0x2,%ebx
 736:	e8 e5 ff ff ff       	callq  720 <fib>
 73b:	01 c5                	add    %eax,%ebp
 73d:	85 db                	test   %ebx,%ebx
 73f:	7f ef                	jg     730 <fib+0x10>
 741:	48 83 c4 08          	add    $0x8,%rsp
 745:	8d 45 01             	lea    0x1(%rbp),%eax
 748:	5b                   	pop    %rbx
 749:	5d                   	pop    %rbp
 74a:	c3                   	retq   
 74b:	b8 01 00 00 00       	mov    $0x1,%eax
 750:	c3                   	retq   
 751:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
 756:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
 75d:	00 00 00 

代码并不复杂。但是有个非常吓人的地方。你看到了几次callq?

我们完整分析一下吧。上手检测edi(n),如果小于等于0就返回1。然后推rbp和rbx进去。726和728乱序执行,清空ebp并且将n赋值给ebx。从736那个callq回来的add来看,ebp看来就是s。同样佐证的还有745行的赋值。每次调用时,传入n-1。每次循环间隙,运算n-2(733行)。人工写成循环的话,就是fib1。

我们来看一下fib1的O2结果。

0000000000000760 <fib1>:
 760:	55                   	push   %rbp
 761:	53                   	push   %rbx
 762:	48 83 ec 08          	sub    $0x8,%rsp
 766:	85 ff                	test   %edi,%edi
 768:	7e 26                	jle    790 <fib1+0x30>
 76a:	89 fb                	mov    %edi,%ebx
 76c:	31 ed                	xor    %ebp,%ebp
 76e:	66 90                	xchg   %ax,%ax
 770:	8d 7b ff             	lea    -0x1(%rbx),%edi
 773:	83 eb 02             	sub    $0x2,%ebx
 776:	e8 e5 ff ff ff       	callq  760 <fib1>
 77b:	01 c5                	add    %eax,%ebp
 77d:	83 fb ff             	cmp    $0xffffffff,%ebx
 780:	7d ee                	jge    770 <fib1+0x10>
 782:	48 83 c4 08          	add    $0x8,%rsp
 786:	89 e8                	mov    %ebp,%eax
 788:	5b                   	pop    %rbx
 789:	5d                   	pop    %rbp
 78a:	c3                   	retq
 78b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
 790:	bd 01 00 00 00       	mov    $0x1,%ebp
 795:	48 83 c4 08          	add    $0x8,%rsp
 799:	89 e8                	mov    %ebp,%eax
 79b:	5b                   	pop    %rbx
 79c:	5d                   	pop    %rbp
 79d:	c3                   	retq
 79e:	66 90                	xchg   %ax,%ax

很类似。先判断edi(n)是否小于等于0,是的话,从790出去。随后同样是n赋值给ebx,清空ebp,调用。但是判断这行有点意思。在fib的形式里,fib的参数可以是0和-1(例如fib(1)的求值中,就会计算fib(0)和fib(-1))。因此,手工转写后,条件应当终止在n > -2上,而不是0。除此外,代码和fib版本高度一致。

但是,fib是什么?不可尾递归版本。无论是fib(n-1)还是fib(n-2)回来,都是要做一些事的。所以gcc实际上支持双路递归,非尾递归形态编译优化为迭代形态的。

顺带一提,fib其实是有TCO算法的,而且开销更小。具体来说就是附录D的fib2函数。但是这明显是另一种算法。

尾递归和转迭代关系

尾递归必然可以转为迭代形态,而可转为迭代形态的未必满足尾递归。

在x86-64的调用条件下,尾递归其实并不难实现。甚至非递归函数,只要满足TCO条件,也可以用这个优化来减少开销(无效的一次出入栈+一个字长的内存消耗)。假设存在函数f和g,f最后调用g并直接返回。f可以将g的参数设定到正确的寄存器后(这个和正常调用相同),将callq g换为long jmp。g在完成调用后的ret会回复到f的调用者手里。

如果是x86的调用条件,要完成尾递归就比较麻烦了。f需要设定g的调用参数,这个参数只能写到f自己的参数地址上去。如果f的参数空间比g短,那尾递归无论如何无法通过这种方式完成。碰到pascal式调用,还能调整栈来解决这个问题。碰到C式调用,即使可以移动栈,空间清理也做不好。

而迭代转递归则是另一个事。我们可以简单研究一下递归可转迭代的条件,以及转换方法。假设递归满足以下模式。

  1. 退出条件在函数开头。这个条件可以被转换为一个退出判断函数test。
  2. 以调用自身的地点为界,之前的函数叫f1,之后的函数叫f2。
  3. 退出时可以将参数p转为另一个数据(例如恒定回复1)。这个转换参数叫final。

可以证明,递归可转为迭代的充分条件是。f1不直接向f2传递数据,包括递归的参数。f2的所有参与数据,只能从环境中,或递归函数的返回中获得。

具体来说,附录E给出了一个例子,如何完成递归转迭代。不过这个例子用的是双递归,所以只能转为单路迭代,迭代内还嵌套递归。另外代码也有一点改动,fib(n-2),实际上需要用到参数。这里变成了从返回值中获得这个数。

至于gcc的优化,我测试了一下,是另一个套路。gcc对于某些递归后运算,可以将其转换为连续迭代来进行计算。主要就是+*这些常见运算。如果你把递归函数写成下面这样子的话,gcc就不优化了。

int foo(int n)
{
	if (n <= 0) {
		return 1;
	}
	return foo(n-1) << 1;
}

O2反汇编

0000000000000770 <foo>:
 770:	85 ff                	test   %edi,%edi
 772:	7e 1c                	jle    790 <foo+0x20>
 774:	48 83 ec 08          	sub    $0x8,%rsp
 778:	83 ef 01             	sub    $0x1,%edi
 77b:	e8 f0 ff ff ff       	callq  770 <foo>
 780:	48 83 c4 08          	add    $0x8,%rsp
 784:	01 c0                	add    %eax,%eax
 786:	c3                   	retq   
 787:	66 0f 1f 84 00 00 00 	nopw   0x0(%rax,%rax,1)
 78e:	00 00 
 790:	b8 01 00 00 00       	mov    $0x1,%eax
 795:	c3                   	retq   
 796:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
 79d:	00 00 00 

可以看到,这个函数保持了递归。

而这个函数是可以转为迭代的。具体可以参考附录E。

附录A:用Python解链表反向问题的六种解法源码

PLACEHOLDER = 'fix data'


def reverse_tco(ll, rslt):
	if not ll:
		return rslt
	return reverse_tco(ll[1], [ll[0], rslt])


def reverse_loop_tco(ll, rslt):
	if not ll or ll[1] == PLACEHOLDER:
		return rslt
	ll[1], rslt, ll = PLACEHOLDER, [ll[0], rslt], ll[1]
	return reverse_loop_tco(ll, rslt)


def reverse_recursion(ll):
	if not ll[1]:
		return ll
	rslt = reverse_recursion(ll[1])
	ll[1][1], ll[1] = ll, None
	return rslt


def reverse_loop_recursion(ll):
	if not ll[1] or ll[1][1] == PLACEHOLDER:
		return ll
	t, ll[1] = ll[1], PLACEHOLDER
	rslt = reverse_loop_recursion(t)
	t[1], ll[1] = ll, None
	return rslt


def reverse_iterate(ll):
	rslt = None
	while ll:
		rslt, ll[1], ll = ll, rslt, ll[1]
	return rslt


def reverse_loop_iterate(ll):
	rslt = None
	while ll and ll[1] != PLACEHOLDER:
		ll[1], rslt, ll = PLACEHOLDER, [ll[0], rslt], ll[1]
	return rslt


def main():
	t = [5, None]
	ll = [1, [2, [3, [4, t]]]]
	t[1] = ll[1][1]
	print(ll)
	# rslt = reverse_tco(ll, None)
	# rslt = reverse_recursion(ll)
	# rslt = reverse_iterate(ll)
	rslt = reverse_loop_tco(ll, None)
	# rslt = reverse_loop_recursion(ll)
	# rslt = reverse_loop_iterate(ll)
	print(rslt)


if __name__ == '__main__':
	main()

附录B: 用C解链表反向问题的六种解法源码

#include <stdio.h>
#include <stdlib.h>

struct node {
	int i;
	struct node *next;
};

struct node node_nil = {-1, NULL};


int print_node(struct node *n, int depth)
{
	if (n != NULL && depth != 0) {
		printf("%i ", n->i);
		print_node(n->next, depth-1);
	} else {
		printf("\n");
	}
	return 0;
}

struct node* reverse_tco(struct node *n, struct node *result)
{
	struct node *t;
	if (n == NULL) {
		return result;
	}
	t = n->next;
	n->next = result;
	return reverse_tco(t, n);
}

struct node* reverse_recursion(struct node *n)
{
	struct node *result = NULL;
	if (n == NULL) {
		return NULL;
	}
	if (n->next == NULL) {
		return n;
	}
	/* result can be defined just in here */
	result = reverse_recursion(n->next);
	n->next->next = n;
	n->next = NULL;
	return result;
}

struct node* reverse_iterate(struct node *n)
{
	struct node *t;
	struct node *result = NULL;
	while (n != NULL) {
		t = n;
		n = n->next;
		t->next = result;
		result = t;
	}
	return result;
}

struct node* reverse_loop_tco(struct node *n, struct node *result)
{
	struct node *t;
	struct node *n1;
	if (n == NULL || n->next == &node_nil) {
		return result;
	}
	n1 = (struct node*)malloc(sizeof (struct node));
	n1->i = n->i;
	n1->next = result;
	t = n->next;
	n->next = &node_nil;
	return reverse_loop_tco(t, n1);
}

struct node* reverse_loop_recursion(struct node *n)
{
	struct node *t;
	struct node *result = NULL;
	if (n == NULL) {
		return NULL;
	}
	if (n->next == NULL || n->next->next == &node_nil) {
		return n;
	}
	/* we had to keep t in stack. */
	t = n->next;
	n->next = &node_nil;
	result = reverse_loop_recursion(t);
	t->next = n;
	n->next = NULL;
	return result;
}

struct node* reverse_loop_iterate(struct node *n)
{
	struct node *t;
	struct node *result = NULL;
	while (n != NULL && n->next != &node_nil) {
		t = (struct node*)malloc(sizeof (struct node));
		t->i = n->i;
		t->next = result;
		result = t;
		t = n->next;
		n->next = &node_nil;
		n = t;
	}
	return result;
}

int main(int argc, char *argv[])
{
	struct node node6 = {6, NULL};
	struct node node5 = {5, &node6};
	struct node node4 = {4, &node5};
	struct node node3 = {3, &node4};
	struct node node2 = {2, &node3};
	struct node node1 = {1, &node2};
	struct node *result;

	/* node3.next = &node2; */

	print_node(&node1, 7);
	/* result = reverse_tco(&node1, NULL); */
	/* result = reverse_recursion(&node1); */
	/* result = reverse_iterate(&node1); */
	result = reverse_loop_tco(&node1, NULL);
	/* result = reverse_loop_recursion(&node1); */
	/* result = reverse_loop_iterate(&node1); */
	print_node(result, 7);
	return 0;
}

附录C: 用Scheme解链表反向问题的六种解法源码

(define placeholder "fix data")

(define (reverse-tco ll rslt)
  (if (eq? ll '())
	  rslt
	  (reverse-tco (cdr ll) (cons (car ll) rslt))))

(define (reverse-loop-tco ll rslt)
  (if (or (eq? ll '())
	  (eq? (cdr ll) placeholder))
	  rslt
	  (let ((t (cdr ll)))
	(set-cdr! ll placeholder)
	(reverse-loop-tco t (cons (car ll) rslt)))))

(define (reverse-recursion ll)
  (cond
   ((eq? ll '()) '())
   ((eq? (cdr ll) '()) ll)
   (else
	(let ((rslt (reverse-recursion (cdr ll))))
	  (set-cdr! (cdr ll) ll)
	  (set-cdr! ll '())
	  rslt))))

(define (reverse-loop-recursion ll)
  (cond
   ((eq? ll '()) '())
   ((or (eq? (cdr ll) '())
	(eq? (cddr ll) placeholder))
	ll)
   (else
	(let ((t (cdr ll)))
	  (set-cdr! ll placeholder)
	  (let ((rslt (reverse-loop-recursion t)))
	(set-cdr! t ll)
	(set-cdr! ll '())
	rslt)))))

(define (reverse-iterate ll)
  (let ((rslt '())
	(t '()))
	(while (not (eq? ll '()))
	   (set! t (cdr ll))
	   (set-cdr! ll rslt)
	   (set! rslt ll)
	   (set! ll t))
	rslt))

(define (reverse-loop-iterate ll)
  (let ((rslt '())
	(t '()))
	(while (and
		(not (eq? ll '()))
		(not (eq? (cdr ll) placeholder)))
	   (set! t (cdr ll))
	   (set-cdr! ll placeholder)
	   (set! rslt (cons (car ll) rslt))
	   (set! ll t))
	rslt))

(define ll '(1 2 3 4 5 6))
(let
	((t (cddr ll)))
  (set-cdr! (cdddr t) t))
(display ll)
(newline)
;; (display (reverse-tco ll '()))
;; (display (reverse-recursion ll))
;; (display (reverse-iterate ll))
(display (reverse-loop-tco ll '()))
;; (display (reverse-loop-recursion ll))
;; (display (reverse-loop-iterate ll))

附录D: 用C解fib的三种解法源码

int fib0(int n)
{
	if (n <= 0) {
		return 1;
	} else {
		return fib0(n-1) + fib0(n-2);
	}
}

int fib1(int n)
{
	int s;
	if (n <= 0) {
		return 1;
	}
	for (s = 0; n > -2; n-=2){
		s += fib1(n-1);
	}
	return s;
}

int fib2(int n, int a, int b)
{
	if (n == 0) {
		return a;
	}
	return fib2(n-1, a+b, a);
}

int main(int argc, char *argv[])
{
	int n = 20;
	printf("fib0(%d): %d\n", n, fib0(n));
	printf("fib1(%d): %d\n", n, fib1(n));
	printf("fib2(%d): %d\n", n, fib2(n, 1, 1));
	return 0;
}

附录E: 用Python解决递归转迭代

def fib(n):
	if n <= 0:
		return 1
	return fib(n-1)+fib(n-2)


def iterate(test, final, f1, f2, p):
	i = 0
	while not test(p):
		p = f1(p)
		i += 1
	p = final(p)
	while i != 0:
		p = f2(p)
		i -= 1
	return p


def fib1(n):
	p = [n, 1]

	def test(p):
		return p[0] <= 0

	def final(p):
		return p

	def f1(p):
		p[0] -= 1
		return p

	def f2(p):
		p[1] += fib1(p[0]-1)
		p[0] += 1
		return p
	return iterate(test, final, f1, f2, p)[1]


def foo(n):
	def test(p):
		return p <= 0

	def final(p):
		return 1

	def f1(p):
		return p-1

	def f2(p):
		return p << 1

	return iterate(test, final, f1, f2, n)


def main():
	print(fib(10))
	print(fib1(10))
	print(foo(3))


if __name__ == '__main__':
	main()