在 Rust 的设计哲学中,借用检查(borrow checking)被视为静态类型系统的核心功能 —— 通过编译期的类型推导来确保内存安全。然而,一位来自温哥华的工程师 Jamie Brandon 在其语言实验项目 Zest 中展示了另一种可能性:完全绕过静态类型系统,借助运行时数据流分析来实现借用检查。这篇文章将深入剖析这一方案的工程实现细节与设计权衡。
从类型系统束缚中解放
传统观点认为借用检查必须依赖静态类型系统,因为只有类型信息才能在编译期确定值的生命周期和借用关系。然而,Brandon 提出了一个根本性的问题:如果我们愿意接受运行时开销,能否在完全动态类型的语言中实现类似的内存安全保证?
这个问题的答案不仅具有理论意义,更具有实际价值。Brandon 关注的语言设计目标包括:动态类型系统提供的灵活性与元编程能力、内推指针(interior pointers)的支持、以及显式栈分配。这些特性在静态类型系统中往往需要复杂的类型推导才能安全实现。
Brandon 的方案核心思想非常简洁:与其在编译期分析借用关系,不如在运行时维护一个精确的借用图。代价是需要额外的引用计数操作,但换来的却是更强大的表达力 —— 这种方案甚至可以支持外部迭代器(external iterators),这是许多第二类引用(second-class references)方案无法做到的事情。
四态引用计数机制
实现运行时借用检查的关键在于如何表示借用的状态。Brandon 设计了一个巧妙的四态引用计数机制,每个变量维护一个引用计数,其含义根据数值的正负和特殊值而被重新定义:
当引用计数等于 INT_MIN(整型的最小值)时,表示该变量中的值已经被部分移动(moved),原变量不再可用。当引用计数为负数(但大于 INT_MIN)时,其绝对值表示有多少个借用引用(borrowed references)指向这个值。当引用计数为零时,该变量可以安全地被借用或共享。当引用计数为正数时,其数值表示有多少个共享引用(shared references)指向这个值。
这种设计的精妙之处在于:每一次安全检查只需要一次整数比较。例如,检查一个值是否可以移动,只需判断其引用计数是否为零;检查是否可以创建借用引用,同样只需判断引用计数是否为零;检查是否可以创建共享引用,则需要判断引用计数是否大于等于零。这意味着运行时的检查成本极低,每次借用操作都是常数时间。
借用的三种形态
在 Brandon 的设计方案中,引用被划分为三种类型,每种类型对应不同的语义和检查规则。第一种是拥有所有权(owned)的引用,通过 box 函数创建在堆上的值,行为类似于 Rust 的 Box<T>。第二种是借用引用(borrowed reference),通过 ! 运算符创建,其特点是当借用引用被_drop_时,值会归还给原始变量 —— 这正是「借用」语义的体现。第三种是共享引用(shared reference),通过 & 运算符创建,原始所有者保留其值的所有权,多个共享引用可以同时存在。
这三种引用形态的交互规则需要精确控制。核心限制包括:拥有引用的盒子不能指向借用或共享引用,这确保了借用和共享引用始终存在于栈上,从而大大简化了生命周期管理。当一个借用引用存在时,不能创建更多的借用或共享引用,这防止了数据竞争。当共享引用存在时,可以继续创建更多共享引用,但不能创建借用引用,因为写入借用引用会影响到所有共享引用的观察者。值不能在其仍有借用或共享引用的情况下被移动,即使移动的部分与借用的部分毫无关系。
这些规则通过引用计数机制得以实现。每当创建一个借用引用时,将源变量的引用计数减一;每当创建一个共享引用时,将源变量的引用计数加一。当引用_drop_时,相应地调整计数。这种调整是局部的,只需要访问被借用变量和借用引用本身,不需要遍历整个数据结构。
贷方与借方的追踪
对于每个借用或共享引用,系统的元数据需要追踪两个关键信息:贷方(lender)和所有者(owner)。贷方是指从哪个变量借用了这个值,当引用_drop_时需要递减该变量的引用计数。所有者是指该值最初属于哪个变量,其生命周期决定了引用是否能安全地写入。
这两个概念在重新借用(reborrowing)场景下会有所区别。考虑以下代码:首先创建对变量 a 的共享引用 b,然后从 b 创建一个借用引用 c,再从 c 创建一个新的借用引用 d。在这个链条中,b、c、d 都拥有相同的所有者 a,但贷方各不相同:b 的贷方是 a,c 的贷方是 b,d 的贷方是 c。
这种设计的目的是构建一条「保管链」(chain of custody)。当 d 存在时,c 不能被访问,因为 d 从 c 借用;当 c 存在时,b 不能被访问,因为 c 从 b 借用。最终的结果是:任何时刻,最多只有一个可变的借用引用可以访问底层值。这与 Rust 的借用规则在语义上是一致的,但实现方式完全不同。
所有权追踪的另一个关键作用是防止悬挂引用。所有者决定了引用的生命周期,如果试图将一个短生命周期的值写入一个长生命周期的引用,系统会检测到这种不匹配并抛出错误。例如,代码试图将一个在内部块中定义的共享引用返回到外部块,就会触发运行时错误,因为内部块的变量会在块结束时_drop_,而引用试图指向它。
错误消息的精确生成
运行时借用检查的一个显著优势是能够生成极其精确的错误消息。当借用规则被违反时,系统不仅知道规则被违反,还知道具体是哪个变量的问题。在大多数情况下,错误消息可以直接指出贷方的身份,例如「不能借用 a,因为它已经被 b 借用」。
然而,存在一种特殊情况需要额外的处理:当借用的变量本身已经被移动,或者借用关系嵌套在复杂数据结构内部时,直接从引用计数信息无法得知借用人是谁。解决方案是扫描栈来查找借用引用。由于所有借用和共享引用都必须存在于栈上(这是设计限制),且栈的大小有限,扫描栈是一个合理的运行时开销。
这个扫描过程使用了一个预计算的类型索引函数 getRefIndexes,它为每个类型 ID 返回该类型中所有引用的位置索引。这类似于垃圾收集器中使用的位图(bitmap)技术,避免了递归遍历整个值结构。扫描算法从栈顶开始向下查找,直到找到 Lender 与目标匹配的引用为止。
跨栈调用的安全过渡
Brandon 的设计还考虑了新线程创建和跨栈调用(from dynamically-typed code to statically-typed code)的场景。为此设计了一个 with_new_stack 函数,它将闭包复制到新栈上执行,执行完毕后再将结果复制回来。
跨栈调用的安全要求包括:捕获的借用或共享引用在被复制到新栈时,需要将来源调整为新栈上的虚拟贷方,这样函数执行期间不需要访问原栈的引用计数;函数返回时才调整原栈上贷方的引用计数;闭包捕获的引用不能包含嵌套的借用或共享引用(只能有一层共享或借用);函数返回值不能包含任何借用或共享引用;闭包必须通过移动(move)而非借用或共享被调用。
这些限制确保了:线程之间不共享引用计数,避免了原子操作的开销;静态类型的代码永远不会看到引用计数或来源信息,从而可以在编译期进行优化。
实践验证与表达力
Brandon 用多个实际例子验证了这套系统的表达力。最引人注目的例子是链表遍历:使用第二类引用的语言通常无法在循环中遍历链表(因为无法在迭代过程中返回对链表节点的引用),但这套动态借用检查方案可以处理这种情况,尽管语法略显笨拙。
迭代器是另一个成功案例。系统支持三种迭代器模式:复制迭代器(返回值的副本)、共享迭代器(返回共享引用)和借用迭代器(返回借用引用)。其中借用迭代器正确地强制了独占访问 —— 同一时刻只能有一个活动迭代器,这与 Rust 的借用规则完全一致。
工程权衡与局限
与 Rust 相比,这套方案的主要局限在于表达力。Rust 的类型系统能够在编译期推导出更多的借用模式,允许自动借用(autoborrow)和解引用强制转换(deref coercion),而动态检查方案要求程序员显式地标注借用意图。此外,Rust 的所有权转移是隐式的,但在这套方案中需要显式使用 ^ 运算符。
运行时开销主要来自两个方面:每次创建或_drop_借用或共享引用时需要调整引用计数,以及每个借用或共享引用需要额外的元数据存储(当前实现为 16 字节)。不过,Brandon 指出,这些开销只在动态类型的代码帧中存在;静态类型的代码完全看不到这些运行时结构,因此可以享受零开销抽象。
这套方案最适合的场景包括:需要动态类型灵活性的语言、追求可预测性能而非自动垃圾回收的系统、需要支持内推指针和显式栈分配的环境。通过将借用检查从编译期转移到运行时,Brandon 证明了一个重要观点:内存安全并不必须是静态类型系统的专利,数据流分析同样可以在运行时发挥威力。
资料来源:本文核心内容基于 Jamie Brandon 在 scattered-thoughts.net 上的实验性语言项目 Zest 中的「Borrow-checking without type-checking」实现,代码仓库位于 github.com/jamii/jams。