Rust 设计模式与最佳实践——不要和借用检查器对抗

在本章中,我们将讨论为什么与 borrow checker 对抗很少是一种成功策略。我们会看到,试图欺骗或绕过 borrow checker,往往会导致复杂且不可靠的代码;而认真听取它的提示,通常会带来更好的结果。我们还会考察 developers 常用的一些绕过 borrow checker 限制的策略,并理解为什么这些策略通常会制造更多问题,而不是提供解决方案。

我们将首先查看一些典型策略:developers 如何试图"击败" borrow checker,以及为什么这些策略会导致代码越来越复杂、越来越脆弱。接下来,我们将讨论 unsafe code:虽然它有时确实必要,但通常并不是解决 borrow checker 问题的答案。最后,我们将考察一种常见冲动:通过 global state 和 mutable statics 来绕过 borrow checker errors,而这种做法经常会导致代码难以阅读和维护。

我们也会继续 calculator project。我们将看到,当我们尝试用各种技巧绕过 borrowing restrictions 时会发生什么,以及这些技巧如何把我们带入更深的问题。我们会发现,认真听 borrow checker 到底在告诉我们什么,通常会揭示出更好的代码组织方式。

本章将覆盖以下主要主题:

  • Trying to defeat the borrow checker
  • unsafe is usually not the answer
  • Over-using globals and mutable statics

Technical requirements

练习的 source code 可以在 GitHub 上找到: github.com/PacktPublis...

该 repository 按 chapter 组织。本章相关练习位于: github.com/PacktPublis...

Trying to defeat the borrow checker:friend or foe?

在本节中,我们将考察为什么 developers 经常试图绕过 borrow checker,以及为什么这样做通常会带来问题。我们会查看一些用于突破 borrowing limits 的典型策略,并发现它们经常造成的问题比解决的问题更多。

在与 borrow checker 进行了许多次战斗后,有时 Rust developers 会开始把它当成需要击败的敌人。如果 borrow checker 持续拒绝你的 design,而你又觉得代码清晰且组织良好,那么这种反应是可以理解的。Compiler errors 可能看起来随机又烦人。一定有办法让 borrow checker 接受你的代码,对吧?

让我们看看这在 calculator project 中是什么样子。

我们将尝试添加对更复杂 expressions 的支持,使它们可以通过记录之前 calculations 的方式复用 previous results,并允许后续引用它们。为了实现这一点,我们会 parse 并 evaluate 每个 expression,同时关注其中是否有对 past results 的引用。然后,我们会取回每个 result,并更新 current value。让我们试试看。

我们可以先创建一个 Calculator struct,用来维护 current value 和 previous results 的 memory。下一段代码中会定义的 evaluate method,会检查 expression 是否引用了 previous result,例如 "result1""result2";如果是,就查询它。否则,就正常 parse 和 evaluate 该 expression:

rust 复制代码
struct Calculator {
     current_value: f64,
     memory: Vec<f64>,
}

impl Calculator {
     fn new() -> Self {
         Self {
             current_value: 0.0,
             memory: Vec::new(),
         }
     }

这个 struct 同时存储 current value,以及一个 Vec,其中包含所有 previous results。new constructor 会将 current value 初始化为 0.0,并将 Vec 初始化为空状态。

现在实现处理 result references 的 evaluate method。该方法首先检查 expression 是否以 "result" 开头,用来处理对 previous calculations 的引用。如果不是,则委托给 parse_and_evaluate 做正常 expression 处理:

rust 复制代码
    fn evaluate(&mut self, expression: &str) -> Result<f64, String> {
         if expression.starts_with("result") {
             if let Some(index) = expression.strip_prefix("result") {
                 if let Ok(offset) = index.trim().parse::<usize>() {
                     return self.get_previous_result(offset);
                 }
             }
         }

        let result = self.parse_and_evaluate(expression)?;
    
        self.memory.push(result);
         self.current_value = result;
    
        Ok(result)
     }

    fn get_previous_result(&self, index: usize) -> Result<f64, String> {
         if index == 0 {
             Ok(self.current_value)
         } else {
             let pos = self.memory.len().checked_sub(index)
                 .ok_or("Invalid result index")?;
             self.memory.get(pos)
                 .copied()
                 .ok_or_else(|| "Invalid result index".to_string())
         }
     }

一开始,这种方法似乎很直接:我们要么取回 previous result,要么计算一个 new result,然后更新 calculator 的 state。

问题出在 parse_and_evaluate method。它既需要 tokenize expression,也就是需要 &self,又需要查找 previous results,也需要 &self,而该 method 本身接收的是 &mut self

rust 复制代码
    fn parse_and_evaluate(&mut self, expression: &str) -> Result<f64, String> {
         let tokens = self.tokenize(expression)?;
    
        for token in &tokens {
             if let Token::ResultReference(index) = token {
                 let prev = self.get_previous_result(*index)?;
                 /* use prev in calculation */
             }
         }
    
        todo!("Actual evaluation TBD")
     }
 }

这看起来像是一个合理的 first draft。我们存储 results,并提供引用它们的方式。然而,borrow checker 有不同意见。当我们尝试 compile 时,它会这样表达:

rust 复制代码
error[E0502]: cannot borrow `*self` as immutable because it is also borrowed as mutable
  --> src/calculator.rs:84:30
   |
80 |     fn parse_and_evaluate(&mut self, expression: &str) -> Result<f64, String> {
   |                           --------- mutable borrow occurs here
...
84 |                 let prev = self.get_previous_result(*index)?;
   |                            ^^^^ immutable borrow occurs here

由于我们已经在 mutably borrowing self,因此无法在 parse_and_evaluate 内部调用 get_previous_result 时,再 immutably borrow 它。

此时很容易想找一些聪明办法绕过这个限制。一种可能方式是尝试把 calculator 拆成独立 structs,或者存储 indexes,而不是立即查找 values。一个看起来简单得多的解决方案,是使用 interior mutability。

使用 RefCell 的 interior mutability 允许我们通过 immutable reference 修改 data,方式是把 borrow checking 移到 runtime。我们把 memory Vec 包进 RefCell 中,这样 get_previous_result 就可以接收 &self,而不是 &mut self。看看 interior mutability 会把我们带到哪里:

rust 复制代码
use std::cell::RefCell;

struct Calculator {
     current_value: f64,
     memory: RefCell<Vec<f64>>,
 }

impl Calculator {
     fn new() -> Self {
         Self {
             current_value: 0.0,
             memory: RefCell::new(Vec::new()),
         }
     }

    fn evaluate(&self,expression:&str)-> Result<f64, String> {
         if expression.starts_with("result") {
             if let Some(index) = expression.strip_prefix("result") {
                 if let Ok(offset) = index.trim().parse::<usize>() {
                     return self.get_previous_result(offset);
                 }
             }
         }

        let result = self.parse_and_evaluate(expression)?;
    
        self.memory.borrow_mut().push(result);
         self.current_value = result;
    
        Ok(result)
     }

这里我们改变的是 memory。现在它被存储在 RefCell<Vec<f64>> 中。读取时通过 borrow() 访问 memory,写入时通过 borrow_mut() 访问。Compiler 会接受这种写法,因为 RefCell 将 borrowing rules 移到了 runtime。再看看剩余 methods。

使用 RefCell 后,get_previous_resultparse_and_evaluate 都可以接收 &self,从而消除 compile-time conflict:

rust 复制代码
    fn get_previous_result(&self, index: usize) -> Result<f64, String> {
         if index == 0 {
             Ok(self.current_value)
         } else {
             let memory = self.memory.borrow();
             let pos = memory.len().checked_sub(index)
                 .ok_or("Invalid result index")?;
             memory.get(pos)
                 .copied()
                 .ok_or_else(|| "Invalid result index".to_string())
         }
     }

    fn parse_and_evaluate(&self, expression: &str) -> Result<f64, String> {
         let tokens = self.tokenize(expression)?;
    
        for token in &tokens {
             if let Token::ResultReference(index) = token {
                 let prev = self.get_previous_result(*index)?;
                 /* Here we would use `prev` in calculation */
             }
         }
    
        todo!("Actual evaluation TBD")
     }
 }

这个版本确实能 compile,但代价是引入了一些问题。因为使用了 RefCell,我们把 Rust 的 borrowing checks 从 compile time 移到了 runtime。RefCell 执行的仍然是普通 references 的 borrowing rules:任意时刻,你可以有多个 immutable borrows,或者一个 mutable borrow,但违反规则时,现在会产生 runtime panics,而不是 compilation errors。

例如,如果代码中的一部分调用 borrow_mut() 来修改 calculator state,同时另一部分仍然持有来自 borrow() 的 reference,就会看到这样的 runtime panic:

arduino 复制代码
thread 'main' panicked at 'already borrowed: BorrowMutError', src/calculator.rs:84:30

这在复杂 code base 中尤其危险,因为 borrowing patterns 并不总是立刻明显。此外,我们让代码变得更复杂,也更难理解。Runtime borrowing checks 会降低效率,也更难发现 bugs,因为原本会是 compile-time errors 的问题,现在变成了 runtime crashes。

Borrow checker 实际上是在告诉我们一件重要的事:我们的 design 有缺陷。我们把 expression parsing,也就是需要查找 previous results 的事情,和 calculation history management 混在一起了。

让我们尝试一种更好的方法。不要与 borrow checker 对抗,而是重新设计 interface。我们将 tokenization 与 evaluation 分离,把 result references 作为独立阶段处理。首先,定义一个可以表示 result references 的 Token type:

scss 复制代码
#[derive(Debug)]
 enum Token {
     Number(f64),
     ResultReference(usize),
     Operator(char),
}

impl Calculator {
     fn tokenize(&self, expression: &str) -> Result<Vec<Token>, String> {
         let mut tokens = Vec::new();
    
        for part in expression.split_whitespace() {
             let token = if let Some(index) = part.strip_prefix("result") {
                 if let Ok(offset) = index.trim().parse() {
                     Token::ResultReference(offset)
                 } else {
                     return Err("Invalid result reference".to_string());
                 }
             } else if let Ok(num) = part.parse() {
                 Token::Number(num)
             } else if part.len() == 1 && "+-*/".contains(part) {
                 Token::Operator(part.chars().next().unwrap())
             } else {
                 return Err(format!("Invalid token: {}", part));
             };
        
            tokens.push(token);
         }
    
        Ok(tokens)
     }

Token enum 现在包含 ResultReference variant,用于存储 index,而不是立即查找 value。tokenize method 会将 input string 转换为 tokens,但不会访问 memory。它只是记录用户请求了一个 result reference。这种分离是关键:tokenization 并不需要访问 calculator state。现在看看如何在独立步骤中 resolve 这些 references。

evaluate method 现在分成清晰阶段:tokenize、resolve references,然后 evaluate。每个阶段先完成,再进入下一个阶段,从而消除 borrowing conflict:

rust 复制代码
    fn evaluate_tokens(&self, tokens: Vec<Token>) -> Result<f64, String> {
         todo!("The evaluator is TBD")
     }

    fn evaluate(&mut self, expression: &str) -> Result<f64, String> {
         let tokens = self.tokenize(expression)?;
    
        let mut resolved_tokens = Vec::new();
         for token in tokens {
             match token {
                 Token::ResultReference(index) => {
                     let value = self.get_previous_result(index)?;
                     resolved_tokens.push(Token::Number(value));
                 }
                 token => resolved_tokens.push(token),
             }
         }
    
        let result = self.evaluate_tokens(resolved_tokens)?;
    
        self.memory.push(result);
         self.current_value = result;
    
        Ok(result)
     }
 }

Resolution loop 会通过查找 values,将 ResultReference tokens 转换为 Number tokens。这发生在 tokenization 完成之后,因此 get_previous_result 所需的 immutable borrow 与任何 mutable operations 之间不会冲突。Borrow checker 正是在引导我们走向这种更清晰的 separation of concerns:先 tokenize,再 resolve references,最后 evaluate。

在这个实现中,我们一次只关注一件事。先把 expression parse 成 tokens,再 resolve 任何 result references,然后 evaluate expression。

通过分离 concerns,并思考 data flow,我们创建了一个更可行的 design。代码更容易管理,也更容易理解。Borrow checker 试图告诉我们:把 expression processing 和 result lookup 混在一起,是一个糟糕 design。通过听它的话,而不是与它对抗,我们找到了更好的答案。

下一节中,我们将探索另一个面对 borrow checker 挫折时的常见反应:伸手去用 unsafe code。我们会看到为什么这通常不是答案,并考察更好的替代方案。

unsafe is usually not the answer:a dangerous shortcut

在本节中,我们将考察为什么 developers 遇到 borrow checker 困难时,有时会求助于 unsafe code,以及为什么这通常不是明智做法。我们会看到,写出能 compile 但实际上 unsound 的代码有多简单,并发现使用 unsafe 通常并不能解决 design 中的根本问题。

有时候,当 developers 尝试了所有其他方法让代码 compile 之后,unsafe 似乎会成为答案。这并不是完全疯狂。语言中包含 unsafe 是有原因的。Rust 的创建者看到,在某些合法目的下,有时需要绕过 safety:例如通过 Foreign Function Interface(FFI)与外部代码交互,实现 low-level data structures,或直接访问 hardware。Rustonomicon 提供了关于何时以及如何正确使用 unsafe 的全面指导:

doc.rust-lang.org/nomicon/

Rust 本身在 standard library 中也使用 unsafe code。看起来,只要谨慎使用,unsafe 就可以成为解决普通方式无法解决问题的方案。也许我们也可以用它来解决 borrowing issues。

让我们给 calculator 添加一个新功能,看看这种思维方式如何把我们引入歧途。

我们想为 calculator 添加 undo 和 redo support。Users 应该能够在 calculation history 中向后 step,并再次向前 step。这是很多 applications 中常见的功能,看起来也很直接。

如果你来自 C 或 C++,可能马上会想到 pointer-based approach:把所有 calculator states 存储在一个 collection 中,并维护一个 "current" pointer,随着 user 请求 undo 或 redo,我们移动这个 pointer。这样可以高效地浏览 history,而无需到处 copy data。

但 borrow checker 不会喜欢我们在一个可能变化的数据结构中维护 pointers。我们可以尝试使用 references,而不是 raw pointers,但 Rust 不允许我们在同一个拥有 Vec 的 struct 中存储指向该 Vec 元素的 reference。这种 self-referential borrow 不被允许;即便允许,也会阻止 Vec 被修改。与其和 lifetimes 纠缠,一个熟悉 C 的 developer 可能会伸手使用 raw pointers,完全绕过这个问题。

我们可以尝试用 unsafe code 来绕过。毕竟,我们只是需要一个简单 pointer。能有多糟?

首先定义 calculator state 的样子。每个 state 捕获被 evaluated 的 expression 及其 result:

rust 复制代码
struct CalculatorState {
    expression: String,
    result: f64,
}

CalculatorState struct 很直接:它存储 expression string 和 computed result。随着 user 执行 calculations,我们会累积这些 states。

现在,构建一个 history structure,用 raw pointer 标记当前位置:

rust 复制代码
struct UnsafeHistory {
    states: Vec<CalculatorState>,
    current: Option<*const CalculatorState>,
}

impl UnsafeHistory {
    fn new() -> Self {
        Self {
            states: Vec::with_capacity(10),
            current: None,
        }
    }

    fn push(&mut self, state: CalculatorState) {
        self.states.push(state);
        self.current = Some(self.states.last().unwrap() as *const CalculatorState);
    }
}

UnsafeHistory struct 将 states 存储在 Vec 中,并维护一个指向 current state 的 raw pointer。我们用容量 10 初始化 Vec,这一点很快会变得重要。push method 添加一个新 state,并更新 current pointer,使其指向该 state。注意,创建 raw pointers 是 safe Rust。危险发生在我们 dereference 它们时。

现在,实现一个 method 来获取 current result。这是我们第一次进入 unsafe territory:

rust 复制代码
fn current_result(&self) -> Option<f64> {
    self.current.map(|ptr| unsafe { (*ptr).result })
}

current_result method 在 unsafe block 中 dereference raw pointer,以获取 result。我们是在告诉 compiler:"相信我,这个 pointer 是有效的。"很快我们会发现,这个承诺我们无法兑现。

能够访问 current result 后,可以实现通过 undo 和 redo methods 在 history 中导航:

rust 复制代码
fn undo(&mut self) -> Option<f64> {
    let ptr = self.current?;

    // Find current position and move back one
    let pos = self.states.iter()
        .position(|s| std::ptr::eq(s, ptr))?;

    if pos > 0 {
        self.current = Some(&self.states[pos - 1] as *const CalculatorState);
        self.current_result()
    } else {
        None // Already at the beginning
    }
}

fn redo(&mut self) -> Option<f64> {
    let ptr = self.current?;

    let pos = self.states.iter()
        .position(|s| std::ptr::eq(s, ptr))?;

    if pos + 1 < self.states.len() {
        self.current = Some(&self.states[pos + 1] as *const CalculatorState);
        self.current_result()
    } else {
        None // Already at the end
    }
}

undo method 通过比较 pointers,在 Vec 中找到当前 position,然后将 current pointer 向后移动一个 slot,并返回该 result。redo method 做相反的事情,在 history 中向前移动。这看起来合理。我们只是在移动一个 pointer。

现在,定义一个使用该 history 的 Calculator struct:

arduino 复制代码
struct Calculator {
    history: UnsafeHistory,
}

Calculator struct 只是包装我们的 UnsafeHistory。所有 state 都保存在 history 中;Calculator 只提供面向 user 的 interface。

实现 Calculator 的 methods,将所有东西串起来:

rust 复制代码
impl Calculator {
    fn new() -> Self {
        Self {
            history: UnsafeHistory::new(),
        }
    }

    fn evaluate(&mut self, expression: &str) -> Result<f64, String> {
        if expression == "undo" {
            return self.history.undo()
                .ok_or_else(|| "Nothing to undo".to_string());
        }
        if expression == "redo" {
            return self.history.redo()
                .ok_or_else(|| "Nothing to redo".to_string());
        }

        let result = self.parse_and_evaluate(expression)?;

        self.history.push(CalculatorState {
            expression: expression.to_string(),
            result,
        });

        Ok(result)
    }

    fn parse_and_evaluate(&self, expression: &str) -> Result<f64, String> {
        todo!("Evaluation TBD")
    }
}

evaluate method 先检查特殊命令 "undo""redo",并委托给 history navigation。对于普通 expressions,它 evaluate expression,并将 result push 到 history stack 中。这样创建了一个干净 interface,users 可以输入 "undo" 来回退。

我们通过执行超过初始 Vec capacity 的 calculations 来测试这段代码:

rust 复制代码
fn main() -> Result<(), String> {
    let mut calc = Calculator::new();

    // Add calculations until we exceed capacity
    for i in 0..15 {
        calc.evaluate(&format!("{} + {}", i, i))?;
    }

    // Try to undo
    println!("Current: {:?}", calc.history.current_result());
    println!("Undo: {:?}", calc.evaluate("undo")?);
    println!("Undo again: {:?}", calc.evaluate("undo")?);

    Ok(())
}

这看起来完美工作......直到突然不工作。问题在于,当 Vec 需要增长到超过其 capacity,也就是我们初始化的 10 时,它必须在新的、更大的 memory block 中重新分配 storage。一旦发生这种情况,所有指向旧 storage 中元素的 pointers 都会失效。正常情况下,Rust 的 borrow checker 会阻止我们拥有这种 dangling references,但通过使用 unsafe code,我们绕过了这种保护。

所以,我们很开心地添加 calculations,直到超过初始 capacity。然后,灾难发生了!正常情况下,Vec 增长 memory 是无害且不可见的。我们不会在意它是否需要 reallocate,除非像这里一样,我们有一个 unsafe pointer,现在它已经 invalid。

在 Miri,也就是 Rust 的 undefined behavior detector 下运行这段 unsafe code,会揭示问题:

kotlin 复制代码
error: Undefined Behavior: pointer to alloc1234 was dereferenced after
this allocation got freed

   --> src/calculator.rs:42:24
    |
42 |     self.current.map(|ptr| unsafe { (*ptr).result })
    |                        ^^^^^^^^^^^^^^^^ pointer to alloc1234 was
    |                        dereferenced after this allocation got freed

Miri 捕获了 tests 可能漏掉的 dangling pointer。这正是 unsafe code 如此危险的原因。它看起来可能能工作,但实际上静默造成 memory corruption。

写出能 compile 的 unsafe code 并不难,而且它在测试中甚至可能看起来能工作。但引入严重问题却出乎意料地简单,而看见它们又出乎意料地困难。我们试图非常小心,但当我们使用 unsafe 时,我们绕过了 borrow checker 一直提供给我们的保护。我们无法保证 concurrent access 的安全。Pointer 如果是 null,我们也不安全。我们正在使用可能在 dropped 后仍被访问的 memory。

这些正是 borrow checker 试图阻止的问题。

这里,我们发现了为什么 unsafe code 如此 unsafe。写出带 bugs 的代码是一回事。但在 Rust 中,有 safety mechanisms,所以即便是 bugs,也必须遵守某些规则。这使 buggy code 能做什么变得可理解,并将 damage scope 限制在你能从代码中看到的范围。

unsafe 可以让你创建 undefined behavior,通常称为 UB,这是另一个层级的问题。C 和 C++ programmers 可能很熟悉 UB。当代码中存在 UB 时,任何事情都可能发生。Program 可能看起来能工作,但造成的 damage 会在之后的另一个时间或地点显现。UB 可以损坏与 unsafe code 无关的数据,或者破坏几乎任何代码层面的东西。

Rust 最常被提及的优势之一就是 safety,而这正是因为它让我们免于担心 UB,并让即便是错误代码也变得可理解。没有 UB,你可以看代码并通过推理判断它在做什么。一旦 UB 出现,这种推理能力就不再适用。

让我们通过思考真正想实现的目标来正确解决这个问题。我们并不需要 pointer 来追踪位置。我们只需要知道自己在 history 中的位置。Index 完美完成这个任务:

rust 复制代码
struct CalculatorState {
    expression: String,
    result: f64,
}

struct History {
    states: Vec<CalculatorState>,
    position: usize,
}

History struct 现在使用 position index,而不是 pointer。Index 只是一个数字。它不指向 memory,因此当 Vec reallocate 时,它不会失效。我们复用同一个 CalculatorState struct,因为它本身设计得已经很好。

现在实现 constructor 和 push method,它们负责 state management:

rust 复制代码
impl History {
    fn new() -> Self {
        Self {
            states: Vec::new(),
            position: 0,
        }
    }

    fn push(&mut self, state: CalculatorState) {
        // When pushing after an undo, discard the "future" states
        self.states.truncate(self.position);
        self.states.push(state);
        self.position = self.states.len();
    }
}

Constructor 会初始化空 history,并将 position 设置为 0。push method 处理了 unsafe 版本忽略的一个细节:当 undo 之后再 push 一个新 state 时,future states 应该被丢弃。这是标准 undo / redo behavior。如果你 undo 两次后执行新操作,就不能 redo 回那些被丢弃的 states。truncate call 干净地处理了这一点。

现在实现 navigation methods:

rust 复制代码
fn current_result(&self) -> Option<f64> {
    if self.position > 0 {
        self.states.get(self.position - 1).map(|s| s.result)
    } else {
        None
    }
}

fn undo(&mut self) -> Option<f64> {
    if self.position > 1 {
        self.position -= 1;
        self.current_result()
    } else {
        None
    }
}

fn redo(&mut self) -> Option<f64> {
    if self.position < self.states.len() {
        self.position += 1;
        self.current_result()
    } else {
        None
    }
}

undoredo methods 只是调整 position index。没有 pointer arithmetic,没有 unsafe blocks,也没有访问 freed memory 的可能。Vec 可以任意 grow 和 reallocate,而我们的 index 仍然有效。current_result method 通过 get() 使用 safe bounds-checked access。

这个版本没有 UB,因为它受到 compile-time checks 保护。它使用不会神秘失败的 safe Rust code。它也更容易理解,更容易维护。

关键要理解的是,使用 unsafe 并没有解决我们的真正问题。我们实际上根本不需要 pointers。我们只需要一种方式追踪自己在 sequence 中的位置,而 integer 就能完美做到。Unsafe approach 更复杂、更危险,甚至没有正确处理 redo-after-undo。

Rust 中确实有合法使用 unsafe code 的场景,例如:

  • Interfacing with foreign functions
  • Implementing low-level optimizations
  • Creating safe abstractions around hardware
  • Building foundational data structures

安全地编写 unsafe Rust 是完全可能的。有很多优秀资源可以帮助你理解如何成功使用 unsafe,它们也能教会我们很多非常适用于 Rust 旅程的东西,包括什么时候不要使用 unsafe。但它必须谨慎、节制地使用。"borrow checker 很难搞" 几乎从来不是使用 unsafe 的好理由。如果你发现自己想用 unsafe 来解决 borrowing issues,这通常说明你应该重新考虑 design。

Why care about being "harder to reason about"?

你可能听过这个说法,特别是在讨论 unsafe code 和 borrow checker workarounds 时。我在谈论不合适的 patterns 和 awkward designs 时,也经常引用它,但几乎总是在代码确实能工作的 context 中。

为什么我如此强调代码是否容易或困难 "to reason about"?

我一位多年好友,也是我非常敬佩的人,喜欢这样说:

"Debugging is twice as difficult as coding, so I code at half complexity. That way, I have some hope of debugging it."

Rust 有时会让人觉得非常困难。到现在,你甚至可能已经在问:"我为什么要让自己经历这些?在我开始学习 Rust 之前,写代码似乎简单多了。"

随着 Rust 旅程继续,你会发现 Rust 正在引导你写出可以被 reason about 的代码。当你以 Rust 中最自然的方式做事情时,通常你也设计出了自己可以理解的东西。Data flow 是清晰的。Things 的 ownership 是显式的。你不需要思考 immutable data,因为你知道它不会变。State 可以变化的位置是清楚的,而且这些位置通常很少。更多时候,你会在 bugs 成为 bugs 之前发现它们,或者根本不会写出它们。在 debugging 时,也会更容易识别 bugs 在哪里、为什么发生,以及该怎么处理。

当 borrow checker 对你不满,或者你的代码为了完成一个任务变得越来越 awkward 时,可以把它看作 Rust 在提醒你:写出你能够 "reason about" 的代码。Debugging 是写代码的两倍困难。当你用 unsafe code 或其他 workarounds 与 borrow checker 对抗时,通常是在让未来 debugging 任务变得远远不止两倍困难。让 Rust 帮你把事情变简单。

下一节中,我们将考察另一种常见的逃避 borrow checker 问题的尝试:过度使用 global state 和 mutable statics。这种方法很诱人,但它经常制造的问题比解决的问题更多。

Over-using globals and mutable statics

在本节中,我们将讨论学习 Rust 的人试图战胜 borrow checker 的另一种方式:涉及 global state 和 mutable static variables 的技巧。正如我们讨论过的许多主题一样,这些东西当然有自己的用途。但 reflexively 使用 global statics 来避免 borrow checker issues,并不是它看起来像是的答案。

正如我们之前看到的,有效使用 Rust 的关键,是管理 data flow。在一个较大的 code base 中管理 references 有时可能很困难。如果其中一些 state 还需要 mutable,就更困难了。Borrow checker 经常会对此表达意见,因此我们需要谨慎管理代码中的 mutable references。

但如果有一种方式可以绕过整个问题呢?

Rust 初学者中一个非常常见的解决方案,是转向 global static variables。LazyStaticOnceCell 这类 crates 提供了非常有用的方式来动态初始化 global statics。OnceCell 后来也最终被包含进 standard library,因为它确实非常有用。使用 MutexRwLock 这类 constructs,可以让这些 static globals 不仅能动态初始化,还能携带 mutable state。

既然 data 可以待在原处,并且 everywhere 使用,那为什么还要花大量时间和精力思考 data flows 呢?

这看起来像一个极好的解决方案,但经常会变得很糟。

让我们试试看:

  • 为 calculator memory 和 variables 创建 global static variables
  • 用一个 numbers Vec 初始化 memory,用一个 HashMap 将 variable names 映射到 numbers,但它们都包进 Mutex 中,这样可以从任何地方更新它们
  • 然后编写 calculator logic,在存储和 evaluate numbers 时引用 globals,以获取和更新 memory 与 variables
  • 我们不必担心 data flow,因为 data 总是可访问的

实现如下。我们使用 lazy_static 为 calculator 的 memory 和 variables 创建 global Mutex-protected state:

rust 复制代码
use std::sync::Mutex;
use lazy_static::lazy_static;
use std::collections::HashMap;

lazy_static! {
     static ref MEMORY: Mutex<Vec<f64>> = Mutex::new(Vec::new());
     static ref VARIABLES: Mutex<HashMap<String, f64>> = Mutex::new(HashMap::new());
 }

struct Calculator;

impl Calculator {
     fn new() -> Self {
         Self
     }

    fn store_result(&self, result: f64) {
         let mut memory = MEMORY.lock().unwrap();
         memory.push(result);
     }

    fn get_previous_result(&self, index: usize) -> Option<f64> {
         let memory = MEMORY.lock().unwrap();
         if index == 0 {
             memory.last().copied()
         } else {
             let pos = memory.len().checked_sub(index)?;
             memory.get(pos).copied()
         }
     }

Calculator struct 现在是空的,也就是一个 "unit struct",因为所有 state 都存在 global statics 中。每个 method 在访问 data 之前都必须 acquire lock,即使是 get_previous_result 这种 read-only operation。注意 lock().unwrap() 调用。如果一个 thread 在持有 lock 时 panic,后续尝试获取 lock 也会 panic。

再看 evaluate method。它必须在不同时间点获取 MEMORYVARIABLES 的 locks:

rust 复制代码
    fn evaluate(&self, expression: &str) -> Result<f64, String> {
         if let Some((name, value_expr)) = expression.split_once('=') {
             let value = self.evaluate(value_expr.trim())?;
             let mut vars = VARIABLES.lock().unwrap();
             vars.insert(name.trim().to_string(), value);
             return Ok(value);
         }

        let result = self.parse_and_evaluate(expression)?;
         self.store_result(result);
         Ok(result)
     }

    fn parse_and_evaluate(&self, expression: &str) -> Result<f64, String> {
         let tokens = self.tokenize(expression)?;
    
        let mut resolved_tokens = Vec::new();
         for token in tokens {
             match token {
                 Token::Variable(name) => {
                     let vars = VARIABLES.lock().unwrap();
                     let value = vars.get(&name)
                         .ok_or_else(|| format!("Undefined variable: {}",
                                     name))?;
                     resolved_tokens.push(Token::Number(*value));
                 }
                 token => resolved_tokens.push(token),
             }
         }
    
        self.evaluate_tokens(resolved_tokens)
     }
 }

注意,我们在 loop 内部获取 VARIABLES lock,每遇到一次 variable reference 就获取一次。如果一个 expression 包含多个 variables,我们就在反复 lock 和 unlock。更糟的是,如果需要查找一个 variable,而它的 value 依赖于同一 expression 中的另一个 variable,就可能进入复杂 locking patterns,并带来 deadlock 风险。Global state 让我们这个简单 calculator 的推理变得出乎意料地复杂。

我们也应该写 tests:

scss 复制代码
use super::*;

fn clear_memory() {
     let mut memory = MEMORY.lock().unwrap();
     memory.clear();
 }

fn clear_variables() {
     let mut variables = VARIABLES.lock().unwrap();
     variables.clear();
}

#[test]
 fn test_calculator_results() -> Result<(), String> {
     clear_memory();
     clear_variables();

    let calc = Calculator::new();

    calc.evaluate("x = 5")?;
     calc.evaluate("y = x + 3")?;

    assert_eq!(calc.evaluate("y")?, 8.0);
     Ok(())
 }

#[test]
 fn test_interdependent_variables() -> Result<(), String> {
     clear_memory();
     clear_variables();

    let calc = Calculator::new();

    calc.evaluate("a = 1")?;
     calc.evaluate("b = a + 1")?;
     calc.evaluate("a = b + 1")?;
     assert_eq!(calc.evaluate("a")?, 3.0);
     Ok(())
 }

如前所述,在这个实现中,Calculator struct 已经变成一个 placeholder,也就是 "unit struct",因为 state 被存储在 global statics 中。大多数 operations 都很熟悉,但有一点应该非常显眼:突然之间,我们每次使用 data structures 时,都必须为它们获取 locks。

这能 compile,看起来也能工作!

不过,当我们更认真地思考代码时,一些问题就变得清晰:

Testing 对可靠软件至关重要,但我们如何测试依赖 global state 的 functions?为了写 tests,我们必须为每个 test 小心设置和清理 state。更糟的是,我们必须非常小心 globals 在代码中如何初始化,这样才能同时处理 production 和 test cases 的运行。我们又回到了担心 data flows 的状态,只不过方式更笨拙。

使用 mutexes 意味着可能出现 locking issues。我们在 parse_and_evaluate 中多次 acquire locks。之前我们问过,如果需要读取一个 value 依赖另一个 variable 的 variable,会怎样?我们意识到这可能导致 deadlock。即使没有 deadlock,现在我们也在管理原本不需要的 locks,而这些 locks 是因为使用 global statics 才出现的。

如果我们并行运行多个 tests,而 Rust 默认就是这样,可能会看到间歇性 failures:

arduino 复制代码
thread 'test_calculator_results' panicked at 'assertion failed: `(left == right)`
  left: `5.0`,
right: `8.0`', src/calculator.rs:799:5

这是因为一个 test 在另一个 test 读取 global state 时修改了它。这个 failure 是 non-deterministic 的,因此极难 debug。

正常情况下,哪些 functions 可能修改 state,哪些 functions 可能依赖该 state,通常非常清楚。但如果一切都是 global,就很难判断什么依赖什么,也很难判断 functions 是否可能互相干扰。

我们能重新设计,避免 mutable global statics 吗?试试看:

rust 复制代码
struct Calculator {
     memory: Vec<f64>,
     variables: HashMap<String, f64>,
}

impl Calculator {
     fn new() -> Self {
         Self {
             memory: Vec::new(),
             variables: HashMap::new(),
         }
     }

    fn evaluate(&mut self, expression: &str) -> Result<f64, String> {
         if let Some((name, value_expr)) = expression.split_once('=') {
             let value = self.evaluate(value_expr.trim())?;
             self.variables.insert(name.trim().to_string(), value);
             return Ok(value);
         }

        let tokens = self.tokenize(expression)?;
         let resolved_tokens = self.resolve_variables(tokens)?;
         let result = self.evaluate_tokens(resolved_tokens)?;
    
        self.memory.push(result);
         Ok(result)
     }

    fn resolve_variables(&self, tokens: Vec<Token>) -> Result<Vec<Token>, String> {
         tokens.into_iter()
             .map(|token| match token {
                 Token::Variable(name) => {
                     let value = self.variables.get(&name)
                         .ok_or_else(|| format!("Undefined variable: {}",
                                     name))?;
                     Ok(Token::Number(*value))
                 }
                 token => Ok(token),
             })
             .collect()
     }

这里,我们更认真地思考了 data 的 flow 和 ownership。现在 data 在 calculator 中的移动更加清晰,ownership 也清楚地表明 functions 何时、何处依赖这些 data,并且何时可以修改它们。

现在写 tests 也更加清楚、简单:

rust 复制代码
use super::*;

#[test]
 fn test_calculator() -> Result<(), String> {
     let mut calc = Calculator::new();

    calc.evaluate("x = 5")?;
     calc.evaluate("y = x + 3")?;

    assert_eq!(calc.evaluate("y")?, 8.0);
     Ok(())
 }

#[test]
 fn test_interlocking_variables() -> Result<(), String> {
     let mut calc = Calculator::new();

    calc.evaluate("a = 1")?;
     calc.evaluate("b = a + 1")?;
     calc.evaluate("a = b + 1")?;
     assert_eq!(calc.evaluate("a")?, 3.0);
     Ok(())
 }

Globals 和 mutable static data 与 Rust 中许多东西一样,存在都有原因。它们有时有用,甚至必要。然而,它们不是仔细思考 ownership、sharing 和 data flow 的替代品。在合适 context 中使用时,globals 和 shared statics 可以绕过困难问题。但如果不谨慎使用,它们本身就会成为问题的来源。

Summary

本章中,我们讨论了与 borrow checker 对抗,并借助 Bad Calculator project 展示了一些 developers 常用的对抗方式,同时也探索了替代方案。

首先,我们探索了为什么 developers 想要避开 borrow checker,以及为什么这种努力经常不像最初看起来那样简单或有帮助。

然后,我们讨论了试图用 unsafe code 绕过 borrow checking,但这可能带来危险,并且实际上并不能带来太多收益。我们看到,除特定场景外,unsafe code 很少能有效解决 ownership problems。

最后,我们讨论了通过创建 static globals 来避免思考 data flows 和 ownership。我们看到,这看起来像是可以简化一切,但之后会在 complexity 和 flexibility 上付出代价。通常,global state 制造的问题比解决的问题更多。

我们看到,这些方法虽然很诱人,但通常会导致代码更难维护、测试和 debug。我们也看到,borrow checker 的限制经常会引导我们走向更好的 designs,与 Rust ownership system 协作会带来更清晰、更可维护的代码。

我们考察了 calculator project 如何使用这些 anti-patterns 来实现,然后展示了如何将它 refactor 成与 Rust ownership system 协作,而不是对抗它的实现。

当你遇到 borrow checker errors 时,可以考虑这些 guidelines:

Separate concerns by phase:先 parse,再 process,最后 store。每个阶段应先完成,再进入下一个阶段。

Use indices instead of references:当你需要引用内部 data,或引用可能在你背后变化的数据时,存储 indices 或 keys,而不是 pointers 或 references。

Let data flow downward:组织代码,使 data 从 creation 流向 processing,再流向 storage,尽量减少 back-references 的需求。

Question whether you need interior mutability :如果你使用 RefCellMutex 只是为了让 compiler 满意,那么你的 design 很可能需要重新思考。

Keep mutable state local:mutable data 的 scope 越小,代码就越容易 reason about。

Trust the borrow checker's feedback:当它抱怨时,它往往正在揭示一个 design flaw。请听它告诉你的 data dependencies 问题。

下一章中,我们将开始考察 traditional design patterns 如何转化到 Rust 中,从 creational patterns 开始。我们将看到什么有效,什么无效,并学习如何成功应用 traditional patterns。我们也会开始一个新项目:"Correct Calculator",在这个项目中,我们将专注于与 Rust 协作,构建一个高质量 application。

相关推荐
Rust研习社2 小时前
你为什么总是入门 Rust 失败
开发语言·后端·rust
techdashen2 小时前
等了两年,Cloudflare 终于给规则引擎加上了通配符
服务器·rust
芳草萋萋鹦鹉洲哦3 小时前
【tauri】为什么接口通信选择invoke而不是Axios
rust·axios·tauri·invoke
天天打码6 小时前
从 Rolldown 到 Oxc:前端工具链正在全面 Rust 化
开发语言·前端·rust
Vallelonga8 小时前
Rust 中 Cargo.toml & Cargo.lock
开发语言·后端·rust
Rust研习社18 小时前
为什么 Rust 没有空指针?
开发语言·后端·rust
xcLeigh1 天前
IoTDB Rust 原生接口开发指南:从零生成 + 完整 RPC 调用
数据库·rpc·rust·接口·api·时序数据库·iotdb
十 一 丶1 天前
如何在客户端实现ssh的免密登录?
运维·rust·ssh
kyriewen1 天前
你等的Babel编译,够喝三杯咖啡了——用Rust重写的SWC,只需眨个眼
前端·javascript·rust