跳到正文
Hacker News· verdagon·· 7 天前AI 评分22

Valen 的新型借用检查器:更灵活的内存安全方案

Valen's Memory Safety: A New Kind of Borrow Checking

AI 导读

Valen 推出基于 Group Borrowing 的灵活借用检查器,将 Rust 式借用检查的"共享异或可变"限制放宽为"无 use-after-free",在编译期捕获内存安全错误且无运行时开销。该方案支持多个引用同时指向同一对象并写入、函数参数指向同一对象等传统借用检查器难以处理的模式,并能与 Rust 代码互操作。

正文 · AI 翻译

2026年10月11日  — 

这篇文章是关于 Valen 的新 灵活借用检查器。 欢迎!

你们都了解我,我热爱探索新的内存安全方法,并以奇怪的方式将它们融合在一起。

这是我永恒追求的一部分,旨在找到内存安全的圣杯:既像 Rust 的借用检查那样强大,又像引用计数或垃圾回收那样简单灵活的东西。 0 1

我长期以来一直怀疑,要找到圣杯,我们必须解决三个内存安全挑战:

  • 我们能否让借用检查器更宽松? 2
  • 我们能否让借用检查器读取更复杂的数据? 3 4
  • 我们能否让这两个系统同时协同工作?

我知道这听起来模糊而神秘!下面我将解释这一切意味着什么,以及 Valen 可能如何为它们找到了解决方案。 5

这篇文章主要关注第一部分:Valen 的更宽松的借用检查器。

所以在这篇文章中,我将讨论它是什么、它能做什么以及它是如何工作的。

在本文中,为了清晰起见,我对语法糖做了一些自由处理:自动解引用、直接索引结构体(my_vec[0])以及从组中推断类型(entity in world.entities[])将在11月推出。除了这些语法调整之外,借用检查器适用于我们讨论的所有内容。 6

而且,在此过程中,我将解释一些更奇怪的小细节,比如:

  • 编译器如何记住引用指向哪里。
  • Valen 如何只有一种借用引用,而其他借用检查器有两种。
  • Valen 的借用检查器是如何设计以帮助它调用 Rust 代码的。

另外,这篇文章非常长且包含大量背景信息,所以我会告诉你们何时可以跳过。

让我们开始吧!

组借用:一次接近成功,以及巨大潜力

(这只是我喜欢讲述的背景故事,欢迎跳过本节!)

Valen 的方法建立在“组借用”提案之上,该提案由我的朋友 Nick Smith 设计。

它的核心概念是编译器记住引用指向哪里,(我稍后会解释)并利用该信息来检查程序是否内存安全。

这个设计一开始并不顺利;它几乎被时间的洪流淹没。它最初是为 Mojo 设计的,但唉,尽管我尽了最大努力,我还是无法说服高层我们应该使用它。 7 组借用还没机会成功就夭折了。

但我知道它有巨大的潜力,只要它能以某种方式进入这个世界。

所以我们花了几个月时间打磨解释并弄清楚它的好处,并在去年写了一篇8关于它的文章,希望其他语言能采用它。

他们确实采用了!它传播得很广。 9

这些语言大多在围绕同一种挑战打转:如何做出更灵活的借用检查器。

例如,Valen 想做出一个足够灵活的借用检查器,以便与引用计数和分代引用配合使用,而 Carbon 想做出一个足够灵活的借用检查器,以便与 C++ 代码配合使用。

这很困难,因为借用检查与其他方法之间存在根本性冲突。可以这样总结:13

  1. 借用检查有“共享异或可变”限制:如果你持有对某个对象的引用,其他人就不能修改该对象。
  2. 其他方法则希望允许在持有对某个对象的引用的同时,让其他人修改它。14 15

你大概在想:“这绝对没办法解决。”

看起来确实如此!

但组借用实际上解决了这一冲突,它把“共享异或可变”放宽为“无释放后使用”。

这听起来很晦涩,也毫无意义,但等我解释组借用如何工作之后,你就会明白了。

所以让我们先看看组借用能做什么,然后我再解释它如何工作。

组借用能做什么

组借用让程序无需运行时开销即可获得内存安全。16

它在编译期捕获释放后使用问题,而且其方式比以往的方法限制更少。

下面是一个在编译期捕获释放后使用错误的例子:

