前言
不知道你第一次接触编程时,是否有这样一个既大胆又浪漫的想法:"总有一天,我要用自己的编程语言做XXX"。在接触编程之初,我也有这么一个梦想。当然要实现一个编程语言并不容易,我们要实现一个自己的编程语言,首先就需要一个属于自己的语言编译器,而其中的文法分析又是最基础且重要的模块,有了文法分析模块,我们才能将一个输入的代码字符串构建成抽象语法树,并实现语言的具体功能。我们今天要聊的便是这个文法分析相关的内容
用递归实现一个表达式解析程序
当初我们在学习栈
这个数据结构时,有学习过使用栈
求解一个表达式值的方法。(关于栈的相关基础知识,感兴趣的同学可以移步到另一篇文章看看递归与栈(解决表达式求值问题)
如:2*3+6/2
这个表达式。我们在拿到这个表达式时,首先在我们思维逻辑结构中,会将这个表达式拆分为以下几部分:
- 加法 :
x+y
- 乘法 :
x*y
- 除法 :
x/y
然后将这几部分构建成表达式树(语法树):
bash
+
/ \
× ÷
/ \ / \
2 3 6 2
这样,我们就把一个表达式求值的问题转换为对树的遍历问题。
我们先来使用代码实现一下刚刚在思维逻辑层面的这个建树的过程看看
typescript
function calc(expStr: string, l: number = 0, r: number = expStr.length - 1) {
// 我们要构建一颗表达式树,那么首先要确定根节点到底是哪个操作符
// 根据树遍历的特性,根节点是在回溯过程中最后才被执行的,因此,根节点的操作符的优先权重应该是最低的
// 所以,我们需要先找到当前表达式中优先级最低的操作符
// 权重最低的运算符所在的位置
let pos = -1;
// 权重最低的运算符权重
// 最小权重初始值之所以是极大值 - 1,为了避免表达式中不存在操作符时与curPri都等于极大值导致pos误更新的情况
let pri = Number.MAX_SAFE_INTEGER - 1;
// 临时权重值
let tmpPri = 0;
// 当前权重值
let curPri;
// 遍历表达始终的每一个字符从中获取操作符并起算相应权重
for (let i = l; i <= r; i++) {
// 每一个操作符默认设为极大值
curPri = Number.MAX_SAFE_INTEGER;
switch (expStr[i]) {
// 在所有操作符中,括号的优先级是最高的,因此,当我们遇到左括号时,给临时权重值加上100
case "(": tmpPri += 100; break;
// 如要右括号时,说明括号已经结束了,此时需要将临时权重值减去100
case ")": tmpPri -= 100; break;
// 加法和减法的权重是一样的,我们直接在临时权重值基础上加1即可
case "+": curPri = tmpPri + 1; break;
case "-": curPri = tmpPri + 1; break;
// 乘法和触发的权重也是一样,比加减法高,因此在临时权重值的基础上加上2
case "*": curPri = tmpPri + 2; break;
case "/": curPri = tmpPri + 2; break;
}
// 让当前的操作符优先级跟上一个记录的操作符优先级比较,如果当前优先级不大于上一个优先级,则用当前优先级覆盖上一个优先级
if (curPri <= pri) {
pos = i;
pri = curPri;
}
}
// 如果经过上述运算符优先级的处理,我们的最低优先级的索引仍为-1,说明我们整个表达式都不存在操作符
// 有可能是一个纯数字类型的表达式,我们直接将字符串转换成数字即可
if (pos === -1) {
let num = 0;
for (let i = l; i <= r; i++) {
let char = expStr[i];
if (char.charCodeAt(0) < '0'.charCodeAt(0) || char.charCodeAt(0) > '9'.charCodeAt(0)) continue;
num = num * 10 + parseInt(char);
}
return num;
}
// 如果存在操作符,我们需要分别递归找出左子树和右子树的结果
const left = calc(expStr, l, pos - 1);
const right = calc(expStr, pos + 1, r);
// 将左右子树的结果求出来之后,再根据根节点的操作符进行四则运算即可
switch (expStr[pos]) {
case "+": return left + right;
case "-": return left - right;
case "*": return left * right;
case "/": return left / right;
}
}
let exp1 = "(3+4)*(6/(2+1))";
let exp2 = "3*4-6/2";
let exp3 = "4/2+3*3";
let exp4 = "2323232";
let exp5 = "-15*(2+(6/3))-8";
console.log(`${exp1} = ${calc(exp1)}`);
console.log(`${exp2} = ${calc(exp2)}`);
console.log(`${exp3} = ${calc(exp3)}`);
console.log(`${exp4} = ${calc(exp4)}`);
console.log(`${exp5} = ${calc(exp5)}`);
// 以下为输出结果
// (3+4)*(6/(2+1)) = 14
// 3*4-6/2 = 9
// 4/2+3*3 = 11
// 2323232 = 2323232
// -15*(2+(6/3))-8 = -68
通过上面的程序,大家脑子里面应该有这样一个概念:原来一个表达式其实等效于一棵表达式树(语法树)。
认识抽象语法树(AST)
表达式与程序的界限
在一个表达式中,强调了该表达式内部的运算顺序(加减乘除、括号等计算优先级),而一段程序,则是在更大的成面上,强调了表达式的执行顺序。如:程序的顺序操作(从上到下、从左到右执行)、分支操作(满足条件则进入if
,不满足时则进入else
分支)、循环操作(反复执行某些表达式)。
因此,其实程序可以看成更大层面的表达式,所以,表达式与程序的界限其实是模糊的。我们可以简单理解为,程序是在强调多个表达式之间的执行顺序。可以说:程序是更高级别的表达式。
既然表达式可以构建出表达式树 ,那么我们的程序当然可以构建出一颗能够说明其执行顺序的树,我们把这个树叫做:语法树或抽象语法树。
简单生成抽象语法树
既然程序也能生成抽象语法树,那么我们尝试将下面的程序生成一个语法树
typescript
a = 2 * 3
b = a + 2
console.log(b - 2)
我们现在思维逻辑层面来构建一下这颗语法树:
bash
程序开始标记start
/ | \
赋值表达式= 赋值表达式= 调用表达式console.log
/ \ / \ \
a * b + -
/ \ / \ / \
2 3 a 2 b 2
上面就是我们在思维逻辑结构中的一棵程序语法树,是不是觉得跟表达式树很像呢?这也说明了,其实表达式和程序之前的界限确实也是挺模糊的。
babel生成抽象语法树
在前端领域,我们可能使用更多的是babel
用来将源码生成抽象语法树。关于babel
如何将一个代码生成语法树,以前有些过相关的文章介绍过,这里就不再赘述,感兴趣的同学可以去看看基于AST的简易代码自动生成工具实现思路与原理剖析
Antlr
上面我们大概了解了一下抽象语法树长成什么样子,接下来我们来借助一个现成的工具Antlr
尝试将外界提供的表达式转换成前缀表达式或计算出表达是的值。
Antlr
是指可以根据输入自动生成语法树并可视化的显示出来的开源语法分析器。目前已经给我们提供了typescript
版本的工具包,可以让我们以自己最熟悉的语言完成将一个表达式转换成目标结果的工作。
首先,我们现在电脑上安装Antlr
工具
bash
# 我们新建一个示例项目,取名为 antlr
mkdir antlr
# 进入目录
cd antlr
# 我们先初始化一下 npm 的 package.json 文件
npm init -y
# 安装 antlr 的相关依赖
yarn add antlr4ts
yarn add antlr4ts-cli -D
# 至此,我们已经完成了前期的准备工作
接下来,我们根据上面的需求,需要准备一个xxx.g4
文件,用来描述我们要解析的源代码的结构
bash
# ./ExprDemo/Expr.g4
# 语法名称:Expr,注意,这里的名称必须跟文件名一样
grammar Expr;
# 定义怎样的是一段程序,是 program 的缩写,代表一个程序是有1个或多个程序语句组成
prog
: stat+ ;
# 代表程序语句,是 statement 的缩写,由单纯的表达式语句,如:1 + 2 * 3 ; 或赋值语句如:a = 1 + 2 * 3 ; 组成
stat
: exprStat
| assignStat
;
# 一个单纯的表达式语句包括基础表达式和后面的分号
exprStat
: expr SEMI
;
# 一个赋值语句由:变量名(ID)、赋值符号(EQ)、基础表达式(expr)、分号(SEMI)组成
assignStat
: ID EQ expr SEMI
;
# 一个基础表达式可以是乘法表达式、除法表达式、加法表达式、减法表达式、整数、变量名、包含括号的表达式
expr
: expr op = (MUL | DIV ) expr # MulDivExpr
| expr op = ( ADD | SUB ) expr # AddSubExpr
| INT # IntExpr
| ID # IdExpr
| LPAREN expr RPAREN # ParenExpr
;
# 定义各种运算符
MUL : '*' ;
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);
fragment
LETTER : [a-zA-Z] ;
fragment
DIGIT : [0-9] ;
有了上述的语法描述文件之后,我们就可以使用antlrts
工具生成解析代码。
bash
# 我们在当前项目根目录下执行以下命令
antlr4ts -visitor ./ExprDemo/Expr.g4 -o output
# 此时会在 output 目录下生成以下文件
./output # 输出目录
└── ExprDemo # 当前项目的输出文件
├── Expr.interp # 用于语法解析的辅助文件
├── Expr.tokens # 用于语法解析的辅助文件
├── ExprLexer.interp # 用于语法解析的辅助文件
├── ExprLexer.tokens # 用于语法解析的辅助文件
├── ExprLexer.ts # 自动生成的词法分析程序,依据我们上面的g4文件生成
├── ExprListener.ts # 自动生成的被动监听表达式节点的监听函数接口
├── ExprParser.ts # 自动生成的转换器代码,
└── ExprVisitor.ts # 自动生成的用于主动遍历表达式节点的接口描述
至此,我们已经根据我们的需求,生成了独属于我们自己的语法解析程序了,接下来,我们来尝试使用一下我们生成的语法解析器
由于我们的项目是ts
程序,因此,我们新建一个ts
文件
typescript
// ./src/expr.ts
import { AddSubExprContext, AssignStatContext, ExprParser, ExprStatContext, IdExprContext, IntExprContext, MulDivExprContext, ParenExprContext, ProgContext } from "../output/ExprDemo/ExprParser";
import { CharStreams, CommonTokenStream } from "antlr4ts"
import { AbstractParseTreeVisitor } from "antlr4ts/tree/AbstractParseTreeVisitor";
import { ExprVisitor } from "../output/ExprDemo/ExprVisitor";
import { ExprLexer } from "../output/ExprDemo/ExprLexer";
/**
* 生成前缀表达式访问器
*/
class ExprTranVisitorPre 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;
}
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`;
}
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}`;
}
visitParenExpr(ctx: ParenExprContext) {
const val = this.visit(ctx.expr());
return val;
}
visitIdExpr(ctx: IdExprContext) {
const id = ctx.ID().text;
return id;
}
visitIntExpr(ctx: IntExprContext) {
const val = ctx.INT().text;
return val;
}
}
/**
* 计算结果访问器
*/
class ExprTranVisitor extends AbstractParseTreeVisitor<number>
implements ExprVisitor<number> {
defaultResult() {
return 0;
}
visitProg(ctx: ProgContext) {
let val = 0;
for (let i = 0; i < ctx.childCount; i++) {
val += this.visit(ctx.stat(i));
}
return val;
}
visitExprStat(ctx: ExprStatContext) {
const val = this.visit(ctx.expr());
return val;
}
visitAssignStat(ctx: AssignStatContext) {
const id = ctx.ID().text;
const val = this.visit(ctx.expr());
return val;
}
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;
}
visitParenExpr(ctx: ParenExprContext) {
const val = this.visit(ctx.expr());
return val;
}
visitIdExpr(ctx: IdExprContext) {
const id = ctx.ID().text;
return 0;
}
visitIntExpr(ctx: IntExprContext) {
const val = ctx.INT().text;
return Number(val);
}
}
function exec(code: string) {
// 接收输入的代码,转换成字符流
const input = CharStreams.fromString(code);
// 使用词法分析器分析词法
const lexer = new ExprLexer(input);
// 根据词法分析器分析的结果生成相应的token
const tokens = new CommonTokenStream(lexer);
// 根据生成的tokens实例化转换器
const parser = new ExprParser(tokens);
// 实例化结果计算访问器
const visitor = new ExprTranVisitor();
// 实例化前缀表达式访问器
const visitor2 = new ExprTranVisitorPre();
// 从转换器中获取抽象语法树
const prog = parser.prog();
// 将语法树放入到不同的访问其中,我们就能得到不同的结果
const result = visitor.visit(prog);
const resultPre = visitor2.visit(prog);
console.log(`${code}\n=====================\n\n前缀表达式:${resultPre}\n计算结果:${result}`);
return result;
}
exec(`3 * ( 7 + 2 * (1+3)) / 2;`);
// 上述程序执行输出结果:
// 3 * ( 7 + 2 * (1+3)) / 2;
// =====================
// 前缀表达式:/ * 3 + 7 * 2 + 1 3 2;
// 计算结果:22.5
至此,我们就已经完成了一个属于我们自己的用于解析表达式的转换器了。
这其实就是实现一个编程语言的基础,无论是java
、c++
还是typescript
,我们不都是用我们约定俗称的编码规范写代码,然后通过语言解释器将其生成抽象语法树或交由具体的程序执行解析么?
以上只是antlr
的部分解析功能,更多详细的功能一起对其他变成语言的支持,可以详看antlr。
上面进行语法分析生成抽象语法树的功能是很通用的,除了如java
、c++
、typescript
等已有编程语言之外,你甚至可以使用antlr
工具生成自己的编程语言的语言解析器。