博客 / 詳情

返回

Hulo 編程語言開發 —— 解釋器

書接上回,在《Hulo 編程語言開發 —— 包管理與模塊解析》一文中,我們介紹了Hulo編程語言的模塊系統。今天,讓我們深入探討編譯流程中的第三個關鍵環節——解釋器。

作為大雜燴語言的集大成者,Hulo吸收了Zig語言的comptime語法糖。在comptime { ... }表達式的包裹下,代碼會在編譯的時候執行,就像傳統的解釋型語言一樣。這也為Hulo的元編程提供了強大的支撐,使得Hulo可以實現類似Rust過程宏、編譯期反射、直接操作AST等強大功能。

編譯時執行

假設我們現在有如下代碼:

let a = comptime {
    let sum = 0
    loop $i := 0; $i < 10; $i++ {
        echo $i;
        $sum += $i;
    }
    return $sum
}

在翻譯成目標語法的時候會以 let a = 45 進行翻譯,中間的一大串代碼都會被提前執行。這個執行的過程其實就是解釋。

對象化 & 求值

求值就是解釋器執行代碼的過程。在Hulo中,解釋器需要能夠執行各種類型的表達式和語句。

對象化

在Hulo中,所有的值都被"對象化"處理。這意味着無論是數字、字符串還是函數,都被包裝成統一的對象接口。

下面是Hulo代碼中關於對象系統的設計:

// 定義類型的基本行為
type Type interface {
    Name() string // 獲取類型名稱

    Text() string // 獲取類型的文本表示

    Kind() ObjKind // 獲取類型種類(如基本類型、對象類型等)

    Implements(u Type) bool // 檢查是否實現了某個接口

    AssignableTo(u Type) bool // 檢查是否可以賦值給某個類型

    ConvertibleTo(u Type) bool // 檢查是否可以轉換為某個類型
}

// 繼承Type接口,定義對象的行為
type Object interface {
    Type
    NumMethod() int // 獲取方法數量
    Method(i int) Method // 根據索引獲取方法
    MethodByName(name string) Method // 根據名稱獲取方法
    NumField() int // 獲取字段數量
    Field(i int) Type // 根據索引獲取字段
    FieldByName(name string) Type // 根據名稱獲取字段
}

// 定義值的基本行為
type Value interface {
    Type() Type        // 獲取值的類型
    Text() string      // 獲取值的文本表示
    Interface() any    // 獲取底層的Go值
}

通過這段代碼不難看出,這有點類似於Golang的反射系統。實際上,對象系統的實現上的確參考了反射機制,所有的單元測試接口甚至也和反射的測試如出一轍。可以説,Hulo的解釋器在抽象AST的過程中就是將值與類型轉換成反射操作,通過統一的接口來操作不同類型的值。

求值過程

在對象化的基礎上,解釋器通過遍歷AST節點來執行代碼,根據節點類型執行相應的操作。

假設這個我們有1 + 2 * 3這樣一個表達式,它的AST結構和求值步驟如下:

BinaryExpr {
    X: Literal(1),
    Op: PLUS,
    Y: BinaryExpr {
        X: Literal(2),
        Op: MULTIPLY,
        Y: Literal(3)
    }
}
  1. 訪問根節點 BinaryExpr(PLUS)
  2. 先求值左子樹 Literal(1) → 1
  3. 先求值右子樹 BinaryExpr(MULTIPLY):

    • 求值左子樹 Literal(2) → 2
    • 求值右子樹 Literal(3) → 3
    • 執行乘法 2 * 3 → 6
  4. 執行加法 1 + 6 → 7

而這個求值的過程,我們可以用偽代碼表示為:

func (interp *Interpreter) Eval(node ast.Node) Object {
    switch node := node.(type) {
        case *ast.Literal:
            return interp.evalLiteral(node)
        case *ast.BinaryExpr:
            return interp.evalBinaryExpr(node)
        // ...
    }
}

func (interp *Interpreter) evalLiteral(node *ast.Literal) Object {
    // 簡化複雜度,我們假設字面量類型都是 number 類型
    return &object.NumberValue{Value: node.Value}
}

