Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rust 新手错误和最差实践 #30

Open
Yidadaa opened this issue Dec 26, 2021 · 0 comments
Open

Rust 新手错误和最差实践 #30

Yidadaa opened this issue Dec 26, 2021 · 0 comments
Labels
Milestone

Comments

@Yidadaa
Copy link
Owner

Yidadaa commented Dec 26, 2021

原文:Common Newbie Mistakes and Bad Practices in Rust: Bad Habits
译者:Yidadaa

很多从其他语言过来的 Rust 新手都会不可避免地利用之前的编码经验来写 Rust,这无可厚非,毕竟确实没必要从头开始学习编程知识。但是,这些经验性知识,却极有可能导致你写出来很垃圾的 Rust 代码。

别再用哨兵值了

这可能是我最讨厌的一点。在大多数沿袭 C 语言设计的语言中(C,C#,Java 等),经常使用一个特殊值来表示某个函数执行失败的情况。比如,在 C# 中,用于在字符串中查找另一个字符串的索引位置的函数 String.IndexOf(t) 会在找不到 t 时返回 -1,从而写出这样的 C# 代码:

string sentence = "The fox jumps over the dog";

int index = sentence.IndexOf("fox");

if (index != -1)
{
  string wordsAfterFox = sentence.SubString(index);
  Console.WriteLine(wordsAfterFox);
}

在其他的语言中,这种用法更是数不胜数,类似的哨兵值还有空字符串 "" 或者 nullNone 之类的空值。

既然它这么常用,为什么还要说它很差劲呢?原因就是你极有可能会忘记处理哨兵值所代表的失败情况,然后导致整个程序直接崩溃。

Rust 则提供了很好的解决方案,那就是 OptionOption 从设计层面就杜绝了忘记考虑 None 时的情况,编译器会在编译时就进行强制检查,如果你忘了处理 None,编译器会马上告诉你。上面字符串例子的代码,在 Rust 中可以写成这样:

let sentence = "The fox jumps over the dog";
let index = sentence.find("fox");

// let words_after_fox = &sentence[index..];
// 如果你直接使用 index,会得到报错:Error: Can't index str with Option<usize>

if let Some(fox) = index {
  let words_after_fox = &sentence[fox..];
  println!("{}", words_after_fox);
}

别再用匈牙利命名了

上世纪 70 年代,程序员们逐渐开始在无类型或动态类型语言中使用匈牙利命名法,他们给变量名加上不同的前缀来表示变量的类型,比如 bVisited 表示布尔型的变量 visitedstrName 表示字符串类型的变量 name

我们可以在 Delphi 语言中见到大量的例子,T 开头的表示类(class)或者类型(type),F 表示属性值(fields),A 表示参数(arguments),诸如此类。

type
 TKeyValue = class
  private
    FKey: integer;
    FValue: TObject;
  public
    property Key: integer read FKey write FKey;
    property Value: TObject read FValue write FValue;
    function Frobnicate(ASomeArg: string): string;
  end;

C# 中也有类似的使用习惯,比如用 I 开头表示一个接口(interface),所以 C# 程序员很可能会写出这种 Rust 代码:

trait IClone {
  fn clone(&self) -> Self;
}

你大可以直接扔掉前面的 I,因为 Rust 的语法已经保证了我们很难混淆 traittype,不像 C# 很容易就分不清 interfaceclass(译者按:Typescript 中就是 interfacetypeclass 大混战了,狗头.jpg)。

此外,你也没有必要在给一些工具函数或者中间变量命名时带上它的类型信息,比如下面的代码:

let account_bytes: Vec<u8> = read_some_input();
let account_str = String::from_utf8(account_bytes)?;
let account: Account = account_str.parse()?;

既然 String.from_utf8() 已经明明白白地返回了一个字符串,为什么还要在命名时加上 _str 后缀呢?

与其他语言不同,Rust 语言鼓励程序员在对变量进行一系列变换操作时,使用同名变量覆写掉不再使用的旧值,比如:

let account: Vec<u8> = read_some_input();
let account = String::from_utf8(account)?;
let account: Account = account.parse()?;

使用相同的变量名可以很好地保证概念的一致性。

有些语言会明令禁止覆写变量,尤其像 Javascript 这种动态类型语言,因为频繁变化的类型,在缺少类型推断的情况下,尤其有可能会导致 bug 出现。

你可能不需要这么多 Rc<RefCell<T>>

OOP 编程实践常常会保存其他对象的引用,并在合适的时候调用他们的函数,这没啥不好的,依赖注入(Dependency Injection)是个蛮不错的实践,不过有别于大多数面向对象的语言,Rust 并没有垃圾内存回收机制(Garbage Collector),并且对共享可变性非常敏感。

举个例子,我们正要实现一个打怪兽的游戏,玩家需要对怪物们造成足量伤害才算打败他们(我也不知道为什么要这么设定,可能是接受了什么委托?)。

先创建一个 Monster 类,包含 health 生命值属性以及 takeDamage() 遭受伤害的方法,为了能知道怪物遭受了多少伤害,我们允许为 Monster 类注入一个回调函数,该回调函数可以接收每次遭受的伤害值。

type OnReceivedDamage = (damageReceived: number) => void;

class Monster {
  health: number = 50;
  receivedDamage: OnReceivedDamage[] = [];

  takeDamage(amount: number) {
    amount = Math.min(this.health, amount);
    this.health -= amount;
    this.receivedDamage.forEach((cb) => cb(amount));
  }

  on(event: "damaged", callback: OnReceivedDamage): void {
    this.receivedDamage.push(callback);
  }
}

然后设计一个伤害计数类 DamageCounter,可以累计怪物伤害值:

class DamageCounter {
  damageInflicted: number = 0;

  reachedTargetDamage(): boolean {
    return this.damageInflicted > 100;
  }

  onDamageInflicted(amount: number) {
    this.damageInflicted += amount;
  }
}

然后我们对怪物造成随机伤害,直到伤害计数达到上限。

const counter = new DamageCounter();

const monsters = [
  new Monster(),
  new Monster(),
  new Monster(),
  new Monster(),
  new Monster(),
];
monsters.forEach((m) =>
  m.on("damaged", (amount) => counter.onDamageInflicted(amount))
);

while (!counter.reachedTargetDamage()) {
  // 随机选中怪物
  const index = Math.floor(Math.random() * monsters.length);
  const target = monsters[index];
  // 产生随机伤害
  const damage = Math.round(Math.random() * 50);
  target.takeDamage(damage);

  console.log(`Monster ${index} received ${damage} damage`);
}

这里是在线运行示例

然后我们用 Rust 重写上述逻辑,Monster 结构体结构保持不变,使用 Box<dyn Fn(u32)> 来接收闭包,该闭包接受一个 u32 型参数。

type OnReceivedDamage = Box<dyn Fn(u32)>;

struct Monster {
    health: u32,
    received_damage: Vec<OnReceivedDamage>,
}

impl Monster {
    fn take_damage(&mut self, amount: u32) {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        for callback in &mut self.received_damage {
            callback(damage_received);
        }
    }

    fn add_listener(&mut self, listener: OnReceivedDamage) {
        self.received_damage.push(listener);
    }
}

impl Default for Monster {
    fn default() -> Self {
        Monster { health: 100, received_damage: Vec::new() }
    }
}

随后是 DamageCounter 类:

#[derive(Default)]
struct DamageCounter {
    damage_inflicted: u32,
}

impl DamageCounter {
    fn reached_target_damage(&self) -> bool {
        self.damage_inflicted > 100
    }

    fn on_damage_received(&mut self, damage: u32) {
        self.damage_inflicted += damage;
    }
}

然后开始打怪:

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    for monster in &mut monsters {
        monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
    }

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        target.take_damage(damage);

        println!("Monster {} received {} damage", index, damage);
    }
}

