Rust 设计模式与最佳实践——反模式:面向对象式设计

16 阅读37分钟

在本章中,我们将讨论一个 anti-pattern:试图在 Rust 中按 object orientation 来设计。Anti-patterns 是人们在代码中常用、但最终被证明无益,甚至会产生反效果的 coding solutions。这些有问题的方法一开始可能看起来有好处,但最终会导致代码难以维护、效率低下,或容易出错。这里特别之处在于,我们将讨论:为什么在某个 context 中非常稳固的 design pattern,到了另一个 context 中却可能变成 anti-pattern。

我们将处理把 Rust 当作 Java 或 C++ 这类 object-oriented(OO)language 使用时造成的常见问题,并探索这种思维带来的常见错误。我们将学习为什么 OO design patterns 在 Rust 中使用时会造成问题,为什么它们并不能真正按预期工作。我们也会查看一些场景,在这些场景中,某些 OO techniques 是有帮助的,甚至是必要的。

首先,我们会深入分析为什么 Rust 可能看起来与熟悉的 OO languages 相似,但这种相似具有欺骗性。上一章已经介绍过这一点,现在我们将更详细地考察它,以理解关键差异。

接下来,我们会深入研究第一个试图把 Rust 当作 OO language 使用的技巧:misusing traits。我们将看到,虽然 traits 类似 abstract classes 或 Java interfaces,但在语言语境中,它们在许多方面的功能完全不同。

接下来要讨论的技巧,是试图使用 Deref trait 来模拟 inheritance。我们会看到,这不是一种成功策略,而且可能带来不良后果。

我们还会讨论使用,或者更准确地说,是误用 generics 来模拟更传统的 class patterns。虽然这是可能的,有时也能工作,但通常这是一条通往灾难的路:一开始看起来很有希望,最终却变得不可行。

最后,我们将讨论使用 enums 实现 OO-like design。我们会看到,在某些场景中,它们确实是实现这个目的的优秀工具;但过度使用这种技术也不是解决方案。

我们还会继续上一章开始的 calculator project。我们将开始构建一个 OO-centric design,并观察它一开始似乎有效,但后来会越来越混乱。我们还会看到,如果我们与语言本身对抗,最终会得到不自然、不可靠的代码。

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

  • Why is Rust not an OO language?
  • Misusing traits
  • Using Deref to simulate inheritance
  • Using generic types to act like classes
  • Using enums where they don't make sense

Technical requirements

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

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

Why is Rust not an OO language?

在上一章中,我们介绍了这样一个话题:Rust 看起来像是一门 OO language,但它在细微处、也在非常重要的地方,与 OO language 不同。在本节中,我们将更详细地探索这一点。

是什么让 Rust 如此不同,以至于许多 OO techniques 在其中不能工作?如果它如此不同,为什么它看起来又如此像一门 OO language?我们能从这些差异中学到什么,并应用到 Rust 工作中?

正如我们之前所看到的,Rust 起初可能看起来支持 OO programming。这种印象并非偶然。Rust 有许多 features,在表面层面上与熟悉语言中的 OO constructs 很相似。当 experienced developers 第一次接触 Rust code 时,这些相似性会自然地让他们倾向于应用熟悉的 OO patterns。

例如,traits 看起来非常像 Java interfaces 或 C++ abstract classes。它们定义 types 必须实现的 method signatures。Structs 可以通过 impl blocks 附加 methods,使它们看起来像带有 encapsulated behavior 的 classes。Drop trait 提供 cleanup functionality,看起来等价于 C++ 中的 destructors,或者 garbage-collected languages 中的 finalizers。Rust syntax 使用 structself 这类熟悉词汇,也强化了这些表面上的相似性。

这些表面相似性会制造一种很有说服力的幻觉:如果 Rust 有 structs、methods 和 cleanup mechanisms,为什么不能使用 inheritance patterns、polymorphism hierarchies 和其他 OO design approaches 呢?看着满屏 Rust code,自然会到处看到 object orientation。

然而,这种感觉是有欺骗性的。如果我们试图像在 Python 或 C# 这样的语言中那样使用这些 features 和 constructs,事情一开始似乎会工作,即使某些地方有点别扭;但随后,我们的选择会让事情变得非常困难。为什么这些选择会让事情变难?我们又应该怎么做?下面来看。

Structs are not classes

当我刚开始使用 Rust 时,最先想到的一件事是:“为什么他们把 classes 叫作 structs?”这些 “structs” 看起来显然有 methods、fields、constructors、encapsulation,以及许多,甚至几乎所有你会期待从 class 中看到的东西。Syntax 有点不同,而且看起来没有 inheritance,但这些差异似乎并不重要。感觉就是语言里有 “classes”,只是出于某种原因被叫作 “structs”。

C 是我最早学习的语言之一,但 Rust structs 看起来并不像 C 中那些只是把 fields 收集在一起的简单 structs。它们更像 C++ structs,而 C++ structs 几乎与 C++ classes 完全相同。因此,在 Rust 中使用 struct 这个词的决定,看起来像是 aesthetic 和 arbitrary 的。

直到使用 Rust 一段时间后,我才理解为什么选择 struct 这个词:Rust structs 不是 classes,而 struct 这个词更准确地描述了 Rust data structures。

最大的差异之一是 inheritance。尽管 inheritance 作为一种 technique 近年来不那么受欢迎,但它仍是 OO languages 中 polymorphism 的核心。围绕 class hierarchies 组织整个 systems 是很常见的。System 的很多 architecture 随后都从这个 class hierarchy 展开。在许多流行语言中,以 class-based polymorphism 思考非常自然,而且这些语言本身的设计也支持这种方式。

然而,Rust 不支持这个模型。更重要的是,它不支持这种思维方式。正如本章后面会讨论的,虽然可以使用 traits 来实现 polymorphism,也虽然 traits 允许一些看起来像 inheritance 的东西,但如果你沿着把这些 features 当作 inheritance 的路走下去,通常会发现自己要么完全被 Rust 缺乏真正 inheritance 所阻挡,要么走向越来越笨重、难以维护甚至难以理解的 designs。

Class hierarchies 是 hierarchical 的。它们在 hierarchy root 定义 data 和 behaviors,然后在 hierarchy 的每一层添加或 refine 它们。Rust structs 不是这样工作的。你不能通过创建 struct 的 children 来 extend 或 modify 它们的 definition。Rust struct 就是它自身。

Traits 看起来有某种 inheritance,但更准确地说,它们是 contracts。一个实现 trait 的 struct 遵守这个 contract;而那些看起来从其他 trait “继承”的 traits,更准确地理解,是把另一个 trait contract 的 terms 添加到自己的 contract 中。