struct Entity {
  hp int;
}
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));
  let first_ref = &world.entities[0];

  world.entities = Vec<Entity>.new();

  // Compile error: Used a borrow after invalidated
  return first_ref.hp;
}

这是一种异常灵活的方法。通常,编译器处理不了下面这个程序,但这种方法理解它是安全的: 17

func main() int {
  let list = Vec<int>.new();

  // Make two refs to the list
  let ref_a = &list;
  let ref_b = &list;

  // Mutate the list through both refs
  ref_a.push(42);
  ref_b.push(73);

  return 42;
}

组借用接受许多通常对借用检查器来说很难处理的模式,例如:

  • 有多个局部变量指向同一个对象,并通过其中任意一个写入(如上所示)。
  • 取一个指向对象的参数,再取另一个指向该对象内部某处的参数,并且只通过后者写入(我们将在下一节看到这一点)。
  • 有多个函数参数指向同一个对象,并通过其中任意一个写入。18
  • 做一个“回滚”结构体,在离开作用域时修改对象,就像 ScopeGuard 模式那样。19 20
  • 有多个结构体持有对某个公共子系统的可变引用。21
  • 还有更多,我会在下面进一步解释。22

这些是我们从 C++ 中喜爱的模式,但还没有哪种语言弄清楚如何在编译期以内存安全的方式做到所有这些。

Valen 的内存安全:一种新型借用检查

0

这一探索带来了许多有趣的实验,涉及约束引用、线性类型、引用计数以及更多内容。

1

本文 0% 由 AI 撰写。更多想法见此。

我的立场:如果你希望别人花时间阅读某样东西,那就花时间亲手写出来。

感谢阅读 =)

2

或者更具体地说,“我们能否做出一个借用检查器,支持对单个对象的多个读写借用引用?”

这篇文章会完整解释这一点,所以请继续读下去!

3

我将在下文进一步解释,但所谓“更复杂的数据”,我指的是完整的数据图,其中对象可以自由地引用其他对象而不受限制,就像你在 C++、Java 等语言中看到的那样,而不是像 Rust 或 Fortran 那样,事物通常不能相互引用。

所以当我说“读取更复杂的数据”时,我的意思是:我们如何让不可变借用引用指向那种任意的数据图?

(注意,Rust 结构体可以引用其他 Rust 结构体,但只能是临时的。这就是为什么 ECS 在 Rust 游戏中如此受欢迎。这也是我仍然把 Rust 归为更偏 Fortran 那一侧的原因。)

4

简而言之,这个问题由 Vale 的代际引用与区域借用相结合来回答;它的不可变区域借用让编译器将所有已存在的数据追踪为一个暂时冻结的“区域”,我们可以拥有指向其中的借用引用。

5

目前,我只想暗示 Valen 可能通过融合 Vale 的方法、2023 年的一个特定思想实验、Nick Smith 的组借用方法,以及一些让它们和谐协作的变体,解决了这些问题。

6

具体来说,在如今的编译器中,需要手动解引用,像 my_vec.data[0] 这样直接索引数组而不是结构体,并且给所有参数标注类型,比如 entity &Entity in world.entities[]。

7

在别人的语言上工作时,生活就是这样!

8

你现在读的这篇文章是对组借用的更好解释,但你可以在组借用:限制更少的零成本内存安全看到旧文章。

9

很快我会写一篇文章,介绍所有这些语言的方法如何运作以及如何相互比较,敬请期待!

10

它通过使用一等权限将生命周期与类型检查器合并,从而拒绝更少的代码。绝对值得一看!

11

Zeta 的借用检查器可以证明两个引用指向同一数据的不同部分(它们是“不相交的”)。例如,当它知道 i != j 时,即使两个索引都是运行时值,它也能证明 list.get_mut(i) 和 list.get_mut(j) 是不相交的。阅读更多内容!

12

Valen 和 Vale 之间的主要区别:

  • Valen 将拥有组借用,Vale 曾拥有区域借用。
  • Valen 将拥有普通的代际引用,Vale 拥有的是概率性代际引用。
  • Valen 将拥有(而 Vale 没有)Rust 互操作、Rc、编译期计算。
  • Valen 将不会拥有(而 Vale 拥有)完美可重放性、无惧 FFI。

两者都有线性类型。

它们的语法仍然大致相同,但 Valen 可能会在语法上变得更像“简化版 Rust”。