同样地,Rust 代码也有一个在线运行示例

然而,编译器狠狠地给我们报了四个错误,全都集中在 monster.add_listener() 这一行:

error[E0596]: cannot borrow `counter` as mutable, as it is a captured variable in a `Fn` closure
  --> src/main.rs:47:48
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                                                ^^^^^^^ cannot borrow as mutable

error[E0499]: cannot borrow `counter` as mutable more than once at a time
  --> src/main.rs:47:39
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              ---------^^^^^^^^------------------------------------
   |                              |        |        |
   |                              |        |        borrows occur due to use of `counter` in closure
   |                              |        `counter` was mutably borrowed here in the previous iteration of the loop
   |                              cast requires that `counter` is borrowed for `'static`

error[E0597]: `counter` does not live long enough
  --> src/main.rs:47:48
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              ------------------^^^^^^^----------------------------
   |                              |        |        |
   |                              |        |        borrowed value does not live long enough
   |                              |        value captured here
   |                              cast requires that `counter` is borrowed for `'static`
...
60 | }
   | - `counter` dropped here while still borrowed

error[E0502]: cannot borrow `counter` as immutable because it is also borrowed as mutable
  --> src/main.rs:50:12
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              -----------------------------------------------------
   |                              |        |        |
   |                              |        |        first borrow occurs due to use of `counter` in closure
   |                              |        mutable borrow occurs here
   |                              cast requires that `counter` is borrowed for `'static`
