构造复杂的程序:将一个递归函数转为非递归函数的通用方法

1,178 阅读8分钟

首先面试题:不支持递归的程序语言如何实现递归程序?

1.for循环如何被执行?

1加到100的Java程序如何转化为指令?

for可以执行1次,也可以执行100w次,无数次,因此指令设计者提供了jump类型指令,可以在程序间跳跃:jump loop:跳回loop标签

var i=1,s=0
对应Java代码,我们先将10存储到两个地址:$i,$s
#store #1->$i
#store #0->$s
#循环开始,预留一个loop标签
loop:#循环标签
for ... i<=100
#$i导入寄存器R0
load $i->R0
​
# 然后用cmp比较指令R0和数字100
cmp R0 #100# 注意指令不会有返回值,它会进行计算,然后改变机器状态(寄存器)
# 比较后有几个特殊的寄存器会保存比较结果
# 然后ja(jump above),如果R0比100大,跳到end标签
ja end
nop
​
# 如果R0<=100,s+i
# s地址数据->寄存器R1
load $s->R1
​
load $s -> R1
# 然后我们把寄存器R0和R1加和,把结果存储寄存器 R2
add R0 R1 R2
# 这时,我们把寄存器 R2 的值存入变量 s 所在的地址
store R2 -> $s
# 刚才我们完成了一次循环
# 我们还需要维护变量 i 的自增
# 现在 i 的值在 R0 中,我们首先将整数 1 叠加到 R0 上
add R0 #1 R0
# 再把 R0 的值存入i所在的内存地址
store R0 -> $i
# 这时我们的循环体已经全部执行完成,我们需要调转回上面 loop 标签所在的位置
# 继续循环
jump loop
nop
end:
​

for->指令->二进制->存储到内存

  1. jump 指令直接操作 PC 指针,但是很多 CPU 会抢先执行下一条指令,因此通常我们在 jump 后面要跟随一条 nop 指令,让 CPU 空转一个周期,避免 jump 下面的指令被执行。是不是到了微观世界,和你所认识的程序还不太一样?
  2. add/store:助记符,整体这段是汇编程序
  3. 不同机器助记符不同,不用太关注我用的是什么汇编语言,也不用去记忆这些指令。 拿到指定芯片直接查阅芯片说明书就ok
  4. 虽然不同CPU指令不同,但也有行业标准,现在使用较多是RISC(精简指令集)和CISC(复杂指令集),Intel和AMD主要使用CISC,ARM和MIPS等主要使用RISC指令集

2.条件控制程序

  • if-else

    • 自上而下知性逻辑
  • switch-case

    • 精准匹配算法,比如1000个case,if-else需要一个个比较,最坏999次,switch-case通过算法直接定位到对应case
    • 更多是依赖数学关系,直接算出case所在治理规划位置,而不是一行行执行和比较

3.函数是如何被执行的?

函数执行过程必须深入到底层,也会设计栈

int add(int a,int b){
    return a+b;
}

分析:

  1. a,b本质是内存中的数据,因此需要给他们分配内存你地址
  2. 函数返回值也是内存中的数据,也需要分配内存地址
  3. 调用函数就是跳转到函数体对应和指令所在位置,因此函数名可以使用一个标签,调用时,就用jump指令和这个标签
add:
load $a->R0
load $b->R1
add R0 R1 R2
store R2->$r

还有2个问题:

  1. 参数如何传递给函数?
  2. 返回值如何传递给调用者?

参数执行过程

image.png

image.png

假设计算11和15的和

先开辟一块单独空间,也就是栈

image.png 每次写入栈SP值增加

为了提高效率,我们通常用一个特殊的寄存器存储栈指针,这个寄存器叫:Stack Pointer,大多数芯片都有这个特殊寄存器,一开始SP指向0x100,此时0x100位置还没有数据

  • 压栈参数11
store #11->$SP // 将11存入SP指向地址0x100
add SP,4,SP //栈指针增加4(32位机器)

将11存入SP寄存器指向内存地址,间接寻址,这里是1个32位宽CPU,如果是64位CPU那么栈指针需要自增8

image.png

  • 压栈15

image.png

  • 将返回值压栈

返回值没计算呢,怎么就压栈了?其实这是占位,后面我们改写这个地址

image.png

  • 调用函数

完成压栈,开始调用函数,用jump指令直接跳到函数标签

这个时候,11和15相加,可以利用SP指针寻找数据,11距离当前SP指针差3个位置,15距离SP指针差2个位置,这种寻址方式是一种复合寻址方式,是间接+偏移量寻址。

