资讯专栏INFORMATION COLUMN

「译」什么是抽象语法树

JouyPub / 3154人阅读

摘要:原文地址原文作者是抽象语法树的缩写词,表示编程语言的语句和表达式中生成的。解释器将会遍历该数组并执行里面的语句。,,,是一组相关的类,每一个类都需要携带方法以使解释器获得它们的值或者对它们求值。

原文地址:What is an Abstract Syntax Tree

原文作者:Chidume Nnamdi

AST 是抽象语法树的缩写词,表示编程语言的语句和表达式中生成的 token。有了 AST,解释器或编译器就可以生成机器码或者对一条指令求值。

小贴士: 通过使用 Bit,你可以将任意的 JS 代码转换为一个可在项目和应用中共享、使用和同步的 API,从而更快地构建并重用更多代码。试一下吧。

假设我们有下面这条简单的表达式:

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。我们的大脑判定这是一个将左值和右值相加的加法运算。现在,为了让计算机像我们的大脑那样工作,我们必须以类似于大脑看待它的形式来表示它。

我们用一个类来表示,其中的属性告诉解释器运算的全部内容、左值和右值。因为一个二元运算涉及两个值,所以我们给这个类命名为 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 值,并将二者相加:

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")

这其实并不合理。因为正如我们在上面看到的,12 都是一条表达式,一条基本的表达式。作为字面量,它们同样需要被求值,并且用 Literal 类来表示。

const oneLit = new Literal("1")  
const twoLit = new Literal("2")

因此,二元表达式会将 oneLittwoLit 分别作为左属性和右属性。

// ...  
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!!

为了让解释器或者编译器这样执行,我们将会在一个包含 conditionbody 属性的类中来表示它。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

使用访问者模式对 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 语句

其它任何你能想到的有趣特性

如果你对此有任何疑问,或者是任何我需要添加、修改、删减的内容,欢迎评论和致邮。

感谢 !!!

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/104726.html

相关文章

  • 】小二百行 JavaScript 打造 lambda 演算解释器

    摘要:在开始解析之前,先通过词法分析器运行源码,这会将源码打散成语法中全大写的部分。我们基于每个规则的名称的左侧为其创建一个方法,再来看右侧内容如果是全大写的单词,说明它是一个终止符即一个,词法分析器会用到它。 本文转载自:众成翻译译者:文蔺链接:http://www.zcfy.cc/article/661原文:http://tadeuzagallo.com/blog/writing-a-l...

    KitorinZero 评论0 收藏0
  • 浏览器工作过程详解()(二)

    摘要:每种可解析的格式必须具有由词汇及语法规则组成的特定的文法,这被称为上下文无关文法。解析器将会从词法分析器获取一个新符号,并且尝试用某一种语法规则去匹配它。第二个匹配到规则的是,它匹配到第三条语法规则。 衔接 接着上文继续。 在构建好render树后,浏览器就开始进行布局了。这意味着浏览器会给每个节点正确的坐标,让它们出现在该出现的地方。下一步就是进行绘制了,浏览器将会遍历render树...

    fasss 评论0 收藏0
  • JavaScript如何工作的:深入类和继承内部原理+Babel和 TypeScript 之间转换

    摘要:下面是用实现转成抽象语法树如下还支持继承以下是转换结果最终的结果还是代码,其中包含库中的一些函数。可以使用新的易于使用的类定义,但是它仍然会创建构造函数和分配原型。 这是专门探索 JavaScript 及其所构建的组件的系列文章的第 15 篇。 想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你! 如果你错过了前面的章节,可以在这里找到它们: JavaScript 是...

    PrototypeZ 评论0 收藏0
  • [] 探索 Angular 使用 ViewContainerRef 操作 DOM

    摘要:在探索抽象类前,先了解下如何在组件指令中获取这些抽象类。下面示例描述在组建模板中如何创建如同其他抽象类一样,通过属性绑定元素,比如上例中,绑定的是会被渲染为注释的元素,所以输出也将是。你可以使用查询模板引用变量来获得抽象类。 原文链接:Exploring Angular DOM manipulation techniques using ViewContainerRef如果想深入学习 ...

    wind5o 评论0 收藏0
  • 」JavaScript 究竟如何工作的?(第一部分)

    摘要:文章的第二部分涵盖了内存管理的概念,不久后将发布。的标准化工作是由国际组织负责的,相关规范被称为或者。随着分析器和编译器不断地更改字节码,的执行性能逐渐提高。 原文地址:How Does JavaScript Really Work? (Part 1) 原文作者:Priyesh Patel 译者:Chor showImg(https://segmentfault.com/img...

    Youngdze 评论0 收藏0

发表评论

0条评论

JouyPub

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<