当我们试图把 structs 当作 classes,通过 inheritance 进行设计时,就会遇到障碍,或者制造混乱的 designs,因为我们无法形成真正的 hierarchies。我们可以画出图,并以 hierarchical 的方式开始编码,但为了实现这些 designs,我们必须费力绕过 Rust 并不是这样工作的事实。这听起来可能非常清楚,但人在不自觉中以 hierarchical 方式思考,其实非常容易。

从本章开始,我们会在整本书中看到这类例子。

Rust has a different take on polymorphism

在大多数现代 OO languages 中,polymorphism 被内置在 classes 的本质中。人们天然期待 child classes 可以与其 parents 和 siblings 互换使用。语言基础设施被设计成让这件事 effortless 和 transparent。

在 Rust 中,情况不同。使用 Rust 一段时间后,你会非常清楚地意识到 Rust types 有 sizes,因为 compiler 会经常提醒你这一点。对 compiler 来说,不同 types 就是不同的东西;如果 compiler 无法确定它正在处理的具体 type,它就会想和你谈谈。为了能互换使用两个 structs,你必须采取额外的、具体且明确的步骤,我们后面会讨论这些步骤,才能做到这一点,并且需要遵守具体 rules,停留在明确 boundaries 内。

在 Rust 中,不可能透明地替换 types。在某些具体场景中,例如可以声明一个 function parameter 的 type 为 impl Traitname,然后用实现了该 trait 的 struct 调用该 function。但这实际上是 generic type 的 syntactic sugar,实际 type 在 compile time 仍然是已知的。要处理真正 polymorphic 的 value,把 trait 存储在 struct 中,或用 compile time unknown 的 type 调用 function,我们必须显式使用 Box<dyn Traitname>。像这样使用 boxed trait 有时正是我们所需要的。但 Rust 要求我们明确选择:我们必须选择支付 heap storage、runtime dynamic dispatch 和 managing dynamic reference 的成本。

即使我们理解 Rust 的 rules,这也可能让人觉得 cumbersome 和 arbitrary。难道 Rust 不能在这么简单常见的事情上帮我们一把吗?

与 class hierarchies 一样,这不只是 implementation 上的差异,它要求我们采用不同的思维方式。利用 Rust 独特的 polymorphism 表达方式,是非常可能的,有时甚至是很好的决定。但它不是这门语言中自然且中心化的特性。因此,我们得到了一个线索:它不应该成为 design 中 reflexive 且未经审视的一部分。

在使用 Python 或 C++ 这类语言时,依赖 polymorphism 可能会变成自动反应。这就是为什么 Rust compiler 抱怨时会令人震惊,也为什么我们似乎必须费尽周折才能让代码 compile 和 work 时,会如此烦人。对 Rust 来说,这类 patterns 如此基础,以至于我们甚至不把它们看作 patterns,但它们需要谨慎且节制地使用。下一节中,我们会看到原因。在第 9–12 章中,我们会讨论更契合这门语言的 patterns。

A word about C++

这里提到 C++ 很有意思。Modern C++ coding style 多年来已经发生了巨大变化,但思考这门语言过去如何使用,以及它最初如何设计,仍然很有启发。与 Rust 一样,C++ 对 polymorphism 提供了选择。Classes 可以声明为 virtual,也可以不声明,这意味着它们可以提供 runtime polymorphism,也可以不提供。我们可以把某个 object 的 reference 赋给一个 parent class reference 类型的变量,或者用 child type 覆盖 parent-typed object。不同之处在于,虽然我们可以做这些选择,但在 C++ 中,compiler 并不会强制太多。更糟的是,因为没有不寻常的 syntax 来提醒我们正在做什么,并要求我们显式表达,我们可能会做出糟糕选择却没有意识到。人们会说:“你会习惯的”;我以前也这么说。但在使用 Rust 后,我开始欣赏它清晰、显式的 syntax 和 compiler enforcement,这帮助我避免了很多深夜排查 bugs 的经历。

The borrow checker prevents object patterns

OO design 中有一个方面如此基础,以至于它甚至并不真正被看作 OO paradigm。当我们使用 OO patterns 设计 systems 时,甚至不会意识到它在那里——那就是 objects 的自由移动。

我们可以创建 class hierarchies 和 designs,利用 polymorphism,使 objects 在任何使用它们的地方都有正确 behavior。但只有当我们能够 everywhere 使用这些 objects 时,这件事才真正变得有用。如果我们创建一个已知 type 的 object instance,然后几行后调用它的方法,那当然可以工作,也可能看起来干净并且运行正确,但我们还没有充分利用 OO patterns 的表达力和灵活性。真正有帮助的是,来自其他地方的 object 可以在这里执行一些事情,而当前这段代码甚至不需要知道它们在做什么。

OO design 成功的原因之一,是它允许我们创建 complexity 很高、包含许多许多 pieces,但仍然可理解和可维护的 systems。为了做到这一点,通常 objects 会在 system 中自由移动。我们并不真正思考这一点,但它对我们用 objects 进行设计的思维方式至关重要。

在 Rust 中,正如我们知道的,我们当然可以把 structs 和 enums 等形式的数据在 system 中移动。但我们也知道,这种移动并不是完全自由的。本书的一个重要主题,后续会详细覆盖,就是理解并思考 data flow,以及它在 Rust 设计中的核心作用。理解 data 如何流动对任何 system design 都至关重要,但 Rust 有一个独特之处:Rust 作为语言,对 data 如何、在哪里、何时可以被 shared 有具体 rules,而这些 rules 会影响 data 如何在你的代码中流动。Borrow checker 会强制执行这一点,我们需要遵守它的 rules。因此,仔细思考 flow 的设计非常重要。

“Object-oriented” design 之所以这样命名,是因为 system 中 objects 的设计,会在 system 自身设计中占据中心位置。OO design 教会我们一种习惯:从 system 中 objects 的良好设计出发。Data flow 从该设计中自然产生,并服务于它。这可以工作,data flow 也可以较少成为关注点,因为我们可以依赖所使用的语言允许 data 大体自由流动。

这是大多数现代 OO design 的关键元素。然而,在 Rust 中进行设计时,这会变成灾难配方,因为 borrow checker 迫使我们更认真地思考 data 的移动和共享。

Pulling it together: types are not objects

以 objects 的 abstraction 来思考 data collections,是我们认为 OO design 的最基础元素。它是组成 object orientation 的各种 disparate languages、frameworks、models 和 patterns 中共同的东西:它们都处理 objects。

Object 是一种极其有用的 abstraction。我不会回顾它的历史,但它以很好的理由席卷了世界,也许一开始是一场缓慢的风暴。能够以这种方式建模 data 和 code 的集合,已经非常有用。