我们可以用下面代码完成11和15导入寄存器过程:

load $(SP-12)->R0
load $(SP-8)->R1

然后相加存入R2

最后我们再次利用数学关系将结果写入返回值所在位置,利用SP中的地址做加减法操作内存

image.png

  • 发现 - 解决问题

一个好的解决方案也会面临问题

  1. 函数计算完成,这时应该跳转回去,可以我们没有记录函数调用钱PC指针位置,因此这里需要改进,我们需要存储函数调用前PC指针方便调用后恢复
  2. 栈不可以被无限使用,11和15作为参数,计算出26,那么它们就可以清空了,如果用调整栈指针的方式清空,我们就会先清空26,顺序问题,因此我们需要调整压栈顺序

首先们将函数参数和返回值换位,这样在清空数据的时候,就会先清空参 数,再清空返回值。

image.png

然后我们在调用函数前,还需要将返回地址压栈。这样在函数计算完成前,就能跳转回对应的返回地址。翻译成指令,就是下面这样

# 压栈返回值
add SP, 4 -> SP
# 计算返回地址
# 我们需要跳转到清理堆栈那行,也就是16行
MOV PC+4*(参数个数*2+1) -> SP
# 压栈参数的程序
……
# 执行函数,计算返回值
call function
# 清理堆栈
add SP, -(参数个数+1)*4, SP

4.递归函数如何被执行?

刚刚用栈解决了函数调用问题,但是这个方案究竟合不合理,还需要更复杂情况验证

int sum(int n){
    if(n==1)return 1;
    return n+sum(n-1);
}

递归每次执行函数都形成一个栈结构:

image.png

比如100

image.png

image.png 到这里,递归消耗了更多空间,但是也保证了中间计算的独立性。当递归执行到 100 次的时候,就会执行下面的语句: 于是触发第 99 次递归执行:上面程序等价于 return 3 ,接着再触发第 98 次递归的执行,然后是第 97 次,最终触发到第一次函数调用返回结果。 由此可见,栈这种结构同样适合递归的计算。事实上,计算机编程语言就是用这种结构来实现递归函数。

5.class如何实现?

class如何翻译成指令?

首先一个class会分为2个部分:数据,函数

class 有一个特殊的方法叫作构造函数,它会为 class 分配内存。构造函数执行的时候,开始扫描类型定义中所有的属性和方法。

  • 遇到属性,为属性分配内存地址
  • 遇到方法,方法本身需要存到正文段(程序所在内存区域),再将方法值设置为方法指令所在内存地址

调用class方法本质是执行了一个函数,因此和函数调用一致:

  1. 返回值和返回地址压栈
  2. 压栈参数
  3. 执行跳转

小问题:有时候class方法用到this,这其实并不复杂,你仔细想想, this 指针不就是构造函数创建的一个指向 class 实例的地址吗?那么,有一种简单的实现,就是我们可以把 this作为函数的第一个参数压栈。这样,类型的函数就可以访问类型的成员了,而类型也就可以翻译成指令了。

6.总结

下面我们做一个简单的总结:

  1. 我们写的程序需要翻译成指令才能被执行,这个翻译工具叫作编译器。
  2. 平时你编程做的事情,用机器指令也能做,所以从计算能力上来说它们是等价的,最终这种计算能力又和图灵机是等价的。如果一个语言的能力和图灵机等价,我们就说这个语言是图灵完备的语言。现在市面上的绝大多数语言都是图灵完备的语言,但也有一些不是,比如 HTML、正则表达式和 SQL 等。
  3. 我们通过汇编语言构造高级程序;通过高级程序构造自己的业务逻辑,这些都是工程能力的一种体现。

7.一个程序语言如果不支持递归函数的话,该如何实现递归算法?

思路:

我们需要用到一个栈(其实用数组就可以); 我们还需要一个栈指针,支持寄存器的编程语言能够直接用寄存器,而不支持直接用寄存器的编程 语言,比如 Java,我们可以用一个变量; 然后我们可以实现压栈、出栈的操作,并按照上面学习的函数调用方法操作我们的栈。

8.思考题

假设你使用的程序语言不支持递归程序,如果要求用栈来模拟下面这个斐波那契求第 n 项的程序,应该如何转换成等价的基于栈的非递归实现?

int fib(int n){
    if(n==1 || n==2)return n;
    return fib(n-1)+fib(n-2);
}

\