首页 > 代码库 > 对SNL语言的解释器实现尾递归优化
对SNL语言的解释器实现尾递归优化
对于SNL语言解释器的内容可以参考我的前一篇文章《使用antlr4及java实现snl语言的解释器》。此文只讲一下“尾递归优化”是如何实现的——“尾递归优化”并不是一个语言实现必须要做的,但这是一个比较有趣的东西,所以我还是想拿来讲一讲。
在前一篇文章中有一个例子:
program recursion
procedure f(integer d);
begin
write(d);
f(d + 1)
end
begin
f(1)
end
这个例子是我用来测试我写的解释器是否正常实现了最基本的尾递归优化的。
这段代码用JAVA翻译过来是这样的:
public class Test {
static void f(long l) {
System.out.println(l);
f(l + 1);
}
public static void main(String[] args) {
f(0);
}
}
就是对一个方法f进行递归调用并且没有任何正常的退出逻辑(要退出就只能抛出异常或我们主动杀死那个进程)。
这段JAVA代码如果运行起来,很快就会发生堆栈溢出的错误(java.lang.StackOverflowError),但我写的SNL代码运行在我实现的SNL解释器中却会一直运行下去,直到那个进程被杀死(不会有类似堆栈溢出的问题抛出来)。之所以会这样就是因为我的解释器实现了“尾递归优化”。
什么是“尾递归优化”呢?
我对尾递归优化的定义是:一个过程返回之前,调用的最后一条语句如果是对当前过程的递归调用时,则可以用一个小技巧把递归调用变成循环。这里使用到的“小技巧”就是尾递归优化了。
科班出身的程序员都受过这样的训练:把各种递归的程序修改成等价的没有递归调用的程序。这其中的各种方法一般都很难实现一个通用的转换程序——输入一个递归的代码,输出一段等价的,无递归的代码——但对于最后一条执行语句是对当前过程的递归时,这个转换过程就比较容易实现了。
从尾递归优化的定义中可以看出两个关键点:
- 先找到最后一条可执行语句(并判断是否是对当前过程的递归)。
- 使用那个“小技巧”。
首先,我们如何知道最后一条语句是哪条呢?
我们知道,SNL语言的语句有七种:条件语句;循环语句;输入语句;输出语句;赋值语句;过程调用语句;return语句
同时,我们知道,SNL文法中,我们解析stmList时,是要先解析第一条stm,然后再看后面的是否还有其它stmList。
这样我们有以下逻辑可以判断当前识别到的stm是否可能是最后一条:
- 如果当前stm之后不再有stmList,则可以认为这个stm就是最后一条了。
- 如果之后再有stmList,且这个stmList下的第一个stm是return语句,则可以认为这个stm就是最后一条了。
有了这两个逻辑还不算完,因为“条件语句”内部可以复合stmList的,这样我就需要在“当前stmList的最后一条语句是条件语句”的情况下还需要判断这个条件语句的两个可能的分支中的stmList的最后一条语句的最后一条语句是哪条了(即然是分支,那么两个分支都自己的最后一条,也就是说可能有多条语句都是最后一条)。
注:对于“循环语句”也是可以在内部复合stmList的,但因为一般我们只有在运行时才知道最后一次循环是什么时候结束的,所以我们暂时不考虑循环语句。
找到最后一条语句(或多条都可能在自己的分支内是最后一条)之后,判断是否为当前过程的递归就简单了:首先它必须是“过程调用语句”,然后其过程签名必须与当前过程完全相同(我在实现SNL语言的解释器时,为了实现简单,直接用过程名做为过程的签名了,但这样实现存在一个问题:在这个SNL语言的实现中就不支持重载了)。
现在我们已经找到了一些可能是最后一条的语句了,那么只要运用那个“小技巧”就可以了。
这个小技巧非常简单:跳转到当前过程的最开头(跳转之前,需要清理一下当前过程创建的上下文——也就是当前过程自己定义的变量等内容)即可。
其实我们自己想像一下也可以想到:递归调用其实就是跳转到最开头,这就是循环了。
到这里还没完——我们如何跳到最开头去呢?
如果我们写的是编译器就比较简单:直接用一个类似JMP或GOTO这样的指令就可以了。
但我们现在写的是解释器,解释器在解释一个proc时几乎不会把整个解释的逻辑放到同一个方法中来实现的(也是几乎不可能做到的),所以当我们当前所在的是用来解析callStm的方法时,如何可以跳转到很多步调用之前的解析procDeclare方法中去呢?
JAVA中只有一种语言机制可以帮助我们做到这点,即:抛异常。