13

我常把这种冲突称为“语言杀手”,因为它摧毁了许多实验性语言的许多版本,包括 Vale 早期的一种方法。

在约束引用和 Vale 的当前混合方案之间,有一种我称之为“混合代际内存”的方法,你可以在一个作用域期间通过“拴住”一个代际引用来保持引用存活。我经历了三十二个版本才放弃。

14

C++ 想要这个,因为这通常是我们使用它的方式;我们想要一个unique_ptr拥有一个对象,以及一堆指向它的非临时原始指针。引用计数和垃圾回收想要同样的东西。在这两种情况下,当别人可以在你持有借用引用时修改对象,困难就会出现。

15

这可能是一个有争议的目标;我们中有些人认为引用的指涉对象永远不应该在你脚下改变,但 ID/索引的指涉对象应该可以(就像 Rust 中那样)。其他人认为那太严格了。我认为用户应该能够选择。

16

尽管任何语言能否以零运行时成本实现内存安全是有争议的。参见追逐零开销内存安全的神话,这也适用于组借用。

17

通常,借用检查器不知道如何拥有两个都能修改对象的引用,所以它们会给出编译错误。组借用足够灵活,不会禁止这些多重读写引用,因为它知道如何更精确地专门防范释放后使用问题。

18

关于这方面的例子,请参见此页面中的“fn attack”示例。这对借用检查器来说通常很难。不过,GhostCell有时会有帮助!

19

普通的借用检查器通常对此有困难,因为用户想要两个读写引用(这违反了共享异或可变):一个直接使用,一个存在于 ScopeGuard 对象内部。

20

这也可能是用户定义defer语句的基础!

21

我没有现成的例子,但想象一下,你想要一堆File对象各自拥有一个对公共FileSystem对象的引用。借用检查通常对此有问题(对同一对象的读写引用太多),但在组借用中应该可以正常工作。

22

我将在下面更详细地介绍,但以组借用为起点,我们可以:

  • 让程序在运行时更快,因为我们可以向优化器提供更多信息。
  • 使用引用计数而无需Cell或RefCell或Mutex。
  • 出于同样的原因,使用代际引用。

一个更有趣的例子

(或者,跳转到工作原理)

例如,这里有一个step函数,它接收一个World引用,以及一个对其中某个实体的引用。

重要的部分是entity in world.entities[] mut,我将在下面解释。

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

注意这些部分:

  • in 表示“对其中一个的引用”。
  • world.entities[] 表示“world 的 entities 的元素”
  • 这里的 mut 表示“我们可以修改它。” 23 24

综合起来:entity 是对 world 的 entities 的元素之一的引用,并且我们可以修改它。

这就是该方法的核心概念:编译器知道引用指向哪里, 25 因为用户(通过 in)指定了它指向哪里。

现在让我们看看这在其他语言中是什么样子,以了解为什么它如此优雅。(或者跳到 它是如何工作的!)

首先,C++ 中的等价写法:

struct World {
  vector<Entity> entities;
};
void step(const World& world, Entity& entity) {
  entity.advance();
  auto collision = world.getCollisionForEntity(entity);
  entity.resolve(collision);
}

当然,上述写法需要小心维护内存安全,因为 C++ 不会在编译时检查内存安全。

让我们看看 Rust 中的等价写法,在 Rust 中我们确实能获得自动内存安全: 26

struct World {
  entities: Vec<Entity>,
}
fn step(world: &mut World, entity_id: usize) {
  let entity_mut = &mut world.entities[entity_id]; // bounds check, possible panic
  entity_mut.advance();
  let entity_read = &world.entities[entity_id]; // bounds check, possible panic
  let collision = world.get_collision_for_entity(entity_read);
  let entity_mut = &mut world.entities[entity_id]; // bounds check, possible panic
  entity_mut.resolve(collision);
}

Rust 通常用起来很舒服,但在某些情况下(比如这个例子),它不如 C++ 那样快或简单,因为每当有人需要同一层级中其他东西的 &mut 时,我们都需要重新获取该引用。

另一种选择是将我们的程序重构得更“扁平”(可以这么说),这有其自身的权衡。 27

如果你想玩得更花哨,可以使用 GhostCell,为你的数据添加 brand,并用 GhostToken 访问它们:

