前端开发中,通常会提到语法解析等功能。这是因为后端负责提供接口和前端调用。那么前端可以独立完成语法解析相关的功能并在浏览器中运行吗?答案是肯定的,本文将描述一种叫做Expr语言的简化语言,在浏览器中完成输入Expr代码的错误验证、执行和翻译等功能。
首先介绍一下我们的Expr语言,这是本文假设的一种简化的类C语言。代码片段如下:
a=2;b=3c=a *(B2);d=a/(c-1);a;//2b应该输出;//3c应该输出;d;前四行的行为是大家熟悉的赋值表达式,后四行打印语句,也就是依次打印每个变量的值。当然这里也有大家耳熟能详的评论。
问题来了。能否独立解析前端Expr语法的代码并解释?如果代码片段有错误,能否给出准确的错误信息?还有更有趣的问题。我们的Expr语言使用中缀表达式。可以把输入码翻译成前缀表达式码吗?我们能格式化输入代码片段吗?我们能把Expr代码翻译成其他语言的源代码吗?
首先,看最后一个演示。我们可以在演示页面的代码框中输入Expr语言的代码,点击执行按钮,查看执行后的结果。
这些功能是在浏览器中使用ANTLR v4完成语法分析,最终实现语法验证、解释和执行等。
ANTLR的介绍
Antlr4是另一个语言识别的工具,也就是另一个语言识别工具。官方介绍是Antlr4是一个强大的解析器生成工具,可以用来读取、处理、执行和翻译结构化文本或二进制文件。
Antlr4生成的解析器包括词法分析程序和语法分析程序。没错,这就是编译原理课程中的词法分析和语法分析。写了几年,你忘了吗?我们只需要知道词法分析程序是把输入的代码字符序列转换成记号序列的程序,而语法分析程序是把记号序列转换成语法树的程序。好在语法定义是按照Antlr4规范做的,Antlr4可以为我们生成解析器源代码。它不仅可以生成Java源代码,还可以生成方便我们前端的JavaScript和TypeScript源代码。是的,在本文中,我们将使用Antlr4生成的TypeScript版本的解析器来解析我们的Expr语言代码。
ANTLR v4的安装使用
关于Antlr4的安装和使用,可以参考Github上的ANTLR v4入门,这里不做介绍。简单来说,使用ANTLR v4一般分为三步:
根据ANTLR v4编译要解析的语言的语法定义文件。主流语言ANTLR v4的语法定义可以在仓库antlr/grammars-v4中找到,一般运行ANTLR工具,语法定义文件的后缀是g4。生成指定目标语言的解析器源代码,使用生成的解析器完成代码的解析等。
.根据上面的介绍,为了解释和执行我们的Expr语言,第一步是定义语法定义文件Expr。根据ANTLR v4规范的EXPR语言G4。下面简单介绍一下ANTLR v4的语法定义思想。更详细的介绍请参考ANTLR作者书《The Definitive ANTLR 4 Reference》。
语法规则
ANTLR v4的语法定义文件在syntax中声明语句和一系列语法规则,其一般结构如下:
语法名称;#声明语法命名名称#定义语法规则1规则2.ruleN在同一时间.Rulen,其中每个语法规则的结构如下:
rule name : alternative 1 | alternative 2 | alternative 3;
这个语法规则陈述了一个名为ruleName的规则,其中| table的名称是Branch,即规则可以匹配三个分支中的任意一个。
最后,ANTLR v4的语法规则可以分为Lexer规则和Parser规则:词法规则定义了如何将代码串序列转换为标签序列;语法规则定义了如何将标签序列转换成语法树。通常,词法规则的规则以大写字母命名,而语法规则的规则以小写字母开头。
Expr语法
云娥expr?expr?expr(英文),是高丽吗表达式g4唉哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟哟:
表达式语法:3360统计方案;stat : exprstat | assignstatexprstat : expr semi assign stat : eq expr semi;expr 3330 expr op=(mul | div)expr # muldivexpr | expr op=(add | sub)expr # addsubexpr | int # intexpr | id # idexpr | LPAR expr那仁# parenexpr穆!穆
L : '*' ;DIV : '/' ;ADD : '+' ;SUB : '-' ;LPAREN : '(' ;RPAREN : ')' ;ID : LETTER (LETTER | DIGIT)* ;INT : <0-9>+ ;EQ : '=' ;SEMI : ';' ;COMMENT : '//' ~<\r\n>* '\r'? '\n'? -> channel(HIDDEN);WS : < \r\n\t>+ -> channel(HIDDEN);fragmentLETTER :有了语法定义Expr.ge,就可以生成我们需要的解析器源码了,这里采用antlr4ts,在package.json中添加script:
"scripts": { "antlr4ts": "antlr4ts -visitor Expr.g4 -o src/parser"},"dependencies": { "antlr4ts": "^0.4.1-alpha.0"}"devDependencies": { "antlr4ts-cli": "^0.4.0-alpha.4", "typescript": "^2.5.3",},执行 npm run antlr4ts,就可以在src/parser目录看到生成的Expr解析器的TypeScript源代码了。
代码和语法树
具体如何利用ANTLR来解释执行输入的Expr代码呢,我们先看下对以下输入代码,ANTLR生成的Token 序列和语法树是怎样的?
a = 1;b = a + 1;b;词法解析得到的Token序列如下图所示,共解析为22个Token,每个Token包含了Token的序号,Token的文本,Token的类型;如序号为0的Token,文本为'a',类型为'ID',即匹配了我们上面在Expr.g4的词法规则ID。
语法树结构如下图所示,树中的节点都对应了在Expr.g4中定义的语法规则或词法规则,有一点需要注意的是,语法树中所有的叶子节点都对应到词法规则或者字符常量,这也是我们在设计Expr.g4中自顶向下的设计方法一样的。
可以看到,跟节点为prog规则节点,它的子节点为三个语句stat节点,其中前两个子节点为赋值语句assignStat节点,最后一个的子节点为表达式语句节点statExpr。根据在第一部分的定义,针对这段代码,我们需要识别出代码中的表达式语句并打印该表达式的值。具体到这个例子中,这段输入代码中只用一个表达式语句,其中的表达式为变量b,为了打印b的值,我们需要通过解释前两条语句,计算出b的值(这里给出舍得,变量的引用必须在变量的定义之后)。所以,整体的思路即我们需要按顺序解释每条语句,并记住语句解释过程中出现的变量和其值,在后续语句的解释过程中,如果遇到变量的引用,需要查找该变量的值。
使用Visitor来访问语法树
为了实现上述的解释过程,我们需要区遍历访问解析器解析出来的语法树,ANTLR提供了两种机制来访问生成的语法树:Listener和Visitor,使用Listener模式来访问语法树时,ANTLR内部的ParserTreeWalker在遍历语法树的节点过程中,在遇到不同的节点中,会调用提供的listener的不同方法;而使用Visitor模式时,visitor需要自己来指定如果访问特定类型的节点,ANTLR生成的解析器源码中包含了默认的Visitor基类/接口ExprVisitor.ts,在使用过程中,只需要对感兴趣的节点实现visit方法即可,比如我们需要访问到exprStat节点,只需要实现如下接口:
export interface ExprVisitor<Result> extends ParseTreeVisitor<Result> { ... /** * Visit a parse tree produced by `ExprParser.exprStat`. * @param ctx the parse tree * @return the visitor result */ visitExprStat?: (ctx: ExprStatContext) => Result; ...}介绍完了如果使用Visitor来访问语法树中的节点后,我们来实现Expr解释器需要的Visitor:ExprEvalVisitor。
上面提到在访问语法树过程中,我们需要记录遇到的变量和其值、和最后的打印结果,我们使用Visitor内部变量来保存这些中间值:
class ExprEvalVisitor extends AbstractParseTreeVisitor<number> implements ExprVisitor<number> { // 保存执行输出结果 private buffers: string<> = <>; // 保存变量 private memory: {
class ExprEvalVisitor extends AbstractParseTreeVisitor<number> implements ExprVisitor<number> { // 保存执行输出结果 private buffers: string<> = <>; // 保存变量 private memory: {
export interface ExprVisitor<Result> extends ParseTreeVisitor<Result> { ... /** * Visit a parse tree produced by the `MulDivExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */visitMulDivExpr?: (ctx: MulDivExprContext) => Result;/** * Visit a parse tree produced by the `IdExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */visitIdExpr?: (ctx: IdExprContext) => Result;/** * Visit a parse tree produced by the `IntExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */visitIntExpr?: (ctx: IntExprContext) => Result;/** * Visit a parse tree produced by the `ParenExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */visitParenExpr?: (ctx: ParenExprContext) => Result;/** * Visit a parse tree produced by the `AddSubExpr` * labeled alternative in `ExprParser.expr`. * @param ctx the parse tree * @return the visitor result */visitAddSubExpr?: (ctx: AddSubExprContext) => Result;...}所以,在我们的ExprEvalVisitor中,我们通过实现不同的接口来访问不同的表达式分支,对于AddSubExpr分支,实现的访问方法如下:
visitAddSubExpr(ctx: AddSubExprContext) { const left = this.visit(ctx.expr(0)); const right = this.visit(ctx.expr(1)); const op = ctx._op; if (op.type === ExprParser.ADD) { return left + right; } return left - right;}对于MulDivExpr,访问方法相同。对于IntExpr分支,由于其子节点只有INT节点,我们只需要解析出其中的整数即可:
visitIntExpr(ctx: IntExprContext) { return parseInt(ctx.INT().text, 10);}对于IdExpr分支,其子节点只有变量ID,这个时候就需要在我们的保存的变量中去查找这个变量,并取出它的值:
visitIdExpr(ctx: IdExprContext) { const id = ctx.ID().text; if (this.memory
visitParenExpr(ctx: ParenExprContext) { return this.visit(ctx.expr());}到这里,你可以发现了,我们上述的访问方法加起来,我们只有从memory读取变量的过程,没有想memory写入变量的过程,这就需要我们访问赋值表达式assignExpr节点了:对于赋值表达式,需要识别出等号左边的变量名,和等号右边的表达式,最后将变量名和右边表达式的值保存到memory中:
visitAssignStat(ctx: AssignStatContext) { const id = ctx.ID().text; const val = this.visit(ctx.expr()); this.memory
至此,我们的VisitorExprEvalVisitor已经准备好了,我们只需要在对指定的输入代码,使用visitor来访问解析出来的语法树,就可以实现Expr代码的解释执行了:
// Expr代码解释执行函数// 输入code// 返回执行结果function execute(code: string): string { const input = new ANTLRInputStream(code); const lexer = new ExprLexer(input); const tokens = new CommonTokenStream(lexer); const parser = new ExprParser(tokens); const visitor = new ExprEvalVisitor(); const prog = parser.prog(); visitor.visit(prog); return visitor.print();}
举例,对如下Expr代码:
a = 2;b = 3;c = a * (b + 2);c;我们转换之后的结果如下,我们支队表达式做转换,而对赋值表达式则不做抓换,即代码中出现的表达式都会转换成:
a = 2;b = 3;c = * a + b 2;c;前缀翻译Visitor
同样,这里我们使用Visitor模式来访问语法树,这次,我们直接visit根节点prog,并返回翻译后的代码:
class ExprTranVisitor extends AbstractParseTreeVisitor<string> implements ExprVisitor<string> { defaultResult() { return ''; } visitProg(ctx: ProgContext) { let val = ''; for (let i = 0; i < ctx.childCount; i++) { val += this.visit(ctx.stat(i)); } return val; } ...}这里假设我们的visitor在visitor语句stat的时候,已经返回了翻译的代码,所以visitProg只用简单的拼接每条语句翻译后的代码即可。对于语句,前面提到了,语句我们不做翻译,所以它们的visit访问也很简单:对于表达式语句,直接打印翻译后的表达式,并加上分号;对于赋值语句,则只需将等号右边的表达式翻译即可:
visitExprStat(ctx: ExprStatContext) { const val = this.visit(ctx.expr()); return `${val};\n`;}visitAssignStat(ctx: AssignStatContext) { const id = ctx.ID().text; const val = this.visit(ctx.expr()); return `${id} = ${val};\n`;}下面看具体如何翻译各种表达式。对于AddSubExpr和MulDivExpr的翻译,是整个翻译器的逻辑,即将操作符前置:
visitAddSubExpr(ctx: AddSubExprContext) { const left = this.visit(ctx.expr(0)); const right = this.visit(ctx.expr(1)); const op = ctx._op; if (op.type === ExprParser.ADD) { return `+ ${left} ${right}`; } return `- ${left} ${right}`;}visitMulDivExpr(ctx: MulDivExprContext) { const left = this.visit(ctx.expr(0)); const right = this.visit(ctx.expr(1)); const op = ctx._op; if (op.type === ExprParser.MUL) { return `* ${left} ${right}`; } return `/ ${left} ${right}`;}由于括号在前缀表达式中是不必须的,所以的ParenExpr的访问,只需要去处括号即可:
visitParenExpr(ctx: ParenExprContext) { const val = this.visit(ctx.expr()); return val;}对于其他的节点,不需要更多的处理,只需要返回节点对应的标记的文本即可:
visitIdExpr(ctx: IdExprContext) { const parent = ctx.parent; const id = ctx.ID().text; return id;}visitIntExpr(ctx: IntExprContext) { const parent = ctx.parent; const val = ctx.INT().text; return val;}执行代码的前缀翻译
至此,我们代码前缀翻译的Visitor就准备好了,同样,执行过程也很简单,对输入的代码,解析生成得到语法树,使用ExprTranVisitor反问prog根节点,即可返回翻译后的代码:
function execute(code: string): string { const input = new ANTLRInputStream(code); const lexer = new ExprLexer(input); const tokens = new CommonTokenStream(lexer); const parser = new ExprParser(tokens); const visitor = new ExprTranVisitor(); const prog = parser.prog(); const result = visitor.visit(prog); return result;}对输入代码:
A * B + C / D ;A * (B + C) / D ;A * (B + C / D);(5 - 6) * 7 ;执行输出为:
+ * A B / C D;/ * A + B C D;* A + B / C D;* - 5 6 7;
关注 {我},享受文章首发体验!
每周重点攻克一个前端技术难点。更多精彩前端内容私信 我 回复“教程”
原文链接:https://github.com/ProtoTeam/blog/blob/master/201712/2.md
作者:蚂蚁金服数据体验技术团队