news 2026/10/6 7:45:31

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rust 内部可变性与 RefCell<T> 实战解析:在不可变外壳下安全修改数据
  • 教程
  • 文档

【免费下载链接】book

The Rust Programming Language

项目地址:https://gitcode.com/gh_mirrors/bo/book
点击查看免费下载

导读

本文围绕 The Rust Programming Language(本仓库 book)第 15 章第 5 节展开,系统讲解 Rust 的**内部可变性(Interior Mutability)**设计模式与标准库类型RefCell<T>。通过本文你将掌握:为什么 Rust 默认的借用规则在编译期检查、而RefCell<T>将检查推迟到运行时;如何在测试中用 mock 对象记录消息、如何用Rc<RefCell<T>>让多份共享数据可被修改,以及这些技巧背后的代价(运行时 panic 与轻微性能开销)。所有示例均来自仓库 listings/ch15-smart-pointers 下的真实可运行代码。

什么是内部可变性

内部可变性是 Rust 中的一种设计模式:即使存在指向数据的不可变引用,也允许修改该数据——而正常情况下一旦存在不可变引用,借用规则会禁止任何修改。为了实现这一点,该模式在数据结构内部使用unsafe代码,绕开 Rust 关于修改与借用的常规规则。unsafe代码向编译器表明:规则改由我们手动检查,而不是依赖编译器来检查(关于unsafe代码的详细讨论见本书第 20 章 ch20-01-unsafe-rust)。

采用内部可变性模式的关键前提是:我们能确保借用规则在运行时一定被遵守,尽管编译器无法在编译期证明这一点。此时,unsafe代码被封装进一个安全的 API,外层类型对外仍是不可变的——调用者拿到的是一个安全、不可变的外观。

RefCell<T>就是遵循内部可变性模式的标准库类型,下面我们从它与普通引用的差异说起。

在运行时强制执行借用规则

Rc<T>允许多个所有者,而RefCell<T>与Box<T>一样,对数据持有单一所有权。那么RefCell<T>和Box<T>有什么本质区别?回顾第 4 章学过的借用规则:

  • 任意时刻,要么只能有一个可变引用,要么可以有任意多个不可变引用(两者不能同时存在);
  • 引用必须始终有效。

区别在于检查时机:

类型借用规则检查时机违反规则时的表现
普通引用(&/&mut)编译期编译器报错,程序无法编译
Box<T>编译期编译器报错
RefCell<T>运行时程序panic!并退出

编译期检查的好处显而易见:错误在开发阶段更早暴露,且所有分析都在运行前完成,对运行时性能零影响。正因如此,编译期检查是绝大多数情况下的最佳选择,也是 Rust 的默认行为。

那为什么还需要运行期检查?因为静态分析天然是保守的。有些代码属性无法通过静态分析确定——最著名的例子是停机问题(Halting Problem)。当编译器无法确认代码符合所有权规则时,它可能拒绝一个本身正确的程序;但反过来,如果 Rust 接受了一个错误的程序,用户就无法信任 Rust 提供的保证。RefCell<T>的价值恰恰在于:当你确信自己的代码遵守借用规则、而编译器却无法理解并保证这一点时,用它来把检查推迟到运行时,从而放行那些被编译期检查拒绝、但内存安全的场景。

与Rc<T>类似,RefCell<T>只适用于单线程场景,在多线程上下文中使用会直接得到编译错误。多线程程序中如何获得RefCell<T>的功能,将在第 16 章讨论(对应的线程安全版本是Mutex<T>)。

何时选 Box、Rc、RefCell

原文给出了一个非常实用的选择清单,可直接作为决策依据:

  • Rc<T>:同一数据的多个所有者;Box<T>和RefCell<T>都是单一所有者;
  • Box<T>:允许不可变或可变借用,编译期检查;Rc<T>:只允许不可变借用,编译期检查;RefCell<T>:允许不可变或可变借用,运行时检查;
  • 因为RefCell<T>允许运行时检查的可变借用,即使RefCell<T>本身是不可变的,也能修改其内部的值——这正是内部可变性模式的核心能力。