struct World<'brand> {
  entities: Vec<GhostCell<'brand, Entity>>,
}
fn step<'brand>(
  world: &World<'brand>,
  entity: &GhostCell<'brand, Entity>,
  token: &mut GhostToken<'brand>,
) {
  entity.borrow_mut(token).advance();
  let collision = world.get_collision_for_entity(entity, token);
  entity.borrow_mut(token).resolve(collision);
}

你可以看到这个难题。

  • C++ 的例子很简单, 28 但不是内存安全的。
  • 原生 Rust 是内存安全的,但稍慢一些,也稍复杂一些。 29
  • 加入 GhostCell 后,我们获得了与 C++ 相同的速度,但它仍然有点复杂。

使用 Valen 的方法,我们得到了快速、安全且简单的东西:

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

一个相当不错的简化!

现在,让我们终于开始解释它是如何工作的。

23

这不像 Rust 的唯一引用(&mut)。可以存在对同一实体的其他引用。

24

这个函数不能修改整个世界,只能修改其中的实体(这实际上是一个巨大的超能力,意味着优化器可以更好地优化这个函数以及它的调用者,但我会在稍后进一步解释)。

25

或者更具体地说,它大致知道它指向哪里; entity in world.entities[] 指向存在于 entities 数组中的对象组中的某处。

26

在 Rust 中,我们需要一个 &mut Entity 来调用 entity_mut.advance(),然后不得不丢弃它,以取回包含它的 &World 来传给 get_collision_for_entity 调用。之后,我们还需要为 resolve 调用重新获取 &mut Entity。

27

具体来说,与借用检查器打交道变得更容易(如果你的游戏恰好进行 ECS 风格的迭代,也会更快),但你会失去单一所有权带来的一些 RAII 好处。

例如,如果你将一棵异构树展平为多个数组,节点不会在其父节点被删除时自动删除。

另外,如果你过度拆分为并行集合,可能会面临“ID 追逐”导致的缓存未命中减速风险。

最后,我对任何会促使我们偏向某一种架构的内存安全方法都持谨慎态度,因为它可能不是最好的。

即使是分组借用也在某种程度上做到了这一点,这就是为什么我很兴奋能融入引用计数或分代引用。

28

是的,我知道在同一句话里使用“C++”和“简单”有多讽刺!

29

这里有些细微差别。通常你可以调整 Rust 代码来匹配组借用的速度,即使没有 GhostCell。

Valen 的借用检查器如何工作

简而言之: 该方法由五种机制组成:

  • 每个对象都根据单一所有权由一个路径来描述。
  • 每个引用都记住它所指向对象的路径。30
  • 当我们销毁一个对象时,所有指向其路径的内容都不再可用。
  • 函数签名描述了它修改的路径。
  • 当我们调用一个函数时,所有指向其修改路径内部的内容都不再可用。

这相当抽象,所以我会更详细地解释所有这些。

单一所有权

简而言之: Valen 的借用检查器基于单一所有权,就像 C++ 和 Rust 一样。每个值都由一个包含对象、数组、栈帧或全局变量“拥有”。31

如果你已经知道 C++ 或 Rust 是如何工作的,请跳过!

如果你不知道(或者只是喜欢阅读!),我会解释什么是单一所有权。

在单一所有权中,每一份数据都有一个“所有者”。例如……

如果我们有这个 C++ 程序:

#include <vector>
struct Engine { int fuel; };
struct Ship { unique_ptr<Engine> engine; };
void foo(vector<Ship>* ships) { ... }
void main() {
    vector<Ship> ships;
    ...
    foo(&ships);
}

……或者这个 Valen 程序:

struct Engine { fuel int; };
struct Ship { engine Box<Engine>; }
func foo(ships &Vec<Ship>) { ... }
func main() {
    ships = Vec<Ship>.new();
    ...
    foo(&ships);
}

……我们可以这样说:32

  • main 的栈帧“拥有” vector<Ship> ships;
  • vector<Ship> ships; 拥有每个 Ship。
  • 每个 Ship 拥有它的 Engine(通过 unique_ptr)。
  • 每个 Engine 拥有它的 int fuel;
  • foo 不拥有该 vector,它只有一个原始指针。
  • main 是唯一没有所有者的东西。

如果你用 C++ 或 Rust 编写过代码,你可能熟悉这种思维方式。

