品牌类型实战:用 Rust 类型系统在编译期锁定索引归属(Comprehensive Rust 之 Token Types)
品牌类型实战用 Rust 类型系统在编译期锁定索引归属Comprehensive Rust 之 Token Types【免费下载链接】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专题的收官篇。该仓库以 mdbook 形式组织课程内容本篇对应的原始讲义位于 src/idiomatic/leveraging-the-type-system/token-types/branded-04-in-action.md是本专题 4 讲中的第 4 讲Branding 4/4。主题核心完成品牌类型Branded Types的最终落地演示如何用不变生命周期invariant lifetime作为品牌把ProvenIndex这类 Token 类型与某个具体的Bytes变量绑定使其无法跨变量使用。在当前项目中的应用场景该讲义是课程善用类型系统Leveraging the Type System章节的一部分紧接前三讲动机、PhantomData与生命周期变型、实现用于课堂演示令牌不可跨变量共享的编译期证明。读者读完后能掌握读懂并复现一个完整的品牌类型实现理解fora高阶生命周期约束与PhantomData*mut id ()不变变型的作用理解 Token 类型为何能充当索引存在的证明以及如何将Bytesid泛化为BrandedVecid, T并延伸到GhostCell等实际库的设计思想。一、背景回顾为什么需要品牌在深入本篇之前先回顾本系列前三讲建立的核心概念它们是理解本篇代码的钥匙。1.1 Token 类型作为不变式证明的类型Token Types 专题的引言页token-types.md给出了最基本的思想带有私有构造函数的类型可以用作不变式的证明Types with private constructors can be used to act as proof of invariants。通过模块边界与结构体私有字段的组合API 设计者可以制造一种用户无法自行构造的类型。例如pub mod token { // A public type with private fields behind a module boundary. pub struct Token { proof: () } pub fn get_token() - OptionToken { Some(Token { proof: () }) } } pub fn protected_work(token: token::Token) { println!(We have a token, so we can make assumptions.) }这里的proof: ()字段至关重要——如果删掉它Token就没有私有字段用户便可以在模块外任意构造该类型的值整个证明机制便形同虚设。课程配套的权限令牌示例permission-tokens.md用AdminToken充当密码校验通过的证明与 MutexGuard 示例mutex-guard.mdMutexGuard是持有锁、拥有读写权限这一事实的证明同时携带访问数据的引用都体现了这一思想。1.2 前三讲从无法构造到无法跨变量使用然而仅仅不可任意构造还不够。第一讲branded-01-motivation.md提出了一个更尖锐的问题我们能否把 Token 绑定到一个具体的变量上考虑最朴素的实现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) } } }ProvenIndex一旦产生就可以被用到另一个Bytes变量上。一旦越界get_unchecked就会产生未定义行为undefined behavior——注释里被屏蔽的那行data_2.get_proven(token_1)演示的正是这种令牌跨界的危险。第二讲branded-02-phantomdata.md给出了方案的两大支柱用生命周期作为每个令牌的独特品牌让两个不同变量的生命周期彼此无法隐式转换通过PhantomData的变型Variance选择使编译器无法在两个品牌生命周期之间建立子类型subtyping关系——即无法判断谁比谁活得更久从而拒绝把一个变量得到的令牌用在另一个变量上。变型的关键在于PhantomData中引用类型的写法。课程的演示路径是PhantomData参数变型行为能否通过第二讲的try_coerce_lifetimes检查id ()生命周期协变covariant能通过不够严格id mut ()生命周期协变、类型不变能通过仍不够严格*mut id mut ()生命周期不变、类型不变不能通过符合要求*mut id ()生命周期不变、类型协变不能通过符合要求原因在于*mut是可变裸指针mutable raw pointer借用检查器无法在安全 Rust 中对它进行推理编译器因此失去了对其中生命周期的子类型化能力。于是最终定型为课程全程使用的#[derive(Default)] struct InvariantLifetimeid(PhantomData*mut id ());第三讲branded-03-impl.md则给出了完整的类型骨架Bytesid作为品牌类型ProvenIndexid作为品牌令牌并通过构造时不直接返回Bytes而是把Bytes交给一个一次性闭包的方式确保生命周期由 API 独家控制。二、本篇核心完整的品牌类型实现本篇branded-04-in-action.md给出的实现是前三讲的集大成版本代码可以直接在 mdbook 的 playground 中运行原讲义标注为rust,editableuse std::marker::PhantomData; #[derive(Default)] struct InvariantLifetimeid(PhantomData*mut id ()); struct ProvenIndexid(usize, InvariantLifetimeid); struct Bytesid(Vecu8, InvariantLifetimeid); implid Bytesid { fn newT( // The data we want to modify in this context. bytes: Vecu8, // The function that uniquely brands the lifetime of a 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 { self.0[ix.0] } }这份实现有三个值得反复咀嚼的设计决策它们全部来自第三讲的问答环节2.1 为什么new不返回Bytes而是把Bytes交给闭包假设new像普通构造函数一样返回Bytesafn newa() - Bytesa { ... }此时生命周期a由调用方选定。调用方完全可以把两个Bytes实例的生命周期统一成同一个a从而绕过每个变量拥有独特品牌的约束。因此 API 设计者必须收回生命周期的选择权new的参数签名是f: impl fora FnOnce(Bytesa) - T这里的foraHigher-Ranked Trait BoundHRTB要求闭包对所有可能生命周期都成立当new内部调用f(Bytes(bytes, ...))时品牌生命周期由new自己引入并传递给Bytes调用方无法替Bytes指定生命周期也就无法让两个变量共享同一个品牌。这在数学上相当于全称量词 Ɐ函数体必须在任意生命周期下都满足借用检查规则而不能假设某个具体的生命周期。这同时也防止了 API 使用者自己定义一个生命周期来钻空子。2.2 为什么需要get_index和get_proven两个方法因为索引是否越界在编译期无法确定——get_index接收任意usize只能在运行时用ix self.0.len()检查后才把这个索引是合法的这一事实编码进ProvenIndexid令牌里。而get_proven之所以敢于直接self.0[ix.0]取下标正是因为索引值来自ProvenIndex即已经通过了get_index的边界检查更关键的是get_proven(self, ix: ProvenIndexid)中的id把Bytes和ProvenIndex绑定在同一个品牌生命周期上——跨变量使用会在编译期被拒绝所以索引必然属于这个Bytes自己。第三讲特意强调这个设计的重点不只是省掉一次边界检查而是从根源上杜绝索引跨界。注意第三讲中get_proven还带有debug_assert!与unsafe的get_unchecked写法而本篇的最终版本直接使用索引语法self.0[ix.0]说明在保证令牌不跨界的前提下越界已经变成逻辑上不可能的情形。2.3 品牌生命周期与PhantomDataInvariantLifetimeid(PhantomData*mut id ())每个实例都通过#[derive(Default)]零成本构造ZST零大小类型运行时不占任何内存纯粹是编译期的品牌标签。*mut裸指针的选择保证了生命周期的不变变型使编译器无法在两个品牌之间做子类型收窄。三、实战演示令牌无法跨变量共享实现就绪后本篇的核心演示是一个嵌套作用域程序。每个Bytes::new闭包都引入一个全新的品牌生命周期因此内层闭包里同时存在bytes_1与bytes_2两个不同品牌的Bytesfn 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}); }运行结果Computations done!逐行解读bytes_1.get_index(2)vec![4, 5, 1]长度为 3索引 2 合法得到index_1: ProvenIndexbrand_1bytes_2.get_index(1)vec![4, 2]长度为 2索引 1 合法得到index_2: ProvenIndexbrand_2bytes_1.get_proven(index_1)、bytes_2.get_proven(index_2)品牌匹配正常返回 1 和 2bytes_2.get_proven(index_1)品牌不匹配无法通过编译。index_1的brand_1与bytes_2的brand_2是不变变型关系编译器无法将前者子类型化为后者于是报错。这正是课程的演示要点我们现在可以编写一个程序其中作为索引存在证明的令牌类型不能在变量之间共享。读者可以在本机复现这一验证将// bytes_2.get_proven(index_1);一行的注释去掉cargo build或课程中的 mdbook 构建会立即报出生命周期不匹配的编译错误。也就是说把 A 变量的索引用到 B 变量上这一运行期未定义行为被完全提前到了编译期拒绝。四、交互式探讨哪些操作能保证产生已验证索引讲义在本演示之后安排了一个开放性问题我们能执行哪些操作从而保证产生一个已验证的索引proven index预期答案是实现一个push方法。push是唯一能稳定保证新索引必然存在的操作——因为值刚刚被追加进Veclen() - 1必然是合法索引。课程给出的建议实现标注为rust,compile_fail即需要去掉注释验证的演示代码fn push(mut self, value: u8) - ProvenIndexid { self.0.push(value); ProvenIndex(self.0.len() - 1, InvariantLifetime::default()) }这个实现再次印证了品牌类型的核心契约push在修改数据的同时返回一个品牌化令牌令牌与数据天然共享同一个id返回ProvenIndex(self.0.len() - 1, ...)相当于即时签发了一个新索引存在的证明调用方无需再做任何运行时检查。与此同时讨论中还可以顺势指出Vec自带的方法如pop、remove、truncate会改变索引的合法性因此品牌 API 必须谨慎暴露这些破坏性操作或只暴露能够同时维护令牌一致性的操作——这正是品牌类型让不变量显式化、让非法状态不可表示的体现。五、泛化从Bytesid到BrandedVecid, T讲义的第二个开放问题能否让它不再局限于字节数组而是成为VecT的通用包装答案很直接可以。只要把内部数据从Vecu8换成VecT品牌机制完全不变struct BrandedVecid, T(VecT, InvariantLifetimeid); implid, T BrandedVecid, T { fn newU( data: VecT, f: impl fora FnOnce(BrandedVeca, T) - U, ) - U { f(BrandedVec(data, 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) - T { self.0[ix.0] } fn push(mut self, value: T) - ProvenIndexid { self.0.push(value); ProvenIndex(self.0.len() - 1, InvariantLifetime::default()) } }可见品牌机制与元素类型完全解耦它约束的是索引令牌与容器变量之间的归属关系而非容器内元素的类型。同理讲师还可以引导学员思考这一思想还能用在哪些领域例如数据库连接句柄与其事务的绑定缓存、arena区域分配器中对象句柄与 arena 实例的绑定任何句柄/令牌必须且只能用于产生它的那个上下文的场景。这与本仓库中PhantomData系列讲义phantomdata-01-types.md 与 phantomdata-02-types-implemented.md里用类型参数做标记、用 ZST 标记类型区分语义的思路一脉相承——只不过品牌类型把标记从类型层面升级到了生命周期层面。六、权衡高度受限的 API意义非凡的安全证明讲义对本篇做了如下收束性评价最终得到的 Token API 是高度受限的highly restrictive但它让 Rust 类型系统得以证明为安全的那些事情意义非凡。这句话值得展开三层理解受限是刻意的Bytes::new的闭包式构造、生命周期不可由调用方指定、令牌不可跨变量使用——这些限制砍掉了用户的大量自由度目的就是让错误用法在编译期变得不可表示证明是真实的一旦程序通过编译编译器便担保索引必然属于对应变量这一不变量运行时无需也无法出现跨界访问相关的越界未定义行为被彻底排除代价换价值这是典型的类型驱动设计取舍——用 API 表面的笨拙换取内层实现可以放心使用get_unchecked一类免检查操作、以及调用方心智负担的降低。从源码结构看这种构造受限 令牌绑定 免检查访问的三段式结构见 branded-03-impl.md 与 branded-04-in-action.md构成了本专题的完整方法论可以与仓库中借用检查器不变量borrow-checker-invariants.md和newtype 模式newtype-pattern.md章节互为参照三者都在用类型系统把运行时错误转化为编译期错误。七、延伸阅读GhostCell 与更广阔的应用讲义的 More to Explore 部分把视线投向学术界GhostCell是一个允许在 Rust 中安全构造循环数据结构以及其他此前难以表达的数据结构的方案它正是利用这种令牌类型确保 cell 不会逃逸出那些已知安全操作的上下文。几个关键事实本系列品牌类型讲义正是基于 GhostCell 论文中的BrandedVec实现改编而来论文覆盖了该用例的许多实现细节可作为理解GhostCell自身实现与用法的温和入门GhostCell还在 Rust 类型系统之外使用形式化校验formal checks证明其在此类上下文生命周期品牌化中所允许的事情是安全的——这说明品牌类型 形式化验证是工业级安全库的两大支柱。对于希望继续深挖的读者可以在本仓库中沿着以下路径继续学习Token Types 专题其余讲义token-types.md引言、branded-01-motivation.md动机、branded-02-phantomdata.md生命周期变型、branded-03-impl.md实现相关模式permission-tokens.md、mutex-guard.md底层理论Rust Reference 中关于 Subtyping 与 Variance 的章节课程讲义 branded-02-phantomdata.md 有详细指引以及PhantomData的官方文档。八、小结本篇作为品牌类型系列的收官讲完成了从理论到实践的闭环理论支柱PhantomData*mut id ()提供不变变型生命周期fora高阶约束把生命周期选择权收回 API 侧实现骨架Bytesid持有数据与品牌ProvenIndexid作为索引存在的证明令牌get_index负责验证、get_proven负责免检查读取实战验证嵌套闭包作用域中跨变量传递令牌会触发编译错误未定义行为被消灭在编译期演化路径push展示必然合法索引的签发方式泛化为BrandedVecid, T后适用于任意元素类型并最终通向GhostCell这样允许安全循环数据结构的真实库。品牌类型给我们的最大启示是类型系统不仅能表达数据是什么还能表达数据从哪里来、能在哪里用。当 API 愿意为此付出高度受限的代价时编译器就会成为最强力的不变量守卫者——这正是 Rust 安全保证的深层形态。【免费下载链接】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),仅供参考