定义 IR
在定义解析器之前,
我们需要先定义 calc 程序要使用的中间表示(IR)。
在基础结构一节中,
我们已经写出过一些像 Statement、Expression 这样的“伪 Rust”结构;
现在我们要把它们真正定义出来。
“Salsa 结构体”
除了普通 Rust 类型之外, 我们还会使用各种 Salsa 结构体。 所谓 Salsa 结构体, 就是那些带有 Salsa 注解之一的结构体:
#[salsa::input]:表示计算的“基础输入”;#[salsa::tracked]:表示计算过程中创建的中间值;#[salsa::interned]:表示易于做相等比较的小值。
所有 Salsa 结构体的字段实际值都存放在 Salsa 数据库中。 这使得我们能够追踪这些字段何时变化, 从而判断哪些工作需要被重新执行。
当你给结构体加上这些 Salsa 属性之一时, Salsa 实际上会生成一大段代码,把这个结构体接入数据库。
输入结构体
我们首先要定义的是输入。 每个 Salsa 程序都会有一些基础输入, 它们驱动后续所有计算。 程序剩余部分必须是这些基础输入的某个确定性函数, 这样当输入变化时,我们才能尽可能高效地重新计算结果。
输入通过带 #[salsa::input] 注解的 Rust 结构体来定义:
#![allow(unused)] fn main() { #[salsa::input(debug)] pub struct SourceProgram { #[returns(ref)] pub text: String, } }
在我们的编译器中,
只有一个很简单的输入:SourceProgram,
它包含一个 text 字段(字符串)。
数据存活在数据库里
虽然 Salsa 结构体的声明形式看起来和普通 Rust 结构体一样,
但实现方式却完全不同。
它们字段的值存放在 Salsa 数据库中,
结构体本身只是对数据库中那份数据的一个引用式句柄。
这意味着这些结构体实例都是可复制的,
无论它们的字段里包含什么类型。
创建结构体实例、读取字段、修改字段,
都是通过 new、getter、setter 等方法完成的。
对 #[salsa::input] 来说,
这个结构体内部持有的是一个 salsa::Id,
也就是一个非零整数。
因此,生成出来的 SourceProgram 大致长这样:
#![allow(unused)] fn main() { #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SourceProgram(salsa::Id); }
Salsa 还会生成一个 new 方法,
让你能够在数据库中创建一个 SourceProgram。
对 input 来说,需要传入数据库的 &db 引用以及每个字段的值:
#![allow(unused)] fn main() { let source = SourceProgram::new(&db, "print 11 + 11".to_string()); }
你可以用 source.text(&db) 读取字段值,
也可以用 source.set_text(&mut db, "print 11 * 2".to_string())
来修改它。
数据库修订
当一个函数接收数据库的 &mut 引用时,
这意味着它只能从程序“增量化部分”的外部被调用,
这一点在概述中已经解释过。
每当你修改某个输入字段的值时,
数据库中的 “revision counter” 就会递增,
表示现在有一些输入与之前不同了。
当我们说数据库的某个 “revision” 时,
指的就是两次输入修改之间数据库所处的状态。
表示解析后的程序
接下来,我们要定义一个 tracked struct。 input 表示计算的起点, 而 tracked struct 表示计算过程中产生的中间值。
在我们的例子里,
解析器会接收前面提到的 SourceProgram,
并返回一个 Program,表示已经完整解析后的程序:
#![allow(unused)] fn main() { #[salsa::tracked(debug)] pub struct Program<'db> { #[tracked] #[returns(ref)] pub statements: Vec<Statement<'db>>, } }
和 input 一样,tracked struct 的字段也存储在数据库里。
不同之处在于,这些字段是不可变的(不能再被 “set”),
而且 Salsa 会在不同修订之间比较它们,判断何时发生了变化。
例如,如果对输入的重新解析产生了与之前相同的 Program
(比如用户只是加了点结尾空白),
那么后续依赖它的计算就不需要重新执行。
除了字段不可变之外, 使用 tracked struct 的 API 与 input 很相似:
- 你仍然通过
new创建一个新值,例如Program::new(&db, some_statements); - 你仍然通过 getter 读取字段值,例如
my_func.statements(db)读取statements字段。- 在这个例子里,字段带有
#[returns(ref)]标注, 所以 getter 返回的是&Vec<Statement>,而不是克隆这个向量。
- 在这个例子里,字段带有
'db 生命周期
与 input 不同,tracked struct 会携带一个 'db 生命周期。
这个生命周期与创建它时使用的 &db 绑定,
并保证只要这个结构体还在被使用,数据库就保持不可变;
换句话说,你不能在这期间去修改某个 salsa::Input 的值。
'db 生命周期还允许 tracked struct 在内部通过指针来实现
(而不是像 salsa::input 结构体那样只保存数值 id)。
对用户来说,这几乎是透明的;
唯一能明显感觉到的好处,是 tracked struct 字段访问这种高频操作会被优化得更快。
表示函数
我们还会用一个 tracked struct 来表示每个函数。
Function 结构体会由解析器创建,
对应用户定义出来的每个函数:
#![allow(unused)] fn main() { #[salsa::tracked(debug)] pub struct Function<'db> { pub name: FunctionId<'db>, name_span: Span<'db>, #[tracked] #[returns(ref)] pub args: Vec<VariableId<'db>>, #[tracked] #[returns(ref)] pub body: Expression<'db>, } }
例如,如果我们创建了一个 Function 实例 f,
后来发现 f.body 字段发生了变化,
那就意味着用户修改了 f 的定义。
于是我们只需要重新执行那些依赖 f.body 的代码,
而不必重新执行依赖其他函数体的那部分逻辑。
除了字段不可变之外, 使用 tracked struct 的方式依旧和 input 很像:
- 通过
new创建新值,例如Function::new(&db, some_name, some_args, some_body); - 通过 getter 读取字段值,例如
my_func.args(db)读取args字段。
id 字段
为了在不同修订之间获得更好的复用,
尤其是在实体顺序发生变化时,
你可以把某些表示实体身份的字段标记为 #[id]。
通常你会把它加在表示“名字”的字段上。
这表示:如果在两个修订 R1 和 R2 中,
创建出来的两个函数拥有相同的名字,
那么它们就被视为同一个实体;
于是我们可以比较它们其他字段的相等性,
从而判断哪些内容需要重新执行。
添加 #[id] 纯粹是一种优化,不会影响正确性。
更多细节可以参考算法一页。
interned 结构体
最后一种 Salsa 结构体是 interned 结构体。 和 input 以及 tracked struct 一样, 它们的数据也保存在数据库中。 但与前两者不同的是: 如果你对同样的数据做两次 interning, 你会拿到同一个整数。
interning 的经典用途,是表示函数名、变量名这类短字符串。
如果一直传递需要 clone 的 String,既麻烦又低效;
比较它们是否相等时还得做字符串比较,同样也不高效。
因此,我们定义了两个 interned 结构体:FunctionId 和 VariableId,
它们各自只有一个字段,用来存储字符串:
#![allow(unused)] fn main() { #[salsa::interned(debug)] pub struct VariableId<'db> { #[returns(ref)] pub text: String, } #[salsa::interned(debug)] pub struct FunctionId<'db> { #[returns(ref)] pub text: String, } }
例如,当你调用 FunctionId::new(&db, "my_string".to_string()) 时,
你会得到一个 FunctionId,
它本质上只是一个 newtype 的整数。
但如果你再次用同样的参数调用 new,
你拿到的会是同一个整数:
#![allow(unused)] fn main() { let f1 = FunctionId::new(&db, "my_string".to_string()); let f2 = FunctionId::new(&db, "my_string".to_string()); assert_eq!(f1, f2); }
interned 值同样携带 'db 生命周期
像 tracked struct 一样,interned 值也会携带 'db 生命周期,
这会阻止它们被跨修订使用。
同时,它也允许这些值在底层通过指针来实现,
从而让字段访问更高效。
interned 值在单个修订内部保证一致性。
在不同修订之间,它们可能会被清空、重新分配甚至重新编号,
但你通常观察不到这一点,
因为 'db 生命周期保证了:
只要某个 interned 值还在被活跃使用,
你就不能去修改输入,也就不能触发新修订。
表达式与语句
对表达式和语句,我们不会使用任何特殊的 “Salsa 结构体”:
#![allow(unused)] fn main() { #[derive(Eq, PartialEq, Debug, Hash, salsa::Update)] pub struct Statement<'db> { pub span: Span<'db>, pub data: StatementData<'db>, } impl<'db> Statement<'db> { pub fn new(span: Span<'db>, data: StatementData<'db>) -> Self { Statement { span, data } } } #[derive(Eq, PartialEq, Debug, Hash, salsa::Update)] pub enum StatementData<'db> { /// Defines `fn <name>(<args>) = <body>` Function(Function<'db>), /// Defines `print <expr>` Print(Expression<'db>), } #[derive(Eq, PartialEq, Debug, Hash, salsa::Update)] pub struct Expression<'db> { pub span: Span<'db>, pub data: ExpressionData<'db>, } impl<'db> Expression<'db> { pub fn new(span: Span<'db>, data: ExpressionData<'db>) -> Self { Expression { span, data } } } #[derive(Eq, PartialEq, Debug, Hash, salsa::Update)] pub enum ExpressionData<'db> { Op(Box<Expression<'db>>, Op, Box<Expression<'db>>), Number(OrderedFloat<f64>), Variable(VariableId<'db>), Call(FunctionId<'db>, Vec<Expression<'db>>), } #[derive(Eq, PartialEq, Copy, Clone, Hash, Debug)] pub enum Op { Add, Subtract, Multiply, Divide, } }
由于语句和表达式本身不被跟踪, 这意味着我们的增量复用粒度只到函数级别: 只要函数体里任意内容发生变化, 我们就会认为整个函数体都脏了, 并重新执行一切依赖它的内容。 通常像这样画出一个“足够粗、但合理”的边界是有意义的。
不过这种设计也有一个缺点: 我们把位置信息直接内联进了每个结构体中。