如果你用 C 编写过代码,你可能也会这样想,尽管 C 并不显式跟踪单一所有权。如果你追踪一个对象从它的 malloc() 调用到它的 free() 调用的整个旅程,指针经过的所有变量/字段都在处理“拥有指针”,可以这么说。这几乎就像侦探追踪证据的“保管链”一样。换句话说,在任何给定时刻,谁对它“负责”。33

哎呀,甚至 Java 和 C# 程序员有时也会从单一所有权的角度思考。如果你应该在某个时候调用一个对象的“dispose”/“cleanup”/“destroy”/“unregister”/等方法,你可以追踪该对象从 new 到那个(概念上具有破坏性的)方法调用的整个旅程,而那些就是处理其“拥有引用”的变量/字段,可以这么说。

到目前为止所解释的单一所有权是许多语言的基础:

  • 如果你添加常规(无限制)指针和引用,你就得到了 C++。
  • 如果你添加分代引用和区域借用,你就得到了 Vale。
  • 如果你添加排他性检查引用,你就得到了 Rust。
  • 如果你添加组借用和其他一些机制,你就得到了 Valen。

有了单一所有权,每个对象都可以用一个“路径”来描述,我接下来会解释。

30

我感觉到“每个引用都记得它所指向对象的路径”和代际引用中“每个引用都记得它所指向对象的ID(‘代’)”之间存在奇怪的共鸣。可以说,组借用是一种编译时的代际引用。但更多内容将在下一篇文章中讨论!

31

我们稍后通过引用计数来放宽这一点。

32

你也可以说main的栈帧拥有foo的栈帧。或者反过来(这就是async的工作方式)。

33

或者更具体地说,负责释放它,或者负责将其交给最终会确保它被释放的人。

路径

每个对象都可以通过路径来描述。

对于这个例子:

struct Entity { hp int; }
struct World {
  diameter int;
  entities Vec<Entity>;
}
func main() int {
  let x = 42;
  let world = World(73, Vec<Entity>.new());

  ...
}

以下是此程序中的所有路径:

  • x
  • world
  • world.diameter
  • world.entities
  • world.entities[]
  • world.entities[].hp

路径总是以以下之一开头:

  • 局部变量(如world)。
  • 函数参数。
  • 函数的“组参数”(稍后我们会看到)。

在此之后,它会包含以下内容:

  • .diameter 用于命名成员(如上所示)。
  • [] 用于命名集合的元素(如上所示)。
  • .SomeType 用于命名联合体的变体。34
  • 以及我稍后会介绍的其他一些选项。35

每个数据片段只有一个所有者(因为单一所有权),所以每个对象只有一条路径。36

那么路径有什么用呢?

引用!

编译器知道每个引用指向哪条路径,这有助于维护内存安全。

34

例如,如果我们有一个联合体/枚举Engine,包含WarpEngine或ImpulseEngine,路径可能是my_engine.WarpEngine。

35

比如关联组,或多类型组的子类型。稍后会详细介绍这些!

36

这并非严格成立;如果我们使用关联组之类的东西,对象实际上可以通过多条路径到达。不过那是另一个话题了。

每个引用都知道它指向的路径

例如,在这个程序中:

struct Entity { hp int; }
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));

  let entity_ref = &world.entities[0];

  ...
}

entity_ref的类型是&Entity in world.entities[]。

这意味着它是对world的entities中某个元素的引用。

在我们之前看到的例子中:

struct World {
  entities Vec<Entity>;
}
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[],指定了一个entity参数,指向world的entities中的某个元素。

如果你了解Rust:路径实际上并不像生命周期。把它们当作不同的东西来理解会更容易。37

现在让我们看看为什么要做这些。让我们看看如何使用路径来实现内存安全!

37

在实践中,生命周期可以用作非常粗粒度的位置,但生命周期更像是一段指令范围。

使用路径实现内存安全

编译器使用路径来检测释放后使用;它检测我们何时解引用一个指向已被销毁数据的引用。

它通过以下两条规则来实现这一点:

  • 当我们修改一个对象时,我们将任何指向该对象内部的引用标记为“无效”。
  • 当我们尝试使用一个“无效”的引用时,编译器会显示错误。

看看这个main函数。

struct Entity { hp int; }
struct World {
  entities Vec<Entity>;
}
func main() int {
  let world = World(Vec<Entity>.new());

  world.entities.push(Entity(42));
  let first_ref = &world.entities[0];

  world.entities = Vec<Entity>.new();

  // Compile error: Used a borrow after invalidated
  return first_ref.hp;
}