乍看之下,Rust 似乎遵循 OO practice。我们可能不把 structs 或 enums 称为 objects,但它们是 data collections,而且它们的 impl blocks 允许我们将特定 code 与特定 data structures 组合在一起。它们允许并强制 encapsulation。它们的 construction 和 destruction model 有点非传统,但也能放进这个框架里。显然,Rust 的这一方面确实遵循 OO model,对吧?

是,也不是。

问题在于,OO 意义上的 objects 会带来一整套 expectations。确实,根据前面的 definition,我们可以把 Rust 中的 struct 或 enum 称为 object,但它们只是在这个宽泛意义上是 objects。我们必须非常小心,不要把我们学会与 “objects” 甚至 “instances” 关联起来的所有东西,都套到这些 Rust data structures 上。

Rust 处理的是表示 memory 中 data 的 types。这些 types 与我们从 OO languages 中认识的 objects 分享一些表面特征,也有一些非常有意为之的相似特征。但很容易把这种相似误认为更深层现实。Rust 不是 OO。让自己远离 objects 的思维,会非常有帮助。

在本节中,我们讨论了 Rust 真的不是一门 OO programming language 的原因。我们讨论了 Rust structs 虽然在某些方面类似 classes,但其实并不真正像 classes。我们也讨论了 Rust traits,以及它们并不真正建模 inheritance。我们考察了 Rust 中的 polymorphism,以及为什么它与 OO languages 中看到的 polymorphism 如此不同。最后,我们讨论了 data flow,以及 OO languages 如何真正依赖 data 的自由移动。

下一节中,我们将更深入地讨论 traits 和 polymorphism,以及试图误用它们来实现熟悉 patterns,如何导致混乱 solution。

Misusing traits

在本节中,我们将讨论 Rust traits,以及在 Rust 设计中它们如何容易被误用。Traits 的存在是为了提供 structs 可以履行的 contracts,这为语言增加了 expressiveness 和 flexibility。然而,我们将看到为什么它们不能替代 OO 意义上的 interfaces 或 abstract classes。

正如上一节讨论的,Rust traits 虽然类似 interfaces 或 pure abstract classes,但实际上并不提供或表示与 classes 相同的 type hierarchy,而且使用 traits 进行 polymorphism 在 Rust 中范围更受限,也不那么方便和自然。人们很容易把 traits 当作建模 inheritance 的方法,或者试图用它们设计以 dynamic polymorphism 为中心的 systems。但在 Rust 中,它们通常不是最优设计方式,也不是解决我们会遇到的挑战的最佳方案。

上一章中,我们开始创建 Bad Calculator project。现在来看我们如何尝试使用 traits 表示 inheritance model,以及它如何工作,或者说不工作。

我们可以开始思考如何建模 calculator 的关键 components。我们会倾向于 OO,所以会以 objects 来思考。

Calculators 接收 operands,例如 numbers 和 expressions,并对它们执行 operations,例如 addition 和 subtraction。我们先只考虑 operators。它们可以是一元 operators,例如 negation 或 square root,也可以是二元 operators,例如 addition 和 subtraction。Operators 有 precedence,所以 multiplication 发生在 addition 之前。我们可能应该把 operands 放在 stack 上,因为我们不知道有多少个。最后,operators 应该有某种 “apply” action,对 operands 执行 operation。

目前,我们先忽略 Operand type。我们只需要能够获得它作为 number 的 value。

让我们尝试建模:

image.png

图 2.1:Bad Calculator UML diagram 1

这里,我们创建了一个直接的 object hierarchy。我们有一个 abstract Operator class,它有两个 abstract subclasses,UnaryOperatorBinaryOperator,覆盖 calculator 实现的 operations 类型。这是经典 OO design,具有清晰 hierarchy,从越来越 general 的 classes 到越来越具体的 classes,最终到达 bottom layer 的 concrete classes,表示实际 operations。每一层定义与该 class 所代表含义相关的 methods。我们还有一个 operand type,表示 operators 消费的 values。

这似乎已经覆盖了。我们没有 abstract classes,也没有任何 classes,但可以使用 traits,并且可以为 precedence 和 symbol 提供 getters。

让我们尝试编码。首先,创建 base traits:

trait Operand {
    fn evaluate(&self) -> f64;
}

trait Operator {
    fn precedence(&self) -> u8;
    fn symbol(&self) -> char;
    fn push_operand(&mut self, operand: Box<dyn Operand>);
    fn pop_operand(&mut self) -> Box<dyn Operand>;
    fn apply(&mut self) -> Box<dyn Operand>;
}

这还不错。已经出现了很多 Box<dyn> types,而我们才刚刚开始,这有点令人担忧,但继续。

接下来,我们编写 sub-traits。这里我们聪明一点。可以在每个 trait 中创建一个 apply method,由它负责 pop operands,这样在 implementations 中只需要提供 operation-specific apply logic:

trait UnaryOperator : crate::Operator {
    fn apply_unary(&self, operand: Box<dyn Operand>) -> Box<dyn Operand>;

    fn apply(&mut self) -> Box<dyn Operand> {
        let operand = self.pop_operand();
        self.apply_unary(operand)
    }
}

trait BinaryOperator: Operator {
    fn apply_binary(&self, operand1: Box<dyn Operand>, operand2: Box<dyn Operand>)
    -> Box<dyn Operand>;

    fn apply(&mut self) -> Box<dyn Operand> {
        let operand2 = self.pop_operand();
        let operand1 = self.pop_operand();
        self.apply_binary(operand1, operand2)
    }
}

很多 Box<dyn>,但它在工作。

现在我们只需要写实现这些 traits 的 concrete structs。也许可以这样做?

struct AdditionOperator {
    stack: Vec<Box<dyn Operand>>
}

impl BinaryOperator for AdditionOperator {
    fn precedence(&self) -> u8 {
        0
    }

    fn symbol(&self) -> char {
        '+'
    }

    fn push_operand(&mut self, operand: Box<dyn Operand>) {
        self.stack.push(operand);
    }

    fn pop_operand(&mut self) -> Box<dyn Operand> {
        self.stack.pop().unwrap()
    }
    fn apply_binary(&self, operand1: Box<dyn Operand>, operand2: Box<dyn Operand>) -> Box<dyn Operand> {
        todo!()
    }

}

看起来不行:

error[E0407]: method `precedence` is not a member of trait `BinaryOperator`
  --> src/main.rs:42:5
   |
42 | /     fn precedence(&self) -> u8 {
43 | |         0
44 | |     }
   | |_____^ not a member of trait `BinaryOperator`

|[Deleting similar errors for each method from "Operator"]

哦!Rust 里实际上没有所谓 sub-trait。Operator 只是 BinaryOperator 的 trait bound。你必须在 Operatorimpl block 中定义 operator methods。

好吧,不能那样做也没关系。我们也实现 Operator

impl BinaryOperator for AdditionOperator {
    fn apply_binary(&self, operand1: Box<dyn Operand>, operand2: Box<dyn Operand>) -> Box<dyn Operand> {
        todo!()
    }

}
impl Operator for AdditionOperator {
    fn precedence(&self) -> u8 {
        0
    }

    fn symbol(&self) -> char {
        '+'
    }

    fn push_operand(&mut self, operand: Box<dyn Operand>) {
        self.stack.push(operand);
    }

    fn pop_operand(&mut self) -> Box<dyn Operand> {
        self.stack.pop().unwrap()
    }
}

这会工作吗?

error[E0046]: not all trait items implemented, missing: `apply`
  --> src/main.rs:47:1
   |
13 |     fn apply(&mut self) -> Box<dyn Operand>;
   |     ---------------------------------------- `apply` from trait
...
47 | impl Operator for AdditionOperator {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `apply` in implementation

为什么会出现 missing apply 的错误?我们已经在 sub-trait 中写了一个 apply implementation!

因为 Rust 中没有 sub-traits。在 compiler 看来,AdditionOperator struct 没有实现 apply。某个不同的 trait 实现了 apply 这件事并不相关。

Rust 中的 traits 不是 abstract classes,也不是 interfaces。它们是 contracts,而 AdditionOperator 没有遵守它的 contract。

好吧,我们只是试图让 polymorphism 工作。显然,整个 apply / apply_binary 并没有帮到我们。但现在,我们先让它 compile:

    impl Operator for AdditionOperator {
        fn precedence(&self) -> u8 {
            0
        }

        fn symbol(&self) -> char {
            '+'
        }

        fn push_operand(&mut self, operand: Box<dyn Operand>) {
            self.stack.push(operand);
        }

        fn pop_operand(&mut self) -> Box<dyn Operand> {

            self.stack.pop().unwrap()
        }
        fn apply(&mut self) -> Box<dyn Operand> {
            let operand2 = self.pop_operand();
            let operand1 = self.pop_operand();
            self.apply_binary(operand1, operand2)
        }
    }
}

这能 compile。

现在我们可以真正实现 AdditionOperator 中的 addition。我们需要一个 concrete Operand type 来使用,所以创建一个:

struct Value(f64);

impl Operand for Value {
    fn evaluate(&self) -> f64 {
        self.0
    }
}

现在可以让 addition 工作:

impl BinaryOperator for AdditionOperator {
    fn apply_binary(&self, operand1: Box<dyn Operand>, operand2: Box<dyn Operand>) -> Box<dyn Operand> {
        let inner_operand1 = operand1.evaluate();
        let inner_operand2 = operand2.evaluate();
        let result = inner_operand1 + inner_operand2;
        Box::new(Value(result))
    }
}

这能 compile。但它也很混乱,很难理解。而且我们还必须为每个 operator 都这样做。我们只有一个 operation,却已经有几屏令人困惑的代码。

我们一直试图使用 OO principles,让事情变得清晰简单,让我们无需担心内部工作,也不必多次重复实现。但现在我们得到的是相反的结果。

Traits 不是 interfaces 或 abstract classes。Box<dyn> 不是模拟 OO polymorphism 的整洁简单方案。我们不能真正把 Rust structs 当作 classes。

但也许有办法改善这一点。

你是否注意到,在 apply_binary implementation 中,我们可以这样做?

let inner_operand2 = operand2.evaluate();

operand2 是一个 box,它包含对 inner operand 的 reference,但我们直接在 box 上调用了 evaluate,而不是在 referenced value 上调用。为了强调这一点,我们可以改写为:

let inner_operand2 = operand2.as_ref().evaluate();

我们能够直接在 boxed trait 上调用 trait method,是因为 Rust 允许使用 automatic dereferencing。对于任何实现了 Deref trait 的 type,这都会工作,就像这里的 box。

Deref trait 在这里帮了我们。也许它可以帮更多。在下一节中,我们将讨论尝试使用 Deref trait 来清理代码。

Using Deref as a poor substitute for inheritance

上一节中,我们已经看到 traits 并不是 inheritance-based polymorphism 的替代品。在本节中,我们将讨论另一种试图重建 class-based inheritance 效果的策略:使用 Deref trait。

Rust 中一个 ergonomic feature 是 automatic dereferencing。由于 Rust 的 types 极其显式,而且有许多 smart pointer types,因此 Rust 使我们在许多情况下,可以直接使用 reference types,而不需要手动 dereference 它们。

Rust 提供 Deref trait,使你能够创建自己的 custom pointer types。目标是允许你使用新的 custom pointer,就像它所 reference 的东西本身一样。它让 reference 变得透明,使代码更清晰、更容易阅读。

Deref 并不知道也不关心它正在 dereference 什么,甚至不关心它是否真的在 dereference 某个东西。它只知道自己在 self 上被调用,并返回某个东西。那个东西是什么并不重要。

这就引出了我们的下一个技巧。为了让代码更干净并促进 reuse,我们可以减少 operators 以及其他地方的 redundant code。在 OO designs 中,我们会把这类代码放进 base classes。Rust 没有这些。有没有某种技巧可以做到同样的事情?

Deref 是一个可能答案。

Attacking the mess

看着我们的 Bad Calculator,有一件事很清楚:目前我们并没有遵守 don’t repeat yourself(DRY)principles。当前我们只有一个 operator,但当我们实现 subtraction、multiplication 和 division 时会发生什么?

我们每次都会不必要地重写代码。如果能找到一种减少重复的方式,会很有帮助。

举一个例子,我们需要在新的 operators 中一遍遍创建 operand stack。因为这是一个示例,可能看起来不算多,但在真实世界 application 中,可能会有更多代码,分布在许多 individual implementations 中,我们不仅要写,还要为下一个 implementor 写文档。

我们可以创建一个 OperandStack type 来帮助,并修改 AdditionOperator 使用它:

struct OperandStack(Vec<Box<dyn Operand>>);

impl OperandStack {
    fn new() -> Self {
        Self(Vec::new())
    }

    fn push_operand(&mut self, operand: Box<dyn Operand>) {
        self.0.push(operand);
    }

    fn pop_operand(&mut self) -> Box<dyn Operand> {
        self.0.pop().unwrap()
    }

}
struct AdditionOperator {
    pub stack: OperandStack
}

impl AdditionOperator {
    fn new() -> Self {
        Self { stack: OperandStack::new()}
    }
}

现在可以这样使用:

let mut addition_operator = AdditionOperator::new();
addition_operator.stack.push_operand(some_operand);
let popped_operand = addition_operator.pop_operand();

