Valen 组借用检查器:把内存安全从独占规则改成路径规则

先看一段代码:

valen
func main() int {
  let list = Vec<int>.new();
  let ref_a = &list;
  let ref_b = &list;
  ref_a.push(42);
  ref_b.push(73);
  return 42;
}

两个引用指向同一个列表,各自往里 push 元素。C++ 收下这段代码,运行结果没有任何问题,前提是程序员自己保证不出内存错误;Rust 拒绝编译它,依据是 shared-xor-mutable 规则:一个对象在同一时刻,要么可以有任意多个只读引用,要么只能有一个可写引用,两者不可兼得。ref_a 和 ref_b 都是可写引用,第二个引用一创建就触发了报错。

2026 年 10 月 11 日,Evan Ovadia 在 verdagon.dev 发表长文,展示了他的新语言 Valen 给出的答案:这段代码原样通过编译。Valen 的组借用(group borrowing)机制把编译期内存安全的检查粒度从「对象」细化到「路径」,在零运行时开销的前提下,允许多个引用读写同一个对象。这篇文章拆解它的实现机制、适用边界,以及它在编程语言行业里已经拿到的回应。

官方文章页:Valen 版 step 函数,entity 参数用 in 子句声明自己指向哪条路径

一个游戏开发的经典痛点

Ovadia 是实验语言 Vale 的作者,Vale 用「概率性代际引用」做内存安全;Valen 是他在 Vale 经验之上的新一轮尝试。文章里的核心例子来自游戏开发:一个 step 函数接收整个世界 World 和其中一个实体 Entity 的引用,实体每帧推进、读取碰撞、解决碰撞。

C++ 的写法最顺手:void step(const World& world, Entity& entity),两个参数各管各的,代价是没有任何编译期内存安全保证。

Rust 的写法安全,但要付出结构上的代价:拿到 entity 的 &mut 之后,整个 world 都被锁住,想再传 &world 去调 get_collision_for_entity,就得先把 &mut Entity 丢掉,用完再重新借用。官方文章给出的 Rust 版本里出现了三处重新索引,每处都带一次边界检查和潜在的 panic 点。补救方案要么把数据结构拍平成 ECS 风格的并行数组(Rust 游戏圈流行的根源之一),要么引入 GhostCell 这类带类型品牌(brand)参数的容器:安全而且快,但签名里的品牌生命周期让代码读起来费劲。

Valen 的版本只有一行签名差异:

valen
func step(world &World, entity in world.entities[] mut) {
  entity.advance();
  let collision = world.get_collision_for_entity(entity);
  entity.resolve(collision);
}

签名里的 entity in world.entities[] mut 是关键:它向编译器声明,参数 entity 指向 world 的 entities 数组里的某个元素,并且函数会修改它。修改范围被精确圈定在 world.entities[] 之内,World 的其他字段碰不到。这带来一个副产品:函数能改什么,签名里写得清清楚楚,团队协作时 API 的边界约束由编译器执行。

编译器怎么知道引用指向哪里

Valen 的借用检查器由五个机制组成:

  1. 每个值有一个所有者(单所有权,与 C++、Rust 同源);
  2. 每个对象可以用一条「路径」描述,比如 world.entities[].hp;
  3. 每个引用记住自己指向的路径;
  4. 函数签名用 mut(路径) 声明自己会修改哪些路径;
  5. 对象被销毁或某条路径被修改时,指向其内部的所有引用被标记失效,之后再使用就报编译错误。

路径的起点只能是局部变量、函数参数或组参数,之后接字段名、数组元素 [] 或联合体变体。单所有权保证每个对象恰好有一条从根到它的路径,这是整个体系能静态运转的前提。

push 的签名展示了声明方式:

valen
func push<T>(self &Vec<T>) mut(self) { ... }

mut(self) 说明 push 会修改 self 指向的整条路径。这里有一个容易被忽略的细节:C++ 和 Rust 把可变性写进引用类型(const T&、&mut T),Valen 把它从类型里拿出来,挂在函数签名上,语法地位类似 async 之于函数。引用自始至终只有一种,可变与否由使用它的函数决定。

失效规则因此变得精确。对 vec 调 push 会修改 vec 本体,此时只失效「指向 vec 内部元素」的引用,指向 vec 本身的引用安然无恙。文章给的例子:vec_ref 和 elem_ref 同时存在,vec.push(Ship(73)) 之后 elem_ref 立即失效(元素可能被重新分配),vec_ref 还能继续用(容器本身还活着)。同样的代码在 Rust 里,两个引用会一起被锁。跨函数的失效传播同样靠签名:step_and_read 调用 step 之后还能继续用 entity,因为 step 的签名只声明修改 world.entities[],调用方据此知道失效边界在哪。

同一个函数的四种写法

官方文章页:同一个 step 函数的 C++ 与 Rust 版本对比,Rust 版三处重新索引各自带边界检查

