書接上回,在《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)
}
}
- 訪問根節點 BinaryExpr(PLUS)
- 先求值左子樹 Literal(1) → 1
-
先求值右子樹 BinaryExpr(MULTIPLY):
- 求值左子樹 Literal(2) → 2
- 求值右子樹 Literal(3) → 3
- 執行乘法 2 * 3 → 6
- 執行加法 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:
// 根據值進行乘法
}
}
節點會逐層遞歸求值,每一層的求值結果作為上一層節點的子樹繼續求值。最終返回的不是原始的string、int、any等類型,而是包裝成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()
}
執行過程:
- 全局環境
{} - 調用outer() →
Fork()→ 創建棧幀1{x: 10, outer: 全局環境} - 調用inner() →
Fork()→ 創建棧幀2{y: 20, outer: 棧幀1} -
執行echo → 在棧幀2中查找變量
- 查找y:棧幀2中找到 20
- 查找x:棧幀2沒有 → 棧幀1中找到 10
- inner()返回 → 銷燬棧幀2,回到棧幀1
- outer()返回 → 銷燬棧幀1,回到全局環境