使用内部可变性:一个不可变值内部的修改

借用规则的一个直接推论是:当你拥有一个不可变值时,就不能对其做可变借用。例如 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; | +++

然而存在这样的实际场景:一个值希望在自身的方法内部修改自己,但对外部的其他代码表现为不可变。RefCell<T>就是实现这种内部可变性的一种方式。需要强调的是:RefCell<T>并没有完全绕过借用规则——编译器的借用检查器放行了这种内部可变性,规则本身仍在运行时被严格执行,一旦违反会得到panic!而不是编译错误。

实战用例:用 Mock 对象做测试

测试替身与 Mock 对象

在测试中,程序员常常用一个类型替换另一个类型,以便观察特定行为并断言其实现正确。这种占位类型被称为测试替身(test double),如同电影里的替身演员:在跑特定危险镜头时代替主角上场。Mock 对象是测试替身的一种:它记录测试期间发生的事件,让你能断言正确的动作确实发生了。

Rust 不像其他语言那样有"对象"的概念,标准库也没有内置 mock 对象功能。但完全可以自定义一个结构体来充当 mock 对象——这正是内部可变性的典型应用场景。

场景:一个配额跟踪库

我们要测试的库:跟踪一个值离最大值的距离,并根据接近程度发送消息——例如跟踪用户允许调用的 API 次数配额。库本身只负责"计算接近程度 + 决定何时发送什么消息",而发送机制由使用方提供(可以是界面提示、邮件、短信……),库只依赖一个它定义的Messengertrait。完整的库代码如下(listing-15-20/src/lib.rs):

pub trait Messenger { fn send(&self, msg: &str); } pub struct LimitTracker<'a, T: Messenger> { messenger: &'a T, value: usize, max: usize, } impl<'a, T> LimitTracker<'a, T> where T: Messenger, { pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, 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: You've used up over 90% of your quota!"); } else if percentage_of_max >= 0.75 { self.messenger .send("Warning: You've used up over 75% of your quota!"); } } }

两个关键设计点:

  1. Messenger::send接收self的不可变引用和消息文本——这是 mock 需要实现的接口,保证 mock 与真实对象可互换;
  2. 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: Vec<String>, } 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装进RefCell<Vec<String>>,send就能在只拿到&self的情况下修改内部向量。修复后的代码(listing-15-22/src/lib.rs):

#[cfg(test)] mod tests { use super::*; use std::cell::RefCell; struct MockMessenger { sent_messages: RefCell<Vec<String>>, } 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); } }

逐处变化解读:

  • 字段类型从Vec<String>改为RefCell<Vec<String>>;
  • new中通过RefCell::new(vec![])在空向量外包一层RefCell;
  • send的第一个参数仍是&self(与 trait 定义一致),内部调用self.sent_messages.borrow_mut()拿到指向向量的可变引用,再对其push记录消息;
  • 断言处改用borrow()获取向量的不可变引用,再调用.len()查看消息条数。

在set_value(80)、max = 100(80% > 75%)的条件下,测试断言 mock 恰好记录了一条警告消息,cargo test即可通过。

运行时跟踪借用:borrow / borrow_mut 的工作原理

普通引用用&和&mut语法创建;RefCell<T>则通过其安全 API 的borrow与borrow_mut方法:

  • borrow返回智能指针Ref<T>(不可变借用);
  • borrow_mut返回智能指针RefMut<T>(可变借用)。

Ref<T>和RefMut<T>都实现了Deref,因此可以像普通引用一样使用(关于Deref与解引用自动化的细节见 ch15-02-deref 和 ch05-03-method-syntax 的"->运算符去哪了"一节)。

RefCell<T>内部维护一个活跃借用计数:每次调用borrow计数 +1,当对应的Ref<T>离开作用域时计数 -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.txt):

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53: RefCell already borrowed note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

RefCell<T>以already borrowed: BorrowMutError(或此处输出中RefCell already borrowed的 panic 信息)的方式处理运行时借用违规。这也提醒我们权衡:运行时检查意味着错误可能直到代码部署到生产环境才暴露,并且跟踪借用计数会带来少量运行时性能开销。但反过来,它让我们能在"只允许不可变值"的上下文(如&self的 mock)中获得修改数据的能力,这是普通引用给不了的。