把四种方案排在同一张桌上,取舍直接体现在签名里:C++ 最简单,但不做内存安全检查;Rust 安全,但这个场景下要多写两次重新借用;GhostCell 达到 C++ 的速度,复杂度留在类型签名里;Valen 用 in 子句把「这个引用指向哪里」写进类型,签名保持干净。文章脚注也没有过度承诺:Rust 程序员可以通过重构达到组借用的速度,Valen 的收益主要在人体工学和灵活性,速度本身并非卖点。

与 Rust 互操作:两种引用体系之间的桥

组借用允许「持引用期间别人改数据」,Rust 的 &mut 要求「持有期间谁都别动」,两种语义直接对接会冲突。Valen 用两个机制搭桥。

Temporary Uniqueness(临时唯一性):调用 Rust 函数时,如果某块数据在这次调用期间只有一个 Valen 引用能到达,这个引用可以临时移交成 Rust 的 &mut;存在多个可达引用时,只能按 &(只读)移交。

Wildcard descendant paths(通配后代路径):Rust 函数返回引用时(比如 Chest::gem(&self) -> &Gem),Valen 无法从 Rust 代码推断返回值指向哪条路径,于是把这类返回统一解释成 in self.gem...,即「指向 self.gem 内部某处」。作者在文中注明这个名字还需要改进;这个机制未来也可能用于隐藏库 API 的内部细节,做前向兼容。

从 Mojo 的弃案到 Carbon 的转向

组借用的设计者是 Nick Smith,最初为 Mojo 设计。Ovadia 在 Mojo 团队期间没能说服决策层采纳,方案险些埋没。两人去年把整理过的提案发到网上("Group Borrowing: Zero-Cost Memory Safety with Fewer Restrictions"),随后出现了一串跟进者:

  • Google 的 Carbon 语言全面转向组借用,并在文档中致谢了这篇文章;
  • Ante 语言开始把组借用融入自己的设计(Shared Mutability and Stability);
  • Plecra 的 exclusion typing 用一等权限重新设计 Rust 风格的借用检查器,组合了组借用与 GhostCell 的概念,目标是拒绝更少的代码;
  • Zeta 把组借用与不相交性(disjointness)证明结合,在 i 和 j 都是运行时值的情况下,能证明 get_mut(i) 与 get_mut(j) 不指向同一元素;
  • Fang 在语言设计中处理组借用的循环引用问题。

这些语言围着同一个命题转:借用检查与「持引用时允许别人改」的方案怎么融合。借用检查的 shared-xor-mutable 与引用计数、GC 这类方案的自由度长期互斥,组借用给出的解法是把安全目标收窄成一条:不允许 use-after-free。只要指向对象内部的引用不会在被销毁后使用,独占与否不再重要。

边界与代价

Valen 的组借用目前有几条明确的技术边界,文章正文和脚注都没有回避:

  1. 类型稳定性还没实现。 附录描述的目标是:push 修改 my_vec 时,指向 my_vec.size 的引用不该失效,因为 Vec 的 size 字段无论怎么改都还是整数,类型稳定。这套规则写在附录里,标注为尚未落地。
  2. 优化器红利需要等 LLVM 改动。 细粒度的 mut 声明理论上能让优化器确认两个函数只在读上重叠,从而自由重排、甚至把计算外提出循环;但 LLVM 现有的 noalias 语义对不上「只读」概念,作者明确说需要给 LLVM 打补丁。
  3. 速度优势并非承诺。 脚注写明:Valen 未必比 C++、Rust 快,主要收益在人体工学和灵活性。
  4. 语法糖还在路上。 文章开头的自动解引用、直接索引、组类型推断要等 11 月的编译器更新;当前示例需要手动解引用、用 my_vec.data[0] 索引、给参数写全类型。

Valen 与 Vale 的分工在脚注里列得很清楚:组借用取代 Vale 的区域借用(region borrowing);正常的代际引用取代概率性代际引用;新增 Rust 互操作、引用计数和编译期求值;放弃完美可重放与无畏 FFI。两边都保留线性类型。

仓库与下一步

valen-lang/Valen 编译器仓库用 Rust 写成,2026 年 8 月 15 日创建,文章发表当天(10 月 11 日)仍有新提交,目前 493 stars、12 forks、18 位贡献者。这篇长文在 Hacker News 拿到 57 分、25 条评论,Lobste.rs 同日上榜。

文章结尾预告了后续计划:解释引用计数和代际引用怎么与组借用融合,思路来自作者 2023 年的 Arrrlang 思想实验,把堆表示成 N 个全局数组(N 为程序中的类型数),让借用检查器把引用计数的对象当作数组来理解。脚注里提到一个佐证:形式化验证项目 Iris 内部正是用这种数组表示,完成了对 Rust 部分安全性的形式化验证。

对写 Rust 的人来说,这篇长文提供了一份现成的对照材料:同样是「引用 + 所有权」的静态检查,把失效条件从独占规则换成路径规则之后,游戏循环、观察者模式、ScopeGuard 这些在 Rust 里需要重构或 GhostCell 才能表达的模式可以直接写。Carbon 的转向给了这套设计第一个工业级背书;编译器能否把承诺兑现,valen-lang/Valen 的提交记录会给出答案。

来源: