定义 IR

在定义解析器之前, 我们需要先定义 calc 程序要使用的中间表示(IR)。 在基础结构一节中, 我们已经写出过一些像 StatementExpression 这样的“伪 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 结构体:FunctionIdVariableId, 它们各自只有一个字段,用来存储字符串:

#![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,
}
}

由于语句和表达式本身不被跟踪, 这意味着我们的增量复用粒度只到函数级别: 只要函数体里任意内容发生变化, 我们就会认为整个函数体都脏了, 并重新执行一切依赖它的内容。 通常像这样画出一个“足够粗、但合理”的边界是有意义的。

不过这种设计也有一个缺点: 我们把位置信息直接内联进了每个结构体中。