书接上回,在《Hulo 编程语言开发 ------ 包管理与模块解析》一文中,我们介绍了Hulo编程语言的模块系统。今天,让我们深入探讨编译流程中的第三个关键环节------解释器。
作为大杂烩语言的集大成者,Hulo吸收了Zig语言的comptime
语法糖。在comptime { ... }
表达式的包裹下,代码会在编译的时候执行,就像传统的解释型语言一样。这也为Hulo的元编程提供了强大的支撑,使得Hulo可以实现类似Rust过程宏、编译期反射、直接操作AST等强大功能。
编译时执行
假设我们现在有如下代码:
bash
let a = comptime {
let sum = 0
loop $i := 0; $i < 10; $i++ {
echo $i;
$sum += $i;
}
return $sum
}
在翻译成目标语法的时候会以 let a = 45
进行翻译,中间的一大串代码都会被提前执行。这个执行的过程其实就是解释。
对象化 & 求值
求值就是解释器执行代码的过程。在Hulo中,解释器需要能够执行各种类型的表达式和语句。
对象化
在Hulo中,所有的值都被"对象化"处理。这意味着无论是数字、字符串还是函数,都被包装成统一的对象接口。
下面是Hulo代码中关于对象系统的设计:
go
// 定义类型的基本行为
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结构和求值步骤如下:
hulo
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
而这个求值的过程,我们可以用伪代码表示为:
go
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)来存储变量,但为什么要环境管理?这涉及到作用域和变量查找的问题。
为什么需要环境管理?
hulo
var globalVar = 100 // 全局变量
fn test() {
let localVar = 200 // 局部变量
echo $globalVar // 可以访问全局变量
echo $localVar // 可以访问局部变量
}
fn another() {
echo $globalVar // 可以访问全局变量
echo $localVar // ❌ 错误!无法访问test函数的局部变量
}
作用域链
Hulo采用词法作用域,变量查找遵循"就近原则":
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
环境链实现
环境通过链表结构实现作用域链:
go
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()
创建一个新的环境,这个新环境就是一个栈帧:
hulo
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,回到全局环境