ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Rust 内部可变性与 RefCell<T> 实战解析:在不可变外壳下安全修改数据

Rust 内部可变性与 RefCell<T> 实战解析:在不可变外壳下安全修改数据 教程文档【免费下载链接】bookThe Rust Programming Language项目地址https://gitcode.com/gh_mirrors/bo/book点击查看免费下载导读本文围绕 The Rust Programming Language本仓库 book第 15 章第 5 节展开系统讲解 Rust 的**内部可变性Interior Mutability**设计模式与标准库类型RefCellT。通过本文你将掌握为什么 Rust 默认的借用规则在编译期检查、而RefCellT将检查推迟到运行时如何在测试中用 mock 对象记录消息、如何用RcRefCellT让多份共享数据可被修改以及这些技巧背后的代价运行时 panic 与轻微性能开销。所有示例均来自仓库 listings/ch15-smart-pointers 下的真实可运行代码。什么是内部可变性内部可变性是 Rust 中的一种设计模式即使存在指向数据的不可变引用也允许修改该数据——而正常情况下一旦存在不可变引用借用规则会禁止任何修改。为了实现这一点该模式在数据结构内部使用unsafe代码绕开 Rust 关于修改与借用的常规规则。unsafe代码向编译器表明规则改由我们手动检查而不是依赖编译器来检查关于unsafe代码的详细讨论见本书第 20 章 ch20-01-unsafe-rust。采用内部可变性模式的关键前提是我们能确保借用规则在运行时一定被遵守尽管编译器无法在编译期证明这一点。此时unsafe代码被封装进一个安全的 API外层类型对外仍是不可变的——调用者拿到的是一个安全、不可变的外观。RefCellT就是遵循内部可变性模式的标准库类型下面我们从它与普通引用的差异说起。在运行时强制执行借用规则RcT允许多个所有者而RefCellT与BoxT一样对数据持有单一所有权。那么RefCellT和BoxT有什么本质区别回顾第 4 章学过的借用规则任意时刻要么只能有一个可变引用要么可以有任意多个不可变引用两者不能同时存在引用必须始终有效。区别在于检查时机类型借用规则检查时机违反规则时的表现普通引用/mut编译期编译器报错程序无法编译BoxT编译期编译器报错RefCellT运行时程序panic!并退出编译期检查的好处显而易见错误在开发阶段更早暴露且所有分析都在运行前完成对运行时性能零影响。正因如此编译期检查是绝大多数情况下的最佳选择也是 Rust 的默认行为。那为什么还需要运行期检查因为静态分析天然是保守的。有些代码属性无法通过静态分析确定——最著名的例子是停机问题Halting Problem。当编译器无法确认代码符合所有权规则时它可能拒绝一个本身正确的程序但反过来如果 Rust 接受了一个错误的程序用户就无法信任 Rust 提供的保证。RefCellT的价值恰恰在于当你确信自己的代码遵守借用规则、而编译器却无法理解并保证这一点时用它来把检查推迟到运行时从而放行那些被编译期检查拒绝、但内存安全的场景。与RcT类似RefCellT只适用于单线程场景在多线程上下文中使用会直接得到编译错误。多线程程序中如何获得RefCellT的功能将在第 16 章讨论对应的线程安全版本是MutexT。何时选 Box、Rc、RefCell原文给出了一个非常实用的选择清单可直接作为决策依据RcT同一数据的多个所有者BoxT和RefCellT都是单一所有者BoxT允许不可变或可变借用编译期检查RcT只允许不可变借用编译期检查RefCellT允许不可变或可变借用运行时检查因为RefCellT允许运行时检查的可变借用即使RefCellT本身是不可变的也能修改其内部的值——这正是内部可变性模式的核心能力。使用内部可变性一个不可变值内部的修改借用规则的一个直接推论是当你拥有一个不可变值时就不能对其做可变借用。例如 no-listing-01-cant-borrow-immutable-as-mutable/src/main.rs 中的代码就无法编译fn main() { let x 5; let y mut x; // 编译错误x 未声明为 mut }编译输出见同目录 output.txt为error[E0596]: cannot borrow x as mutable, as it is not declared as mutable -- src/main.rs:3:13 | 3 | let y mut x; | ^^^^^^ cannot borrow as mutable | help: consider changing this to be mutable | 2 | let mut x 5; | 然而存在这样的实际场景一个值希望在自身的方法内部修改自己但对外部的其他代码表现为不可变。RefCellT就是实现这种内部可变性的一种方式。需要强调的是RefCellT并没有完全绕过借用规则——编译器的借用检查器放行了这种内部可变性规则本身仍在运行时被严格执行一旦违反会得到panic!而不是编译错误。实战用例用 Mock 对象做测试测试替身与 Mock 对象在测试中程序员常常用一个类型替换另一个类型以便观察特定行为并断言其实现正确。这种占位类型被称为测试替身test double如同电影里的替身演员在跑特定危险镜头时代替主角上场。Mock 对象是测试替身的一种它记录测试期间发生的事件让你能断言正确的动作确实发生了。Rust 不像其他语言那样有对象的概念标准库也没有内置 mock 对象功能。但完全可以自定义一个结构体来充当 mock 对象——这正是内部可变性的典型应用场景。场景一个配额跟踪库我们要测试的库跟踪一个值离最大值的距离并根据接近程度发送消息——例如跟踪用户允许调用的 API 次数配额。库本身只负责计算接近程度 决定何时发送什么消息而发送机制由使用方提供可以是界面提示、邮件、短信……库只依赖一个它定义的Messengertrait。完整的库代码如下listing-15-20/src/lib.rspub trait Messenger { fn send(self, msg: str); } pub struct LimitTrackera, T: Messenger { messenger: a T, value: usize, max: usize, } impla, T LimitTrackera, T where T: Messenger, { pub fn new(messenger: a T, max: usize) - LimitTrackera, T { LimitTracker { messenger, value: 0, max, } } pub fn set_value(mut self, value: usize) { self.value value; let percentage_of_max self.value as f64 / self.max as f64; if percentage_of_max 1.0 { self.messenger.send(Error: You are over your quota!); } else if percentage_of_max 0.9 { self.messenger .send(Urgent warning: Youve used up over 90% of your quota!); } else if percentage_of_max 0.75 { self.messenger .send(Warning: Youve used up over 75% of your quota!); } } }两个关键设计点Messenger::send接收self的不可变引用和消息文本——这是 mock 需要实现的接口保证 mock 与真实对象可互换set_value不返回任何值因此测试必须通过观察 messenger 被调用的结果来断言行为给定一个实现Messenger的对象和特定max当传入不同的value时messenger 应当收到正确的消息。借用检查器不接受的 Mock我们希望 mock 在send被调用时不真正发邮件/短信而是把消息记录下来。测试流程是新建 mock → 用 mock 构造LimitTracker→ 调用set_value→ 检查 mock 记录的消息是否符合预期。但第一版实现listing-15-21/src/lib.rs会被借用检查器拒绝#[cfg(test)] mod tests { use super::*; struct MockMessenger { sent_messages: VecString, } impl MockMessenger { fn new() - MockMessenger { MockMessenger { sent_messages: vec![], } } } impl Messenger for MockMessenger { fn send(self, message: str) { self.sent_messages.push(String::from(message)); } } #[test] fn it_sends_an_over_75_percent_warning_message() { let mock_messenger MockMessenger::new(); let mut limit_tracker LimitTracker::new(mock_messenger, 100); limit_tracker.set_value(80); assert_eq!(mock_messenger.sent_messages.len(), 1); } }问题出在send的self.sent_messages.push(...)send接收的是self而push需要可变借用。编译输出listing-15-21/output.txt给出了明确错误error[E0596]: cannot borrow self.sent_messages as mutable, as it is behind a reference -- src/lib.rs:58:13 | 58 | self.sent_messages.push(String::from(message)); | ^^^^^^^^^^^^^^^^^^ self is a reference, so it cannot be borrowed as mutable | help: consider changing this to be a mutable reference in the impl method and the trait definition错误信息建议把 trait 和实现都改成mut self但我们不会为了测试而改变Messengertrait 的设计真实对象用self发消息完全合理。这正是内部可变性出场的地方。用 RefCell 修复 Mock把sent_messages装进RefCellVecStringsend就能在只拿到self的情况下修改内部向量。修复后的代码listing-15-22/src/lib.rs#[cfg(test)] mod tests { use super::*; use std::cell::RefCell; struct MockMessenger { sent_messages: RefCellVecString, } impl MockMessenger { fn new() - MockMessenger { MockMessenger { sent_messages: RefCell::new(vec![]), } } } impl Messenger for MockMessenger { fn send(self, message: str) { self.sent_messages.borrow_mut().push(String::from(message)); } } #[test] fn it_sends_an_over_75_percent_warning_message() { let mock_messenger MockMessenger::new(); let mut limit_tracker LimitTracker::new(mock_messenger, 100); limit_tracker.set_value(80); assert_eq!(mock_messenger.sent_messages.borrow().len(), 1); } }逐处变化解读字段类型从VecString改为RefCellVecStringnew中通过RefCell::new(vec![])在空向量外包一层RefCellsend的第一个参数仍是self与 trait 定义一致内部调用self.sent_messages.borrow_mut()拿到指向向量的可变引用再对其push记录消息断言处改用borrow()获取向量的不可变引用再调用.len()查看消息条数。在set_value(80)、max 10080% 75%的条件下测试断言 mock 恰好记录了一条警告消息cargo test即可通过。运行时跟踪借用borrow / borrow_mut 的工作原理普通引用用和mut语法创建RefCellT则通过其安全 API 的borrow与borrow_mut方法borrow返回智能指针RefT不可变借用borrow_mut返回智能指针RefMutT可变借用。RefT和RefMutT都实现了Deref因此可以像普通引用一样使用关于Deref与解引用自动化的细节见 ch15-02-deref 和 ch05-03-method-syntax 的-运算符去哪了一节。RefCellT内部维护一个活跃借用计数每次调用borrow计数 1当对应的RefT离开作用域时计数 -1。规则与编译期借用规则一致——任意时刻允许任意多个不可变借用或至多一个可变借用。违反时不会得到编译错误而是运行时panic!。listing-15-23/src/lib.rs 故意在同一作用域创建两个可变借用演示运行时拦截impl Messenger for MockMessenger { fn send(self, message: str) { let mut one_borrow self.sent_messages.borrow_mut(); let mut two_borrow self.sent_messages.borrow_mut(); one_borrow.push(String::from(message)); two_borrow.push(String::from(message)); } }代码能编译通过但运行测试会失败。测试输出listing-15-23/output.txtthread tests::it_sends_an_over_75_percent_warning_message panicked at src/lib.rs:60:53: RefCell already borrowed note: run with RUST_BACKTRACE1 environment variable to display a backtraceRefCellT以already borrowed: BorrowMutError或此处输出中RefCell already borrowed的 panic 信息的方式处理运行时借用违规。这也提醒我们权衡运行时检查意味着错误可能直到代码部署到生产环境才暴露并且跟踪借用计数会带来少量运行时性能开销。但反过来它让我们能在只允许不可变值的上下文如self的 mock中获得修改数据的能力这是普通引用给不了的。组合 Rc 与 RefCell 多个所有者 可变数据RcT允许多个所有者但只提供不可变访问。把RefCellT放进RcT就能得到既有多所有者、又能修改的值。回顾 ch15-04-rc 中的 cons list 例子RcT让多个列表共享对另一个列表的所有权但由于RcT只持有不可变值一旦创建就无法更改。现在把RefCellT加进Cons定义listing-15-24/src/main.rs#[derive(Debug)] enum List { Cons(RcRefCelli32, RcList), Nil, } use crate::List::{Cons, Nil}; use std::cell::RefCell; use std::rc::Rc; fn main() { let value Rc::new(RefCell::new(5)); let a Rc::new(Cons(Rc::clone(value), Rc::new(Nil))); let b Cons(Rc::new(RefCell::new(3)), Rc::clone(a)); let c Cons(Rc::new(RefCell::new(4)), Rc::clone(a)); *value.borrow_mut() 10; println!(a after {a:?}); println!(b after {b:?}); println!(c after {c:?}); }执行流程拆解value Rc::new(RefCell::new(5))创建共享根节点5被装进RefCell再由Rc引用计数列表a的Cons持有Rc::clone(value)——clone 的是Rc使a与value共同拥有内部的5而不是转移所有权或借用b、c通过Rc::clone(a)共享列表a与第 4 节 ch15-04-rc 的 Listing 15-18 相同*value.borrow_mut() 10borrow_mut借助自动解引用穿透RcT到达内部RefCellT返回RefMutT再用解引用运算符*修改内部值使5变成15。运行结果listing-15-24/output.txt显示三个列表共享的节点都被更新a after Cons(RefCell { value: 15 }, Nil) b after Cons(Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil)), ...) c after Cons(Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil)), ...)这个技巧的精妙之处在于外层List依然是不可变的值却通过RefCellT提供的内部可变性在需要时修改数据运行时的借用检查保护我们免于数据竞争。注意RefCellT不适用于多线程代码——线程安全版本是MutexT将在第 16 章 ch16-03-shared-state 中讨论。权衡与总结把RefCellT放进决策框架与BoxT、RcT对比BoxT单一所有者编译期借用检查性能零开销——默认首选RcT多所有者仅不可变借用编译期检查单线程RefCellT单一所有者不可变/可变借用皆可运行时检查单线程允许不可变外壳 内部修改RcRefCellT多所有者 可修改是组合两者的经典方案MutexTRefCellT的线程安全版第 16 章。选择运行时借用检查意味着错误发现得更晚、且每次借用都要付出计数开销但换来的是 mock 对象、共享可变数据结构等普通引用无法表达的能力。在 src/ch15-00-smart-pointers.md 的综述中RefT与RefMutT经由RefCellT访问被列为标准库中最常用的智能指针之一。若想进一步了解RefCell之外的标准库内部可变性类型如CellT与线程安全的MutexT、RwLockT等可继续阅读 ch16-00-concurrency而RcT与RefCellT组合后可能产生的引用循环内存泄漏问题及其解法WeakT见 ch15-06-reference-cycles。所有示例代码均可直接在仓库 listings/ch15-smart-pointers 目录下对应子目录中运行验证。赞分享教程文档【免费下载链接】bookThe Rust Programming Language项目地址https://gitcode.com/gh_mirrors/bo/book点击查看免费下载相关推荐comprehensive-rust 深入解析Rust 内部可变性Interior Mutability——用 Cell 与 RefCell 在共享引用背后安全修改数据comprehensive rust 深入解析Rust 内部可变性Interior Mutability——用 Cell 与 RefCell 在共享引用背文档教程Rust 智能指针与内部可变性实战从 C/C 内存管理到 BoxT、RcT、CellT/RefCellTRust 智能指针与内部可变性实战从 C/C 内存管理到 BoxT 、 RcT 、 CellT / RefCellT 本文导读 本文以 Rust文档教程Pop GTK主题的国际化与本地化多语言支持实现指南Pop GTK主题的国际化与本地化多语言支持实现指南 Pop GTK主题作为System76开发的Linux桌面环境主题以其现代化设计和流畅体验受到众多上一篇WarcraftHelper魔兽争霸3终极优化指南5分钟解锁144Hz流畅体验下一篇魔兽争霸3性能优化终极指南5步解锁高帧率与宽屏体验创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表