这有些帮助。如果 OperandStack 的 implementation 中做了更多事情,那会更有帮助。但我们希望的是,pushpop 能直接包含在 operator 的 API 中。

Deref 能帮我们吗?

如果我们在 AdditionOperator 上实现 DerefDerefMut traits,并让每个都返回对 OperandStack struct 的 reference,那么应该可以利用 automatic dereferencing 改善情况。比如这样做:

impl Deref for AdditionOperator {
    type Target = OperandStack;

    fn deref(&self) -> &Self::Target {
        &self.stack
    }
}

impl DerefMut for AdditionOperator {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.stack
    }
}

现在我们可以直接在 AdditionOperator 上调用 OperandStack 的 methods:

addition_operator.push_operand(some_operand);
let popped_operand = addition_operator.pop_operand();

这似乎是一个改进,但现在我们必须在每个 operator 上实现 DerefDerefMut,所以仍然不遵守 DRY。

我们可以尝试对所有 operators 做 generic implementation。我们需要为 stack 创建 getter,这将变成每个 operator 都必须实现的东西,也会成为 operator API 中包含的东西。它会像这样:

trait Operator {
    fn precedence(&self)-> u8;
    fn symbol(&self)-> char;
    fn push_operand(&mut self, operand: Box<dyn Operand>);
    fn pop_operand(&mut self)-> Box<dyn Operand>;
    fn apply(&mut self)-> Box<dyn Operand>;
    fn stack(&self)-> &OperandStack;
    fn stack_mut(&mut self)-> &mut OperandStack;
}


impl<T: Operator> Deref for T {
    type Target = OperandStack;

    fn deref(&self)-> &Self::Target {
        self.stack()
    }
}

impl<T: Operator> DerefMut for T {
    fn deref_mut(&mut self)-> &mut Self::Target {
        self.stack_mut()
    }
}

不幸的是,这不能 compile:

error[E0210]: type parameter `T` must be used as the type parameter for some local type (e.g., `MyStruct<T>`)
   --> src/main.rs:110:6
    |
