第 05 篇完成了名称解析:每个变量引用都绑定到了确切的定义。但编译器仍然不知道 x + y 是在做整数加法还是布尔运算。这篇给 AST 中的每个表达式节点分配类型,产出一棵全新的中间表示——Typed HIR。类型检查不通过的程序将被拦在代码生成之前。

类型的表示

Sprout 当前只有三种值类型和一个内部哨兵:

1
2
3
4
5
6
enum Ty {
I64,
Bool,
Unit,
Error,
}

I64 对应 64 位有符号整数,Bool 对应布尔值,Unit 是没有有意义返回值时使用的类型。Error 不是语言中可写的类型,它只在类型检查发现错误时注入节点,防止一处错误沿表达式树向上引发大量连锁报告。如果操作数之一已经是 Error,类型检查器直接把结果也标为 Error,不再重复报错。

自底向上的类型检查

类型检查器遍历名称已解析的 AST,对每个表达式节点计算类型。过程是自底向上的:先确定叶子节点的类型,再根据操作符的规则推导父节点。

字面量。 整数字面量的类型是 I64truefalse 的类型是 Bool

二元运算。 算术运算符 +-*/ 要求左右操作数都是 I64,结果也是 I64。比较运算符 <<=>>===!= 要求操作数类型相同,结果是 Bool。逻辑运算符 &&|| 要求操作数都是 Bool,结果也是 Bool

一元运算。 取负 - 要求操作数为 I64,逻辑非 ! 要求操作数为 Bool

变量引用。 第 05 篇为每个定义分配了 DefId。类型检查器从符号表中按 DefId 查到声明类型。

赋值。 右侧表达式的类型必须与声明类型一致。至于目标变量是否声明为 mut,第 05 篇的名称解析阶段已经完成了检查——可变性是名字绑定的属性,不涉及类型推导,类型检查器不再重复验证。

return 语句。 返回值的类型必须与当前函数签名中声明的返回类型匹配。

什么是 Typed HIR

类型检查器的输入是 AST,输出是 Typed HIR(高层中间表示)。这不是在原来的 AST 节点上贴一个类型字段那么简单——两者是不同的数据结构。

AST 贴近语法:它保留括号、保留语法糖、保留源码中书写的一切冗余结构。HIR 贴近语义:括号已经融入树结构的嵌套关系,类型已解析到每个节点,后续可能的语法糖也在这里脱去。后续的解释器、控制流构建、SSA 构造都只接受 Typed HIR 作为输入,不再回头查看 AST。

一个 Typed HIR 节点大致长这样:

1
2
3
4
5
struct TypedExpr {
kind: ExprKind,
ty: Ty,
span: Span,
}

kind 描述这是加法、变量引用还是函数调用;ty 是类型检查器计算出的类型;span 记录对应的源码范围,供后续诊断和源码映射使用。

下面是类型检查器的核心遍历逻辑。它递归地走过每个表达式节点,自底向上推导类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fn check_expr(&mut self, expr: &Expr) -> TypedExpr {
match expr {
Expr::IntLit(n) => TypedExpr { kind: TExprKind::IntLit(*n), ty: Ty::I64 },
Expr::BoolLit(b) => TypedExpr { kind: TExprKind::BoolLit(*b), ty: Ty::Bool },
Expr::Var(name) => {
let ty = self.lookup_var(name).unwrap_or(Ty::Error);
TypedExpr { kind: TExprKind::Var(name.clone()), ty }
}
Expr::Binary(op, lhs, rhs) => {
let lhs = self.check_expr(lhs);
let rhs = self.check_expr(rhs);
let ty = self.check_binary_op(*op, &lhs.ty, &rhs.ty);
TypedExpr { kind: TExprKind::Binary(*op, Box::new(lhs), Box::new(rhs)), ty }
}
Expr::Unary(op, operand) => {
let operand = self.check_expr(operand);
let ty = self.check_unary_op(*op, &operand.ty);
TypedExpr { kind: TExprKind::Unary(*op, Box::new(operand)), ty }
}
Expr::Call(name, args) => {
let args: Vec<_> = args.iter().map(|a| self.check_expr(a)).collect();
let ty = self.check_call(name, &args);
TypedExpr { kind: TExprKind::Call(name.clone(), args), ty }
}
_ => TypedExpr { kind: TExprKind::Error, ty: Ty::Error },
}
}

每个分支的规则对应前面散文描述的类型规则。check_binary_opcheck_unary_op 按运算符类别返回结果类型,类型不匹配时返回 Ty::Error 并记录诊断。

诊断码与错误门控

每种错误有独立的诊断码,便于文档引用和工具过滤:

诊断码 含义 示例
E0201 类型不匹配 let x: i64 = true;
E0202 未定义变量 return y;(第 05 篇已实现)
E0203 对不可变变量赋值 x = 1;(x 未声明 mut,第 05 篇已实现)

类型检查器不在遇到第一个错误时中断。它收集所有错误,最后一起报告。这样编程者一次编译就能看到多处问题,不必反复修改、编译、再修改。

1
2
3
4
5
6
7
8
9
10
11
12
$ sproutc check bad.spr
E0201: expected i64, found bool
--> bad.spr:1:16
|
1 | let x: i64 = true;
| ^^^^ expected i64

E0201: condition requires bool, found i64
--> bad.spr:3:5
|
3 | if 42 { return 0; }
| ^^ expected bool

错误门控是一条关键规则:只要存在任何一个诊断错误,编译器就拒绝进入代码生成阶段。没有通过类型检查的程序不会产出机器码。这比"生成了错误的机器码然后在运行时崩溃"要好得多。Error 类型在这里也起到作用——它让类型检查器能跑完整棵树,收集尽量多的真实错误,同时不因一处错误把后续节点全标红。

两个具体例子

整数赋给布尔变量:

1
let x: i64 = true;

类型检查器先判定 true 的类型为 Bool,再与声明类型 I64 比较,发现不匹配,生成 E001。

条件表达式要求布尔值:

1
if 42 { return 0; }

42 的类型是 I64,但 if 的条件位置要求 Bool,生成 E001。Sprout 不做隐式的整数到布尔转换。

练习

考虑这段程序:

1
let x: i64 = if true { 1 } else { 2 };

如果 if-else 是表达式而不是语句,它的类型应该是什么?两个分支都返回 I64,所以整个 if 表达式的类型是 I64,与 x 的声明一致。如果两个分支类型不同呢?Sprout 目前不支持表达式形式的 if,但这个问题值得在纸上推演一遍:它直接引出"分支类型必须一致"这条规则,和许多语言中 if-else 表达式的设计一致。

资料

Rust 编译器中 HIR 的定义和用途见 rustc dev guide 的 HIR 章节。类型检查的经典参考是 Benjamin Pierce 的 Types and Programming Languages 第 8–11 章,覆盖了简单类型 lambda 演算的类型规则和类型安全证明。

上一篇:05 - 名字、作用域与可变变量。下一篇:07 - 用参考解释器固定语言语义