...
50 |     while !counter.reached_target_damage() {
   |            ^^^^^^^ immutable borrow occurs here

看起来是一团乱麻,让我来翻译翻译,什么叫惊喜

  • 闭包捕获了对 counter 的引用;
  • counter.on_damage_received() 方法接受 &mut self,所以闭包需要 &mut 可变引用。由于这个闭包在循环里,所以对同一个对象的可变引用 &mut 会执行多次,这是不被 Rust 允许的;
  • 我们传入的闭包没有任何生命周期标记,意味着它需要掌控所有包含变量的所有权,所以 counter 需要被移动到闭包内部,而在循环中,重复移动某值就会造成 use of moved value 错误;
  • counter 被移动到闭包内后,我们又尝试在条件语句中使用它,显然也会报错。

总之,情况不太妙。

一个经典解决方法是把 DamageCounter 用引用计数指针裹起来,这样我们可以重复使用它了,此外由于我们需要使用 &mut self,所以我们需要使用 RefCell 来在运行时做借用检查(borrow checking),而不是在编译时。

 fn main() {
     let mut rng = rand::thread_rng();
-    let mut counter = DamageCounter::default();
+    let mut counter = Rc::new(RefCell::new(DamageCounter::default()));
     let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

     for monster in &mut monsters {
-        monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
+        let counter = Rc::clone(&counter);
+        monster.add_listener(Box::new(move |damage| {
+            counter.borrow_mut().on_damage_received(damage)
+        }));
     }

-    while !counter.reached_target_damage() {
+    while !counter.borrow().reached_target_damage() {
         let index = rng.gen_range(0..monsters.len());
         let target = &mut monsters[index];
         ...
     }
 }

这里是改动后的代码

虽然现在代码可以正常运行了,但是整块代码会被 Rc<RefCell>Vec<Foo>>> 之类的玩意儿搞得乌烟瘴气。而且当代码变得更复杂时,RefCell 也有可能会被可变借用多次。而如果在多线程中使用了 Arc<Mutex<Vec<Foo>>>RefCell 引发 panic 之后,整个程序会死锁。

所以,一个更好的解决方法是避免在结构体中存储持久化引用。我们对 Monster::take_damage() 稍加改造:

struct Monster {
    health: u32,
}

impl Monster {
    fn take_damage(&mut self, amount: u32, on_damage_received: impl FnOnce(u32)) {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        on_damage_received(damage_received);
    }
}

impl Default for Monster {
  fn default() -> Self { Monster { health: 100 } }
}

// 省略了 `DamageCounter` 的代码

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        target.take_damage(damage, |dmg| counter.on_damage_received(dmg));

        println!("Monster {} received {} damage", index, damage);
    }
}

这里是在线示例

由于避免了存储回调函数,改造后的代码行数从 62 行下降到了 47 行。

此外,我们也可以给 take_damage() 加个返回值,这样可以把伤害值放在返回值里,以备后用:

impl Monster {
    fn take_damage(&mut self, amount: u32) -> AttackSummary {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        AttackSummary { damage_received }
    }
}

struct AttackSummary {
    damage_received: u32,
}

// 省略了 `DamageCounter` 的代码

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        let AttackSummary { damage_received } = target.take_damage(damage);
        counter.on_damage_received(damage_received);

        println!("Monster {} received {} damage", index, damage);
    }
}

当代码复杂度上升时,代码也不会变成一团糟,而且它看起来更“函数式”。

错用整数类型

另一个从 C 语言带来的坏毛病是错用整数类型,导致代码里到处都是 usize 的类型转换,尤其是在对数组做索引时。

