Xero Diary #2 | 语义分析与编译框架预备
在最近的更新中,我们聚焦在语义分析的构建和编译框架的构建中。这为我们最终实现 LLVM 编译路线奠定重要的基石。
无论是解释器路线还是编译器路线,它们都需要从 Lexer 到 Parser。我们现在的 Runtime 运行时,实际上是不够干净的,有大量的类型检查、常量识别等语义分析操作被纳入其中,显得混乱。
为了能够生成 LLVM IR,我们需要提供完整的编译期信息,比如类型、符号表、函数签名、字面量等。而解释器还需要运行时信息,比如变量值、字符串值、数组长度等。
我们一步步来重新调整模块。
类型系统
对于编译期,我们只要知道类型名称、宽度、是否使用堆存储这些信息就基本可行。但现在的 Type 类还包括了 Runtime 时使用的函数指针,属于执行层了,需要分离。
首先是创建 Sema 语义分析类,Type 定义被纳入 Sema 模块,而 Type 实现被留在 Runtime 中,并且还包括对应的查找表。两者的结构是几乎对称的,如 Type 和 TypeTable,TypeImpl 和 TypeImplTable。我们先注册 Type 类型定义,再注册 TypeImpl 类型实现。定义要通过函数指针绑定对应的实现。
为了叙述清晰,现在我们认为:Type 以及 TypeTable 指的是 Sema 中的类型定义;TypeImpl 以及 TypeImplTable 指的是 Runtime 中的类型实现。
表达式类型推断
在解释器中,每个 AstNode 都能由 Exec 返回 Obj,对于 OperExpr 如 1 + 2 我们返回一个 i32,对于 DeclExpr 如 x: i32 = 3 我们返回 none 即可。
每个表达式返回的 Obj 都能被用于下一步执行,同理在语义分析中,每个表达式都有一个最终返回的类型,用于生成 LLVM IR。编译期我们要完成所有的类型推断和一致性验证,例如二元运算表达式要求左右类型匹配或兼容,函数调用时参数类型与顺序要与签名匹配。而且经由语义分析后,我们在解释器中也可以不用重复地做类型相关的检查了,更能聚焦于执行。
关键是在 AstNode 中加入 resolved_type 返回类型。例如 IdExpr 返回变量的类型,FnCallExpr 返回函数返回值类型。然后就是在 Sema 中实现和 Xengine 类似的递归遍历 Exec,逐个处理并填充语义信息。
Warning
但只做到这一步,仍然有部分节点还不能完成构建:
- FnCallExpr 需要函数签名信息,但我们还没有收集;
- ForStmt 遍历 Array 需要知道元素类型,但目前是由第一个插入的元素决定类型,没法静态分析…
后续内容会回答如何解决这些问题。
符号表
语义分析要回答一个基本问题:“这个名字是什么”。
比如 x: i32 = 3;,在该语句后再遇到 x + 1 时,我们需要知道 x 的类型是 i32;遇到 add(1, 2) 时,我们需要知道 add 的签名。这些信息都要靠 SymbolTable 符号表来收集和查找。
符号表按词法作用域组织。进入一个块(函数体、if 分支等)就压入一层作用域,离开时弹出;每个作用域内维护一个 名字 -> 符号 的映射。查找时从内向外逐层搜索,所以内层声明可以遮蔽外层的同名符号。
符号分两种:
- VarSymbol:变量符号,记录变量的类型;
- FnSymbol:函数符号,记录函数的签名;
函数定义时,自动创建对应的符号并落入当前作用域。同一作用域内重复声明同名符号会直接报错,避免静默覆盖。
函数签名
无论是函数还是类的方法,它们都需要函数签名,就像 Type 那样。
对于如下两种形态的函数:
# 直接声明函数
fn add(x: i32, y: i32) -> i32 {
return x + y;
}
# 存储在函数变量中
add: function = fn (x: i32, y: i32) -> i32 {
return x + y;
};
要收集如下信息:
- 返回类型:
i32; - 参数形态:
(i32, i32)
对于参数,Xero 按如下布局划分,两者均是可选:
- 定长段:固定长度的参数个数,每个参数类型可指定;
- 变参段:位于定长段之后,允许写入无限数量的参数,但类型均固定统一;
可以注意到函数签名不包括函数名称。
- 从函数类型上看:我们可以对其它函数变量赋值以传递函数,且调用这类函数的方法就是
add(),用的是变量名称。如果函数签名内写死了名称,就会显得混乱了。 - 从函数签名上看:它只关心如何描述一个函数的使用方式;
函数与方法的定义位置
函数定义受到所处作用域的限制,方法定义则是全局可用的。当然我们尚未实现类,所以方法定义就先从简单来处理。
函数定义时,自动创建对应的符号并落入 SymbolTable 符号表当前定义域中。方法则不通过符号存储,通过 (type, name) 类型和函数名称建立键,落入 MethodTable 方法表中。
内置函数与自定义函数统一容器
Xero 工具链由 C++ 编写。对于部分内置函数,如 print,type,它们的数据源和执行方式无法通过 Xero AstNode 表示,属于 Native 函数,反之属于 Lang 函数。
强调一下,Native 和 Lang 的区别,只在于定义方式,即使用 C++ Lambda 表示,或使用 AstNode 表示。内置函数并不等于只使用 C++ Lambda 实现。
内置函数的符号注册在 SymbolTable 最外层的全局作用域,保证处处可见。
参数化类型
在处理 ForStmt 语义分析时,对于 Array 数组的迭代尚未具备条件,原因是我们还不知道元素类型。在之前的架构中,元素类型并不是从一开始定义就确定的,因为我们还不支持诸如 C++ 的 <T> 泛型,只是取第一个插入元素的类型作为数组元素类型。这一步骤是发生在运行时的,我们要想办法在编译期就能确定类型。
参数化类型,也就是类型(基类)携带着指定数量的参数。我们会据此构造出全新的类型,其实现继承自基类。例如 array[=i32=],基类是 array,参数有 i32。基类不一定就是 array,任意类型都可以,包括参数化类型。
你已经注意到这里用的不是常见的尖括号,这是因为会和运算表达式中的比较运算符产生规约上的冲突。在我了解到的现有解决方案中,C++、Java 等语言仍然使用了复杂的判断策略,Go、Haskell 等语言则是使用了新的符号来表达。
使用新的符号能够很快解决问题,我们可以选择单字符,双字符,或者用关键字来处理。我认为 [= 和 =] 是相对最好的一种选择,而且这是在 Lexer 中直接识别和确定的独立符号。
- 嵌套观感:它使用方括号作为重要组成部分,能显著看出嵌套关系,如
map[=i32, array[=f64=]=]; - 输入速度:在多种键盘配列上,
[、]和=的位置都是紧贴的,且输入仅使用 1 步敲击,速度很快。 - 输入便捷:在 IDE 上,输入括号后光标一般会定位到括号内,此时只需快速输入 2 次等号、左移 1 次即可完整输入;
虽然仍然不如 < 和 > 这样的单字符看着简短,但我认为这是权衡了实现难度和语法表现后的选择。
fn make(x: i32) -> array[=i32=] {
a: array[=i32=] = [x, x, x];
return a;
}
println(make(2));
Note
尚未确定 [= 和 =] 的命名。在 Xero 的 TokenType 中,它们使用的是 Equal Bracket 缩写 EBkt。
因为类型的表达变得复杂了,因此我们创建新的 AstNode,即 TypeExpr,其中记录基类和参数。随后是将 Type 的现有字段(除 name 等公共部分以外)集中到 BaseInfo,然后新增 ParamInfo。内部数据使用 variant 记录。普通类型只使用 BaseInfo,而参数化类型使用 ParamInfo,其中记录基类指针以及参数数组。
这样的拆分与之相对的,是直接在原来的 BaseInfo 字段中加上基类指针和参数数组。新方案的好处是保证只能用到自己需要的字段,确保开发者知道自己可能在操作什么类型。
代码中多处对 Type 的使用都需要重新处理。因为有的情况我们需要基类做大类判断,比如判断 array[=i32=] 是不是 array;而有的情况我们要精确匹配,比如数组内的元素是 array[=i32=] 还是 array[=bool=]。
总结
适配 LLVM 以最终实现编译运行的改造还有很长的路要走。类型映射、内存布局等尚未被解决,以及后续 IR 生成。Xero 未来的策略是解释器快速预览功能,编译器构建可执行程序,不过现在仍以解释器为核心。2026.0.0 还处于 Beta 阶段,由于最近对架构的大幅度改动,我想需要多次测试才能进入 RC 和 Release。