资讯动态

用借用检查器强化 API 不变量:comprehensive-rust 中的所有权驱动设计实践

发布时间:2026/9/10 8:41:40 来源:尧图企业网站定制
用借用检查器强化 API 不变量comprehensive-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借用检查器Borrow Checker自诞生之日起是为了防止 use-after-free、数据竞争等内存安全缺陷。但在 Google Android 团队的 Rust 课程 comprehensive-rust 中借用检查器被赋予了第二重身份一种不依赖运行时开销的 API 设计工具。本文基于课程 borrow-checker-invariants.md 展开通过门锁状态机、数据库事务、加密 Nonce、文件描述符等真实案例讲解如何用所有权、借用与生命周期在编译期强制业务不变量让 API难以被误用。读完本文你将掌握三类核心技术利用所有权移动模拟状态机与单次使用值、利用别名与可变性互斥Aliasing XOR Mutability封锁资源访问、利用PhantomData在零运行时开销的前提下捕获类型标签与生命周期关系。借用检查器的双重身份从内存安全到 API 设计课程的 borrow-checker-invariants.md 开篇提出一个核心观点语言特性往往为特定目的而引入但用户会发展出当初设计时未曾预料的用法。文档以 Java 泛型为例Java 5 于 2004 年引入泛型最初明确目的只是类型安全集合采纳初期进展缓慢但此后开发者把泛型扩展到了更广泛的类型安全 API 设计领域——通过ClassT、Guava 的TypeTokenT持有类信息用递归泛型实现 Builder 模式等。借用检查器同理它最初被引入是为了防止 use-after-free 与数据竞争但其底层的逻辑系统并不知道内存是什么它只是强制执行一套关于操作顺序的规则因此我们可以把这些规则转译到与内存安全完全无关的业务领域把借用检查器当作又一种 API 设计工具来使用。要做到这一点需要暂时忘记借用检查器防止可变别名mutable aliasing的原始目的想象自己身处规则相同但含义略有不同的场景中。这与课程中另一章节 typestate-pattern.md 的思路一脉相承都是把状态迁移编码进类型系统把非法操作变成编译错误。门锁示例用所有权移动模拟物理状态机借用检查器最直观的业务化用法是让值的所有权转移对应物理对象的真实状态变化。课程给出了一个门锁模型门只能开或关开锁/上锁都需要正确的钥匙。/// Doors can be open or closed, and you need the right key to lock or unlock /// one. Modelled with a Shared key and Owned door. pub struct DoorKey { pub key_shape: u32, } pub struct LockedDoor { lock_shape: u32, } pub struct OpenDoor { lock_shape: u32, } fn open_door(key: DoorKey, door: LockedDoor) - ResultOpenDoor, LockedDoor { if door.lock_shape key.key_shape { Ok(OpenDoor { lock_shape: door.lock_shape }) } else { Err(door) } } fn close_door(key: DoorKey, door: OpenDoor) - ResultLockedDoor, OpenDoor { if door.lock_shape key.key_shape { Ok(LockedDoor { lock_shape: door.lock_shape }) } else { Err(door) } } fn main() { let key DoorKey { key_shape: 7 }; let closed_door LockedDoor { lock_shape: 7 }; let opened_door open_door(key, closed_door); if let Ok(opened_door) opened_door { println!(Opened the door with key shape {}, key.key_shape); } else { eprintln!( Door wasnt opened! Your key only opens locks with shape {}, key.key_shape ); } }这个示例蕴含了四个关键设计要点钥匙共享、门独占DoorKey以共享引用DoorKey传入可以反复使用而门以所有权传入一次只能被使用一次。状态迁移即所有权转移open_door消费consume一个LockedDoor返回一个全新的OpenDoor旧的LockedDoor值不再可用。一旦门被打开任何试图再次使用已打开的门的行为都会在编译期报错。失败路径归还所有权如果钥匙不匹配门保持锁定状态通过Result的Err分支把LockedDoor原样归还给调用者——信息不丢失状态不损坏。对称的双向转换close_door同理消费一个OpenDoor产生LockedDoor从编译期杜绝了重复关门这类无效操作。这正是借用检查器搭便车piggy-backing设计模式的雏形借用检查器的规则本为内存安全而生却被用来设计难以或不可能被误用的 API。抽象规则共享引用、独占引用与所有权三种取用方式在深入案例前课程在 generalizing-ownership.md 中把借用检查器的规则从引用层面抽象为语义层面。这段代码里没有任何可变操作、没有跨线程却依然展示了借用规则如何约束操作顺序pub struct Internal; pub struct Data(Internal); fn shared_use(value: Data) - Internal { value.0 } fn exclusive_use(value: mut Data) - mut Internal { mut value.0 } fn deny_future_use(value: Data) {} fn demo_exclusive() { let mut value Data(Internal); let shared shared_use(value); // let exclusive exclusive_use(mut value); // ❌ 编译错误 let shared_again shared; } fn demo_denied() { let value Data(Internal); deny_future_use(value); // let shared shared_use(value); // ❌ 编译错误 }Rust 的借用检查器提供了三种取用一个值的方式取用方式含义规则拥有的值T作用域结束时被释放除非被返回给其他作用域一个值同时只有一个所有者共享引用T允许别名同时存在多个但不允许可变访问共享引用存在期间不能取可变引用可变引用mut T同一时刻只能存在一个但可以用它派生出共享引用独占访问问自己上面两处被注释的代码为什么无法编译demo_exclusive中shared在取得exclusive可变引用后仍被使用let shared_again shared;共享引用与可变引用在同一时刻共存违反Aliasing XOR Mutabilitydemo_denied中value在前一行被deny_future_use(value)按值消费随后再从value取引用已不可能。另一个容易忽略的事实是每个T和mut T都隐式携带一个生命周期只是多数时候编译器允许我们省略elide标注。关于省略规则的细节可参考课程 lifetime-elision.md。理解这三种抽象取用方式是后续所有不变量设计的基础。Aliasing XOR Mutability用可变借用锁住资源访问aliasing-xor-mutability.md 展示了一个极具现实意义的场景异步数据库查询 API。背景是API 中查询被踢出去异步执行结果只有在整个事务提交后才可用。如果用户误以为查询是立即执行的就会在结果尚未就绪时读取数据读到不完整或错误的数据。随着 API 规模和用户群增长对底层系统有深入了解的用户占比会越来越小这种误用几乎不可避免。解决方案是把事务进行中编码为对连接的独占可变借用pub struct QueryResult; pub struct DatabaseConnection {/* fields omitted */} impl DatabaseConnection { pub fn new() - Self { Self {} } pub fn results(self) - [QueryResult] { [] // fake results } } pub struct Transactiona { connection: a mut DatabaseConnection, } impla Transactiona { pub fn new(connection: a mut DatabaseConnection) - Self { Self { connection } } pub fn query(mut self, _query: str) { // Send the query over, but dont wait for results. } pub fn commit(self) { // Finish executing the transaction and retrieve the results. } } fn main() { let mut db DatabaseConnection::new(); // The transaction tx mutably borrows db. let mut tx Transaction::new(mut db); tx.query(SELECT * FROM users); // This wont compile because db is already mutably borrowed by tx. // let results db.results(); // ❌ 编译错误 // The borrow of db ends when tx is consumed by commit(). tx.commit(); // Now it is possible to borrow db again. let results db.results(); }设计要点Transaction::new接收a mut DatabaseConnection并把可变引用存进Transaction值。这里的显式生命周期并不可怕它只是表达Transaction不会活得比传入的DatabaseConnection更久。可变引用意味着在Transaction存续期间DatabaseConnection变量完全被锁定既不能开启新事务也不能读取查询结果。取消注释db.results()一行即可看到编译错误。借用关系在tx.commit()消费self时结束此后db才能再次被借用。一个容易被忽视的细节查询结果字段设为私有只能通过 getter 访问从而强制没有活跃事务时才能查看查询结果这一不变量如果结果被放进公有字段该不变量立刻失效。单次使用值让值只能被使用一次single-use-values.md 处理的问题是如何保证一个值只能被使用一次密码学中的 NonceNumber used ONCE是典型代表——Nonce 是用于防止重放攻击replay attack的随机唯一数据现实中人们会意外地重用 Nonce轻则导致加密协议完全失效重则在特定条件下让攻击者可计算出私钥。pub struct Key(/* specifics omitted */); /// A single-use number suitable for cryptographic purposes. pub struct Nonce(u32); /// A cryptographically sound random generator function. pub fn new_nonce() - Nonce { Nonce(4) // chosen by a fair dice roll } /// Consume a nonce, but not the key or the data. pub fn encrypt(nonce: Nonce, key: Key, data: [u8]) {} fn main() { let nonce new_nonce(); let data_1: [u8; 4] [1, 2, 3, 4]; let data_2: [u8; 4] [4, 3, 2, 1]; let key Key(/* specifics omitted */); // The key and data can be re-used, copied, etc. but the nonce cannot. encrypt(nonce, key, data_1); // encrypt(nonce, key, data_2); // ️ 编译错误 }Rust 实现用过即焚不变量有一个天然工具按值传递owned argument。注意encrypt对nonce按值接收、对key和data按引用接收。第一次调用encrypt后nonce已被移动第二次调用必然编译失败。课程进一步总结了保证值唯一性的四条完整策略构造器保持私有用户无法自行构造出相同内部值的第二个实例不实现Clone/Copy及等价方法防止用户复制本应唯一的数据内部类型保持不透明opaque借用 newtype 模式参见 newtype-pattern.md让用户无法自行修改既有值模块边界课程中提醒还缺什么的答案没有模块边界时用户可以在模块外自行构造 Nonce正确做法是把Key、Nonce、new_nonce一起放进一个私有模块。同时课程也点出了这种设计的边界More to Explore如果 Nonce 来源于一个没有真实随机性的伪随机过程仍可能被使用两次——这是逻辑 bugAPI 设计无法完全杜绝。此设计只防止同一 Nonce 被复制重用并不防止所有逻辑错误。PhantomData在零成本下为类型系统注入语义PhantomDataT是一个带类型参数的零尺寸类型ZST如同()或struct MyTag;它本身不占用任何存储却能让类型系统感知到某个并不实际存储的类型参数或生命周期。课程用四讲见 borrow-checker-invariants 目录递进讲解。第一讲打破 DRY 困境的类型标签phantomdata-01-types.md 提出的问题是用 newtype 区分权限普通用户UserId、赞助者PatronId、版主ModeratorId、管理员AdminId但底层都是u64却要为每个类型重复实现同样的 trait违背 DRY 原则。候选方案包括改用枚举、把权限令牌打包进结构体、引入编码权限的类型参数这正是后续采用的方向。第二讲用类型参数做权限标签phantomdata-02-types-implemented.md 给出了基于标签类型的解法// use std::marker::PhantomData; pub struct ChatIdT { id: u64, tag: T } pub struct UserTag; pub struct AdminTag; pub trait ChatUser {/* ... */} pub trait ChatAdmin {/* ... */} impl ChatUser for UserTag {/* ... */} impl ChatUser for AdminTag {/* ... */} // Admins are users impl ChatAdmin for AdminTag {/* ... */} impl T: ChatUser ChatIdT {/* All functionality for users and above */} impl T: ChatAdmin ChatIdT {/* All functionality for only admins */}要点标签类型tag/marker type是零尺寸类型带有对用户和 API 设计者而言的语义含义权限通过标签类型是否实现对应权限 trait来门控。问题来了如果tag: T真的存一个实例且T不是零尺寸类型就会为了只在编译期相关的类型信息多分配内存。把tag字段整个删掉无法编译——存在未使用的幻影类型参数。这正是PhantomData登场的时机。改造后tag: PhantomDataT。构造时既可用PhantomData字面量let phantom: PhantomDataUserTag PhantomData;也可用PhantomData::default()。例如实现Fromu64implT Fromu64 for ChatIdT { fn from(value: u64) - Self { ChatId { id: value, // Or PhantomData::default() tag: PhantomData, } } }PhantomData可以服务于 Typestate 模式让TaggedDataStart实现TaggedDataEnd没有的方法或 trait从而为同构数据 不同方法集合提供编译期区分。完整的 Typestate 案例见 typestate-example.md其中Serializer与SerializeStruct的状态流转图展示了 consume-and-produce 的迁移方式。第三讲用生命周期捕获外部资源关系phantomdata-03-lifetimes.md 把场景切换到 FFI我们拿到了一个原样照搬、无法影响的 C 数据库 API句柄只是u8最多同时打开 255 个数据库。我们想在它之上实现与第二讲中Transaction相同的语义——事务必须借自创建它的连接——但不希望Transaction真的存储一个引用。直接持有a mut DatabaseConnection有两个缺点在 64 位平台上多占 7 字节指针是 8 字节而句柄是 1 字节且每次访问都要多一次指针解引用。但若删掉引用只留生命周期参数又会报未使用的生命周期参数。解法依然是PhantomDatastruct Transactiona { connection: DatabaseConnection, _phantom: PhantomDataa mut DatabaseConnection, }构造方法同步更新impl DatabaseConnection { fn new_transactiona(a mut self) - Transactiona { Transaction { connection: DatabaseConnection(self.0), _phantom: PhantomData } } }这样得到的Transaction拥有连接值、却被生命周期参数绑定在创建它的DatabaseConnection上借用检查器仍会阻止在事务存续期间使用原连接同时由于PhantomData是零尺寸类型Transaction的大小与u8相同远小于存引用版本的usize大小。课程还提示了延伸方向这种类型与值之间关系的编码与 unsafe 结合会非常强大生命周期可被近乎任意操纵也因此危险可配合外部机械验证的证明如 2021 年的 GhostCell 工作在安全地编码循环/自引用类型的同时保留生命周期与安全预期。第四讲OwnedFd 与 BorrowedFd——标准库中的 PhantomData 实践phantomdata-04-borrowedfd.md 指出Rust 标准库中的BorrowedFd是PhantomData的典型应用课程为便于演示给出简化实现。背景知识在类 Unix 系统上设备与操作系统特定功能都以文件形式暴露文件描述符fd代表某个进程对文件的访问权。use std::marker::PhantomData; use std::os::raw::c_int; mod libc_ffi { use std::os::raw::{c_char, c_int}; pub unsafe fn open(path: *const c_char, oflag: c_int) - c_int { 3 } pub unsafe fn close(fd: c_int) {} } struct OwnedFd { fd: c_int, } impl OwnedFd { fn try_from_fd(fd: c_int) - OptionSelf { if fd 0 { return None; } Some(OwnedFd { fd }) } fn as_fda(a self) - BorrowedFda { BorrowedFd { fd: self.fd, _phantom: PhantomData } } } impl Drop for OwnedFd { fn drop(mut self) { unsafe { libc_ffi::close(self.fd) }; } } struct BorrowedFda { fd: c_int, _phantom: PhantomDataa (), } fn main() { // Create a file with a raw syscall with write-only and create permissions. let fd unsafe { libc_ffi::open(cc_str.txt.as_ptr(), 065) }; // Pass the ownership of an integer file descriptor to an OwnedFd. // OwnedFd::drop() closes the file descriptor. let owned_fd OwnedFd::try_from_fd(fd).expect(Could not open file with syscall!); // Create a BorrowedFd from an OwnedFd. // BorrowedFd::drop() does not close the file because it doesnt own it! let borrowed_fd: BorrowedFd_ owned_fd.as_fd(); // std::mem::drop(owned_fd); // ❌ 编译错误 std::mem::drop(borrowed_fd); let second_borrowed owned_fd.as_fd(); // owned_fd will be dropped here, and the file will be closed. }设计要点OwnedFd是文件描述符的拥有型包装显式实现Drop析构时调用close关闭文件描述符BorrowedFd是借用的对应物没有显式实现Drop析构时不负责关闭文件BorrowedFd用PhantomDataa ()捕获生命周期强制不变量如果这个BorrowedFd存在那么底层的 OS 文件描述符仍然打开——尽管它不负责关闭。生命周期参数要求程序中存在另一个值此处是OwnedFd与它等长或比它长寿取消注释std::mem::drop(owned_fd)再编译可以看到borrowed_fd依赖owned_fd的生命周期API 设计者把这个关系编码为那个其他值才是保持文件访问打开的原因。由于借用检查器强制一个值至少活得和另一个值一样久API 使用者完全无需亲自处理文件描述符的别名与关闭逻辑——这正是 borrowing 章节中所讲借用规则在标准库设计中的落地。设计清单与学习路径把课程内容整理为可直接复用的设计决策清单想强化的不变量使用的借用检查器机制课程出处状态只能按合法顺序迁移如门、Serializer所有权移动 状态类型typestateborrow-checker-invariants.md、typestate-pattern.md资源被占有时禁止访问如事务、锁可变引用独占Aliasing XOR Mutabilityaliasing-xor-mutability.md值只能用一次如 Nonce按值传递 私有构造器 不实现 Clone/Copy 模块边界single-use-values.md同构数据不同语义如权限标签类型参数 PhantomDataTphantomdata-02-types-implemented.md与外部资源生命周期绑定如 FFI 事务、BorrowedFd生命周期参数 PhantomDataa Tphantomdata-03-lifetimes.md、phantomdata-04-borrowedfd.md在 comprehensive-rust 的课程体系中本节位于惯用法Idiomatic的利用类型系统Leveraging the Type System分支下与 typestate-pattern.md、token-types.md、raii.md 等章节互为补充共同构成用类型与借用规则消灭整类运行时错误的设计哲学。继续深入可配合 borrowing 与 lifetimes 的基础章节建立完整认知。小结借用检查器为内存安全而设计但它的规则并不理解内存这一概念——它只是一套操作排序规则。本文展示的正是把这套规则再诠释到业务层的完整方法门锁用所有权转移模拟状态机数据库事务用可变引用独占锁定资源Nonce 用按值传递保证单次使用PhantomData以零运行时开销为类型系统注入类型标签与生命周期关系最终在编译期把整类 API 误用变为不可能。下次设计 API 时不妨先问自己这个不变量能否用借用检查器在编译期替我强制执行【免费下载链接】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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价