C 程序员在初学时就被各种教程教会了使用 int 类型来做索引和循环,当他们开始写 Rust 时,也自然而然地用 Vec<i32> 类型来做数组切片。但是阿 Rust 真的很严格,不让程序员使用 usize 以外的类型对数组、切片和 Vec 进行索引,这就不得不在索引的时候进行一次 i32 as usize 的类型转换。

Rust 这么做有诸多好处:

  • 无符号类型可以避免负数索引(译者按:Python 程序员请求出战);
  • usize 与普通指针的大小相同,指针运算不会造成隐式类型转换;
  • 内存操作函数 std::mem::size_of()std::mem::align_of() 返回 usize 类型。

所以,请尽量使用 usize 类型作为可能涉及索引操作的中间变量的首选类型。

没人比我更懂 unsafe

每次当我看到 C 程序员使用 std::mem::transmute() 函数或者裸指针来跳过编译器的借用检查时,我都会想起论坛中那条古老的 Rust 圣经:Obstacles, by Daniel Keep

建议你现在就去读一读,我可以等。(译者按:有空了给大伙翻译一下。)

你可能已经身经百战见得多了,精通八种编程语言,所以毫无顾忌地破坏 Rust 精心构筑的规则:创建自引用的结构体、用 unsafe 创建全局变量。而且每一次,你都用同样的借口:“这是个单线程程序,所以 static mut 百分之一万没问题”、“这在 C 语言里跑得好好的”。

unsafe 很微妙,你必须要对 Rust 的借用检查规则和内存模型有深刻的认识才行。我也不想像祥林嫂一样念叨:“未成年人请在编译器监督下编写 unsafe 多线程代码”,但如果你刚开始学这门语言,我衷心建议你耐心从编译器报错的痛苦中慢慢品味 Rust 的美妙。

当你成为了 Rust 大师,你可以尽情玩弄 unsafe 代码,但在那之前,我还是想告诉你,unsafe 不是杀死编译器报错的板蓝根,也不是能让你自在书写 C 风味 Rust 代码的作弊码。

不舍得用命名空间

C 语言中的另一个常见实践是给函数增加所属的库名或者模块名作为前缀,比如 rune_wasmer_runtime_load() 就表示 rune 库的 wasmer/runtime 模块下的 load() 函数。Rust 提供了非常好用的命名空间机制,请尽情使用它,比如刚刚这个函数就可以写成 rune::wasmer::Runtime::load()

滥用切片索引

C 语言离不开 for (int i = 0; i < n; i ++),就像西方不能没有耶路撒冷。

所以下面的 Rust 的代码也就不足为奇:

let points: Vec<Coordinate> = ...;
let differences = Vec::new();

for i in 1..points.len() [
  let current = points[i];
  let previous = points[i-1];
  differences.push(current - previous);
]

就像呼吸一样自然。然而,就算是老司机也难免会中招下标越界 bug ,尤其当你想在循环里取前一个值时,你就得花心思去考虑 i 是否是从 1 开始的。

Rust 很担心你,所以拿出了迭代器,切片类型甚至还有 windows()array_windows() 这种高级函数来获取相邻的元素对。上面的代码可以重写为:

let points: Vec<Coordinate> = ...;
let mut differences = Vec::new();

for [previous, current] in points.array_windows().copied() {
  differences.push(current - previous);
}

甚至可以用链式调用来炫技:

let differences: Vec<_> = points
  .array_windows()
  .copied()
  .map(|[previous, current]| current - previous)
  .collect();

有些人会主张使用了 map()collect 版本的代码更加“函数式“,我则觉得仁者见仁,智者见智。

不仅如此,迭代器的性能往往比朴素的 for 循环更好,你可以在这里了解原因。

滥用迭代器

一旦你用迭代器用上瘾了,你极有可能跑向对立面:拿着迭代器这个锤子,看啥都像钉子。由 mapfilterand_then() 堆叠成的链式调用会让代码可读性下降,而且频繁使用闭包,会让数据类型变得不再直观。

下面有个例子,演示了迭代器如何让你的代码变得更复杂,你可以读一读这段代码,并猜猜它是干啥的:

