Mojo 生命周期、origin 与引用(ref)实战:从 lifetime checker 到容器引用安全
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
导读
本文以仓库中 Mojo/docs/site/code/manual/values/lifetimes/README.md 所指向的 Mojo Manual 的 Lifetimes, origins, and references 章节 及其配套代码示例为主线,系统讲解 Mojo 编译器的生命周期检查机制:什么是 origin(起源)、如何用ref参数与ref返回值表达参数化可变性的引用,以及Span、Pointer等按 origin 参数化的类型如何与容器安全协作。读完本文,你将能看懂并写出带显式 origin 与ref的 Mojo 类型签名,理解origin_of()、ImmStaticOrigin、origin 并集等核心概念,并掌握如何在 Bazel 工程中编译运行这些示例。
代码示例目录的定位
仓库中的Mojo/docs/site/code/manual/values/lifetimes/目录是 Mojo Manual 中 "Lifetimes, origins, and references" 一节的配套代码与测试集。目录结构与约定如下:
- 每个
.mojo文件都是一个独立、可单独运行的 Mojo 应用程序; - BUILD.bazel 为每个
.mojo文件生成一个mojo_binary目标(以去扩展名的文件名命名),并为每个二进制目标生成一个modular_run_binary_test测试目标(以_test后缀命名)。
目前目录包含三个示例文件,分别覆盖了 origin 与引用主题的三个侧面:
| 文件 | 主题 |
|---|---|
| name_list.mojo | ref返回值:__getitem__()返回可变引用并直接就地修改 |
| immutable_name_list.mojo | ref返回值的参数化可变性:不可变入参自动退化为只读引用 |
| inferred_origin.mojo | 推断 origin:用Origin[mut=is_mutable]参数把入参 origin 传递到返回的Span |
从 BUILD.bazel 可以看到其构建方式:
MOJO_SRCS = glob(["*.mojo"]) [ mojo_binary( name = src.split(".")[0], srcs = [src], deps = ["@mojo//:std"], ) for src in MOJO_SRCS ] [ modular_run_binary_test( name = src.split(".")[0] + "_test", size = "small", binary = src.split(".")[0], ) for src in MOJO_SRCS ]即:所有.mojo文件自动成为二进制目标,并各配一个small大小的运行测试。这意味着每个示例不仅是文档片段,更是被持续集成验证的可执行程序。
理解 origin:编译器追踪生命周期与引用有效性的核心
Mojo 编译器内置了一个生命周期检查器(lifetime checker)——一个分析程序数据流的编译器 pass。它判断变量在哪些时刻仍然有效,并在变量生命周期结束时自动插入析构调用(deinitializer calls)。
编译器使用一种特殊值origin来追踪变量的生命周期与引用的有效性。具体而言,一个 origin 回答两个问题:
- 哪个变量"拥有"这个值?—— 决定引用的存活期间需要延长谁的寿命;
- 通过这个引用能否修改该值?—— 决定可变性(mutability)与独占性检查的语义。
考虑以下代码(取自 lifetimes.mdx):
def print_str(s: String): print(s) def main(): var name: String = "Joan" print_str(name)name = "Joan"声明了一个带标识符(name)和一块String逻辑存储空间的变量。当把name传入print_str()时,函数拿到的是对该值的不可变引用。于是name与s指向同一块逻辑存储空间,各自关联的 origin 值让编译器能够对它们进行统一的推理。
关键认知:origin 追踪发生在编译期。origin 并不追踪name变量实际分配的那块存储地址,而是符号化地追踪变量——编译器记录"print_str()是用调用方作用域中由name拥有的值调用的"。通过追踪被拥有的数据如何在程序中流动,编译器就能确定值的生命周期。
大多数情况下,origin 由编译器自动处理。但在以下两类场景中,你需要直接与 origin 打交道:
- 使用引用时——尤其是
ref参数与ref返回值; - 使用以 origin 为类型参数的类型时——例如
Pointer与Span,它们都以所指向数据的 origin 作为参数化依据。
Origin 类型:ImmOrigin、MutOrigin与Origin
Mojo 提供了一个Origin结构体以及一组comptime类型别名,用于在签名中显式书写 origin 类型。见 Mojo/stdlib/std/origin/init.mojo:
comptime ImmOrigin = Origin[mut=False] # 不可变 origin 引用类型 comptime MutOrigin = Origin[mut=True] # 可变 origin 引用类型Origin[mut=...]结构体携带一个mut: Bool参数,表示该 origin 是可变的、不可变的,还是"可变性取决于外围 API 给定的参数"。
例如,固定不可变的引用类型可以这样声明:
struct ImmutRef[origin: ImmOrigin]: pass而参数化可变性的引用类型则写成:
struct ParametricRef[ is_mutable: Bool, //, origin: Origin[mut=is_mutable] ]: pass这里的is_mutable是一个infer-only(仅推断)参数——调用方无法显式指定,只能由编译器根据实参推断。origin参数同样常常是被推断的。例如下面的代码创建了一个指向既有值的Pointer,但无需书写 origin——origin 自动从既有值推断而来:
from std.memory import Pointer def use_pointer(): var a = 10 var ptr = Pointer(to=a)从源码看,Origin结构体(Mojo/stdlib/std/origin/init.mojo)本身带有两个参数:mut: Bool以及一个原始 MLIR origin 值(_mlir_origin),并且继承自TrivialRegisterPassable——这印证了 origin 是编译期符号、可像寄存器值一样传递,且不产生运行时表示。
Origin 集(OriginSet)
OriginSet并不是一种 origin 类型,而是一组 origin 的集合,用于追踪参数化闭包(parametric closures)中捕获值的生命周期。需要强调的是,OriginSet不是表达"多个 origin 组合"的通用机制——表达多 origin 组合应使用下面的origin_of()并集形式。对应到 MLIR 层面,它是!lit.origin.set这一单例类型(见 Mojo/stdlib/std/origin/init.mojo 与 origin-design.md)。
Origin 值的五种来源
大多数 origin 值由编译器创建。作为开发者,有几种方式可以获得或指定 origin 值:
1. 静态 origin:ImmStaticOrigin
ImmStaticOrigin代表存活于整个程序运行期间的不可变值。字符串字面量(StringLiteral)就拥有ImmStaticOrigin。例如as_string_slice()返回一个指向原始字符串字面量的StringSpan——字符串字面量在编译期分配、永不销毁,因此该切片使用不可变静态 origin。
2. 派生 origin:origin_of()
origin_of(value)是一个魔法操作符,返回传入值(或值列表)关联的 origin。它的参数可以是任意求值结果满足以下条件之一的表达式:
- 一个 origin 值;
- 一个具有内存位置(memory location)的值。
例如:
origin_of(self) origin_of(x.y) origin_of(foo())origin_of()是纯静态的编译期分析——传给它的表达式永远不会被真正求值(例如编译器分析origin_of(foo())时并不会执行foo()函数)。
下面的BoxedString结构体展示了派生 origin 的典型用法:as_ptr()返回一个Pointer,其 origin 与原OwnedPointer相同:
from std.memory import OwnedPointer, Pointer struct BoxedString: var o_ptr: OwnedPointer[String] def __init__(out self, value: String): self.o_ptr = OwnedPointer(value) def as_ptr(mut self) -> Pointer[String, origin_of(self.o_ptr)]: return Pointer(to=self.o_ptr[])注意as_ptr()的self采用mut self约定。若使用默认参数约定,self就是不可变的,派生出的origin_of(self.o_ptr)也会变成不可变 origin——参数的 mutability 会通过origin_of()传导到返回类型上。
origin_of()还可以接收多个表达式以表达 origin 的并集:origin_of(a, b)。
3. 推断 origin
由于 origin 本质上是参数,编译器可以根据传入函数的实参推断origin 值(即"参数推断"机制)。这使得函数能够返回一个与传入参数同 origin 的值——ref参数一节将给出具体示例。
4. 未追踪 origin:MutUntrackedOrigin与ImmUntrackedOrigin
未追踪 origin 表示不与任何既有值别名的值:它们指向不被任何其他变量拥有的内存,因此生命周期检查器不追踪它们。例如alloc()返回一个指向新分配动态内存块的Allocation,其 origin 为MutUntrackedOrigin——表示这块内存不受 Mojo 所有权系统管理。使用此类不安全 API 时,寿命管理责任在开发者自己:例如一个分配内存的结构体,通常应在析构函数中释放该内存。
从 Mojo/stdlib/std/origin/init.mojo 的实现可以看到,未追踪 origin 正是#lit.origin.union<>——一个空并集,承诺该引用不别名任何编译器管理的值。
5. 通配 origin:ImmUnsafeAnyOrigin与MutUnsafeAnyOrigin
通配 origin 是特殊情形,表示可能访问任何存活值的引用。它们曾被广泛用于不安全指针。携带通配 origin 的指针进入某作用域后,会带来三重副作用:
- 只要指针存活,就停用该作用域内所有值的 ASAP(尽可能早)析构;
- 阻止 Mojo 强制执行参数独占性检查;
- 隐藏未使用变量的警告。
因此,通配 origin 的使用被明确劝阻,应作为最后手段。源码注释同样强调这是"早期 Mojo 时代的临时编译器逃生舱,永不稳定,计划弃用并移除"(Mojo/stdlib/std/origin/init.mojo)。
使用引用:ref参数与ref返回值
ref关键字可用于参数与返回值,表达参数化可变性(parametric mutability)的引用——即引用本身在调用点既可能是可变的也可能是不可变的。ref返回值对调用方而言看起来与普通返回值无异,但它是对既有值的引用而非拷贝。
ref参数
ref参数约定让你指定一个可变性参数化的参数:无需事先知道传入实参是可变的还是不可变的。使用ref参数通常出于以下原因:
- 希望接受参数化可变性的参数;
- 希望把某个参数的寿命绑定到另一个参数的寿命;
- 希望参数保证以内存形式传递:这对需要"身份"的参数化类型很有用,无论具体类型是否可以按寄存器传递。
ref参数的语法有两种形式:
ref arg_name: arg_type或带 origin 子句的形式:
ref[origin_specifier(s)] arg_name: arg_type第一种形式下,ref参数的 origin 与可变性从传入值推断;第二种形式在方括号内给出一个或多个 origin 说明符。origin 说明符可以是:
一个 origin 值;
任意表达式,视为
origin_of(expression)的简写——以下两种声明等价:ref[origin_of(self)] ref[self]一个
AddressSpace值;下划线
_,表示 origin 是未绑定的(等价于省略 origin 说明符):def add_ref(ref a: Int, b: Int) -> Int: return a+b
你也可以显式命名 origin。这在想把参数限制为ImmOrigin或MutOrigin之一、或想把函数返回值绑定到某个参数的 origin 时非常有用。
实战示例:把 origin 从参数传递到返回值
Span是连续数据的非拥有视图(如字符串的子串、列表的子集)。因为它指向自己不拥有的数据,所以以 origin 值参数化,该 origin 代表其所指向数据的寿命与所有权。
to_byte_span()接收一个List[Byte],返回一个与列表同 origin 的Span[Byte](该示例正是目录中的 inferred_origin.mojo):
from std.collections import List, Span def to_byte_span[ is_mutable: Bool, //, origin: Origin[mut=is_mutable], ](ref[origin] list: List[Byte]) -> Span[Byte, origin]: return Span(list) def main(): var list: List[Byte] = [77, 111, 106, 111] _ = to_byte_span(list)这里的origin参数由list实参推断而来,并被用作返回Span的 origin。由于Span继承了list实参的 origin,编译器可以识别该 span 的数据由list拥有:span 与 list 有相同的寿命,且 list 可变时 span 也可变。
从设计文档 origin-design.md 可知,这种"无 origin 说明符的ref参数"在解析时会获得一个推断 origin:parser 为函数添加两个隐式参数(一个Bool表示可变性、一个!lit.origin<mut=该bool>表示 origin 本身),并把参数的!lit.ref类型绑定到它们。这就是单个ref a: Int签名无需两个重载就能同时接受example(1)与example(someMutVar)的底层原理。
ref返回值
与ref参数类似,ref返回值允许函数返回对某个值的可变或不可变引用。其语法为:
-> ref[origin_specifier(s)] arg_type注意:ref返回值必须提供 origin 说明符。允许的说明符取值与ref参数一致。
ref返回值是高效处理集合元素更新的常用手段。标准做法是实现__getitem__()与__setitem__()这对 dunder 方法——它们分别在下标读取与写入时被调用:
var value = list[a] list[b] += 10当__getitem__()使用ref参数/返回值时,它可以返回一个可直接就地修改的可变引用。与__setitem__()相比各有取舍:
- 可变引用更高效——单次更新不会被打散成两个方法调用;但代价是被引用的值必须位于内存中。
__getitem__()/__setitem__()成对方案则允许在取值与赋值时运行任意代码,例如__setitem__()可以对输入值做校验或约束。
示例:返回引用的__getitem__()
以下NameList的__getitem__()返回一个引用(即目录中的 name_list.mojo):
from std.collections import List struct NameList: var names: List[String] def __init__(out self, *names: String): self.names = List[String]() for name in names: self.names.append(name) def __getitem__(ref self, index: Int) raises -> ref[self.names[0]] String: if index >= 0 and index < len(self.names): return self.names[index] else: raise Error("index out of bounds") def main() raises: var list = NameList("Thor", "Athena", "Dana", "Vrinda") ref name = list[2] print(name) name += "?" print(list[2])输出:
Dana Dana?这里用ref name = list[2]创建了一个引用绑定(reference binding),随后name += "?"直接修改了list中下标 2 的元素。注意返回值类型中的ref[self.names[0]]——它把返回引用的 origin 派生自self.names的首元素 origin,从而将引用与NameList内部的存储正确关联。
重要区分:如果把ref返回值赋给普通变量,变量得到的只是被引用项的拷贝;只有使用引用绑定语法才能捕获引用供后续使用:
var name_copy = list[2] # owned copy of list[2] ref name_ref = list[2] # reference to list[2]返回值的参数化可变性
ref返回值的另一大优势是支持参数化可变性。回到__getitem__()的签名:
def __getitem__(ref self, index: Int) raises -> ref[self] String:由于返回值的 origin 绑定在self的 origin 上,当方法通过可变引用调用时返回可变引用;而当你只有NameList的不可变引用时,方法依然可用,只是返回不可变引用(即目录中的 immutable_name_list.mojo):
def pass_immutable_list(list: NameList) raises: print(list[2]) # list[2] += "?" # Error, this list is immutable def main() raises: var list = NameList("Sophie", "Jack", "Diana") pass_immutable_list(list)输出:
Diana注释行list[2] += "?"若被取消注释将触发编译错误——因为此处list是不可变参数。若没有参数化可变性,你就必须为__getitem__()编写两个版本:一个接受不可变self,另一个接受可变self。这正是ref机制的价值所在。
带 origin 并集的返回值
ref返回值的 origin 说明符中可以包含多个值,从而表达 origin 的并集(union)。例如下面的pick_one()返回两个输入字符串之一的引用,其 origin 是两者 origin 的并集:
def pick_one(cond: Bool, ref a: String, ref b: String) -> ref[a, b] String: return a if cond else b由于编译器无法静态确定运行时会走哪个分支,该函数必须使用并集 origin[a, b]。这确保编译器在返回引用存活的整个期间同时延长两个值的寿命。并集引用只有在所有组成 origin 都可变时才可变(对应设计文档中的规则:并集的可变性是各成员可变性的逻辑 AND)。
从 origin-design.md 的 IR 层面描述可以看到,并集在 MLIR 中表现为#lit.origin.union<...>属性;CheckLifetimespass 在引用存活期间会延长所有成员 origin 的寿命,这正是"生命周期扩展(lifetime extension)"不变量的一部分。
底层实现:origin 如何穿过编译管线
虽然本目录以代码示例为主,但理解 origin 的底层生命周期有助于写出更可靠的类型签名。根据仓库中的设计文档 origin-design.md,origin 信息经过三个阶段:
Mojo source ▼ Parser (MojoParser) ── 生成带 origin 的 LIT IR;检查调用点独占性 ▼ LowerSemanticCF ── 为分析降低语义控制流 ▼ CheckLifetimes ── 检查未初始化变量使用、插入析构调用 ▼ LowerLIT ── lit → kgen;剥离所有 origin 类型/属性 ▼ Elaboration, code generation, etc...- Parser负责构建绝大多数 origin:为函数签名、局部变量、
ref参数等附加 origin 属性,并在每个调用点执行参数独占性检查(读/读别名允许,读/写、写/写冲突在同一 origin 上被拒绝)。 - CheckLifetimes是生命周期检查核心,强制执行四条不变量:使用先于销毁(引用不得比其命名的值存活更久)、生命周期扩展(被引用值存活到最后一个引用消失,包括并集 origin)、参数独占性、ASAP 析构(在最早安全点插入析构调用)。
- LowerLIT在寿命验证完成后把
!lit.ref<T, origin>降低为普通指针!kgen.pointer<T>,所有 origin 属性被替换为空结构体——origin 仅存在于编译期,零运行时开销。
该文档还详细描述了 interior origin(内部 origin)机制:List等容器把元素存放在堆存储中,list[i]返回的引用指向容器拥有的内存。编译器通过 interior origin(如诊断信息中渲染的list["element"])对流敏感的引用失效建模——append()等可能重分配缓冲区的操作会使先前取得的元素引用失效,后续使用将报编译错误,而不是像 C/C++ 那样成为未定义行为。这也解释了为什么NameList.__getitem__的返回类型需要显式书写ref[self.names[0]]这类 origin 派生表达式:它把返回引用的寿命与容器的具体存储槽位正确挂钩,让编译器能够精确追踪失效关系。
如何构建与运行这些示例
仓库采用 Bazel 构建。要在本地编译运行本目录的示例与测试,可以基于 Mojo/docs/site/code/manual/values/lifetimes/BUILD.bazel 的规则定义,使用仓库根目录的bazelw包装脚本执行类似命令:
./bazelw build //Mojo/docs/site/code/manual/values/lifetimes:name_list ./bazelw test //Mojo/docs/site/code/manual/values/lifetimes:name_list_test(name_list可替换为immutable_name_list、inferred_origin等任一示例名。)每个示例的mojo_binary目标依赖@mojo//:std(即标准库),modular_run_binary_test目标负责把可执行文件跑一遍作为冒烟测试。
此外,本目录的权威文字说明位于 Mojo/docs/site/manual/values/lifetimes.mdx,而更深入的编译器内部设计(LIT IR、origin 属性代数、interior origin)可继续阅读 Mojo/proposals/origin-design.md;origin 类型的标准库定义与全部别名(ImmOrigin、MutOrigin、ImmUntrackedOrigin、MutUntrackedOrigin、ImmStaticOrigin、OriginSet等)集中在 Mojo/stdlib/std/origin/init.mojo。
小结
Mojo 的 origin 系统把"值归谁拥有"与"能否通过此引用修改"两个问题提升为编译期一等公民:Origin[mut=...]提供参数化的可变性类型,origin_of()提供派生 origin 与并集表达,ref参数与ref返回值则让 API 既能参数化地接受或返回引用,又能把寿命关系从入参精确传递到出参。仓库中三个配套示例从"可变引用就地修改"、"不可变自动退化"到"origin 跨函数传递"层层递进,配合 Bazel 构建测试,是动手验证上述概念的最小可运行集。掌握这些机制,是写出既安全又高效的容器与指针类 Mojo API 的基础。
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考