在本章中,我们将讨论 Rust 中更多常见 anti-patterns,重点是为了避免处理 ownership 和 borrowing,而过度使用 cloning 和 smart pointers。我们会看到,当 developers 遇到 borrow checker errors 时,经常会寻找快速修复方案;同时我们也会讨论为什么这些方法通常会导致低效、混乱,并且潜在有 bug 的代码。
首先,我们将探索 developers 如何试图完全避免思考 ownership 和 borrowing,从而导致与 Rust 原本使用方式相冲突的 designs。然后,我们将考察用 clone() 解决所有 ownership conflicts 的诱惑,以及为什么这会造成 performance 和 correctness 问题。最后,我们将讨论 reference counting 和 interior mutability,也就是通过 Rc<RefCell<>> 的误用;这种误用往往是绕过 borrow checker 的错误方式,同时我们也会看哪些场景下这确实是正确解决方案。
我们还会继续 calculator project。我们会添加一些以不同方式访问 data 的功能,并发现 borrow checker 会对我们的 approach 提出异议。我们会先尝试用看起来很容易的方案绕过这些问题,但会发现它们会导致糟糕设计。随后,我们会看看如何以更符合 Rust 工作方式的方法来完成这些事情。
本章将覆盖以下主要主题:
- Avoiding ownership concerns
- Cloning everything
- Misusing Rc and RefCell(and friends)
Technical requirements
练习的 source code 可以在 GitHub 上找到: github.com/PacktPublis…
该 repository 按 chapter 组织。本章相关练习位于: github.com/PacktPublis…
Avoiding ownership concerns
在本节中,我们将讨论 developers 经常如何试图完全绕开 Rust 的 ownership system,而不是与之协作。我们会看看为什么这种方法会产生有问题的 designs,并考察支持 Rust ownership paradigm 的不同选项。
The allure of ignoring ownership
当 developers 第一次接触 Rust 的 ownership structure 时,它可能看起来像是一种额外负担。Rust 关于谁拥有某个值、何时拥有的严格规则,对于来自那些 data 可以自由交换和修改的语言的人来说,可能显得限制性强又令人烦恼。虽然基本规则本身很简单:每个 value 恰好有一个 owner;borrowing 允许在不取得 ownership 的情况下临时访问;mutable borrows 是独占的,但人们仍然很容易想寻找方式,完全忽略这些规则。
让我们看看这在 calculator project 中如何体现。我们想添加一个功能,用于追踪 calculation history,并允许 recall previous results。
为了实现这个功能,calculator 需要维护一份 calculation history,这份 history 可以被程序的不同部分查看和修改。有些 components 只需要查看 history,而另一些则需要修改它。在许多语言中,我们可能会用 interfaces 或 abstract classes 来控制 access。
在 Java 或 Python 这样的语言中,这似乎相对简单。将它翻译到 Rust 中,我们可以为需要访问 history 的 components 创建一个 trait,再为需要修改 history 的 components 创建一个 trait。然后,我们可以为 Calculator 实现这两个 traits。让我们尝试一个 Rust implementation。
首先,定义 data structures 和 traits。我们会创建用于保存 calculation results 的 types,以及用于访问 expression history 的 traits。然后,将它们包装到 Calculator struct 中:
struct CalculationResult {
expression: String,
result: f64,
}
struct Calculator {
history: Vec<CalculationResult>,
current_expression: Option<String>,
}
trait HistoryViewer {
fn view_history(&self) -> &[CalculationResult];
fn get_last_result(&self) -> Option<f64>;
}
trait HistoryManager {
fn add_to_history(&self, expression: String, result: f64);
fn clear_history(&self);
}
impl Calculator {
fn new() -> Self {
Self {
history: Vec::new(),
current_expression: None,
}
}
}
这里,CalculationResult 是我们的 result container。它保存 numeric value,也保存产生该 value 的 expression。我们还定义了两个 traits,用于处理 calculation history:HistoryViewer 是读取 history 的 interface,而 HistoryManager 允许我们添加和清除 history。随后,Calculator 通过包装一个 expression Vec 和最近的 expression String 来维护 history 并追踪 current expression。
现在,我们实现前面代码片段中定义的 traits 来控制 access。先从 HistoryViewer 开始,它提供对 history 的 read-only access:
impl HistoryViewer for Calculator {
fn view_history(&self) -> &[CalculationResult] {
&self.history
}
fn get_last_result(&self) -> Option<f64> {
self.history.last().map(|r| r.result)
}
}
实现 HistoryViewer 没有问题。我们可以用 &self reference 需要的数据,compiler 也很满意。
但是,当我们实现需要修改 data 的 HistoryManager 时,就遇到了麻烦:
impl HistoryManager for Calculator {
// This won't compile - we need &mut self to modify history
fn add_to_history(&self, expression: String, result: f64) {
self.history.push(CalculationResult {
expression,
result,
});
}
fn clear_history(&self) {
self.history.clear();
}
}
这个 design 一开始看起来很合理。我们为查看和修改 history 分别提供 traits,使程序中不同部分可以获得不同级别的 access。然而,正如 add_to_history 中看到的,由于我们试图通过对 self 的 shared reference 来修改 self.history,它不会 compile。把它改成 &mut self 可以工作,但那意味着凡是想使用 history 的地方,都需要 Calculator 的 mutable references。
很容易想寻找一种答案,让我们可以假装 ownership 不存在。Interior mutability,也就是使用 RefCell 这类 type 允许我们通过 immutable reference 修改 data,看起来像是一个快速修复。让我们尝试通过把 data 包进 RefCell 来让代码 compile:
use std::cell::RefCell;
struct Calculator {
history: RefCell<Vec<CalculationResult>>,
current_expression: RefCell<Option<String>>,
}
impl HistoryManager for Calculator {
// Now this "works" - but at what cost?
fn add_to_history(&self, expression: String, result: f64) {
self.history.borrow_mut().push(CalculationResult {
expression,
result,
});
}
fn clear_history(&self) {
self.history.borrow_mut().clear();
}
}
现在代码可以 compile,但我们引入了几个问题。我们不再使用 compile-time guarantees,而是依赖 runtime borrow checking。这引入了 calculator 运行时发生 panics 的可能性,而这些问题本来 compiler 可以提前阻止,从而确保 safety。
代码也变得更难读、更难推理,因为我们把 mutation 隐藏在 shared references 后面。有时候这是正确方法,但在这里,它并没有增加任何价值。
Why does ownership matter?
与其与 borrow checker 对抗,不如尝试让 calculator 在 ownership rules 内工作。我们可以重新设计 API,让 ownership 和 mutability requirements 显式且清晰:将 read-only operations 与需要 mutation 的 operations 分开,并为不同 access 类型创建特定 types。让我们用 explicit ownership 重新设计:
struct Calculator {
history: Vec<CalculationResult>,
current_expression: Option<String>,
}
impl Calculator {
fn new() -> Self {
Self {
history: Vec::new(),
current_expression: None,
}
}
现在,我们可以根据需要什么样的 access 来划分 API。先定义只需要读取的方法。这里可以直接使用 &self,因为我们只需要 immutable access。我们思考需要什么 access,并把它限制在真正需要的范围内:
fn view_history(&self) -> &[CalculationResult] {
&self.history
}
fn get_last_result(&self) -> Option<f64> {
self.history.last().map(|r| r.result)
}
现在,可以定义需要修改 history 的方法。对于这些方法,我们使用 &mut self:
fn add_to_history(&mut self, expression: String, result: f64) {
self.history.push(CalculationResult {
expression,
result,
});
}
fn evaluate(&mut self, expression: String) -> Result<f64, String> {
let result = self.calculate_expression(&expression)?;
self.add_to_history(expression, result);
Ok(result)
}
}
如果我们需要共享对 history 的 access,可以创建一个 dedicated type。这会引入 lifetimes,也就是 Rust 用来追踪 references 在多久内有效的方式。下面的 'a lifetime parameter 告诉 compiler:HistoryView 不能比它引用的数据活得更久,从而防止 dangling references:
struct HistoryView<'a> {
entries: &'a [CalculationResult],
}
impl Calculator {
fn create_history_view(&self) -> HistoryView<'_> {
HistoryView {
entries: &self.history,
}
}
}
我们的目标是为不同类型的 access 创建不同 APIs,并且我们通过创建清晰区分 viewing methods 和 modifying methods 的方式,干净地完成了这一点。现在也可以通过 HistoryView 创建一个清晰的 view-only type。在这个版本中,由于 method signatures 很明确,我们可以看出什么时候会发生 mutation,也能保留关于 data access 的 compile-time guarantees。代码避免了 runtime borrow checking overhead,也更容易理解和推理。
上一章中,我们看到许多试图掩盖 ownership 和 borrowing 问题的情况,它们根本不起作用。本章中,我们讨论的是另一种情况:一些容易的修复方案大多、某种程度上能工作。
你可能会问:“如果这些技术能工作,为什么不用呢?”
这是一个非常好的问题!本章中用于绕过 borrow checker 的技巧,依赖的是 Rust 中因为确实有需要才加入的 features。这意味着,从总体上说,使用这些 features 本身不是错误;但在不需要时到处使用它们,就是错误,并可能导致各种问题,从低效、难读的代码,到以非常不愉快方式失效的代码。
这也正是本章中的 anti-patterns 最常见、最持久,也最危险的原因。它们常见,因为它们很简单。它们持久,因为它们经常看起来能工作,它们确实经常能工作,但有代价。它们危险,因为它们会形成坏习惯,之后引发问题,并隐藏 Rust compiler 通常会阻止的 subtle bugs。
下一节中,我们将讨论其中一种 anti-pattern:cloning everything。
Cloning everything
在本节中,我们将探索另一个常见 anti-pattern:每当出现 ownership 相关错误或问题时就使用 clone()。Rust 中的 cloning 会创建 data 的完整、独立副本。Clone trait 提供 clone() method,用于复制一个 value,分配新 memory 并复制所有内容。虽然 cloning 在某些情况下正是应该使用的工具,但过度使用它会导致低效代码,并掩盖更深层的 design issues。
The clone hammer
当 developers 第一次遇到 borrow checker errors 时,最诱人的解决方案之一,就是简单地 clone 任何导致 conflicts 的 data。这非常常见,以至于几乎每个人,包括我自己,在 Rust 之旅开始时都这样做过。这是一种让 compiler 停止抱怨的简单方式,不需要认真思考或重构,而且似乎能工作。很多时候,它确实在一定程度上能工作。
有一句老话:“手里只有锤子时,看什么都像钉子。”当你把 clone() 当作锤子时,每个 ownership problem 都像是需要用 clone() 锤子敲打的钉子。
让我们看看这在 calculator 中可能如何体现。假设我们想为 expressions 添加 variables support。我们需要处理 variables,并在 evaluation 期间维护一个 working set of tokens。问题在于,我们会在许多地方共享 data。Borrow checker 可能会对此提出意见。让我们尝试用 clone() 避免这些问题:
#[derive(Clone)]
struct Variable {
name: String,
value: f64,
}
#[derive(Clone)]
enum Token {
Number(f64),
Variable(Variable),
Operator(char),
}
struct Calculator {
variables: HashMap<String, Variable>,
}
注意 Variable 和 Token 上的 #[derive(Clone)]。这是第一个警告。Clone 很有价值,有时也确实需要。这就是它存在于语言中的原因。但当我们开始为所有东西 derive 它时,就应该保持怀疑。
现在可以实现 methods。我们通过 “judicious” 地使用 clone() 来避免 borrowing issues:
impl Calculator {
fn tokenize(&self, expression: &str) -> Vec<Token> {
let mut tokens = Vec::new();
for part in expression.split_whitespace() {
if let Some(var) = self.variables.get(part) {
// Just clone it!
tokens.push(Token::Variable(var.clone()));
}
// ... rest of tokenization
}
tokens
}
对每个 variable 调用 clone(),让我们完全不用担心 ownership。至少暂时,它让我们绕过了问题。
那 evaluate method 呢?处理 Vec<Token> 可能会很烦,但我们有 clone() 锤子:
fn evaluate(&self, tokens: Vec<Token>) -> f64 {
let mut working_tokens = tokens.clone();
while working_tokens.len() > 1 {
// Find next operator
let op_pos = working_tokens.iter().position(|t| {
matches!(t, Token::Operator(_))
}).unwrap();
let left = working_tokens[op_pos - 1].clone();
let right = working_tokens[op_pos + 1].clone();
let result = self.apply_operator(left, right);
// Remove old tokens and insert result
working_tokens.drain(op_pos-1..=op_pos+1);
working_tokens.insert(op_pos-1, Token::Number(result));
}
match working_tokens[0] {
Token::Number(n) => n,
_ => panic!("Invalid expression")
}
}
}
这段代码充满了不必要的 cloning:创建 tokens 时 clone variables,处理前 clone 整个 token vector,evaluation 过程中 clone individual tokens。
每次 clone 都会创建新的 allocation 并复制 data,影响 performance,而我们本可以使用 references。但 performance impact 甚至还不是最大的问题。过度 cloning 掩盖了 fundamental design issues,使代码比必要情况更复杂,并导致 data flow 不清晰、低效。
A better approach
通过思考 data ownership 和 lifetimes,我们可以消除不必要 cloning,同时保持相同功能。让我们重写:
struct Variable {
name: String,
value: f64,
}
enum Token<'a> {
Number(f64),
Variable(&'a Variable),
Operator(char),
}
注意,现在 Token 持有对 Variable 的 reference,而不是拥有一个 clone。'a lifetime parameter 将 token 的有效性绑定到它所引用的 variable 上。
现在,methods 使用 references 而不是 clones:
impl Calculator {
fn tokenize<'a>(&'a self, expression: &str) -> Vec<Token<'a>> {
let mut tokens = Vec::new();
for part in expression.split_whitespace() {
if let Some(var) = self.variables.get(part) {
// Just use a reference
tokens.push(Token::Variable(var));
}
// ... rest of tokenization
}
tokens
}
通过使用 references,我们消除了对每个 variable 的浪费性复制。代码看起来几乎一样,只是没有了 clone。这能够工作,是因为现在 Token struct 使用 references 来表示 variables,所以不需要 clone data。我们可以直接存储 variable reference。
现在看 evaluate。之前我们 clone 了整个 Vec<Token>。使用 references 可以高效得多:
fn evaluate(&self, mut tokens: Vec<Token>) -> f64 {
while tokens.len() > 1 {
// Find next operator
let op_pos = tokens.iter().position(|t| {
matches!(t, Token::Operator(_))
}).unwrap();
// Calculate result using references
let result = match (&tokens[op_pos-1], &tokens[op_pos+1]) {
(Token::Number(n1), Token::Number(n2)) => {
self.apply_operator(*n1, *n2)
}
(Token::Variable(v1), Token::Number(n2)) => {
self.apply_operator(v1.value, *n2)
}
// ... other combinations
_ => panic!("Invalid expression")
};
// Remove old tokens and insert result
tokens.drain(op_pos-1..=op_pos+1);
tokens.insert(op_pos-1, Token::Number(result));
}
match tokens[0] {
Token::Number(n) => n,
_ => panic!("Invalid expression")
}
}
}
我们通过在需要时获取 references,避免 clone 所有 tokens。
这个版本在必要位置使用 references,减少不必要 copying。这让 ownership 更明显,也得到更有效、更可理解的代码。
我们已经看到,过度 cloning 会增加 overhead,并可能产生混乱代码。但它还有可能是危险的。前面我们讨论过 interior mutability,下一节也会继续讨论。正如之前所说,interior mutability 允许通过 immutable reference 修改 struct 内部 values。虽然有时它正是合适工具,但过度使用或误用 interior mutability,可能意味着 data 会在我们不知情时发生变化。
Rust compiler 的作用是确保我们不会做危险的事情。但如果我们到处使用 clone() 来让 borrow checker 静音,会发生什么?正常情况下,这也会工作。但如果内部 value 例如是一个 mutably borrowed RefCell,Rust 会保护我们免于 data races 和 data corruption。遗憾的是,它保护我们的方式是 panic。
最重要的认识是:cloning 应该是一种有意识的决定,而不是面对 borrow checker errors 时的自动反应。当你发现自己 reflexively reaching for clone() 时,这通常表示你需要重新思考 design。
下一节中,我们将讨论 Rust 的 smart pointers:Rc 和 RefCell。这些工具在谨慎使用时非常强大,但不加思考地使用时会导致混乱。
Misusing Rc and RefCell(and friends)
在本节中,我们将考察 developers 如何经常误用 Rc 和 RefCell 这类 types,把它们当作绕过 Rust ownership rules 的方式。Rust 中的 smart pointers 是像 pointers 一样工作的 data structures,但提供额外功能。Rc<T> 通过跟踪某个 value 当前有多少 references,支持 shared ownership。RefCell<T> 提供 interior mutability,使你即使通过 immutable reference 也能 mutate data,并在 runtime 执行 borrowing rules。虽然这些 types 有有效应用,但把它们当作 ownership issues 的万灵药,会导致代码效率更低,更难维护,也意味着 runtime reliability 会降低。
The siren song of smart pointers
在与 borrow checker 战斗时,Rc 和 RefCell,以及它们 thread-safe 的 counterparts:用于跨 threads shared ownership 的 Arc(Atomic Reference Counted),以及用于 thread-safe interior mutability 的 Mutex,都是非常诱人的工具。它们看起来像是提供了一种绕开 Rust strict rules 的方式,使 data sharing 和 modification 更开放。
继续 calculator 的例子,假设我们想 parse expressions,把它们存起来以便 later evaluation,并维护它们的 results。所以 core evaluation logic 现在需要处理一系列 tokens,按正确顺序应用 operators,并正确处理 variables 和 errors。
我们要跟踪很多 data!我们还要从许多地方以许多方式访问这些 data。Rust 会让这变得困难。我们可以用 reference counting 和 interior mutability 在整个程序中共享这些 data。
让我们看看如何在 calculator 中使用它。下面是有人可能如何用 smart pointers 过度设计这个功能。所有东西都被包在 Rc<RefCell<...>> 中:
struct Expression {
tokens: Rc<RefCell<Vec<Token>>>,
result: Rc<RefCell<Option<f64>>>,
}
struct Calculator {
current_expression: Rc<RefCell<Option<Expression>>>,
variables: Rc<RefCell<HashMap<String, f64>>>,
}
impl Calculator {
fn new() -> Self {
Self {
current_expression: Rc::new(RefCell::new(None)),
variables: Rc::new(RefCell::new(HashMap::new())),
}
}
结果是,methods 被 borrow() 和 borrow_mut() calls 弄得很杂乱。RefCell 用绕过 borrow checker 的便利,换来了显式请求 value access 的不便。这些 dynamic borrows 突然到处出现:
fn set_expression(&self, expr: &str) {
let tokens = self.tokenize(expr);
*self.current_expression.borrow_mut() = Some(Expression {
tokens: Rc::new(RefCell::new(tokens)),
result: Rc::new(RefCell::new(None)),
});
}
fn evaluate(&self) -> Result<f64, String> {
let expr = self.current_expression.borrow();
let expr = expr.as_ref().ok_or("No expression set")?;
let mut tokens = expr.tokens.borrow_mut();
let vars = self.variables.borrow();
// Process tokens...
let result = self.process_tokens(&mut tokens, &vars)?;
*expr.result.borrow_mut() = Some(result);
Ok(result)
}
现在,set_expression 和 evaluate 需要能够修改 tokens 和 result 的 state。由于 interior mutability,我们可以在这些 methods 中使用 &self,然后通过 borrow() 和 borrow_mut() 动态借用这些 values。这允许 set_expression 修改 token Vec,也允许 evaluate 更新 results,而无需处理 borrow checker。
实际 token processing 使用的是 borrow_mut() 返回的 borrowed reference。这意味着 processing code 具有干净的 reference semantics,并且从不直接处理我们的 Rc<RefCell<>> values。这也意味着,对于讨论 interior mutability 来说,token processing 本身并不是很有趣。
因此,为了这个示例,我们可以创建一个假的 process_tokens,因为它做的事情不涉及 interior mutability:
fn process_tokens(
&self,
tokens: &mut Vec<Token>,
variables: &HashMap<String, f64>
) -> Result<f64, String> {
// Implementation
Ok(42.0)
}
}
这段代码有几个严重问题。最明显的是,它更难读,也更冗长,这会为 bugs 创造藏身之处。除此之外,我们正在用 manually managing lifetimes 来换掉 compiler 对 safety 和 correctness 的 guarantees,这可能导致 runtime panics 或 reference cycles。缺乏清晰性也让理解 data flow 和 ownership semantics 更困难。最后,reference counting 本身有固有成本,这里可能不那么重要,但在 time-critical 或 resource-critical code 中可能非常重要。
Simpler is better
我们可以在不诉诸不必要 smart pointers 的情况下解决这些问题。通过提前思考 ownership 和 data flow,我们可以创建一个既高效又更容易推理的 design。
让我们重写 calculator,使其满足相同 requirements,但不误用 smart pointers。在这个 redesign 中,data 单向流动:expressions 以 strings 形式进入,被 parse 成 tokens,然后 evaluated 产生 results,最后 results 被存入 history。每一步都会产生 output,供下一步消费。Calculator 直接拥有自己的 variables 和 history,所以不需要 shared ownership。
下面是使用 direct ownership 的更干净方法。先定义核心 types:
#[derive(Debug)]
enum Token {
Number(f64),
Variable(String),
Operator(char),
}
struct ParsedExpression {
tokens: Vec<Token>,
}
struct Calculation {
expression: String,
tokens: Vec<Token>,
result: f64,
}
struct Calculator {
variables: HashMap<String, f64>,
history: Vec<Calculation>,
}
现在 Token enum 将 variable names 存储为 String types,而不是使用 Rc。ParsedExpression 保存 tokenized form,Calculation 存储每次 evaluation 的完整记录。
现在,我们可以开始实现新的 interface。我们会创建 methods,区分哪些 operations 需要 mutate data,哪些只需要 read。先从新的、没有 RefCell 的 constructor 开始:
impl Calculator {
fn new() -> Self {
Self {
variables: HashMap::new(),
history: Vec::new(),
}
}
parse method 会对 expression 进行 tokenization:
fn parse(&self, expr: &str) -> Result<ParsedExpression, String> {
let tokens = self.tokenize(expr)?;
Ok(ParsedExpression { tokens })
}
evaluate_parsed method 处理 tokens,并把 result 存入 calculator 的 history:
fn evaluate_parsed(&mut self, expr: String, parsed: ParsedExpression) -> Result<f64, String> {
let result = self.evaluate_tokens(parsed.tokens.clone())?;
self.history.push(Calculation {
expression: expr,
tokens: parsed.tokens,
result,
});
Ok(result)
}
在这些 methods 中,我们只请求自己需要的 reference 类型,mutable 或 immutable,然后获得干净的、compiler-verified 的 data access。
由于 access patterns 明确,我们也可以创建符合这一模式的 convenience methods。例如,可以添加一个将 parsing 和 evaluation 组合起来的 wrapper:
fn evaluate(&mut self, expr: String) -> Result<f64, String> {
let parsed = self.parse(&expr)?;
self.evaluate_parsed(expr, parsed)
}
剩余 methods 提供对 history 和 variables 的访问:
fn history(&self) -> &[Calculation] {
&self.history
}
fn last_result(&self) -> Option<f64> {
self.history.last().map(|calc| calc.result)
}
fn set_variable(&mut self, name: String, value: f64) {
self.variables.insert(name, value);
}
fn get_variable(&self, name: &str) -> Option<f64> {
self.variables.get(name).copied()
}
}
Calculator implementation 全程使用 direct ownership。读取 data 的 methods 使用 &self,修改 state 的 methods 使用 &mut self。没有 RefCell,没有 runtime borrow checks,也没有 borrowing conflicts 导致 panic 的可能。
当我们确实需要对 calculator 进行 thread-safe access 时,可以通过专门的 wrapper type 添加该能力,而不是复杂化 core logic。这让 thread-safety concerns 与 main implementation 分离。如果需要在线程间共享 calculator,可以将它包进适当的 synchronization:
use std::sync::{Arc, Mutex};
struct ThreadSafeCalculator {
inner: Arc<Mutex<Calculator>>,
}
impl ThreadSafeCalculator {
fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Calculator::new())),
}
}
fn evaluate(&self, expr: String) -> Result<f64, String> {
let mut calc = self.inner.lock().map_err(|_| "Lock poisoned")?;
calc.evaluate(expr)
}
// Other methods following the same pattern...
}
impl Clone for ThreadSafeCalculator {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
这个 revised version 带来多个改进:
Clear ownership semantics:每个 component 直接拥有自己的 data,没有不必要的 indirection。
Explicit state management:Calculation struct 将相关 data 放在一起,history 明确由 calculator 拥有。
Better error handling:我们使用 Result types 显式处理 errors,而不是冒 runtime panics 的风险。
Thread-safety when needed:不是在代码各处撒 Rc 和 RefCell,而是在需要 concurrent access 时提供一个单独的 thread-safe wrapper。
Simpler mental model:因为 ownership 和 mutation 是显式的,所以更容易推理 data flow。
Better performance:在常见情况下避免 reference counting 和 runtime borrowing checks 的开销。
下面是可能如何使用这个 API。我们会设置一些 variables,执行一些 calculations,然后查看 history。我们也会试用 thread-safe wrapper:
fn main() -> Result<(), String> {
let mut calc = Calculator::new();
calc.set_variable("pi".to_string(), 3.14159);
let result1 = calc.evaluate("2 * pi".to_string())?;
let result2 = calc.evaluate("result + 1".to_string())?;
for calculation in calc.history() {
println!("{} = {}", calculation.expression, calculation.result);
}
let thread_safe = ThreadSafeCalculator::new();
let threads: Vec<_> = (0..3)
.map(|i| {
let calc = thread_safe.clone();
std::thread::spawn(move || {
calc.evaluate(format!("{} + 1", i))
})
})
.collect();
Ok(())
}
这个 design 实现了原始版本想做的所有事情,但使用的是合适的 Rust idioms。当确实需要 shared access 或 thread-safety 时,我们在合适的 abstraction level 添加它,而不是用不必要的 smart pointers 复杂化 core logic。
When to use smart pointers
Rc 和 RefCell 这类 smart pointers 确实有合法用途:
Rc 适用于 single-threaded contexts 中的 shared ownership,前提是这种 sharing 是 fundamental design 的一部分,例如某些 graph structures。
RefCell 适用于实现 interior mutability patterns,也就是确实需要在 immutable reference 后进行 mutation 的场景。Client 不需要 owned value 或 mutable reference,也能使用允许 mutation 的 interface。
Arc 和 Mutex,以及 RwLock,是这些 building blocks 在线程安全和 async programming 场景中的变体。
Smart pointers 非常出色,但不应该只是为了避免思考 ownership 而使用它们。虽然我们重点讨论了避免误用 Rc 和 RefCell,但理解它们什么时候合适同样重要。在使用 Rc 或 RefCell 之前,先问自己:
- Shared ownership 是否真的是 design 所必需的,还是我只是想避免思考 ownership?
- 我真的需要 interior mutability 吗,还是可以重构代码,使用正常 borrowing rules?
- 这个问题能不能通过更简单的 lifetime annotations 或 ownership patterns 解决?
Smart pointers 是强大的工具!当在正确场景中使用,也就是它们必要且有帮助时,它们可以是极佳的盟友。但当它们并不真正适合时,我们完全可以不使用它们,写出清晰、idiomatic 且有效的代码。
Summary
本章中,我们探索了 Rust 中三个常见 anti-patterns。我们讨论了为什么人们很容易想避免思考 ownership concerns,并看到与 Rust ownership system 协作,而不是对抗它的重要性。然后,我们讨论了过度使用 clone 这一非常常见的问题,以及为什么它往往是 design issues 的信号。最后,我们讨论了同样常见的 smart pointers 误用,例如 Rc 和 RefCell,并讨论了如何判断 smart pointers 何时适用,何时是在被误用。
我们也考察了 calculator project 如何用这些 anti-patterns 实现,然后展示了如何将其 refactor 为更 idiomatic 的 Rust patterns。
下一章中,我们将探索一个相关但不同的 anti-pattern:与 borrow checker 对抗。本章聚焦的是绕过 ownership rules 的技术,而下一章将考察当 developers 试图通过 unsafe code 和其他 workarounds 来胜过 borrow checker 时会发生什么。