pub fn functional_blur(input: &Matrix) -> Matrix {
    assert!(input.width >= 3);
    assert!(input.height >= 3);

    // 先保存首尾两行,方便后续使用
    let mut rows = input.rows();
    let first_row = rows.next().unwrap();
    let last_row = rows.next_back().unwrap();

    let top_row = input.rows();
    let middle_row = input.rows().skip(1);
    let bottom_row = input.rows().skip(2);

    let blurred_elements = top_row
        .zip(middle_row)
        .zip(bottom_row)
        .flat_map(|((top, middle), bottom)| blur_rows(top, middle, bottom));

    let elements: Vec<f32> = first_row
        .iter()
        .copied()
        .chain(blurred_elements)
        .chain(last_row.iter().copied())
        .collect();

    Matrix::new_row_major(elements, input.width, input.height)
}

fn blur_rows<'a>(
    top_row: &'a [f32],
    middle_row: &'a [f32],
    bottom_row: &'a [f32],
) -> impl Iterator<Item = f32> + 'a {
    // 保存头尾元素,以备后用
    let &first = middle_row.first().unwrap();
    let &last = middle_row.last().unwrap();

    // 获取上中下的 3x3 矩阵来做平均
    let top_window = top_row.windows(3);
    let middle_window = middle_row.windows(3);
    let bottom_window = bottom_row.windows(3);

    // 滑动窗口取均值,除了首尾元素
    let averages = top_window
        .zip(middle_window)
        .zip(bottom_window)
        .map(|((top, middle), bottom)| top.iter().chain(middle).chain(bottom).sum::<f32>() / 9.0);

    std::iter::once(first)
        .chain(averages)
        .chain(std::iter::once(last))
}

在线示例

看起来好像并不难,做个均值滤波罢了,不过我这里有个更好的实现:

pub fn imperative_blur(input: &Matrix) -> Matrix {
    assert!(input.width >= 3);
    assert!(input.height >= 3);

    // 直接从输入值拷贝返回值矩阵,这样就不需要考虑边界条件
    let mut output = input.clone();

    for y in 1..(input.height - 1) {
        for x in 1..(input.width - 1) {
            let mut pixel_value = 0.0;

            pixel_value += input[[x - 1, y - 1]];
            pixel_value += input[[x, y - 1]];
            pixel_value += input[[x + 1, y - 1]];

            pixel_value += input[[x - 1, y]];
            pixel_value += input[[x, y]];
            pixel_value += input[[x + 1, y]];

            pixel_value += input[[x - 1, y + 1]];
            pixel_value += input[[x, y + 1]];
            pixel_value += input[[x + 1, y + 1]];

            output[[x, y]] = pixel_value / 9.0;
        }
    }

    output
}

在线示例

我想你的心里已经有答案了吧。

不会用模式匹配

让我们回到一开始的 IndexOf() 函数,我们用 Option 类型举了一个很好的例子,先看下原始代码:

int index = sentence.IndexOf("fox");

if (index != -1)
{
  string wordsAfterFox = sentence.SubString(index);
  Console.WriteLine(wordsAfterFox);
}

然后,你可能会看到这样的 Rust 代码:

let opt: Option<_> = ...;

if opt.is_some() {
  let value = opt.unwrap();
  ...
}

或者这样的:

let list: &[f32] = ...;

if !list.is_empty() {
  let first = list[0];
  ...
}

这些条件语句都在避免某些边界条件,不过就像之前说到的哨兵值一样,我们在重构的时候依然会极有可能引入 bug。

而使用 Rust 的模式匹配,你可以保证当且仅当值有效时才会执行到对应的代码:

if let Some(value) = opt {
  ...
}

if let [first, ..] = list {
  ...
}

相比于之前的代码,由于避免了 opt.unwrap()list[index],模式匹配可以有更好的性能(作者的一点忠告:不要在网上听风就是雨,如果你真的想知道真相,建议写个 Benchmark 验证下)。

别再构造函数后初始化

许多语言都会在构造对象后调用对应的初始化函数(init() 之类的),但这有悖于 Rust 的约定:让无效状态不可见。

假设你在写个 NLP 程序,需要加载一个包含所有关键词的词表:

let mut dict = Dictionary::new();
// 读取文件并且把值存到哈希表或者列表里
dict.load_from_file("./words.txt")?;

然而,如果这么写了,意味着 Dictionary 类有两个状态:空的和满的。

那么如果后续代码假设 Dictionary 有值,并且直接使用它,那当我们错误地对一个空状态的 Dictionary 进行索引时,就会造成 panic。

