- 原文地址:What is an Abstract Syntax Tree
- 原文做者:Chidume Nnamdi
AST 是抽象语法树的缩写词,表示编程语言的语句和表达式中生成的 token。有了 AST,解释器或编译器就能够生成机器码或者对一条指令求值。git
小贴士: 经过使用 Bit,你能够将任意的 JS 代码转换为一个可在项目和应用中共享、使用和同步的 API,从而更快地构建并重用更多代码。试一下吧。github
假设咱们有下面这条简单的表达式:算法
1 + 2
复制代码
用 AST 来表示的话,它是这样的:编程
+ BinaryExpression
- type: +
- left_value:
LiteralExpr:
value: 1
- right_vaue:
LiteralExpr:
value: 2
复制代码
诸如 if
的语句则能够像下面这样表示:设计模式
if(2 > 6) {
var d = 90
console.log(d)
}
IfStatement
- condition
+ BinaryExpression
- type: >
- left_value: 2
- right_value: 6
- body
[
- Assign
- left: 'd';
- right:
LiteralExpr:
- value: 90
- MethodCall:
- instanceName: console
- methodName: log
- args: [
]
]
复制代码
这告诉解释器如何解释语句,同时告诉编译器如何生成语句对应的代码。数组
看看这条表达式: 1 + 2
。咱们的大脑断定这是一个将左值和右值相加的加法运算。如今,为了让计算机像咱们的大脑那样工做,咱们必须以相似于大脑看待它的形式来表示它。bash
咱们用一个类来表示,其中的属性告诉解释器运算的所有内容、左值和右值。由于一个二元运算涉及两个值,因此咱们给这个类命名为 Binary
:编程语言
class Binary {
constructor(left, operator, right) {
this.left = left
this.operator = operator
this.right = right
}
}
复制代码
实例化期间,咱们将会把 1
传给第一个属性,把 ADD
传给第二个属性,把 2
传给第三个属性:函数
new Binary('1', 'ADD', '2')
复制代码
当咱们把它传递给解释器的时候,解释器认为这是一个二元运算,接着检查操做符,认为这是一个加法运算,紧接着继续请求实例中的 left
值和 right
值,并将两者相加:ui
const binExpr = new Binary('1', 'ADD', '2')
if(binExpr.operator == 'ADD') {
return binExpr.left + binExpr.right
}
// 返回 `3`
复制代码
看,AST 能够像大脑那样执行表达式和语句。
单数字、字符串、布尔值等都是表达式,它们能够在 AST 中表示并求值。
23343
false
true
"nnamdi"
复制代码
拿 1 举例:
1
复制代码
咱们在 AST 的 Literal(字面量) 类中来表示它。一个字面量就是一个单词或者数字,Literal 类用一个属性来保存它:
class Literal {
constructor(value) {
this.value = value
}
}
复制代码
咱们能够像下面这样表示 Literal 中的 1:
new Literal(1)
复制代码
当解释器对它求值时,它会请求 Literal 实例中 value
属性的值:
const oneLit = new Literal(1)
oneLit.value
// `1`
复制代码
在咱们的二元表达式中,咱们直接传递了值
new Binary('1', 'ADD', '2')
复制代码
这其实并不合理。由于正如咱们在上面看到的,1
和 2
都是一条表达式,一条基本的表达式。做为字面量,它们一样须要被求值,而且用 Literal 类来表示。
const oneLit = new Literal('1')
const twoLit = new Literal('2')
复制代码
所以,二元表达式会将 oneLit
和 twoLit
分别做为左属性和右属性。
// ...
new Binary(oneLit, 'ADD', twoLit)
复制代码
在求值阶段,左属性和右属性一样须要进行求值,以得到各自的值:
const oneLit = new Literal('1')
const twoLit = new Literal('2')
const binExpr = new Binary(oneLit, 'ADD', twoLit)
if(binExpr.operator == 'ADD') {
return binExpr.left.value + binExpr.right.value
}
// 返回 `3`
复制代码
其它语句在 AST 中也大可能是用二元来表示的,例如 if 语句。
咱们知道,在 if 语句中,只有条件为真的时候代码块才会执行。
if(9 > 7) {
log('Yay!!')
}
复制代码
上面的 if 语句中,代码块执行的条件是 9
必须大于 7
,以后咱们能够在终端上看到输出 Yay!!
。
为了让解释器或者编译器这样执行,咱们将会在一个包含 condition
、 body
属性的类中来表示它。condition
保存着解析后必须为真的条件,body
则是一个数组,它包含着 if 代码块中的全部语句。解释器将会遍历该数组并执行里面的语句。
class IfStmt {
constructor(condition, body) {
this.condition = condition
this.body = body
}
}
复制代码
如今,让咱们在 IfStmt 类中表示下面的语句
if(9 > 7) {
log('Yay!!')
}
复制代码
条件是一个二元运算,这将表示为:
const cond = new Binary(new Literal(9), "GREATER", new Literal(7))
复制代码
就像以前同样,希望你还记得?这回是一个 GREATER 运算。
if 语句的代码块只有一条语句:一个函数调用。函数调用一样能够在一个类中表示,它包含的属性有:用于指代所调用函数的 name
以及用于表示传递的参数的 args
:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
}
复制代码
所以,log("Yay!!") 调用能够表示为:
const logFuncCall = new FuncCall('log', [])
复制代码
如今,把这些组合在一块儿,咱们的 if 语句就能够表示为:
const cond = new Binary(new Literal(9), "GREATER", new Literal(7));
const logFuncCall = new FuncCall('log', []);
const ifStmt = new IfStmt(cond, [
logFuncCall
])
复制代码
解释器能够像下面这样解释 if 语句:
const ifStmt = new IfStmt(cond, [
logFuncCall
])
function interpretIfStatement(ifStmt) {
if(evalExpr(ifStmt.conditon)) {
for(const stmt of ifStmt.body) {
evalStmt(stmt)
}
}
}
interpretIfStatement(ifStmt)
复制代码
输出:
Yay!!
复制代码
由于 9 > 7
:)
咱们经过检查 condition
解析后是否为真来解释 if 语句。若是为真,咱们遍历 body
数组并执行里面的语句。
使用访问者模式对 AST 进行求值。访问者模式是设计模式的一种,容许一组对象的算法在一个地方实现。
ASTs,Literal,Binary,IfStmnt 是一组相关的类,每个类都须要携带方法以使解释器得到它们的值或者对它们求值。
访问者模式让咱们可以建立单个类,并在类中编写 AST 的实现,将类提供给 AST。每一个 AST 都有一个公有的方法,解释器会经过实现类实例对其进行调用,以后 AST 类将在传入的实现类中调用相应的方法,从而计算其 AST。
class Literal {
constructor(value) {
this.value = value
}
visit(visitor) {
return visitor.visitLiteral(this)
}
}
class Binary {
constructor(left, operator, right) {
this.left = left
this.operator = operator
this.right = right
}
visit(visitor) {
return visitor.visitBinary(this)
}
}
复制代码
看,AST Literal 和 Binary 都有访问方法,可是在方法里面,它们调用访问者实例的方法来对自身求值。Literal 调用 visitLiteral,Binary 则调用 visitBinary
。
如今,将 Vistor 做为实现类,它将实现 visitLiteral 和 visitBinary 方法:
class Visitor {
visitBinary(binExpr) {
// ...
log('not yet implemented')
}
visitLiteral(litExpr) {
// ...
log('not yet implemented')
}
}
复制代码
visitBinary 和 visitLiteral 在 Vistor 类中将会有本身的实现。所以,当一个解释器想要解释一个二元表达式时,它将调用二元表达式的访问方法,并传递 Vistor 类的实例:
const binExpr = new Binary(...)
const visitor = new Visitor()
binExpr.visit(visitor)
复制代码
访问方法将调用访问者的 visitBinary,并将其传递给方法,以后打印 not yet implemented
。这称为双重分派。
Binary
的访问方法。Binary
) 反过来调用 Visitor
实例的visitBinary
。咱们把 visitLiteral 的完整代码写一下。因为 Literal 实例的 value 属性保存着值,因此这里只需返回这个值就好:
class Visitor {
visitBinary(binExpr) {
// ...
log('not yet implemented')
}
visitLiteral(litExpr) {
return litExpr.value
}
}
复制代码
对于 visitBinary,咱们知道 Binary 类有操做符、左属性和右属性。操做符表示将对左右属性进行的操做。咱们能够编写实现以下:
class Visitor {
visitBinary(binExpr) {
switch(binExpr.operator) {
case 'ADD':
// ...
}
}
visitLiteral(litExpr) {
return litExpr.value
}
}
复制代码
注意,左值和右值都是表达式,多是字面量表达式、二元表达式、调用表达式或者其它的表达式。咱们并不能确保二元运算的左右两边老是字面量。每个表达式必须有一个用于对表达式求值的访问方法,所以在上面的 visitBinary 方法中,咱们经过调用各自对应的 visit
方法对 Binary 的左属性和右属性进行求值:
class Visitor {
visitBinary(binExpr) {
switch(binExpr.operator) {
case 'ADD':
return binExpr.left.visit(this) + binExpr.right.visit(this)
}
}
visitLiteral(litExpr) {
return litExpr.value
}
}
复制代码
所以,不管左值和右值保存的是哪种表达式,最后均可以进行传递。
所以,若是咱们有下面这些语句:
const oneLit = new Literal('1')
const twoLit = new Literal('2')
const binExpr = new Binary(oneLit, 'ADD', twoLit)
const visitor = new Visitor()
binExpr.visit(visitor)
复制代码
在这种状况下,二元运算保存的是字面量。
访问者的 visitBinary
将会被调用,同时将 binExpr 传入,在 Vistor 类中,visitBinary
将 oneLit 做为左值,将 twoLit 做为右值。因为 oneLit 和 twoLit 都是 Literal 的实例,所以它们的访问方法会被调用,同时将 Visitor 类传入。对于 oneLit,其 Literal 类内部又会调用 Vistor 类的 visitLiteral 方法,并将 oneLit
传入,而 Vistor 中的 visitLiteral 方法返回 Literal 类的 value 属性,也就是 1
。同理,对于 twoLit 来讲,返回的是 2
。
由于执行了 switch 语句中的 case 'ADD'
,因此返回的值会相加,最后返回 3。
若是咱们将 binExpr.visit(visitor)
传给 console.log
,它将会打印 3
console.log(binExpr.visit(visitor))
// 3
复制代码
以下,咱们传递一个 3 分支的二元运算:
1 + 2 + 3
复制代码
首先,咱们选择 1 + 2
,那么其结果将做为左值,即 + 3
。
上述能够用 Binary 类表示为:
new Binary (new Literal(1), 'ADD', new Binary(new Literal(2), 'ADD', new Literal(3)))
复制代码
能够看到,右值不是字面量,而是一个二元表达式。因此在执行加法运算以前,它必须先对这个二元表达式求值,并将其结果做为最终求值时的右值。
const oneLit = new Literal(1)
const threeLit =new Literal(3)
const twoLit = new Literal(2)
const binExpr2 = new Binary(twoLit, 'ADD', threeLit)
const binExpr1 = new Binary (oneLit, 'ADD', binExpr2)
const visitor = new Visitor()
log(binExpr1.visit(visitor))
6
复制代码
if
语句将 if
语句带到等式中。为了对一个 if 语句求值,咱们将会给 IfStmt 类添加一个 visit
方法,以后它将调用 visitIfStmt 方法:
class IfStmt {
constructor(condition, body) {
this.condition = condition
this.body = body
}
visit(visitor) {
return visitor.visitIfStmt(this)
}
}
复制代码
见识到访问者模式的威力了吗?咱们向一些类中新增了一个类,对应地只须要添加相同的访问方法便可,而这将调用它位于 Vistor 类中的对应方法。这种方式将不会破坏或者影响到其它的相关类,访问者模式让咱们遵循了开闭原则。
所以,咱们在 Vistor 类中实现 visitIfStmt
:
class Visitor {
// ...
visitIfStmt(ifStmt) {
if(ifStmt.condition.visit(this)) {
for(const stmt of ifStmt.body) {
stmt.visit(this)
}
}
}
}
复制代码
由于条件是一个表达式,因此咱们调用它的访问方法对其进行求值。咱们使用 JS 中的 if 语句检查返回值,若是为真,则遍历语句的代码块 ifStmt.body
,经过调用 visit
方法并传入 Vistor,对数组中每一条语句进行求值。
所以咱们能够翻译出这条语句:
if(67 > 90)
复制代码
接着来添加一个函数调用。咱们已经有一个对应的类了:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
}
复制代码
添加一个访问方法:
class FuncCall {
constructor(name, args) {
this.name = name
this.args = args
}
visit(visitor) {
return visitor.visitFuncCall(this)
}
}
复制代码
给 Visitor
类添加 visitFuncCall
方法:
class Visitor {
// ...
visitFuncCall(funcCall) {
const funcName = funcCall.name
const args = []
for(const expr of funcCall.args)
args.push(expr.visit(this))
// ...
}
}
复制代码
这里有一个问题。除了内置函数以外,还有自定义函数,咱们须要为后者建立一个“容器”,并在里面经过函数名保存和引用该函数。
const FuncStore = (
class FuncStore {
constructor() {
this.map = new Map()
}
setFunc(name, body) {
this.map.set(name, body)
}
getFunc(name) {
return this.map.get(name)
}
}
return new FuncStore()
)()
复制代码
FuncStore
保存着函数,并从一个 Map
实例中取回这些函数。
class Visitor {
// ...
visitFuncCall(funcCall) {
const funcName = funcCall.name
const args = []
for(const expr of funcCall.args)
args.push(expr.visit(this))
if(funcName == "log")
console.log(...args)
if(FuncStore.getFunc(funcName))
FuncStore.getFunc(funcName).forEach(stmt => stmt.visit(this))
}
}
复制代码
看下咱们作了什么。若是函数名 funcName
(记住,FuncCall
类将函数名保存在 name
属性中)为 log
,则运行 JS console.log(...)
,并传参给它。若是咱们在函数保存中找到了函数,那么就对该函数体进行遍历,依次访问并执行。
如今看看怎么把咱们的函数声明放进函数保存中。
函数声明以 fucntion
开头。通常的函数结构是这样的:
function function_name(params) {
// function body
}
复制代码
所以,咱们能够在一个类中用属性表示一个函数声明:name 保存函数函数名,body 则是一个数组,保存函数体中的语句:
class FunctionDeclaration {
constructor(name, body) {
this.name = name
this.body = body
}
}
复制代码
咱们添加一个访问方法,该方法在 Vistor 中被称为 visitFunctionDeclaration:
class FunctionDeclaration {
constructor(name, body) {
this.name = name
this.body = body
}
visit(visitor) {
return visitor.visitFunctionDeclaration(this)
}
}
复制代码
在 Visitor 中:
class Visitor {
// ...
visitFunctionDeclaration(funcDecl) {
FuncStore.setFunc(funcDecl.name, funcDecl.body)
}
}
复制代码
将函数名做为键便可保存函数。
如今,假设咱们有下面这个函数:
function addNumbers(a, b) {
log(a + b)
}
function logNumbers() {
log(5)
log(6)
}
复制代码
它能够表示为:
const funcDecl = new FunctionDeclaration('logNumbers', [
new FuncCall('log', [new Literal(5)]),
new FuncCall('log', [new Literal(6)])
])
visitor.visitFunctionDeclaration(funcDecl)
复制代码
如今,咱们来调用函数 logNumbers
:
const funcCall = new FuncCall('logNumbers', [])
visitor.visitFuncCall(funcCall)
复制代码
控制台将会打印:
5
6
复制代码
理解 AST 的过程是使人望而生畏而且很是消耗脑力的。即便是编写最简单的解析器也须要大量的代码。
注意,咱们并无介绍扫描仪和解析器,而是先行解释了 ASTs 以展现它们的工做过程。若是你可以深刻理解 AST 以及它所须要的内容,那么在你开始编写本身的编程语言时,天然就事半功倍了。
熟能生巧,你能够继续添加其它的编程语言特性,例如:
for-of
语句while
语句for-in
语句若是你对此有任何疑问,或者是任何我须要添加、修改、删减的内容,欢迎评论和致邮。
感谢 !!!