书接上回,在《 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)
}
}
而这个求值的过程,我们可以用伪代码表示为:
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()
}
执行过程:
{}
Fork()
→ 创建栈帧 1 {x: 10, outer: 全局环境}
Fork()
→ 创建栈帧 2 {y: 20, outer: 栈帧 1}
1
spritecn 14 小时 55 分钟前
点赞,每次写 sh/bat 都头疼,语法太另类了,但现在有 ai 了,好了一点
|
2
spritecn 14 小时 53 分钟前
我有一个意见 ,语法能不能不要大杂烩,要么延用 go,要么延用 python 或 js,学习成本低,并且编辑器提示友好
|
![]() |
3
llsquaer 14 小时 27 分钟前
Hulo /ˈhjuːloʊ/ 是一个现代化的、面向批处理的编程语言,可以编译为 Bash 、PowerShell 和 VBScript 。它旨在通过简洁一致的 DSL 来统一跨平台的脚本编写。
感觉定位脚本语言就好了嘛。为啥非得装饰上编程语言。 |
4
w568w 13 小时 56 分钟前
为啥楼主每次发帖都有人问不经大脑的问题,冷嘲热讽之前不先思考一下吗?
利益相关:我是 amber-lang 的 contributor ,和楼主这个项目的定位类似。 ---- 下面是一些常见问题: Q:为什么不直接写 Python/JS/VBScript/JScript/Lua/bat/...? A:你说的这些都不跨平台(尤其是臭名昭著的 Windows ),脚本编写者需要学习多门语言、维护多套脚本,增加维护成本。 Q:为什么不直接写一个新的脚本语言?非要翻译干什么。 A:不用在用户设备上安装解释器,便携性强。 Q:哎呀,没听说过脚本都需要跨平台,需要时再写不就行了? A:相同项目确实少见,那不同项目的脚本呢?比如说,今天可能要给 Windows 客户机写批处理,明天要给 Linux 服务器写脚本,后天又给自己的 mac 写小脚本,用中间语言就不需要同时学三门语言了。 Q:脚本语言有什么难学的?我 [此处填时长] 就学会了。 A:脚本语言的问题是(通常为了向后兼容)有非常多的 quirks 。比如 Bash ,很多人喜欢用的 set -o pipefail 和 -e 其实都是有问题的,在计算数值非 0 时也可能当作错误退出;另外,它的 local scope 是 dynamic scope 而不是 lexical scope ,如果不为每个函数重命名变量,会导致意外的变量覆盖;此外还有 string interpolation 和转义的各种复杂规则。Windows bat 也有诸如参数传递和变量展开延迟这些问题。你不碰到自然没事,碰到就会很难受。使用中间语言有助于以统一的方式解决怪癖,提高 QoL 。 Q:为什么要重新发明新语言?直接给现有语言写编译器不行吗? A:脚本语言的逻辑差异很大,比如命令( command )语法,现有语言不支持。另外脚本语言大多是弱类型或动态类型,支持的语法特性很少,很难写一个 1:1 的编译器。 Q:现在 AI 都能写了,你这根本没用。 A:AI 写稍微复杂一点的脚本逻辑,依然会漏洞百出。另外后续可维护性会极差(亲身经历)。 Q1:你用的 AI 不行 / 你自己提示水平不行,我的 AI 从没出问题。 Q2:AI 很快就全面解放人类劳动了,写这些东西根本没有意义。 Q3:古法手工编程的传统码农又来啦 / 写代码写出优越感了 A:¯\_(ツ)_/¯ |
![]() |
5
ansurfen OP @spritecn 每个语法的设计都有考量的,整体上语法是师从 typescript + rust 的。1. 在接口的定义和实现上抄了 rust 的 impl, 分离具体的实现类和接口之间的耦合,这样可以在任何位置实现,比如说 impl findstr for grep, impl grep for findstr ,不同平台命令可以相互实现。2. comptime 这个语法糖他是一个表达式,可以塞在任何地方,所以他和传统的宏还不太一样,他可以耍无赖的放在函数的参数里面,比如说 echo ( true, comptime { ... }) 至于这要做有什么用,就是后面 hulo 会开发的,hulo 的 comptime 可以操作 ast ,但是不同于传统的编程语言 只能操作包裹的子类节点,hulo 的 comptime 能够直接操作父类,也就是说 经过 comptime { ... } 的执行,外层的 echo 可能被替换成 Write-host ,之所以会这样设计 也是为了跨平台的考量。如果是 go, python 的语法糖压根达不到这样的水平。3. hulo 的命令是基础类型,可以对命令进行组合相互实现,例如 use grep = find & findstr 这样的,有点类似 ts 的类型体操,但是 hulo 的机制更加复杂,因为命令有不同的 options 还需要更细粒度的组合,因此 hulo 还参考了 css 的选择器,支持对命令进行更精细化的提取。总的来说,Hulo 的这些语法糖就是为了统一这些平台的语言设计的。
|
![]() |
6
ansurfen OP @llsquaer Hulo 现在的定位是批处理脚本的编译时,因为 Hulo 的完成度不是很高,对外宣传的还是比较保守。Hulo 现在能够基础的解释,未来还会为对接上 LLVM ,设计出自己的字节码。至于为什么这么做?因为很多项目都是 bash 、powershell 写的,可以实现一个提升器(lifter) 或者说是 反编译器,将这些语法向 hulo 转换,而 hulo 就能成为这些语言的 IR ,那他能够打包成其他语言,甚至提供 runtime 、native 机制。最终成为批处理脚本的 LLVM
|
![]() |
7
vfs 12 小时 25 分钟前
@w568w 为什么会觉得别人提问的时候没有经过大脑呢? “为什么不直接写一个新的脚本语言?非要翻译干什么。” 我之前就问过类似问题, 那是我思考了之后提问的,也并没有要嘲讽说作者的项目没有用,只是想探讨一下做这样的选择背后的原因而已。
|
![]() |
8
ansurfen OP @w568w 感谢支持。除了 V2EX 我在 b 站的时候宣传评论区也存在诸多不解。大部分人的第一印象就是 为什么不直接用 python 、js 等脚本语言写什么批处理,就存在这种刻板印象。不过也还好,质疑的声音越多,我就可以试着表达我的想法和告诉他们为什么需要 Hulo 这样的语言,也能带来更多的曝光度。要开发一个跨平台的批处理编译时真的,复杂度太大了。目标语言的语法一坨又一坨 要统一他们只有各种的语法糖(运算符重载、操作 ast 、comptime 条件编译...)。有时候 bash 用户可能没有 bc 要用 awk ,有时候可能只想用 (( )) ,batch 的延迟展开 这些操作 真的蛋疼
|
![]() |
10
ansurfen OP @vfs 其实最直接的理由就是,著名是开源项目 Kubernetes 中 bash 2.4 % + powershell 0.2% > python 0.0% 而 Hulo 存在的意义就是要让 2.6% 变成 Hulo 。不过这可能是目前的幻想 hhhh
|
11
w568w 12 小时 12 分钟前
@vfs 先给你道歉,语气暴躁了点。
上面最后列的这几个问题不是我在 V 站看到的(我也没看过你之前提问的那个帖子),是我自己在用户群和 Reddit 宣传的时候遇到的常见问题。不是说「提这些问题的人 = 不经大脑思考」。如果不小心地图炮到你了,不好意思。 |
![]() |
12
vfs 12 小时 4 分钟前
@ansurfen 这个想法没问题,其实问题在于如果真的使用 hulo 来取代 2.4% 的 bash 和 0.2% 的 powershell ,hulo 能减少多少代码,为了让 hulo 能自动转到这几个语言,hulo 脚本中需要写多少平台相关的代码
|
![]() |
14
ansurfen OP @vfs 是的,蛋疼就蛋疼在这里,所以 hulo 需要一堆语法糖去实现这个过程,让用户写起来不需要关注平台的封装,Hulo 会将所有复杂性留在标准库里面,而且也不做编译器内的硬编码,所有的转换都在 .hl 文件里面实现
|
![]() |
15
vfs 11 小时 32 分钟前
@ansurfen 这感觉也是必然要面对的事情。将尽可能多的细节藏起来(放在 hulo 提供的标准库中)。 其实比较担心的是将来会长期陷入往标准库中加入更多的语法糖。。。只要能收敛,就没问题
|
16
gullitintanni 6 小时 53 分钟前
@Livid 这个主题是不是放推广节点更好一些?
虽然 OP 做的工具确实有技术含量,也存在一定的使用场景,但短时间内多次发布,把一个公共论坛当作自己产品的 news page 了。甚至最近几个帖子直接发到了“程序员”节点,但内容没有普适的讨论价值,只是在介绍自己产品的技术细节。 |
![]() |
17
ansurfen OP @gullitintanni 虽然发这个帖子有一定的推广存在,但是如果把每篇文章串联起来,你会对“一个编程语言是如何开发的?”有比较清晰的认识,也算是一种教程/科普性质的文章。每次文章出现的 Hulo 和标题代的 Hulo 其实可有可无,换成其他语言也是同理,主要是为了浏览器 SEO , 因为这类项目的关注度和推广难度真的很难做起来。
|