组合 Rc 与 RefCell :多个所有者 + 可变数据

Rc<T>允许多个所有者,但只提供不可变访问。把RefCell<T>放进Rc<T>,就能得到"既有多所有者、又能修改"的值。

回顾 ch15-04-rc 中的 cons list 例子:Rc<T>让多个列表共享对另一个列表的所有权,但由于Rc<T>只持有不可变值,一旦创建就无法更改。现在把RefCell<T>加进Cons定义(listing-15-24/src/main.rs):

#[derive(Debug)] enum List { Cons(Rc<RefCell<i32>>, Rc<List>), 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:?}"); }

执行流程拆解:

  1. value = Rc::new(RefCell::new(5)):创建共享根节点,5被装进RefCell再由Rc引用计数;
  2. 列表a的Cons持有Rc::clone(&value)——clone 的是Rc,使a与value共同拥有内部的5,而不是转移所有权或借用;
  3. b、c通过Rc::clone(&a)共享列表a(与第 4 节 ch15-04-rc 的 Listing 15-18 相同);
  4. *value.borrow_mut() += 10:borrow_mut借助自动解引用穿透Rc<T>到达内部RefCell<T>,返回RefMut<T>,再用解引用运算符*修改内部值,使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依然是"不可变"的值,却通过RefCell<T>提供的内部可变性在需要时修改数据;运行时的借用检查保护我们免于数据竞争。注意RefCell<T>不适用于多线程代码——线程安全版本是Mutex<T>,将在第 16 章 ch16-03-shared-state 中讨论。

权衡与总结

把RefCell<T>放进决策框架,与Box<T>、Rc<T>对比:

  • Box<T>:单一所有者,编译期借用检查,性能零开销——默认首选;
  • Rc<T>:多所有者,仅不可变借用(编译期检查),单线程;
  • RefCell<T>:单一所有者,不可变/可变借用皆可(运行时检查),单线程,允许"不可变外壳 + 内部修改";
  • Rc<RefCell<T>>:多所有者 + 可修改,是组合两者的经典方案;
  • Mutex<T>:RefCell<T>的线程安全版(第 16 章)。

选择运行时借用检查,意味着错误发现得更晚、且每次借用都要付出计数开销;但换来的是 mock 对象、共享可变数据结构等普通引用无法表达的能力。在 src/ch15-00-smart-pointers.md 的综述中,Ref<T>与RefMut<T>(经由RefCell<T>访问)被列为标准库中最常用的智能指针之一。若想进一步了解RefCell之外的标准库内部可变性类型(如Cell<T>与线程安全的Mutex<T>、RwLock<T>等),可继续阅读 ch16-00-concurrency;而Rc<T>与RefCell<T>组合后可能产生的引用循环内存泄漏问题及其解法(Weak<T>),见 ch15-06-reference-cycles。所有示例代码均可直接在仓库 listings/ch15-smart-pointers 目录下对应子目录中运行验证。

  • 教程
  • 文档

【免费下载链接】book

The Rust Programming Language

项目地址:https://gitcode.com/gh_mirrors/bo/book
点击查看免费下载

相关推荐

上一篇:WarcraftHelper:魔兽争霸3终极优化指南,5分钟解锁144Hz流畅体验
下一篇:魔兽争霸3性能优化终极指南:5步解锁高帧率与宽屏体验

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 7:24:53

LinkSwift 完整指南:浏览器里对 9 大网盘做直链解析的方法

LinkSwift 完整指南&#xff1a;浏览器里对 9 大网盘做直链解析的方法 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动云盘 / …

作者头像 李华
网站建设 2026/10/6 7:23:39

GSD 桌面通知配置指南:让 Auto Mode 无人值守期间的事件可见

人工智能AI Agent代码智能体Agent 编排CLIAI 应用 【免费下载链接】gsd-2 A powerful meta-prompting, context engineering and spec-driven development system that enables agents to work for long periods of time autonomously without losing track of the big picture…

作者头像 李华