在 Rust 中,最好在构造时就对结构体进行初始化,来避免结构体的空状态。

let dict = Dictionary::from_file("./words.txt")?;

impl Dictionary {
  fn from_file(filename: impl AsRef<Path>) -> Result<Self, Error> {
    let text = std::fs::read_to_string(filename)?;
    let mut words = Vec::new();
    for line in text.lines() {
      words.push(line);
    }
    Ok(Dictionary { words })
  }
}

Dictionary::from_file() 直接执行了初始化操作,并返回了初始化后的、立即可用的结构体,从而避免了上述问题。

当然,遇到这种问题的频率因人而异,完全取决于你的编码经验和代码风格。

一般来讲,函数式语言强调不可变性,所以函数式语言的使用者会天然地掌握这个经验。毕竟当你不能随便改变某个值时,你也不大可能创建一个初始化了一半的变量,然后再用什么其他值去填满它。

但面向对象的语言就不太一样了,它可能更鼓励你先构造个空对象,然后再调用具体函数初始化它,毕竟对象引用很容易为 null,而且他们也不关心什么可变性之类的玩意儿……现在你知道为啥那些面向对象语言会经常由于 NullPointerException 崩溃了吧。

保护性拷贝

不可变对象的一个显而易见的优点时,你永远可以相信它不会发生变化,而放心地使用它的值。但某些语言,比如 Python 或者 Java,不可变性没有传递性。举个例子,x 是个不可变对象,x.y 却不一定是不可变的,除非显式地定义它的可变性。

这意味着会出现下面的 Python 代码:

class ImmutablePerson:
  def __init__(self, name: str, age: int, addresses: List[str]):
    self._name = name
    self._age = age
    self._addresses = addresses

  # 只读属性
  @property
  def name(self): return self._name
  @property
  def age(self): return self._age
  @property
  def addresses(self): return self._addresses

后来其他人用了这个号称不可变的 ImmutablePerson,但是却不小心把它搞乱了:

def send_letters(message: str, addresses: List[str]):
  # 注意:发信 api 只接受大写字母,所以这里要预处理
  for i, address in enumerate(addresses):
    addresses[i] = addresses.upper()

  client = PostOfficeClient()
  client.send_bulk_mail(message, addresses)


person = ImmutablePerson("Joe Bloggs", 42, ["123 Fake Street"])

send_letters(
  f"Dear {person.name}, I Nigerian prince. Please help me moving my monies.",
  person.addresses
)

print(person.addresses) # ["123 FAKE STREET"]

我承认,这个例子确实有点刻意了,但是修改一个函数的传参却非常常见(译者按:尤其在某些深度学习项目里)。当你知道你自己定义的 ImmutablePersonaddresses 属性不可变时,不会有什么大问题,但是当你和别人协作,而且别人还不知道 addresses 不可变时,那就出大问题了。

事情也不是无法挽回,解决这个问题的经典方法是,总是在获取属性值的时候,返回它的拷贝,而非它自己:

class ImmutablePerson:
  ...

  @property
  def addresses(self): return self._addresses.copy()

这样就可以保证别人在使用该对象的属性时,不会意外改变它的原始值。

考虑到这篇文章的主题是 Rust,你可能已经猜到了造成这种问题的根本原因:别名与可变性。

并且你也可能想到,这种情况不会发生在 Rust 中,生命周期机制以及“有且只能有一处可变引用”的机制,保证了程序员无法在取得变量的所有权情况下去改动它的值,也没法显式地使用 std::sync::Mutex<T> 去改变某个共享引用值。

备注:你可能见过别人用 .clone() 来处理借用检查器的报错,然后就大喊“你看,Rust 还不是强迫我们做了保护性拷贝措施?”。我想说的是,这种代码基本都是由于程序员不熟悉生命周期机制,或者代码设计有问题导致的。

总结

本文并不能覆盖所有的最差实践,有些是因为我没亲身经历过,有些则是由于没法给出精简的例子。

衷心地感谢回复我在 Rust 论坛发布的这个帖子的各位同仁,尽管帖子的最后有点跑偏,各位 Rust 老鸟的论战还是让我受益颇深。

@Yidadaa Yidadaa added the 译文 label Dec 26, 2021
@Yidadaa Yidadaa added this to the 编程 milestone Dec 26, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant