技术预计阅读 13 分钟4801 字0 次浏览

Antlr4系列⑤:支持循环语句

while循环、break/continue(用异常做控制流跳转)

目录

在上一篇文章中我们实现了if / else if / else分支语句,本篇文章我们继续完善语法,实现while循环语句,并顺带实现循环中常用的breakcontinue

一、语法设计

  1. while循环

    参考主流语言设计while (条件) { 语句 },条件为真时反复执行代码块,条件为假时结束循环。

  2. 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)* ;

为方便和上一篇文章中的语法进行对比,对于本次新增的部分使用注释进行了标注,接下来对本次新增的部分进行解释。

  1. 语法部分

    • whileStmt复用了上一篇已经定义好的condblock,写法上和ifStmt非常相似,这也是当初把block单独定义成一个复用main的语法的意义所在——新增一种语句时不需要重新设计"语句集合",直接拿来用即可。

    • breakStmtcontinueStmt看起来只是简单包一层BREAK/CONTINUE词法,为什么不直接在main里加BREAK SEMI?这种更简洁的写法,而是单独定义了两个规则?原因在第②篇文章中提到过:main规则本身没有使用#定义别名,如果直接在main的某个分支上加别名(比如为了后面生成对应的visitXXX方法更方便),根据antlr4的规则,main下面所有分支都必须加别名,这会连带影响到setArgprintifStmt等已有分支的代码生成,属于没必要的破坏性改动。单独定义成breakStmtcontinueStmt两个规则,就可以在不影响main原有结构的前提下,让antlr4为它们各自生成独立的visitBreakStmtvisitContinueStmt方法。

三、语法实现

接下来使用idea插件将定义好的.g4文件重新生成java文件覆盖之前的文件。在实现while之前,先思考一个问题:breakcontinue很可能出现在if语句内部(比如while循环体里套了一个ifbreak写在if的代码块里),这意味着"跳出循环"这个信号要能从很深的嵌套层级里一路传递回最外层的while。如果用返回值层层向上传递"是否需要跳出"的标记,每一层visitXXX方法都要写额外的判断逻辑,非常繁琐。这里采用了一种更简洁的做法——用异常来实现控制流跳转break/continue被访问到时直接抛出一个自定义异常,Java运行时会自动帮我们把调用栈一路展开,直到被while所在的visitWhileStmt方法捕获,中间经过多少层ifblock都不需要关心。

定义两个控制流异常:

/**
 * 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的计数循环,验证whilebreakcontinue是否符合预期:

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 + 1i自增,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(此时读到的还是paramDefinei的旧值),再把结果写回paramDefine,两步操作顺序刚好让这种写法能够模拟出自增的效果。

五、遗留的问题

上一篇文章末尾提到,paramDefine目前是MyRuleSetVisitor里唯一的一个全局共享的Mapif代码块内定义的变量在外面也能访问。这个问题在本篇的while循环里表现得更加明显:循环体内number i = i + 1访问和修改的其实一直是外层定义的同一个i,而不是一个"循环体内部的局部变量"——目前的实现里,"定义"和"赋值"本质上是同一回事,都只是paramDefine.put,没有做任何作用域隔离。下一篇文章我们将专门解决这个问题:引入作用域链,让每个block拥有自己的变量表,同时借这个机会把"定义新变量"和"给已有变量赋值"这两种不同的语义区分开来。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论