在其中,发生了以下两件事:

  • 在 world.entities = ... 处,编译器使所有指向其内部的引用(world.entities[])失效;它使 first_ref 失效。
  • 在 return first_ref.hp 处,编译器看到我们正在使用 first_ref,因此显示错误。

注意我是怎么说的:

当我们修改一个对象时,我们将任何指向该对象内部的引用标记为“失效”。

我们不会使指向对象的引用失效。我们使指向对象内部的引用失效。

看看这个 main 函数。

struct Ship { fuel int; }
func main() int {
  let vec = Vec<Ship>.new();
  vec.push(Ship(42));

  let vec_ref = &vec;
  let elem_ref = &vec[0];

  // Mutate `vec`.
  // Don't invalidate `vec_ref`.
  // Invalidate `elem_ref`.
  vec.push(Ship(73));

  // Can still use `vec_ref`.
  vec_ref.push(Ship(81));

  // Can't use `elem_ref`,
  // Compile error: Used a borrow after invalidated
  return elem_ref.fuel;
}

当我们调用 push 时,这会修改 vec。

这会使所有指向 vec 内部的引用失效。

这意味着 elem_ref 失效了(尽管 vec_ref 仍然有效)。

所以我们仍然可以使用 vec_ref,但不能使用 elem_ref。

注意这一切的发生都没有描述引用是否可变。

然而,Valen 只有一种借用引用。

如果你来自 C++ 和 Rust,这可能会让人困惑,因为它们都有多种引用类型(C++ 有 const 和非 const,Rust 有 & 和 &mut)。

在 Valen 中,某物的可变性不是其类型的一部分,而是由周围的函数决定的。

Valen 确实在函数参数上放置了 mut,但那是语法糖。mut 不是参数类型的一部分。38

38

mut 是函数签名的一部分。这类似于语言将 async 放在函数上,而不是参数上。

函数签名传达失效信息

那么编译器如何知道 push 修改了 vec?

push 必须在其签名中用 mut 关键字说明这一点。

这是 push 的签名:

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

……这是以下内容的语法糖:

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

注意 mut(self) 不是 self 类型的一部分。它更像是函数的一部分。

所以当 main 调用 vec.push(...) 时,main 将 vec 作为 self 传入。

然后编译器看到 mut(self),知道 self 实际上是 vec,因此知道 vec 被 mut 修改了。

这就是它如何知道要使任何指向 vec 内部的内容失效。

这是另一个例子:

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

我们不是 mut 修改整个 world;我们只是 mut 修改 world.entities[] 中的某个东西。

这很好,因为这不会使调用者对实体的引用失效。

例如,这个 step_and_read 调用了上面的 step 函数,该函数不会使 entity 失效。

func step_and_read(world &World, entity in world.entities[] mut) {
  // Only modifies an entity's contents.
  step(world, entity);

  // ...so can still use `entity`!
  entity.read_book();
}

如果我们正确构建 Valen 编译器(并修补 LLVM),这可能让我们以任何语言从未有过的方式利用优化器。39

39

我在下面进一步讨论这一点,但简而言之,如果优化器知道两个函数只在读取上重叠而不在写入上重叠,那么它可以重新排序它们,甚至可能将计算提升到循环之外。

组借用的一个变体

最初的组借用设计包含了很多上述内容(以及更多!)。以下是 Valen 将在其基础上添加的内容,以使其大放异彩。

不可变中的可变

回想一下这个程序,我们接收一个 world,它是不可变的,除了它的 entities 元素:

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

我们接收两个参数引用,我们可以通过更具体的那个进行修改,而不能修改它之外的任何东西。

Valen 添加这个是为了让我们不必采用经典的借用检查模式,即把整个 world 可变地传入。

这会带来几个好处。

首先,它有助于程序的架构设计,因为它帮助我们构建更强的 API。

我们可以确保整个函数中没有任何东西能改变 world 中我们预期之外的部分。

我想这在团队中与有进取心的新员工(或者喜欢向你扔 vibe code 的产品经理)合作时会特别有用。

其次,我怀疑这个特性可以让 Valen 代码更好地优化。