110 | impl<T: Operator> Deref for T where T: Operator {
    |      ^ type parameter `T` must be used as the type parameter for some local type
    |
    = note: implementing a foreign trait is only possible if at least one of the types for which it is implemented is local
    = note: only traits defined in the current crate can be implemented for a type parameter

这可以通过创建一个 local wrapper type 来解决。但请考虑我们现在走到了哪里。为了能使用这个技巧,我们正在让代码越来越难用,也越来越难理解。

我们退一步。

当我们只为 AdditionOperator 实现 Deref 时,那能 compile 吗?

error[E0046]: not all trait items implemented, missing: `push_operand`, `pop_operand`
  --> src/main.rs:63:5
   |
20 |     fn push_operand(&mut self, operand: Box<dyn Operand>);
   |     ------------------------------------------------------
`push_operand` from trait
21 |     fn pop_operand(&mut self) -> Box<dyn Operand>;
   |     --------------------------------------------- `pop_operand` from trait
...
63 |     impl Operator for AdditionOperator {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `push_operand`, `pop_operand` in implementation

我们的 Operator trait 并不知道 Deref 技巧。我们可以从 trait 中删除 pushpop,但那样这些 operations 就不能对所有 operators 通用。

这一切都可以强行做成。但我们已经与 compiler 和代码战斗了一段时间,而目标原本是让代码更清晰、更容易处理、更容易理解。

我们实际做的是让代码更困惑、更难读、更难用,并且依赖脆弱技巧才能工作。

再考虑一件事。

如果我们给 OperandStack 添加更多 methods 会怎样?比如加一个 clear method。现在它看起来像这样:

impl OperandStack {
    fn new() -> Self {
        Self(Vec::new())
    }

    fn push_operand(&mut self, operand: Box<dyn Operand>) {
        self.0.push(operand);
    }

    fn pop_operand(&mut self) -> Box<dyn Operand> {
        self.0.pop().unwrap()
    }

    fn clear (&mut self) {
        self.0.clear();
    }

}

我们向 OperandStack 的 API 添加了一个 function。在 context 中,这个新 method 的目的完全可以理解。

但现在,这可以 compile:

addition_operator.clear();

在这个 context 中,我们不知道 clear 做什么,而且可能并不希望对 AdditionOperator 做这件事。

我们花了大量时间和精力,让代码变得更糟。

Deref 非常擅长让 smart pointer types 更易用、更符合人体工程学。正因为它如此方便,很容易被诱惑着用它来尝试添加 OO-style code reuse,让 composition 看起来像 inheritance。但它远没有看起来那么有帮助,而且代价是创建会让未来 coders 感到意外的 behavior,并可能暴露有害 interfaces。它不是我们寻求 code reuse 的可靠方式,也不是我们应该使用的 pattern。

我们会在第 6 章《Structural Patterns: Connecting and Aggregating》中讨论在设计中 productive 地使用 traits 的方式,并在第 10 章《Patterns That Leverage the Type System》中继续讨论。

下一节中,我们将尝试使用 generic types 作为 classes 的替代,并看到它并不是我们 design 的简单修复方案。

Using generic types to act like classes

我们已经尝试了一些方式,在 Rust 中建模 class hierarchies 和 objects,并发现它们带来的问题比解决的问题更多,而且会产生 awkward code。不过,Rust 是一门灵活语言,拥有许多 features,可以用很多方式完成事情。也许我们能找到另一种方式来建模 class hierarchies。

Rust 中的 generic types,如果使用得当,是设计和架构 systems 与 applications 的极其富有表达力、极其有效的工具。它们位于许多最有用 patterns 和 techniques 的核心,使我们能够创建 robust、flexible、compact 且 readable 的代码。它们能帮助我们构建类似 class hierarchy 的东西吗?

让我们用 calculator 试试看。传统 OO approach 中,我们可能会用 class hierarchy 来建模 operations:

image.png

图 2.2:Operation class hierarchy

Operation base class 会拥有 common fields,例如 precedence,以及一个 abstract method calculate(),每个 operation 会以不同方式实现它。

Rust 没有 inheritance,所以我们需要以不同方式建模。能否使用 generics 来实现这一点?

image.png

图 2.3:Operation class hierarchy with generics

这里,我们让 operations 实现一个名为 OperationType 的 trait,它有一个 calculate() method。Operation 现在是一个 struct,带有一个 type parameter,该参数有 OperationType trait bound。

让我们写代码!首先,我们可以使用 structs 和一个 trait 定义一些 operations,这应该适用于任何 operation:

trait OperationType {
    fn calculate(&self, left: f64, right: f64) -> f64;
    fn precedence(&self) -> u8;
}

struct Add;
impl OperationType for Add {
    fn calculate(&self, left: f64, right: f64) -> f64 {
        left + right
    }

    fn precedence(&self) -> u8 {
        1
    }
}

struct Multiply;
impl OperationType for Multiply {
    fn calculate(&self, left: f64, right: f64) -> f64 {
        left * right
    }

    fn precedence(&self) -> u8 {
        2
    }
}

现在可以尝试使用 generics 来模拟 base class:

struct Operation<T: OperationType> {
    symbol: String,
    op_type: T,
}

impl<T: OperationType> Operation<T> {
    fn new(symbol: String, op_type: T) -> Self {
        Operation { symbol, op_type }
    }

    fn evaluate(&self, left: f64, right: f64) -> f64 {
        self.op_type.calculate(left, right)
    }

    fn get_precedence(&self) -> u8 {
        self.op_type.precedence()
    }
}

我们也写一个 main() function 来试一下:

fn main() {
    let add_op = Operation::new("+".to_string(), Add);
    let mul_op = Operation::new("*".to_string(), Multiply);

    println!("5 {} 3 = {}", add_op.symbol, add_op.evaluate(5.0, 3.0));
    println!("5 {} 3 = {}", mul_op.symbol, mul_op.evaluate(5.0, 3.0));
}

这似乎能工作!它能 compile,并按预期运行。

这有什么问题吗?如果我们想 parse 一个 expression,比如 2 + 3 * 4,并把所有 operations 存储在一个 vector 中,以便按 precedence 处理,会怎样?

fn main() {
    let add_op = Operation::new("+".to_string(), Add);
    let mul_op = Operation::new("*".to_string(), Multiply);

    println!("5 {} 3 = {}", add_op.symbol, add_op.evaluate(5.0, 3.0));
    println!("5 {} 3 = {}", mul_op.symbol, mul_op.evaluate(5.0, 3.0));

    // Attempt to create a vector of different operations
    let operations: Vec<Operation> = vec![add_op, mul_op];
}

这能工作吗?来看:

     > cargo build
     Compiling calculator v0.1.0
  error[E0107]: missing generics for struct `Operation`
    --> src/main.rs:43:25
     |
  43 |     let operations: Vec<Operation> = vec![add_op, mul_op];
     |                         ^^^^^^^^^ expected 1 generic argument
     |
  note: struct defined here, with 1 generic parameter: `T`
    --> src/main.rs:20:8
     |
  20 | struct Operation<T: OperationType> {
     |        ^^^^^^^^^ -
  help: add missing generic argument
     |
  43 |     let operations: Vec<Operation<T>> = vec![add_op, mul_op];
     |                                  +++

Operation 需要一个 type argument。没关系!我们可以使用 wildcard _,让 compiler 推断 type:

let operations: Vec<Operation<_>> = vec![add_op, mul_op];

这能工作吗?

     > cargo build
     Compiling calculator v0.1.0
  error[E0308]: mismatched types
    --> src/main.rs:43:51
     |
  43 |     let operations: Vec<Operation<_>> = vec![add_op, mul_op];
     |                                                      ^^^^^^ expected `Operation<Add>`, found `Operation<Multiply>`
     |
     = note: expected struct `Operation<Add>`
                found struct `Operation<Multiply>`

这里发生了什么?

对 Rust compiler 来说,Operation 并不是真正的 concrete type;它只是一个 type 与其 type argument 的组合。Operation<Add>Operation<Multiply> 是 concrete Rust types,但并不存在一个真正的 “generic” Operation type。当 compiler 看到 Operation<_> 时,它确实按我们期待推断了 type,但它推断的是左侧 type 为 Vec<Operation<Add>>,因为这是它看到的第一个 Operation。当它随后看到 Operation<Multiply> 时,就无法 compile,因为它不能被存储在 Vec<Operation<Add>> 中。

不同于 OO languages,Rust 没有 generics 的 runtime type information。这意味着你无法拥有一个包含不同 Operation<T> types 的 collection,而这正是 calculator 处理 mixed operations 所需要的。

Generics 能做很多事,但误用它们来模拟 OO style 并不可行。

在第 10 章《Patterns That Leverage the Type System》中,我们会介绍令人惊叹的 TypeState pattern,这是使用 generics 的优秀方式,并且与 Rust 非常协调。

下一节中,我们将讨论使用 enums 创建 sound designs,以及过度使用 enums 创建糟糕 designs。

Using enums where they don't make sense

在本节中,我们将讨论使用 enums 来表示具有 OO flavor 的 designs。有时这极其有效,并且非常 idiomatic!但 enums 也可能被误用和过度使用,从而损害 system 或 application 的 design。我们会依次探索这些正面和负面情况。

A "not-so-bad" calculator

Rust enums 是这门语言的一个惊人 feature。无论它们被称作 enums,在 Rust 中是这样;algebraic types,在 Scala 或 Haskell 中;type unions,在 Python 或 TypeScript 中;还是 sum types,它们都是一种极其强大、富有表达力的工具,能够支持清晰、简单、优雅的 designs。

它们也是一种优秀方式,可以用 idiomatic 的方式表达我们可能认为属于 OO models 和 constructs 的东西。

为了说明这一点,我们重新开始 Bad Calculator,并让它 “not so bad”。

如果你这样思考,Rust enums 实际上有几个具有 OO flavor 的 features:

  • Enums 携带并封装自己的 data
  • Enums 允许在一个 supertype 伞下拥有许多 subtypes
  • Enums 让基于 type 选择 behavior 变得简单
  • Enums 提供了一种在一个 implementation 内复用 code 的方式

但它们也不同于我们通常认为的 OO:

  • Enum variants 没有自己的 methods。它们共享该 type 的一个 impl block。
  • Enums 要求我们在每个使用位置 match variant。
  • Enum variants 没有统一的 data layout 或 structure。每个 variant 都是独立 type。

我们可以基于 enums 重新思考 calculator。

因为目前我们不关心 operands,我们之后会处理它们,所以先创建一个简单的 Value type,只保存一个 f64 value:

struct Value(f64);

impl Value {
    fn evaluate(&self) -> f64 {
        self.0
    }
}

这段代码只包含 numeric value,并允许我们使用 evaluate method 获取它。

现在考虑 operators。之前为了创建其中一个,我们遇到了那么多困难,写了那么多代码。让我们使用 enum 来实现它。我们为每个 operator 创建一个 variant,并在每个 variant struct 中直接保存 operand values:

enum Operator {
    Addition { lhs: Value, rhs: Value },
    Subtraction { lhs: Value, rhs: Value },
    Multiplication { lhs: Value, rhs: Value },
    Division { lhs: Value, rhs: Value },
    Negation { operand: Value },
}

这简单多了!只用几行代码,我们定义了所有 operators,并处理了它们 operands 的存储。

现在需要考虑每个 operator 所需的方法。之前我们有:

  • 一个 apply method,用于执行 calculation
  • 一个 precedence method,返回表示 operator precedence 的 u8
  • 一个 symbol method,返回我们在 interface 中用来表示 operator 的 character

实现这些 methods 需要多少代码?我们如何实现它们?

Rust 有一个内置 pattern:可以对 variant type 进行 match,并在每个 match case 中执行任何想要的 actions。

我们可以让 apply pattern match 每个 variant,并将 variables 绑定到它们包含的 values。然后执行所需 calculations。

precedencesymbol methods 也可以类似写,但可以忽略 contained values,并把产生相同结果的 variants 合并到一个 case 中。

下面是实现方式:

impl Operator {
    fn apply(&self) -> Value {
        let inner = match self {
            Operator::Addition { lhs, rhs } => lhs.evaluate() + rhs.evaluate(),
            Operator::Subtraction { lhs, rhs } => lhs.evaluate() - rhs.evaluate(),
            Operator::Multiplication { lhs, rhs } => lhs.evaluate() * rhs.evaluate(),
            Operator::Division { lhs, rhs } => lhs.evaluate() / rhs.evaluate(),
            Operator::Negation { operand } => -operand.evaluate(),
        };
        Value(inner)
    }

    fn precedence(&self) -> u8 {
        match self {
            Operator::Addition { .. } | Operator::Subtraction { .. } => 0,
            Operator::Multiplication { .. } | Operator::Division { .. } => 1,
            Operator::Negation { .. } => 2,
        }
    }

    fn symbol(&self) -> char {
        match self {
            Operator::Addition { .. } => '+',
            Operator::Subtraction { .. } => '-',
            Operator::Multiplication { .. } => '*',
            Operator::Division { .. } => '/',
            Operator::Negation { .. } => '-',
        }
    }
}

当我们回想之前 implementation 中为了一个 operator 就写了多少代码、制造了多少复杂性,再看到这里用这么少的行数就实现了五个不同 operators 及其所有 methods,会感到很惊讶。

这个版本非常简单易用。我们可以 native 且简单地创建 values 和 operators,然后调用 apply 得到 result:

let addition = Operator::Addition { lhs: Value(2.0), rhs: Value(3.0) };
let subtraction = Operator::Subtraction { lhs: Value(5.0), rhs: Value(1.0) };
let negation = Operator::Negation { operand: Value(-7.0) };

println!("Addition result: {}", addition.apply().evaluate());
println!("Subtraction result: {}", subtraction.apply().evaluate());
println!("Negation result: {}", negation.apply().evaluate());

在之前的 implementation 中,我们确实有一个 Operand type,它可以包含不只是 Value type 的内容,尽管我们实际上没有创建任何其他 Operand types。为了完整性,加上它。我们会使用一个 enum,暂时只有一个 variant,来表示 operands:

enum Operand {
    Value(f64)
}

impl Operand {
    fn evaluate(&self) -> f64 {
        match self {
            Operand::Value(v) => *v
        }
    }
}

我们还需要修改 Operator implementation,使其使用新的 Operand type。这只是一次 search and replace,加上返回 value 的地方做一点改变:

enum Operator {
    Addition { lhs: Operand, rhs: Operand },
    Subtraction { lhs: Operand, rhs: Operand },
    Multiplication { lhs: Operand, rhs: Operand },
    Division { lhs: Operand, rhs: Operand },
    Negation { operand: Operand },
}

impl Operator {
    fn apply(&self) -> Operand {
        let inner = match self {
            Operator::Addition { lhs, rhs } => lhs.evaluate() + rhs.evaluate(),
            Operator::Subtraction { lhs, rhs } => lhs.evaluate() - rhs.evaluate(),
            Operator::Multiplication { lhs, rhs } => lhs.evaluate() * rhs.evaluate(),
            Operator::Division { lhs, rhs } => lhs.evaluate() / rhs.evaluate(),
            Operator::Negation { operand } => -operand.evaluate(),
        };
        Operand::Value(inner)
    }
[The "precedence" and "symbol" methods don't change]
}

只用之前为了糟糕实现一个 operator 所需代码的一小部分,我们的 not-so-bad calculator 已经实现了五个 operators。我们做到了这一点,同时没有牺牲任何我们使用 OO design 想维护的 principles。

之所以能够做到,是因为我们没有试图让 Rust 像 OO language 一样运作。我们利用了语言中自然的 features 和 patterns,并获得了截然不同的结果。

A worse calculator

“但是,”我听见你说,“这不是关于 anti-patterns 的一章吗?Enums 在 Rust 中是如何被误用的?Enums 在什么时候不合理?”

这是一个非常好的问题!

回顾我们完成的事情:我们拿一个基于 OO class hierarchy 的 model,替换为一个基于 enum 的 approach,将 algebraic data type 的灵活性,与 functional、pattern-matching 的 methods 方法结合起来。

我们用 enums 做了很棒的事情,那就看看能不能过度使用它!

有一件事可能已经让你有些不舒服,那就是 precedencesymbol methods。它们这样工作,但我们真的需要那样 match variants 吗?难道不能利用 variants 已经在保存 data 这个事实,继续存储更多 data?这样 methods 就可以直接返回 value,而不需要 match。

第一步很简单,我们只需要向每个 variant 的 data 中添加 precedencesymbol

enum Operator {
    Addition { lhs: Operand, rhs: Operand, precedence: u8, symbol: char  },
    Subtraction { lhs: Operand, rhs: Operand, precedence: u8, symbol: char  },
    Multiplication { lhs: Operand, rhs: Operand, precedence: u8, symbol: char  },
    Division { lhs: Operand, rhs: Operand, precedence: u8, symbol: char  },
    Negation { operand: Operand, precedence: u8, symbol: char },
}

现在只需要把 data 初始化成正确 values。怎么做?每次创建 operator instance 时都指定这些 values,并不实际。

我们可以创建一个 new function 作为 constructor。也许本来就应该这么做。这应该不难。

作为 parameters,我们需要 operands,以及某种指定要创建什么的方式。可能有一个或两个 operands,所以我们可以传入 operand array 或 slice,也可以让第二个 operand 是 optional。为了指定我们要创建什么,也许可以使用 string。替代方案是创建第二个 enum,并且它需要与 Operator enum 保持同步,这听起来更糟。

让我们试试:

fn new(operator_type: String, operand1: Operand, operand2: Option<Operand>) -> Self {
    match operator_type.as_str() {
        "add" => Operator::Addition {lhs: operand1, rhs: operand2.unwrap(), precedence:0,
        symbol: '+'},
        "sub" => Operator::Addition {lhs: operand1, rhs: operand2.unwrap(),
        precedence: 0, symbol: '-'},
        "mul" => Operator::Division {lhs: operand1, rhs: operand2.unwrap(),
        precedence: 1, symbol: '*'},
        "div" => Operator::Division {lhs: operand1, rhs: operand2.unwrap(),
        precedence: 1, symbol: '/'},
        "neg" => Operator::Negation {operand: operand1, precedence:2, symbol: '-'},
        _ => panic!("Unknown operator")
    }
}

仔细看这些 match arms。"sub" case 创建的是 Operator::Addition,而 "mul" 创建的是 Operator::Division。由于 apply() 根据 enum variant 而不是 symbol field 来 dispatch,subtraction 会静默地执行 addition,multiplication 会静默地执行 division。Compiler 无法捕获这个问题,因为每个 variant 的 fields 都拥有相同 types。这正是 stringly-typed interfaces 会引入的那种 silent、hard-to-diagnose bug:代码能 compile,简单 cases 的 tests 甚至可能通过,但结果是错的。

现在我们在不需要 match string 的地方 match string,同时还要在 runtime 处理 invalid parameters。我们还需要更新 apply method,以处理这些 parameters 的存在。换句话说,我们仍然在 match,只是换了一个更糟糕的地方和更糟糕的理由。

这是误用 enums 的一种方式。

但还有一种更 ambitious 的误用方式。

The worst calculator

在 not-so-bad calculator 中,我们用 enum 表示之前 Bad Calculator 中需要一层 inheritance 才能表示的内容。但如果我们想表示更多层 inheritance 呢?

想象一个 calculator,不仅执行 arithmetic operations,还处理 text manipulation 和 date calculations。我们可能试图用一个单一 Operator enum 表示所有这些 operations。我们可以为不同 sub-types 创建 sub-enums,一个用于 normal numbers,一个用于 text,一个用于 dates:

enum ArithmeticOperator {
    Addition {lhs: f64, rhs: f64},
    Subtraction {lhs: f64, rhs: f64},
    // ...
}

enum TextOperator {
    Concatenate {lhs: String, rhs: String},
    SubString {operand: String, bounds: Range<usize>},
    // ...
}

enum DateOperator {
    AddDays {lhs: Instant, rhs: Duration},
    SubtractDays {lhs: Instant, rhs: Instant},
    // ...
}

enum Operator {
    Arithmetic(ArithmeticOperation),
    Text(TextOperation),
    Date(DateOperation),
}

我们可能应该改成使用一个 Operand type。它需要覆盖所有这些不同 data types:

enum Operand {
    NumericValue(f64),
    StringValue(String),
    RangeValue(Range<usize>),
    InstantValue(Instant),
    DurationValue
}

现在我们已经覆盖了所有不同 types 的 examples。我们为每种 calculation 创建了 sub-enums。现在只需要写 apply

为所有内容编写一个带 cascading match expressions 的 apply method,看起来可能不是很 readable。所以我们可以写 sub-apply functions:

impl ArithmeticOperator {
    fn apply(&self) -> Operand {
        match self {
            ArithmeticOperator::Addition {lhs, rhs} => todo!(),
            ArithmeticOperator::Subtraction {lhs, rhs} => todo!(),
        }
    }
}
impl TextOperator {
    fn apply(&self) -> Operand {
        match self {
            TextOperator::Concatenate {lhs, rhs} => todo!(),
            TextOperator::SubString {operand, bounds} => todo!(),
        }
    }
}

impl DateOperator {
    fn apply(&self) -> Operand {
        match self {
            DateOperator::AddDays {lhs, rhs} => todo!(),
            DateOperator::SubtractDays { lhs, rhs} => todo!(),
        }
    }
}
impl Operator {
    fn apply(&self) -> Operand {
        match self {
            Operator::Arithmetic(a) => a.apply(),
            Operator::Date(d) => d.apply(),
            Operator::Text(t) => t.apply()
        }
    }
}

这能工作,但现在我们把原本相对干净的 patterns,变成了非常难以使用的东西。

在真正的 OO class hierarchy 中,我们可以把不同 pieces 打包在一起,并使用 polymorphism 来确保只有应该搭配的 types 会一起使用。在这个层级混乱的设计中,很难推理代码,也很难知道正确类型的 operands 是否会被发送到正确的 operators。我们必须在 runtime 让这一切工作,并在每个 level 处理 errors。

这里和之前一样,试图使用 OO thinking,会把我们带向不可行的 design。

要解决类似问题,更 idiomatic 的方法是在 Rust application 中把每个 calculator 构建成自己的 module,通过 module system 将相关 types 放在一起。我们会在第 5 章《Creational Patterns: Making Things》和第 9 章《Architectural Patterns》中覆盖 leveraging modules 这个主题。

Summary

本章中,我们讨论了试图在 Rust 中应用 OO design 的 anti-pattern。我们考察了为什么使用熟悉的 OO patterns 似乎很自然,甚至在一定程度上可能工作,但这些 patterns 并不是在 Rust 中成功设计和构建 systems 与 applications 的道路。

首先,我们探索了为什么 Rust 看起来很像 OO language,但实际上并不是;以及把它误认为 OO language 如何导致糟糕 designs 和 messy code。

然后,我们使用 Bad Calculator project,尝试把 Rust traits 当作 interfaces 或 pure abstract classes 的替代品,并看到它一开始似乎是一个可行方案,但随着 application 构建推进,它变得越来越不可行。

接下来,我们尝试使用 Deref trait 在代码中重建 inheritance 的好处,但发现这个技巧并不划算。

之后,我们尝试使用 generics 来模拟 base class,并发现虽然它看似可行,但实际上并不真正可行。

最后,我们探索了使用 enums 来实现类似某些熟悉 OO patterns 的 designs,并发现当它们被用在合适位置时,确实非常有用、非常 idiomatic;但在不合理时过度使用它们也是可能的。

通过理解为什么 OO design patterns 在 Rust 中无法有效工作,并学习识别自己何时在无意识地使用 OO design,我们就能够从一开始就组织代码,使其与语言和 compiler 协作,而不是与它们对抗。这个主题会在第 4 章《Don’t Fight the Borrow Checker》中覆盖。随着继续前进,我们也有了一个基础,可以理解 Rust-specific design patterns 的原因和价值。

下一章中,我们将聚焦一个非常常见的 anti-pattern:使用 cloneRc<> 来避免思考 borrow checker。这看起来像是管理 data 的非常简单方案,但我们会看到它远没有表面上那么有帮助。我们也会继续 Bad Calculator project。我们将看到在实践中用 cloneRc<> 粉饰 errors 的效果,并讨论本可以如何用不同方式处理这些问题。