定义解析器:记忆化函数与输入

calc 编译器的下一步是定义解析器。 解析器负责接收 ProgramSource 输入, 读取 text 字段中的字符串, 并创建ir 模块中定义好的 StatementFunctionExpression 结构。

为了尽量减少依赖,我们会编写一个递归下降解析器。 另一种选择是使用某种 Rust 解析框架。 本教程不会展开介绍解析算法本身, 如果你感兴趣可以去读源码; 这里我们只关注与 Salsa 有关的部分。

parse_statements 函数

解析器的入口是 parse_statements 函数:

#![allow(unused)]
fn main() {
#[salsa::tracked]
pub fn parse_statements(db: &dyn crate::Db, source: SourceProgram) -> Program<'_> {
    // Get the source text from the database
    let source_text = source.text(db);

    // Create the parser
    let mut parser = Parser {
        db,
        source_text,
        position: 0,
    };

    // Read in statements until we reach the end of the input
    let mut result = vec![];
    loop {
        // Skip over any whitespace
        parser.skip_whitespace();

        // If there are no more tokens, break
        if parser.peek().is_none() {
            break;
        }

        // Otherwise, there is more input, so parse a statement.
        if let Some(statement) = parser.parse_statement() {
            result.push(statement);
        } else {
            // If we failed, report an error at whatever position the parser
            // got stuck. We could recover here by skipping to the end of the line
            // or something like that. But we leave that as an exercise for the reader!
            parser.report_error();
            break;
        }
    }

    Program::new(db, result)
}
}

这个函数被标注为 #[salsa::tracked]。 这意味着当它被调用时,Salsa 会跟踪它读取了哪些输入,以及它返回了什么值。 返回值会被记忆化(memoized), 所以如果之后再次调用这个函数且输入没有变化, Salsa 就会直接克隆结果,而不是重新执行函数体。

tracked 函数是复用的基本单位

tracked 函数是 Salsa 实现增量复用的核心。 框架的目标是尽量避免重新执行 tracked 函数, 而改为复用它们已经记忆化的结果。

Salsa 使用红绿算法来决定何时需要重新执行函数。 简而言之,tracked 函数会在以下两种情况下被重新执行:

  • 它直接读取了某个输入,而这个输入已经变化。
  • 它直接调用了另一个 tracked 函数,而那个函数的返回值已经变化。

parse_statements 的例子里, 它直接读取 ProgramSource::text, 所以只要文本变化,解析器就会重新执行。

通过选择哪些函数要标记为 #[tracked], 你就能控制自己想获得多细粒度的复用。 在这里,我们选择把最外层的解析函数标记为 tracked, 而不是更内部的那些函数。 这意味着输入变化时,我们总会重新解析整个输入, 并重新创建得到的语句等结果。 不过稍后我们会看到, 这并不意味着类型检查器和编译器其他部分也总得重新跑一遍。

这是一个合理的取舍,因为:

  • 解析本身非常便宜,额外引入更细粒度跟踪的开销通常不值得。
  • 字符串本质上只是一个缺少结构的大字节块,很难精准判断 IR 的哪一部分需要重解析。

有些系统确实会做更细粒度的重解析, 通常方式是先对字符串做一遍“预扫描”,给它一点结构信息, 例如先识别函数边界,再把每个函数体的真正解析延后。 在 Salsa 里实现这种方案也不难, 而且会用到和后面避免重复执行类型检查器时相同的原理。

tracked 函数的参数

tracked 函数的第一个参数始终是数据库, 也就是 db: &dyn crate::Db

tracked 函数的第二个参数始终是某种 Salsa 结构体。

tracked 函数也可以接收额外参数, 虽然这里的例子没有这样做。 带额外参数的函数通常效率更低、灵活性也更差。 如果可以,最好尽量把 tracked 函数组织成“单个 Salsa 结构体 -> 结果”的形式。

returns(ref) 注解

你可能已经注意到,parse_statements 被标注成了 #[salsa::tracked(returns(ref))]。 通常情况下,调用 tracked 函数时,返回值会从数据库里克隆出来。 returns(ref) 表示改为返回一个指向数据库内部的引用。 因此,调用 parse_statements 时, 你拿到的是 &Vec<Statement>,而不是一个被克隆出来的 Vec。 这是一种性能优化。 (你可能还记得教程 ir 一节里提到过 returns(ref), 它放在结构体字段上时含义也基本相同。)