我不会在这里深入讨论,40 但基本上,当你给优化器提供更细粒度的信息,告诉它什么可能改变、什么不会改变时,它就能更好地识别出可以重新排列代码以提升速度的地方。41 42

临时唯一性

Valen 的目标之一是能够无缝调用 Rust 代码,就像 Kotlin 调用 Java,或者 C++ 调用 C 一样容易。

当然,这会很棘手,因为组借用和 Rust 有不同种类的引用:

  • 组借用允许其他人修改你的引用所指向的数据。
  • 在 Rust 中,每个引用要么是共享的(&),要么是唯一的(&mut)。当你持有一个引用时,没有其他人可以修改你的引用所指向的内容。

所以出现的问题是:我们能否临时将一个组借用引用转换为 Rust 引用?

答案是,可以!

机制相当简单。当 Valen 调用 Rust 时:

  • 如果 Valen 引用是那次调用期间唯一能访问该数据的引用,我们可以把它交给 Rust 的 &mut。
  • 如果我们有多个 Valen 引用可以指向同一个对象,它们只能交给 Rust 的 & 引用。

我在 Memory Safety Across the Valen/Rust Boundary 中稍微谈到了这一点,去看看!

这种临时转换可能有一天会演变成一个更广泛的特性。我们希望 Valen 支持类似 Rust 的 Vec<&mut Ship>,其中每个元素都是唯一引用,而组借用没有这个。

我猜想 Valen 可以有类似“唯一组”的东西,例如在函数上标注 unique(world.ships[]),这会阻止创建指向该层级部分的新引用。

不过这方面需要更多思考。Valen 最大的优势之一是它只有一种借用引用……所以引入第二种“唯一”引用需要仔细考虑。

40

在 Mastodon 或 Bluesky 上问我,我很乐意解释!

41

这可能需要给 LLVM 打补丁,让它能更好地利用这些信息。它倾向于以 noalias 来思考,而不是“只读”。棘手,但可能!

42

这并不一定意味着 Valen 会比 C++、Rust 等更快。我认为你可以在那些语言中做额外的重构或使用 unsafe 来获得同等的加速。Valen 在这里的主要好处是人体工程学和灵活性,不一定是速度。

通配符后代路径

这是另一个帮助 Valen 调用 Rust 代码的功能。

在尝试让 Golden Spike 工作时,我遇到了一个难题。

当 Valen 调用一个返回引用的 Rust 函数(如下面的 -> &Gem)时,它如何知道该引用指向哪里?

例如:

impl Chest {
  pub fn gem(&self) -> &Gem { &self.gem }
}

Valen 对此感到困惑,因为 Valen 喜欢知道每个引用的路径。

在 Valen 的理想世界中,它会这样看待上述内容:

func gem(self &Chest) -> &Gem in self.gem { &self.gem }

但可惜!Valen 无法从 Rust 代码中推断出 in self.gem。

但如果你稍微退后一步看,Rust 代码试图表达的是它正在返回某个东西到 self 的“内部某处”。

所以,让我们把这个概念加入 Valen!

Valen 现在将上述 Rust 函数解释为如下形式:

func gem(self &Chest) -> &Gem in self.gem... { &self.gem }

in self.gem... 的意思是“指向 self.gem 内部某处”。

这被称为通配符后代路径,它确实需要一个更好的名字。

我在 Memory Safety Across the Valen/Rust Boundary 中对此有更多描述。

我怀疑这不仅对 Rust 互操作有用。它可以从签名中抹去内部细节,使库 API 更加灵活和向前兼容。不过,我们在这里也要小心,因为理论上其他功能也可以服务于同样的目的。43

43

例如,我们可以让一个类型以公共名称公开重新导出一个私有组。可以说,类似于“关联组”的东西。

这一切如何契合

上面,我提到了“内存安全圣杯”,以及我们需要解决的三个挑战才能找到它:

  • 我们能否让借用检查器更宽松?
  • 我们能否让借用检查器读取更复杂的数据?
  • 我们能否让这两个系统同时协同工作?

Valen 的借用检查器是解决 #1 的关键:它允许我们对任何对象有多个引用,并允许通过其中任何一个进行读写。

#2 和 #3 将在另一篇文章中讨论(因为这篇文章变得太长了!),但我忍不住要快速预览一下它的内容。

这些是我匆忙涂写的,试图在半页内解释它。没有人应该试图理解它。快,跳到前面!

