Antlr4系列⑤:支持循环语句
while循环、break/continue(用异常做控制流跳转)
在上一篇文章中我们实现了if / else if / else分支语句,本篇文章我们继续完善语法,实现while循环语句,并顺带实现循环中常用的break和continue。
一、语法设计
-
while循环
参考主流语言设计
while (条件) { 语句 },条件为真时反复执行代码块,条件为假时结束循环。 -
break / continue
break:立即跳出当前所在的循环,不再判断条件。continue:跳过本次循环剩余的语句,直接进行下一次条件判断。
二、语法定义
根据以上设计原则对之前的语法定义进行完善,完善后的结果如下:
grammar RuleSet; //程序名称和.g4名称一致即可
prog: main+
;
main: setArg SEMI?
| print SEMI?
| ifStmt
| whileStmt // 新增
| breakStmt // 新增
| continueStmt // 新增
;
// 新增:代码块,用花括号包裹0个或多个main,支持if语句内部嵌套if
block: '{' main* '}'
;
// 新增:if / else if / else,else if可以出现0次或多次,else可选
ifStmt: IF '(' cond ')' block
(ELSE IF '(' cond ')' block)*
(ELSE block)?
;
// 新增:while循环,条件为真时反复执行block
whileStmt: WHILE '(' cond ')' block
; // 新增
// 新增:跳出循环和跳过本次循环剩余部分
breakStmt: BREAK SEMI?
; // 新增
continueStmt: CONTINUE SEMI?
; // 新增
// 条件表达式,支持比较运算、逻辑运算以及布尔字面量/布尔变量
cond: NOT cond # notCond
| cond AND cond # andCond
| cond OR cond # orCond
| calcu opt=(GT|LT|GE|LE|EQ|NE) calcu # compare
| '(' cond ')' # condParens
| BOOL # boolLiteral
| ID # condArgValue
;
setArg :'number' id = ID '=' calcu #numarg
|'string' id = ID '=' STRING #chararg
|'boolean' id = ID '=' BOOL #boolarg
;
calcu: calcu opt=(MUL|DIV) calcu # mulAndDiv
| calcu opt=(ADD|SUB) calcu # addAndSub
| '(' calcu ')' # parens
| NUMBER # number
| ID # argValue
;
print: PRINT '(' value = ID ')' # printArg
| PRINT '(' value = BOOL ')' # printBoolean
| PRINT '(' value = STRING ')' # printString
;
WS : [ \t\n\r]+ -> skip ; // ->skip表示antlr4在分析语言的文本时,符合这个规则的词法将被无视
ADD : '+' ;
SUB : '-' ;
MUL : '*' ;
DIV : '/' ;
NUMBER : '-'? [0-9]+('.'([0-9]+)?)? ; // 数字正则
BOOL : 'true' | 'false' ;
STRING : '"' .*? '"';
PRINT : 'print';
SEMI: ';';
IF : 'if' ;
ELSE : 'else' ;
AND : '&&' ;
OR : '||' ;
NOT : '!' ;
GT : '>' ;
LT : '<' ;
GE : '>=' ;
LE : '<=' ;
EQ : '==' ;
NE : '!=' ;
//以下为本篇新增词法
WHILE : 'while' ;
BREAK : 'break' ;
CONTINUE : 'continue' ;
fragment ID_LETTER : [a-z] | [A-Z] | '_' ;
fragment DIGIT : [0-9] ;
ID : ID_LETTER (ID_LETTER | DIGIT)* ;
为方便和上一篇文章中的语法进行对比,对于本次新增的部分使用注释进行了标注,接下来对本次新增的部分进行解释。
-
语法部分
-
whileStmt复用了上一篇已经定义好的cond和block,写法上和ifStmt非常相似,这也是当初把block单独定义成一个复用main的语法的意义所在——新增一种语句时不需要重新设计"语句集合",直接拿来用即可。 -
breakStmt和continueStmt看起来只是简单包一层BREAK/CONTINUE词法,为什么不直接在main里加BREAK SEMI?这种更简洁的写法,而是单独定义了两个规则?原因在第②篇文章中提到过:main规则本身没有使用#定义别名,如果直接在main的某个分支上加别名(比如为了后面生成对应的visitXXX方法更方便),根据antlr4的规则,main下面所有分支都必须加别名,这会连带影响到setArg、print、ifStmt等已有分支的代码生成,属于没必要的破坏性改动。单独定义成breakStmt、continueStmt两个规则,就可以在不影响main原有结构的前提下,让antlr4为它们各自生成独立的visitBreakStmt、visitContinueStmt方法。
-
三、语法实现
接下来使用idea插件将定义好的.g4文件重新生成java文件覆盖之前的文件。在实现while之前,先思考一个问题:break、continue很可能出现在if语句内部(比如while循环体里套了一个if,break写在if的代码块里),这意味着"跳出循环"这个信号要能从很深的嵌套层级里一路传递回最外层的while。如果用返回值层层向上传递"是否需要跳出"的标记,每一层visitXXX方法都要写额外的判断逻辑,非常繁琐。这里采用了一种更简洁的做法——用异常来实现控制流跳转:break/continue被访问到时直接抛出一个自定义异常,Java运行时会自动帮我们把调用栈一路展开,直到被while所在的visitWhileStmt方法捕获,中间经过多少层if、block都不需要关心。
定义两个控制流异常:
/**
* break语句使用的控制流异常,命中break时抛出,由while循环捕获后跳出循环
* 只是用来传递流程跳转的信号,不需要收集堆栈信息,重写fillInStackTrace避免每次抛出都填充堆栈带来的开销
*/
public class BreakException extends RuntimeException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
/**
* continue语句使用的控制流异常,命中continue时抛出,由while循环捕获后跳过本次循环剩余部分
*/
public class ContinueException extends RuntimeException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
关于
fillInStackTrace:正常创建异常时,JVM会收集当前完整的调用堆栈信息,这一步有一定的性能开销。而这里的两个异常只是用来传递"跳出循环"、"跳过本次循环"这样的信号,并不需要真正定位异常发生的位置,所以重写fillInStackTrace直接返回自身,跳过堆栈收集,避免在循环次数较多时产生不必要的性能损耗。
接着在MyRuleSetVisitor中新增本篇涉及到的方法:
/**
* while循环,cond为真时反复执行block
* block内部命中break时会抛出BreakException,在此处捕获后跳出循环
* block内部命中continue时会抛出ContinueException,在此处捕获后直接进行下一次cond判断
*/
@Override
public VisitorResult visitWhileStmt(RuleSetParser.WhileStmtContext ctx) {
while (visit(ctx.cond()).getBool()) {
try {
visit(ctx.block());
} catch (BreakException e) {
break;
} catch (ContinueException e) {
// 不需要处理,直接进入下一次while条件判断
}
}
return VisitorResult.nil();
}
/**
* break语句,抛出BreakException,由外层的while捕获
*/
@Override
public VisitorResult visitBreakStmt(RuleSetParser.BreakStmtContext ctx) {
throw new BreakException();
}
/**
* continue语句,抛出ContinueException,由外层的while捕获
*/
@Override
public VisitorResult visitContinueStmt(RuleSetParser.ContinueStmtContext ctx) {
throw new ContinueException();
}
四、测试代码与执行结果
接下来编写测试代码,用一个1到10的计数循环,验证while、break、continue是否符合预期:
public class Test {
public static void main(String[] args) {
String expression =
"number i = 0 \n" +
"while (i < 10) { \n" +
" number i = i + 1 \n" +
" if (i == 5) { \n" +
" continue \n" +
" } \n" +
" if (i == 8) { \n" +
" break \n" +
" } \n" +
" print(i) \n" +
"}";
calcute(expression);
}
// calcute方法与上一篇文章保持一致,这里不再重复贴出
}
执行结果如下:
执行:
number i = 0
while (i < 10) {
number 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
循环从i = 0开始,每次先执行number i = i + 1让i自增,i等于5时命中continue跳过打印,i等于8时命中break跳出循环,因此最终打印了1 2 3 4 6 7,中间跳过了5,8之后不再继续,符合预期。
关于自增写法:细心的读者可能注意到,循环体内让
i自增用的是number i = i + 1,看起来像是"重新定义"了一个number类型的变量i。这是因为目前的语法里还没有设计单独的"赋值语句"(类似i = i + 1,不带number关键字),只能复用变量定义的语法来达到重新赋值的效果——visitNumarg的实现是先计算等号右边的calcu(此时读到的还是paramDefine里i的旧值),再把结果写回paramDefine,两步操作顺序刚好让这种写法能够模拟出自增的效果。
五、遗留的问题
上一篇文章末尾提到,paramDefine目前是MyRuleSetVisitor里唯一的一个全局共享的Map,if代码块内定义的变量在外面也能访问。这个问题在本篇的while循环里表现得更加明显:循环体内number i = i + 1访问和修改的其实一直是外层定义的同一个i,而不是一个"循环体内部的局部变量"——目前的实现里,"定义"和"赋值"本质上是同一回事,都只是paramDefine.put,没有做任何作用域隔离。下一篇文章我们将专门解决这个问题:引入作用域链,让每个block拥有自己的变量表,同时借这个机会把"定义新变量"和"给已有变量赋值"这两种不同的语义区分开来。