ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Comprehensive Rust 深入篇:用 Branding(生命周期品牌化)实现变量专属 Token 类型,在编译期杜绝索引越界

2026/9/10 2:11:24 拓冰建站 浏览量
Comprehensive Rust 深入篇:用 Branding(生命周期品牌化)实现变量专属 Token 类型,在编译期杜绝索引越界 Comprehensive Rust 深入篇用 Branding生命周期品牌化实现变量专属 Token 类型在编译期杜绝索引越界【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust导读本文基于 Google Android 团队编写的 Rust 课程Comprehensive Rust中「Token Types令牌类型」系列的第一课深入讲解Branding品牌化这一高级类型系统技术如何让一个 Token 类型不仅代表「索引必然合法」还能让它在编译期被品牌绑定到某个特定变量从根本上防止把一个数组的合法索引错误地用cross over到另一个数组上。读完本文你将掌握Token 类型的基本原理、生命周期作为唯一品牌的设计思路、PhantomData与型变Variance如何约束生命周期子类型关系、fora高阶 trait bound 的作用以及一份可运行的Bytes/ProvenIndex完整实现并了解它在GhostCell等真实 API 设计中的延伸应用。一、问题起点Token 类型能证明索引合法但证明不了属于哪个变量1.1 动机用 Token 作为合法索引的证明在 Token Types 总览页 中我们已经看到带有私有构造函数的类型可以充当不变量已成立的证明。具体来说通过模块边界module boundary和私有字段API 使用者无法自行构造 Token只能通过 API 开发者提供的函数获得它——于是能拿到 Token本身就等价于满足了 API 开发者设定的前置条件。本课branded-01-motivation.md的动机是我们希望拥有一个 Token 类型它代表指向某字节数组的一个已知、合法in-range的索引。一旦持有这类已被证明合法的索引proven index我们就能够完全跳过边界检查bounds check因为 Token 本身就是该索引确实存在的证明。课程的示例代码构造了这样一个最小实现// // Copyright 2025 Google LLC // // SPDX-License-Identifier: Apache-2.0 struct Bytes { bytes: Vecu8, } struct ProvenIndex(usize); impl Bytes { fn get_index(self, ix: usize) - OptionProvenIndex { if ix self.bytes.len() { Some(ProvenIndex(ix)) } else { None } } fn get_proven(self, token: ProvenIndex) - u8 { unsafe { *self.bytes.get_unchecked(token.0) } } } fn main() { let data_1 Bytes { bytes: vec![0, 1, 2] }; if let Some(token_1) data_1.get_index(2) { data_1.get_proven(token_1); // Works fine! } }这里get_index在返回前做一次运行时检查只有ix self.bytes.len()才返回Some(ProvenIndex(ix))而get_proven因为信任 Token 的合法性直接使用unsafe的get_unchecked跳过边界检查。1.2 核心缺陷Token 可以在不同变量之间串用上面的实现有一个致命漏洞ProvenIndex与产生它的Bytes之间没有任何类型层面的关联。也就是说data_1得到的合法索引完全可以拿去访问data_2// let data_2 Bytes { bytes: vec![0, 1] }; // data_2.get_proven(token_1); // Panics! Can we prevent this?课程明确给出了结论在这个例子里没有任何东西阻止一个数组的 proven index 被用在另一个数组上。如果此时索引恰好越界get_unchecked会直接导致未定义行为undefined behavior——这正是我们最不能接受的后果。把被注释的data_2.get_proven(token_1);取消注释并运行代码会 panic。课程指出我们真正想要的是在编译期就阻止这种 Token 的跨变量串用crossover而不是等到运行时才 panic。二、为什么运行时边界检查不是好方案课程在与学员的互动中专门讨论了这个替代方案Vec::get和Bytes::get_index本身就已经在做运行时边界检查但运行时检查无法在第一时间阻止错误的串用发生——它只是在错误已经发生之后保证程序以 panic而非 UB收场。换句话说运行时检查解决的是崩得安不安全而不是根不根得上错。我们想要的是把这个索引只能用于产生它的那个变量这一约束提前到编译期让错误的代码根本无法通过编译。这里的核心矛盾是一个Vecu8运行时才知道自己有多长所以get_index在编译期不可能静态判断某个usize是否越界但我们可以把越界与否的证明责任转移给类型系统让能拿到 Token就等价于该索引已经被验证过合法。三、Branding用生命周期给每个 Token 打上独一无二的品牌课程给出的答案是Branding品牌化。这是 Token 类型技术的高级形态它把 Token 类型的适用范围扩展到更多 API 设计场景。核心思路来自两页后续课程3.1 两条设计原则见 branded-02-phantomdata.md把生命周期当作每个 Token 的唯一品牌brand让不同变量的生命周期彼此足够不同使它们之间无法隐式互相转换无法建立子类型关系。为什么是生命周期因为 Rust 中生命周期是唯一支持子类型关系subtyping的实体。子类型关系允许编译器判断一个生命周期是否长于另一个也允许两个不同的生命周期在它们重叠的区域里被当作同一个使用。这通常是我们想要的例如把两个引用的最短公共生命周期视为两者共同的生存区间但在 Token 场景下我们恰恰不希望两个 Token 的生命周期能互相比较、互相收缩成公共子类型——否则两个不同变量的 Token 就会被编译器视为相似从而放行。于是目标明确构造出两个生命周期让编译器无法判断谁更长。只有在这种情况下两个 Token 才能既共存于同一作用域又在类型层面彼此不可互通。3.2PhantomData与型变Variance如何锁死生命周期PhantomData在本系列中承担关键角色——它引入一个形式上使用、实际上零开销的假想类型或生命周期参数让结构体在不存储任何实际字段的情况下仍然携带生命周期信息PhantomData的完整讲解见 borrow-checker-invariants 章节的 phantomdata 系列。但仅仅写PhantomDataid ()是不够的。课程的讲解指出生命周期在PhantomData里的行为不仅取决于生命周期来自哪里还取决于引用是怎么定义的——这由型变Variance决定写法生命周期型变类型型变能否阻止子类型收缩id ()协变covariant协变❌ 太宽松会编译通过id mut ()协变不变❌ 仍不够*mut id ()不变invariant协变✅ 不可收缩*mut id mut ()不变不变✅ 不可收缩课程建议在课堂上依次演示这四种写法从id ()生命周期、类型均协变一路收紧到*mut id mut ()生命周期、类型均不变与*mut id ()生命周期不变、类型协变。后两种无法通过编译也就是我们终于找到了把生命周期绑进PhantomData、使其彼此不可比较的正确姿势。原理上*mut表示可变裸指针——Rust 确实有裸指针但在安全 Rust 中无法对裸指针进行推理。把带生命周期的引用放进可变裸指针里会显著增加编译器做子类型推断的难度因为借用检查器无法在可变裸指针上建立子类型关系。于是InvariantLifetime中生命周期的型变被压到仅当两个生命周期完全相同时才能建立子类型关系达到我们想要的各变量 Token 彼此隔离。对应的关键类型定义后续课程 branded-03-impl.md 的完整实现use std::marker::PhantomData; #[derive(Default)] struct InvariantLifetimeid(PhantomData*mut id ());3.3fora让闭包对所有可能生命周期都成立并堵死 API 使用者的后门Branding 实现中的第二个关键构件是fora高阶 trait boundHigher-Ranked Trait BoundHRTB。它把a作为生命周期泛型参数引入函数类型并要求闭包主体对所有可能的生命周期都成立。其效果有二编译器无法对该生命周期做任何具体假设——因为调用者负责代入真实生命周期函数自身不能指定这相当于数学里的全称量词 ∀或是类型变量T之于类型只是这里作用在生命周期上防止 API 使用者自行指定生命周期。设想如果允许用户选择生命周期他们就能让两个变量的生命周期恰好相同从而绕过我们想强加的隔离约束。课程用fn fooT, U(first: T, second: U)打比方即使传入的两个实参类型相同函数体内也无从得知T与U是不是同一个类型。同理fora保证了被传入的闭包必须在任意生命周期下都能通过借用检查。下面的代码片段用lifetime_separatortry_coerce_lifetimes作为编译期探针只要两个Wrapper的生命周期还能收缩成公共子类型try_coerce_lifetimes(wrapped_1, wrapped_2)就能编译当InvariantLifetime收紧到不变型变后这行代码必须注释掉才能通过编译——从而验证品牌隔离是否生效// // Copyright 2025 Google LLC // // SPDX-License-Identifier: Apache-2.0 use std::marker::PhantomData; #[derive(Default)] struct InvariantLifetimeid(PhantomDataid ()); // 主焦点从 id () 收紧到 *mut id () struct Wrappera { value: u8, invariant: InvariantLifetimea } fn lifetime_separatorT(value: u8, f: impl fora FnOnce(Wrappera) - T) - T { f(Wrapper { value, invariant: InvariantLifetime::default() }) } fn try_coerce_lifetimesa(left: Wrappera, right: Wrappera) {} fn main() { lifetime_separator(1, |wrapped_1| { lifetime_separator(2, |wrapped_2| { // 我们希望这行不要编译 try_coerce_lifetimes(wrapped_1, wrapped_2); }); }); }课程备注不必期望学员立刻完全理解型变把它当作限制生命周期建立子类型关系的严格性阶梯即可——协变最宽松不变最严格。四、完整实现BrandedBytesProvenIndex在 branded-03-impl.md 中课程给出了完整的可运行实现// // Copyright 2025 Google LLC // // SPDX-License-Identifier: Apache-2.0 use std::marker::PhantomData; #[derive(Default)] struct InvariantLifetimeid(PhantomData*mut id ()); struct ProvenIndexid(usize, InvariantLifetimeid); struct Bytesid(Vecu8, InvariantLifetimeid); implid Bytesid { fn newT( // 我们想在此上下文中处理的数据 bytes: Vecu8, // 唯一品牌化某个 Bytes 实例生命周期的函数 f: impl fora FnOnce(Bytesa) - T, ) - T { f(Bytes(bytes, InvariantLifetime::default())) } fn get_index(self, ix: usize) - OptionProvenIndexid { if ix self.0.len() { Some(ProvenIndex(ix, InvariantLifetime::default())) } else { None } } fn get_proven(self, ix: ProvenIndexid) - u8 { debug_assert!(ix.0 self.0.len()); unsafe { *self.0.get_unchecked(ix.0) } } }实现中有三个值得注意的设计决策课程逐一追问并解答为什么new不返回Bytes而是接收一个一次性闭包因为我们需要Bytes拥有一个由 API 控制、完全唯一的生命周期。假如写成假想的fn newa() - Bytesa生命周期a就由API 使用者选择我们便无法再保证不同Bytes实例的生命周期彼此唯一、无法互相子类型收缩。闭包形式把生命周期的选择权完全收回 API 内部每次调用new闭包参数Bytesa中的a都由fora统一量化变量之间天然彼此隔离。为什么既要有get_index又要有get_proven因为在编译期无法知道某个索引是否被占用所以必须先有get_index做运行时检查换取 Token而get_proven拿到 Token 后就能跳过边界检查同时哪些索引已被占用的知识被限定在单个变量内不会错误地用到别的变量上。这里的重点不只是省掉边界检查更在于防止索引串用。debug_assert!的意义是什么在安全保证之上再留一道调试期防线用于在开发阶段捕获逻辑错误正式路径中真正的不变量由类型系统生命周期品牌保证。五、实际效果Token 无法跨变量使用见 branded-04-in-action.md有了完整的实现我们可以写出这样的程序// // Copyright 2025 Google LLC // // SPDX-License-Identifier: Apache-2.0 use std::marker::PhantomData; #[derive(Default)] struct InvariantLifetimeid(PhantomData*mut id ()); struct ProvenIndexid(usize, InvariantLifetimeid); struct Bytesid(Vecu8, InvariantLifetimeid); implid Bytesid { fn newT( bytes: Vecu8, f: impl fora FnOnce(Bytesa) - T, ) - T { f(Bytes(bytes, InvariantLifetime::default())) } fn get_index(self, ix: usize) - OptionProvenIndexid { if ix self.0.len() { Some(ProvenIndex(ix, InvariantLifetime::default())) } else { None } } fn get_proven(self, ix: ProvenIndexid) - u8 { self.0[ix.0] } } fn main() { let result Bytes::new(vec![4, 5, 1], |mut bytes_1| { Bytes::new(vec![4, 2], |mut bytes_2| { let index_1 bytes_1.get_index(2).unwrap(); let index_2 bytes_2.get_index(1).unwrap(); bytes_1.get_proven(index_1); bytes_2.get_proven(index_2); // bytes_2.get_proven(index_1); // ❌ 无法编译 Computations done! }) }); println!({result}); }把被注释的bytes_2.get_proven(index_1);取消注释编译器会直接报错——来自不同变量的 Token 无法互相使用。这正是本课Branding 1/4提出的问题在系列后续三课中的完整落地方案同作用域内可以存在多个变量但每个变量的类型在编译期自动彼此不同几乎零样板代码。5.1 扩展思考课程在 branded-04-in-action.md 中进一步追问了几个延伸方向哪些操作可以保证产出 proven index例如push向容器尾部追加成功后self.0.len() - 1必然合法可以直接返回 Token// // Copyright 2025 Google LLC // // SPDX-License-Identifier: Apache-2.0 fn push(mut self, value: u8) - ProvenIndexid { self.0.push(value); ProvenIndex(self.0.len() - 1, InvariantLifetime::default()) }能否从字节数组泛化到任意VecT当然可以——把Bytesid推广为BrandedVecid, T即可。这套技术还能用在哪里这就是GhostCell的用武之地它是一种允许在 Rust 中安全构建循环数据结构以及其他此前难以表达的数据结构的构造正是用这种 Token 类型来保证cell 不会逃逸出我们知道相关操作安全的上下文。课程注明本系列Branded Types正是基于GhostCell论文中的BrandedVec实现改编而来作为理解GhostCell本身的温和入门GhostCell还在 Rust 类型系统之外使用形式化验证证明了生命周期品牌化相关操作的安全性。六、延伸阅读Token 类型的其他形态Branding 是 Token 类型在变量隔离方向上的深化而同属 token-types 目录 的其他课程展示了 Token 类型的更多应用有助于理解本文技术的定位Permission Tokens权限令牌AdminToken作为已通过密码校验的证明——不持有 Token 就无法调用add_moderator于是能调用本身就等价于具备权限。它说明 Token 类型非常适合建模受检权限checked permission。Token Types with Data: Mutex GuardsMutexGuard是权限 数据合一的 Token——它既证明你在当前时刻拥有对该值的读写权限又通过Deref/DerefMut在Mutex对用户隐藏数据的前提下提供数据访问。拿不到MutexGuard就既无权限、也无任何途径访问被保护的数据这与 C 中 mutex 与 lock guard 不控制数据访问、仅靠使用者自觉检查的做法形成鲜明对比。Newtype Pattern新类型模式Token 类型与 newtype 一样都借助结构体字段私有 模块边界来限制构造区别在于 newtype 侧重值必须满足不变量才可构造而 Token 侧重必须满足访问条件才可获得。七、小结本课Branding 1/4的核心贡献是提出了一个看似简单、实则需要整套高级类型系统技巧的问题Token 类型可以证明索引已通过检查、必然合法从而允许跳过边界检查但如果不做品牌化Token 会被允许跨变量串用越界时触发未定义行为运行时边界检查只能保证panic 而非 UB无法从根源阻止错误解决之道是Branding用生命周期作为每个 Token 的唯一品牌借助PhantomData*mut id ()把生命周期的型变压到仅在完全相同生命周期间才可子类型收缩配合fora高阶 trait bound 把生命周期选择权收回 API 内部最终在编译期把不同变量的 Token 不可互通变成类型系统的硬性约束。由此得到的 Token API 是高度受限的但正因为受限它能在 Rust 类型系统内部证明的事情才真正有意义——从本文的索引品牌化到GhostCell的安全循环数据结构Branding 为 Token 类型打开了通往更广泛 API 设计的大门。后续课程将分别深入PhantomData与型变Branding 2/4、完整实现Branding 3/4与实战Branding 4/4。【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考