回想一下上面的例子:

struct World {
  entities Vec<Entity>;
}
func step(world &World, entity in world.entities[] mut) {
  ...
}

我希望用户在不想要指定路径时,可以选择使用引用计数的 class。像这样:

class World {
  entities Vec<Entity>;
}
func step(world World, entity Entity) mut {
  ...
}

能让用户自由地这样简化,那将是非常棒的。44

当然,混合引用计数和借用是出了名的困难。 45

答案在于我在 2023 年做的一个思想实验,我称之为 Arrrlang。它的名字是个玩笑,但它有一个有趣的前提:将堆表示为 N 个全局数组,其中 N 是程序中类型的数量。46

我想到:组借用理解数组。而引用计数可以被认为是 N 个数组,它们都相互引用。

如果我们把这两个事实结合起来,那么关键洞察就出现了:如果我们将引用计数的类描述给借用检查器,就好像它是一堆数组一样,那么我们也许能够以内存安全的方式拥有指向它们的借用引用。

编译器当然不需要实际将引用计数对象编译为数组。但它可以用组借用的术语这样描述给借用检查器,。

如果你那样做,很多问题就会突然消失,并且能清楚地说明我们需要为组借用添加哪些构建块:

  • 一个“多类型组”,类似于 Vale 区域,与每个组都需要一个类型的普通组借用相对。47 48
  • 一个“运行时”组。对运行时组的引用不会被无效化。

就像我说的,这些都不会有意义。请留意下一篇讨论所有这些的文章!

44

引用计数的限制要少得多(例如,引用不需要像借用检查中那样是临时的),这意味着你可以解锁更多的模式(观察者、侵入式链表等)。这也意味着在你访问对象时没有人可以释放它,这通常是一件好事。

但这取决于你的视角。引用计数偶尔会有循环问题,并且它与线性类型不太兼容。对于大多数事情,我喜欢引用计数。对于更复杂的部分,或者需要速度的部分,我喜欢借用。

我认为一门语言同时支持两者是件好事。

45

由于与 Rc<RefCell<T>> 中为什么有那个 RefCell 相同的原因,这很困难。

46

这是一个“零成本”语言的例子,但实际上有一些成本,以 ID 追踪和边界检查的形式。

47

这对枚举也会有用!

48

有趣的事实是,这就是他们在 Iris 底层表示堆的方式,Iris 被用来形式化验证 Rust 的部分是安全的。

目前就这些

感谢阅读!

在接下来的几篇文章中,我将更多地解释我们如何混合引用计数和分代引用,以及 Valen 的组借用检查器是如何实现的。

这只是冰山一角,所以请通过订阅我的 RSS 源、r/valen 或加入 Valen discord 来保持关注。并在 Bluesky 和 Mastodon 上关注我!

干杯,

- Evan Ovadia

附录:类型稳定性例外

这部分尚未在 Valen 中实现。敬请期待!

组借用也可以足够智能,知道如果你说 my_vec.push(42),那么即使 push 说 mut(self),它也不会使指向 my_vec.size 的指针无效。

这是合理的,因为无论任何人如何修改 my_vec(它是一个 Vec),my_vec.size 处的对象仍然会在那里。没有人可以将 Vec 的 size 修改为整数以外的任何东西。换句话说,它是“类型稳定的”。

稍微推广一下,组借用永远不会使任何类型稳定的数据无效。

它不会使指向任何包含的类型稳定事物(基元、结构体或固定大小数组)的指针无效,也不会使它们内部的任何类型稳定事物无效。它只会使指向任何类型不稳定事物(枚举的内容、指针的指向对象)内部的指针无效。

例如,在这个程序中,没有人修改 Level 的内容可以使你对它的 Terrain 的引用无效。

struct Level {
  terrain Terrain;
  entities HashMap<EntityId, Entity>;
  location_to_entity HashMap<Location, EntityId>;
}
func add_entity(level &Level mut, entity Entity) {
  level.location_to_entity.insert(entity.loc, entity.id);
  level.entities.insert(entity.id, entity);
}
func main() {
  let level = ...;
  let terrain_ref = &level.terrain;

  // Doesn't invalidate terrain_ref!
  level.add_entity(Entity(1, Loc(4, 5), 42));

  print(terrain_ref.tiles.len());
}

来源:Hacker News · verdagon.dev