Antlr4系列⑥:作用域链与变量赋值
Scope作用域链、区分"定义"与"赋值"、修复变量泄漏
在前两篇文章中我们分别实现了if / else if / else分支语句和while循环语句,并且在这两篇文章的结尾都留了同一个问题:目前所有变量都保存在MyRuleSetVisitor里唯一的一个全局Map(paramDefine)中,代码块内部定义的变量出了代码块依然能访问到,"定义变量"和"给变量重新赋值"也一直是同一回事。本篇文章我们就来解决这两个问题:引入作用域链,并把"定义"和"赋值"拆成两种不同的语法。
一、问题重现
在动手之前,先分析一下上一篇while循环的例子,如果不做任何改动会发生什么。假设我们给block引入独立的变量表(这正是本篇要做的事),循环体里继续沿用上一篇number i = i + 1的写法:
number i = 0
while (i < 10) {
number i = i + 1 // 仍然使用number关键字
print(i)
}
while的条件i < 10是在while所在的外层作用域里判断的,而循环体{ }每次进入时都会拿到一个新的子作用域。number i = i + 1是"定义"语句,定义永远只会作用于当前(也就是子)作用域——所以每次循环体都会在子作用域里新建一个i,用完之后子作用域被销毁,外层的i从始至终都没有被修改过,while的条件永远是0 < 10,这会是一个死循环。
这说明只有作用域是不够的,还必须能够表达"我要修改的是外层已经存在的这个变量",而不是"在当前作用域新建一个同名变量",这正是"赋值"和"定义"的本质区别。
二、语法设计
-
作用域
- 每个
block拥有自己独立的变量表。 block内新定义的变量,只在这个block内部(含嵌套的block)可见,离开block后失效。block内部可以访问外层作用域已经定义的变量,也可以定义一个和外层同名的变量来"遮盖"外层的变量(术语叫遮蔽/shadowing),不影响外层变量。
- 每个
-
赋值语句
新增一种不带类型关键字的语句,专门用来修改一个已经存在的变量的值:
变量名 = 值。和setArg的区别是:setArg(number/string/boolean开头)永远是在当前作用域新建一个变量;新增的赋值语句则是沿着作用域链,找到变量真正定义的那一层作用域并修改它,如果一路找到最外层还是找不到,说明变量根本没有定义过,应该报错,不允许"顺手"定义。
三、语法定义
根据以上设计新增assignStmt语法:
main: setArg SEMI?
| assignStmt SEMI? // 新增
| print SEMI?
| ifStmt
| whileStmt
| breakStmt
| continueStmt
;
// 新增:给已经定义过的变量重新赋值,与setArg的区别是不需要number/string/boolean类型关键字
assignStmt: id = ID '=' calcu #numAssign
| id = ID '=' STRING #charAssign
| id = ID '=' BOOL #boolAssign
; // 新增
assignStmt的写法和setArg几乎一模一样,只是去掉了开头的类型关键字,同样按等号右边是数值表达式、字符串还是布尔值分成三种规则。这里不需要新增任何词法,也不会和已有语法产生歧义——因为main目前的几个分支里,只有assignStmt是以裸的ID开头的(setArg以number/string/boolean开头,print以PRINT开头,ifStmt以IF开头,以此类推),antlr4只需要看一个token就能确定接下来要按哪个分支解析。
四、作用域的实现
新建一个Scope类表示一个作用域:
/**
* 作用域,每个block在执行时会创建一个新的Scope,parent指向外层作用域,多个Scope串联起来形成作用域链
* 定义(define)永远作用于当前作用域;赋值(assign)会沿着作用域链向外查找变量真正所在的作用域并修改
*/
public class Scope {
private final Scope parent;
private final Map<String, Object> variables = new HashMap<>();
public Scope(Scope parent) {
this.parent = parent;
}
public Scope getParent() {
return parent;
}
/**
* 在当前作用域定义一个新变量,如果当前作用域已存在同名变量则覆盖,不影响外层作用域中的同名变量
*/
public void define(String name, Object value) {
variables.put(name, value);
}
/**
* 给已经定义过的变量赋值,从当前作用域开始沿着作用域链向外查找变量真正定义的位置并修改
*
* @return 变量存在并赋值成功返回true,变量未定义返回false
*/
public boolean assign(String name, Object value) {
Scope scope = findDefiningScope(name);
if (scope == null) {
return false;
}
scope.variables.put(name, value);
return true;
}
/**
* 读取变量的值,从当前作用域开始沿着作用域链向外查找
*/
public Object get(String name) {
Scope scope = findDefiningScope(name);
return scope == null ? null : scope.variables.get(name);
}
private Scope findDefiningScope(String name) {
Scope scope = this;
while (scope != null) {
if (scope.variables.containsKey(name)) {
return scope;
}
scope = scope.parent;
}
return null;
}
}
Scope内部用parent指向外层作用域,多个Scope用parent串起来就形成了一条链。define只操作当前这一层的Map,get和assign都是从当前层开始,沿着parent一路往外找,找到第一个包含该变量名的作用域为止——这就是"作用域链"名字的由来。
五、语法实现
MyRuleSetVisitor不再持有一个全局的Map,而是持有一个当前作用域currentScope,初始时是没有parent的最外层(全局)作用域:
/**
* 当前所在的作用域,初始为最外层的全局作用域,进入block时会切换到为该block新建的子作用域,
* 离开block时切回原来的作用域,多个Scope构成一条作用域链
*/
private Scope currentScope = new Scope(null);
之前所有操作paramDefine的地方,"定义"类的操作(visitNumarg/visitChararg/visitBoolarg)改成调用currentScope.define(...),"读取"类的操作(visitArgValue/visitPrintArg/visitCondArgValue)改成调用currentScope.get(...),这部分只是简单的替换,不再逐一贴出。
真正的关键改动在visitBlock:
/**
* 代码块,进入时基于当前作用域新建一个子作用域并切换过去,块内定义的变量只会保存在这个子作用域中;
* 依次执行块内的每一条main语句;无论正常结束还是因为break/continue异常提前退出,
* 离开时都要用finally切回原来的作用域,避免作用域链混乱
*/
@Override
public VisitorResult visitBlock(RuleSetParser.BlockContext ctx) {
currentScope = new Scope(currentScope);
try {
for (RuleSetParser.MainContext mainCtx : ctx.main()) {
visit(mainCtx);
}
} finally {
currentScope = currentScope.getParent();
}
return VisitorResult.nil();
}
关于
try/finally:上一篇文章中break/continue是通过抛出异常来实现跳转的,异常会一路穿过visitBlock向外抛。如果不用finally,一旦block内部抛出了BreakException/ContinueException,currentScope = currentScope.getParent()这一行就会被跳过,作用域永远不会被正确地切回去,后续代码用到的currentScope就会是一个已经不该存在的子作用域,引发莫名其妙的bug。try/finally保证不管block是正常执行完,还是被异常提前中断,都会正确地把作用域切回上一层。
接着实现赋值语句,三种类型的公共逻辑抽成一个私有方法:
/**
* 给已定义的数值型变量重新赋值,沿作用域链找到变量真正所在的作用域后修改
*/
@Override
public VisitorResult visitNumAssign(RuleSetParser.NumAssignContext ctx) {
VisitorResult visit = visit(ctx.calcu());
assign(ctx.id.getText(), visit.getNumber());
return VisitorResult.nil();
}
/**
* 给已定义的字符型变量重新赋值
*/
@Override
public VisitorResult visitCharAssign(RuleSetParser.CharAssignContext ctx) {
assign(ctx.id.getText(), ctx.STRING().getText());
return VisitorResult.nil();
}
/**
* 给已定义的布尔型变量重新赋值
*/
@Override
public VisitorResult visitBoolAssign(RuleSetParser.BoolAssignContext ctx) {
assign(ctx.id.getText(), Boolean.parseBoolean(ctx.BOOL().getText()));
return VisitorResult.nil();
}
/**
* 赋值的公共逻辑:沿作用域链查找变量真正定义的位置并修改,找不到说明变量未定义,赋值前必须先用
* number/string/boolean定义
*/
private void assign(String name, Object value) {
if (!currentScope.assign(name, value)) {
throw new RuntimeException("未定义的变量:" + name + ",赋值前需要先用number/string/boolean定义");
}
}
六、测试代码与执行结果
分三个用例验证:循环里用真正的赋值语句实现自增、代码块内的变量不会影响外层同名变量、给未定义的变量赋值会报错。
String loopExpression =
"number i = 0 \n" +
"while (i < 10) { \n" +
" i = i + 1 \n" + // 不再需要number关键字
" if (i == 5) { \n" +
" continue \n" +
" } \n" +
" if (i == 8) { \n" +
" break \n" +
" } \n" +
" print(i) \n" +
"}";
calcute(loopExpression);
String scopeExpression =
"number a = 1 \n" +
"if (a == 1) { \n" +
" number a = 100 \n" + // if代码块内重新定义a,遮蔽外层的a
" print(a) \n" +
"} \n" +
"print(a)"; // 离开代码块后应该还是外层的a
calcute(scopeExpression);
String errorExpression = "b = 1"; // b从未定义过,直接赋值应当报错
calcute(errorExpression);
执行结果如下:
执行:
number i = 0
while (i < 10) {
i = i + 1
if (i == 5) {
continue
}
if (i == 8) {
break
}
print(i)
}
1.0
2.0
3.0
4.0
6.0
7.0
执行:
number a = 1
if (a == 1) {
number a = 100
print(a)
}
print(a)
100.0
1.0
执行:
b = 1
Exception in thread "main" java.lang.RuntimeException: 未定义的变量:b,赋值前需要先用number/string/boolean定义
at cn.irule.MyRuleSetVisitor.assign(MyRuleSetVisitor.java:145)
at cn.irule.MyRuleSetVisitor.visitNumAssign(MyRuleSetVisitor.java:117)
......
第一段验证了改用真正的赋值语句后,循环不再死循环,结果和上一篇文章完全一致;第二段验证了if代码块内用number a = 100重新定义的a只在块内生效,打印出100.0,离开代码块后外层的a丝毫没有受到影响,打印出1.0;第三段验证了对一个从未定义过的变量b直接赋值会抛出运行时异常,提示必须先定义,符合预期。
七、遗留的问题
至此,我们的小型DSL已经具备了变量、四则运算、字符串/布尔类型、分支、循环、作用域这些编程语言的基本要素,但还缺一样东西——函数。目前所有代码都写在一个"主程序"里,没有办法把一段逻辑封装起来重复使用。下一篇文章我们将实现自定义函数:函数的定义、参数传递、return返回值,并且会引入"调用栈"的概念——这也是为后续实现单步调试打基础的关键一步。