func (interp *Interpreter) evalBinaryExpr(node *ast.BinaryExpr) Object {
    lhs := interp.Eval(node.Lhs) // 計算左值
    rhs := interp.Eval(node.Rhs) // 計算右值

    // 由 evalLiteral 可知 lhs、rhs 都是 *object.NumberValue,並假設 NumberValue 的類型為 NumberType
    switch node.Op {
        case token.PLUS: // 根據值進行加法
            // 假設 NumberType 有 add 方法可以直接運算
            return lhs.Type().(*object.NumberType).MethodByName("add").call(rhs)
        case token.MULTIPLY:
            // 根據值進行乘法
    }
}

節點會逐層遞歸求值,每一層的求值結果作為上一層節點的子樹繼續求值。最終返回的不是原始的stringintany等類型,而是包裝成Object接口的對象,體現了"一切皆對象"的設計理念。

環境管理

解釋器維護一個環境(Environment)來存儲變量,但為什麼要環境管理?這涉及到作用域和變量查找的問題。

為什麼需要環境管理?

var globalVar = 100  // 全局變量

fn test() {
    let localVar = 200  // 局部變量
    echo $globalVar     // 可以訪問全局變量
    echo $localVar      // 可以訪問局部變量
}

fn another() {
    echo $globalVar     // 可以訪問全局變量
    echo $localVar      // ❌ 錯誤!無法訪問test函數的局部變量
}

作用域鏈

Hulo採用詞法作用域,變量查找遵循"就近原則":

let x = 1  // 全局作用域

fn outer() {
    let x = 2  // 局部作用域,遮蔽了全局的x

    fn inner() {
        let x = 3  // 更內層的作用域
        echo $x    // 輸出3,找到最近的x
    }

    echo $x  // 輸出2,找到outer函數中的x
}

echo $x  // 輸出1,找到全局的x

環境鏈實現

環境通過鏈表結構實現作用域鏈:

type Environment struct {
    store map[string]Value  // 當前作用域的變量
    outer *Environment      // 外層環境(父作用域)
}

func (e *Environment) Get(name string) (Value, bool) {
    // 先從當前環境查找
    obj, ok := e.store[name]
    if ok {
        return obj, true
    }

    // 如果沒找到,繼續在外層環境查找
    if e.outer != nil {
        return e.outer.Get(name)
    }

    // 所有環境都沒找到
    return nil, false
}

// Fork創建新的環境,類似於函數調用的棧幀
func (e *Environment) Fork() *Environment {
    env := NewEnvironment()  // 創建新的環境
    env.outer = e           // 將當前環境作為外層環境
    return env              // 返回新環境
}

Ps. 這個代碼只是用於展示的最小實現,實際Hulo的實現將更為複雜。

環境創建過程

棧幀(Stack Frame) 是函數調用時在調用棧上分配的一塊內存,用於存儲函數的局部變量、參數和返回地址。

在Hulo中,每次函數調用都會通過 Fork() 創建一個新的環境,這個新環境就是一個棧幀:

fn outer() {
    let x = 10
    fn inner() {
        let y = 20
        echo $x + $y  // 30
    }
    inner()
}

執行過程:

  1. 全局環境 {}
  2. 調用outer()Fork() → 創建棧幀1 {x: 10, outer: 全局環境}
  3. 調用inner()Fork() → 創建棧幀2 {y: 20, outer: 棧幀1}
  4. 執行echo → 在棧幀2中查找變量

    • 查找y:棧幀2中找到 20
    • 查找x:棧幀2沒有 → 棧幀1中找到 10
  5. inner()返回 → 銷燬棧幀2,回到棧幀1
  6. outer()返回 → 銷燬棧幀1,回到全局環境
user avatar infodator 頭像 rwxe 頭像 anygraphanywhere 頭像 nianqingyouweideyanjing 頭像 xushuhui 頭像 tongbo 頭像 yidianyihengchang 頭像
7 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.