用 Rust 实现编译器 (二): 重构, 重构, 重构, 重构

最近一段时间编译器的进度是这样的.


终于碰到了 Rust 最头疼的点了: 管理引用与生命周期. 而在编译器中当然会有很多这样的问题, 因为许多数据 (例如变量/类名, 类型信息等) 会被重复在多个地方被使用, 就经常会有数据共享的问题. 下面按照时间顺序梳理我的几次重构, 用于警示自己不要再发病了.

混沌阶段: 随意引用和智能指针

在写 semantic check 的最开始还没有意识到数据共享和引用会带来什么生命周期的问题, 只是模糊中有一些概念. 所以当时的做法是全部字符串用一个 &'a str 包起来, 然后类型等信息就用 Rc<Info<'a>>, 表用 Rc<RefCell<Table<'a>>>. 这样一开始写的时候完全没有遇到任何问题, 顶多就是不知道 ASTNode 里面应该怎样生成 Info, 纠结了半天, 后来去掉一个生命周期的标注就很神奇地通过了.

然而, 到了把 parser 和 semantic 两个板块和在一起的时候, 问题就出现了! 由于 SymbolCollector 需要将 ASTNode 中的许多信息提取出来临时存储, 这就导致了他必须要获得 ASTNode 的可变引用, 然而这些信息里面有些被标明了生命周期 'a, 所以 Collector 也要获得 'a 的可变引用, 也就是把 ASTNode 的整个生命周期夺走了, 这当然是不可接受的!

那就没办法了, 重构吧.

小修小补: 分模块划分生命周期

ChatGPT 告诉我上面这种情况可以通过细分生命周期来解决, 于是我就这么做了, 然后惊奇地发现: ParserCollector 不能划分到两个生命周期! 原来是 ASTNode 并没有获得其自身信息到完整所有权, 还有一个 &'a str 指向的是 Parser 的生命周期, 因此 ASTnode 没办法跳出 Parser.

那好像得重构更多了…

摆烂阶段: Copy anything

一个解决资源共享最简单的方案当然就是存储多份副本, 我就采取了这种方案. 把每一个字符串都用 String 存, 每一个 Info 都用 Rc<> 套壳. 这样一来, 就完全没有所谓的信息共享了. 但是作为一个 C/C++ 程序员, 我写着写着就无法容忍自己写出的 copy on share 代码, 这样还不如去写 Java 呢.

然后开始有脑子的重构.

试图动脑: 开始规划生命周期的细节

到这里才算是对 Rust 的生命周期有了一点粗浅的认知, 于是开始规划生命周期的细节. 首先各类信息的最终所有者当然都应该是 ASTNode, 所有就在这里面存储原始变量, 其他地方的字符串和 Info 都存为 &'a 的引用 (毕竟不可变), Table 还是照旧用 Rc<RefCell<Table<'a>>> 存储. 这样一来, ASTNode 就可以在 ParserCollector 之间传递了, 也不会有生命周期的问题.

很遗憾, 编译器告诉我, 我还是过分乐观了. 即使我这样做, 编译器也无法识别出来 Collector 只需要短时间的可变引用, 于是这里又得变成 unsafe. 前面初始化 Info 的时候问题就更大了, 这里因为存在自身引用, 只能进行生命周期强转, 不行的是, 在强转完以后很快节点就发生了所有权转移, 此前强转得到的引用不出意外地失效了… 看来所有权也同样是一个需要精心设计的东西…

思考: Rust 真的适合用来写编译器吗

The reason not to go for Rust is simple: Lifetimes make flexible data abstraction rather annoying, and you really, really don’t need that fine-grained control over allocation. GC is perfectly fine.

在 Reddit 看到的一句话, 姑且先在这里记录下来. 希望后面能找到上述问题的解决方案.