十六、无畏并发

安全且高效的处理并发编程是 Rust 的另一个主要目标。并发编程Concurrent programming),代表程序的不同部分相互独立的执行,而 并行编程parallel programming)代表程序不同部分于同时执行,这两个概念随着计算机越来越多的利用多处理器的优势时显得愈发重要。由于历史原因,在此类上下文中编程一直是困难且容易出错的:Rust 希望能改变这一点。

最初,Rust团队认为确保内存安全和防止并发问题是两个独立的挑战,需要用不同的方法来解决。随着时间的推移,团队发现所有权和类型系统是一组功能强大的工具,可以帮助管理内存安全和并发性问题!通过利用所有权和类型检查,Rust中的许多并发错误都是编译时错误,而不是运行时错误。因此,错误的代码将拒绝编译,并给出解释问题的错误,而不是让您花费大量时间试图重现运行时并发性错误发生的确切情况。因此,您可以在处理代码时进行修复,而不必等到将其交付到生产环境后再进行修复。我们把Rust的这方面称为无畏并发( fearless concurrency)。无畏并发允许您编写没有细微错误的代码,并且易于重构而不引入新的错误。

注意:为了简单起见,我们将许多问题称为并发问题,而不是更精确地称为并发和/或并行问题。如果这本书是关于并发和/或并行的,我们会更具体。在本章中,当我们使用并发时,请在心里替换为并发和/或并行。

许多语言对于它们提供的处理并发问题的解决方案是教条的。例如,Erlang在消息传递并发性方面有出色的功能,但在线程之间共享状态的方法却很模糊。对于高级语言来说,只支持可能解决方案的一个子集是一种合理的策略,因为高级语言承诺通过放弃一些控制来获得抽象。然而,低级语言被期望在任何给定的情况下提供性能最好的解决方案,并且对硬件有更少的抽象。因此,Rust以适合您的情况和需求的任何方式为建模问题提供了各种工具。

以下是我们将在本章涵盖的主题:

  • 如何创建线程来同时运行多段代码
  • 消息传递(Message-passing)并发性,通道在线程之间发送消息
  • 共享状态(Shared-state)并发,其中多个线程可以访问某些数据
  • SyncSend traits,它们将Rust的并发性保证扩展到用户定义的类型以及标准库提供的类型

16.1 使用线程同时运行代码

在大多数当前的操作系统中,可执行程序(executed program)代码运行在一个进程(process)中,操作系统将同时管理多个进程。在程序(program)中,也可以有同时运行的独立部分。运行这些独立部分的特性称为线程( thread)。例如,一个web服务器可以有多个线程,这样它就可以在同一时间响应多个请求。

将程序中的计算拆分为多个线程以同时运行多个任务可以提高性能,但也增加了复杂性因为线程可以同时运行,所以对于不同线程上的代码部分的运行顺序没有内在的保证。这可能会导致以下问题:

  • 竞争条件(Race conditions),即线程以不一致的顺序访问数据或资源
  • 死锁(Deadlocks),即两个线程相互等待,阻止两个线程继续
  • 只在某些情况下发生的错误(Bugs ),很难复制和可靠地修复

Rust试图减轻使用线程的负面影响,但是在多线程上下文中编程仍然需要仔细考虑,并且需要不同于在单线程中运行的程序的代码结构。

编程语言以几种不同的方式实现线程,许多操作系统提供了该语言可以调用的API来创建新线程。Rust标准库使用线程实现的1:1模型,即一个程序对一个语言线程使用一个操作系统线程。还有实现其他线程模型的crates ,它们对1:1模型做出了不同的权衡。

16.1.1 使用 spawn 创建新线程

要创建一个新线程,我们调用thread::spawn函数并向它传递一个闭包(我们在第13章讨论过闭包),其中包含我们想要在新线程中运行的代码。示例16-1中的示例打印来自主线程的一些文本和来自新线程的其他文本:
16-1

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

注意,当Rust程序的主线程完成时,所有派生的线程都将关闭,无论它们是否已经完成运行。这个程序的输出可能每次都有一点不同,但它看起来类似于以下内容:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

thread::sleep的调用迫使线程在短时间内停止执行,允许另一个线程运行。线程可能会轮流使用,但不能保证:这取决于操作系统如何调度线程。在此运行中,主线程首先打印,尽管衍生线程的打印语句首先出现在代码中。即使我们告诉衍生线程打印到i = 9,它只在主线程关闭前打印到5

如果运行这段代码,只看到主线程的输出,或者没有看到任何重叠,请尝试增加范围中的数字,为操作系统在线程之间切换创造更多机会。

16.1.2 使用 join 等待所有线程结束

示例16-1中的代码不仅在大多数情况下由于主线程的结束而过早地停止了衍生线程,而且因为不能保证线程运行的顺序,我们也根本不能保证衍生线程将会运行!

我们可以通过在变量中保存thread::spawn的返回值来修复衍生线程不运行或提前结束的问题。thread::spawn的返回类型是JoinHandleJoinHandle是一个自有值,当我们调用它上的join方法时,它将等待它的线程完成。示例16-2显示了如何使用我们在示例16-1中创建的线程的JoinHandle并调用join来确保衍生线程(spawned thread)在main退出之前完成:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

在句柄上调用join将阻塞当前正在运行的线程,直到由句柄表示的线程终止阻塞(Blocking )线程意味着阻止线程执行工作或退出。因为我们把join调用放在主线程的for循环之后,所以运行示例16-2应该会产生类似这样的输出:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

两个线程继续交替,但主线程会等待,因为调用了handle.join(),直到衍生线程完成才会结束。

但是让我们看看当我们把handle.join()移动到main中的for循环之前会发生什么,像这样:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

主线程会等待衍生线程完成,然后运行for循环,这样输出就不会再交错了,如下所示:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

一些小细节,比如在哪里调用join,可能会影响线程是否同时运行。

16.1.3 线程与 move 闭包

我们经常把move关键字的闭包传递给thread::spawn,因为闭包随后会从环境中获得它使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在第13章的“用闭包捕获环境”一节中,我们讨论了闭包上下文中的move 。现在,我们将更多地关注movethread::spawn之间的交互。

注意,在示例16-1中,传递给thread::spawn的闭包没有参数:在衍生线程的代码中,我们没有使用主线程的任何数据。要在衍生线程中使用来自主线程的数据,衍生线程的闭包必须捕获它需要的值。示例16-3显示了在主线程中创建一个vector 并在衍生线程中使用它的尝试。然而,这还不能正常工作,稍后您将看到这一点。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

闭包使用v,因此它将捕获v并使其成为闭包环境的一部分。因为thread::spawn在一个新线程中运行这个闭包,所以我们应该能够在新线程中访问v。但是当我们编译这个例子时,我们得到以下错误:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {:?}", v);
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` due to previous error

Rust推断如何捕获v,并且因为println!只需要一个v的引用,闭包就会试图借用v。然而,有一个问题:Rust无法知道衍生线程(spawned thread )会运行多长时间,所以它不知道对v的引用是否总是有效

示例16-4提供了一个场景,其中对v的引用更有可能是无效的:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

如果Rust允许我们运行这段代码,那么衍生线程就有可能立即被放到后台而不运行。衍生线程内部有一个对v的引用,但是主线程立即删除v,使用我们在第十五章讨论过的drop函数。然后,当衍生线程开始执行时,v不再有效,因此对它的引用也是无效的。噢,不!

要修复示例16-3中的编译器错误,我们可以使用错误消息的建议:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

通过在闭包之前添加move关键字,我们迫使闭包获得它正在使用的值的所有权,而不是允许Rust推断它应该借用这些值。示例16-5所示的对示例16-3的修改将按照我们的意愿编译和运行:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

我们可能会尝试用同样的方法来修复示例16-4中主线程调用drop的代码,方法是使用move闭包。但是,这个修复将不起作用,因为示例16-4试图做的事情由于不同的原因不允许。如果我们在闭包中添加move,我们将把v移动到闭包的环境中,我们就不能再在主线程中调用drop了。相反,我们会得到这样的编译错误:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  | 
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {:?}", v);
   |                                           - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` due to previous error

Rust的所有权规则又一次拯救了我们!我们从示例16-3中的代码中得到了一个错误,因为Rust很保守,只为线程借用了v,这意味着主线程理论上可以使派生线程的引用无效。通过告诉Rust将v的所有权转移到派生线程,我们可以保证Rust主线程不再使用v。如果我们以同样的方式更改示例16-4,那么当我们试图在主线程中使用v时,就违反了所有权规则。move关键字覆盖Rust的保守违约借款;它不允许我们违反所有权规则。

在对线程和线程API有了基本了解之后,让我们看看可以用线程做些什么。

16.2 使用消息传递在线程之间传输数据

确保安全并发的一种日益流行的方法是消息传递(message passing),其中线程或参与者通过相互发送包含数据的消息进行通信。这是Go语言文档中的一句口号:“不要通过共享内存进行交流;相反,应该通过交流来分享记忆。(Do not communicate by sharing memory; instead, share memory by communicating.)”

为了实现消息发送的并发性,Rust的标准库提供了通道(channels)的实现。通道是一个通用的编程概念,数据通过它从一个线程发送到另一个线程。

您可以将编程中的通道想象成水的定向通道,如小溪或河流。如果你把橡皮鸭之类的东西放进河里,它会顺流而下,一直游到水道的尽头。

信道有两个部分:发送者(transmitter )和接收者(receiver)。发送者是上游位置,也就是你把橡皮鸭放进河里的地方,而接收者是橡皮鸭最终流向下游的地方。代码的一部分使用您想要发送的数据调用送者上的方法,另一部分检查接收端是否有到达的消息。当发送者或接收者任一被丢弃时可以认为通道被 关闭closed)了。

在这里,我们将逐步开发一个程序,该程序有一个线程生成值并将其发送到通道中,另一个线程接收值并将其打印出来。我们将使用通道在线程之间发送简单的值来演示该特性。一旦您熟悉了这种技术,您就可以为任何需要相互通信的线程使用通道,例如聊天系统或多个线程执行部分计算并将部分发送给聚合结果的线程的系统。

首先,在示例16-6中,我们将创建一个通道,但不使用它做任何事情。注意,这还不能编译,因为Rust不能告诉我们想通过通道发送什么类型的值。

16-6

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

我们使用mpsc::channel函数创建一个新通道;mpsc 代表多个生产者,单一消费者(multiple producer, single consumer )。简而言之,Rust的标准库实现通道的方式意味着通道可以有多个产生值的发送端,但只有一个使用这些值的接收端。想象一下,多条小溪汇集成一条大河:从任何一条小溪流下的所有东西最终都会流入一条河流。现在我们将从单个生产者开始,但当我们让这个示例运行时,我们将添加多个生产者。

mpsc::channel函数返回一个元组,其中的第一个元素是发送端(即发送者),第二个元素是接收端(即接收者)。缩写txrx传统上分别用于发射器和接收器的许多领域,所以我们这样命名变量来表示两端。我们使用let语句和一个模式来分解元组;我们将在第18章讨论let语句和解构中模式的使用。现在,我们知道以这种方式使用let语句是提取mpsc::channel返回的元组片段的一种方便方法。

让我们将发送端移动到衍生线程中,并让它发送一个字符串,以便派生线程与主线程通信,如示例16-7所示。这就像在上游的河里放一只橡皮鸭,或者从一个线程向另一个线程发送聊天消息。

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

同样,我们使用thread::spawn创建一个新线程,然后使用movetx移动到闭包中,因此衍生线程拥有tx。衍生线程需要拥有发送者,以便能够通过通道发送消息。发送者有一个send方法,它接受我们想要发送的值。send方法返回Result<T, E>类型,因此,如果接收者已经被丢弃,且无处发送值,则send操作将返回一个错误。在本例中,我们调用unwrap以防止出现错误。但是在真正的应用程序中,我们会正确地处理它:回到第9章,回顾正确的错误处理策略。

在示例16-8中,我们将从主线程中的接收器获取值。这就像从河的尽头的水里找回橡皮鸭或接收聊天信息。

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

接收者有两个有用的方法:recvtry_recv。我们使用recv (receive的缩写),它将阻塞主线程的执行并等待一个值通过通道发送。一旦发送了一个值,recv将以Result<T, E>的形式返回它。当发送者关闭时,recv将返回一个错误信号,表示不会有更多的值。

try_recv方法不会阻塞,而是会立即返回Result<T, E>:如果有可用的消息,则返回一个Ok值,如果这次没有任何消息,则返回一个Err值。如果这个线程在等待消息时还有其他工作要做,那么使用try_recv是很有用的:我们可以编写一个循环,它每隔一段时间就调用try_recv,如果消息可用,就处理消息,否则在再次检查之前先做一段时间的其他工作。

为了简单起见,我们在本例中使用recv;除了等待消息,主线程没有任何其他工作要做,所以阻塞主线程是合适的。

16.2.1 通道与所有权转移

所有权规则在消息发送中起着至关重要的作用,因为它们帮助您编写安全的并发代码。防止并发编程中的错误是考虑整个Rust程序的所有权的好处。让我们做一个实验来展示通道和所有权如何一起工作以防止问题:我们将尝试在我们将val值发送到通道之后,在衍生线程中使用它。尝试编译示例16-9中的代码,看看为什么不允许这个代码:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val);
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

在这里,我们尝试在通过tx.sendval发送到通道后打印它。允许这样做是一个坏主意:一旦值被发送到另一个线程,该线程可能在我们尝试再次使用该值之前修改或删除它。其他线程的修改可能会由于数据不一致或不存在而导致错误或意外结果。然而,如果我们试图编译示例16-9中的代码,Rust会给我们一个错误:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:31
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {}", val);
   |                               ^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` due to previous error

我们的并发性错误导致了编译时错误。send函数获得其参数的所有权,当值移动时,接收方获得该参数的所有权。这可以防止我们在发送后不小心再次使用该值;所有权系统会检查一切是否正常。

16.2.2 发送多个值并观察接收者的等待

示例16-8中的代码编译并运行,但它没有清楚地向我们显示两个独立的线程正在通过通道相互通信。在示例16-10中,我们做了一些修改,以证明示例16-8中的代码是并发运行的衍生线程现在将发送多个消息,并在每个消息之间暂停一秒钟。
16-8

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

这一次,衍生线程有一个字符串向量,我们希望将其发送给主线程。我们遍历它们,分别发送它们,并通过调用Duration值为1秒的thread::sleep函数在它们之间暂停。

在主线程中,我们不再显式调用recv函数:相反,我们将rx视为迭代器。对于接收到的每个值,我们都会打印它。当通道关闭时,迭代将结束。
When running the code in Listing 16-10, you should see the following output with a 1-second pause in between each line:

Got: hi
Got: from
Got: the
Got: thread

因为在主线程的for循环中没有任何暂停或延迟的代码,所以我们可以知道主线程正在等待从衍生线程接收值。

16.2.3 通过克隆发送者来创建多个生产者

之前我们提到mpsc是多个生产者,单一消费者的缩写。让我们使用mpsc并展开示例16-10中的代码来创建多个线程,所有线程都将值发送到相同的接收器。我们可以通过克隆发射器来实现这一点,如示例16-11所示:
16-11

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }

    // --snip--
}

这一次,在创建第一个衍生线程之前,我们在发送者上调用clone。这将给我们一个新的发送者,我们可以传递给第一个衍生线程。我们将原始发送器传递给第二个衍生线程。这给了我们两个线程,每个线程向一个接收者发送不同的消息。

When you run the code, your output should look something like this:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

您可能会以另一种顺序看到这些值,这取决于您的系统。这就是并发性既有趣又困难的原因。如果您尝试使用thread::sleep,在不同的线程中给它不同的值,那么每次运行都将更加不确定,并且每次都会创建不同的输出。

现在我们已经了解了通道是如何工作的,让我们看看另一种并发的方法。

16.3 共享状态并发

消息传递是处理并发性的一种很好的方法,但并不是唯一的方法。另一种方法是让多个线程访问相同的共享数据。再考虑一下Go语言文档中的这部分口号:“不要通过共享内存进行通信。”

通过共享内存进行通信是什么样子的?另外,为什么消息传递热衷者警告不要使用内存共享呢?

在某种程度上,任何编程语言中的通道都类似于单一所有权,因为一旦将一个值向下传输到通道中,就不应该再使用该值共享内存并发类似于多个所有权:多个线程可以同时访问相同的内存位置。正如您在第15章中看到的,智能指针使多重所有权成为可能,多重所有权会增加复杂性,因为这些不同的所有者需要管理。Rust的类型系统和所有权规则极大地帮助实现正确的管理。例如,让我们看看互斥锁,这是共享内存中比较常见的并发原语之一。

16.3.1 互斥锁一次只允许一个线程访问数据

互斥(Mutex )是mutual exclusion的缩写,互斥在任何给定时间只允许一个线程访问某些数据。为了访问互斥锁中的数据,线程首先需要通过获取互斥锁的 锁(lock)来表明其希望访问数据。锁是一种数据结构,是互斥锁的一部分,它跟踪当前谁对数据具有独占访问权。因此,互斥锁被描述为通过锁定系统保护它所持有的数据。

互斥锁以难以使用而闻名,因为你必须记住两个规则:

  • 在使用数据之前,必须尝试获取锁。
  • 当您处理完互斥锁保护的数据后,您必须解锁数据,以便其他线程可以获得锁。

对于互斥锁的一个真实的比喻,想象一个只有一个麦克风的会议小组讨论。在一个小组成员发言之前,他们必须要求或示意他们想要使用麦克风。当他们拿到麦克风后,他们可以想说多久就说多久,然后把麦克风交给下一个要求发言的小组成员。如果一个小组成员在用完话筒后忘记把话筒递给别人,其他人就不能发言了。如果共享麦克风的管理出错,面板将不能按计划工作!

要正确地管理互斥锁可能非常困难,这就是为什么那么多人热衷于通道的原因。然而,多亏了Rust的类型系统和所有权规则,锁定和解锁不会出错。

16.3.2 The API of Mutex

作为如何使用互斥锁的例子,让我们从在单线程上下文中使用互斥锁开始,如示例16-12所示:

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {:?}", m);
}

与许多类型一样,我们使用相关函数new创建Mutex<T>。为了访问互斥锁内部的数据,我们使用lock 方法来获取锁。这个调用将阻塞当前线程,因此它不能做任何工作,直到轮到我们获得锁。

如果另一个持有锁的线程陷入恐慌,对锁的调用将失败。在这种情况下,没有人能够获得锁,所以如果我们在那种情况下,我们选择unwrap 并让线程恐慌。

在获得锁之后,我们可以将返回值(在本例中名为num)视为对内部数据的可变引用。类型系统确保我们在使用m中的值之前获得一个锁。m的类型是Mutex<i32>,而不是i32,因此我们必须调用lock才能使用i32的值。我们不能忘记;否则,类型系统将不允许我们访问内部i32

正如您可能怀疑的那样,Mutex<T>是一个智能指针。更准确地说,lock 调用返回一个名为MutexGuard的智能指针,它被封装在LockResult中,我们用调用unwrap处理它。MutexGuard智能指针实现了Deref指向我们的内部数据;智能指针还有一个Drop实现,当MutexGuard超出作用域时,它会自动释放锁,这发生在内部作用域的末尾。因此,我们不会忘记释放锁并阻止互斥锁被其他线程使用,因为锁的释放是自动发生的。

在v锁之后,我们可以打印互斥锁的值,并看到我们能够将内部的i32更改为6

16.3.3 在多个线程之间共享Mutex<T>

现在,让我们尝试使用Mutex<T>在多个线程之间共享一个值。我们将快速创建10个线程,并让它们每个线程增加一个计数器值1,因此计数器从0增加到10。示例16-13中的下一个示例将出现一个编译器错误,我们将使用该错误了解更多关于使用Mutex<T>的信息,以及Rust如何帮助我们正确使用它。

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

我们创建一个counter 变量来在Mutex<T>中保存i32,如示例16-12所示。接下来,我们通过在一组数字上迭代创建10个线程。我们使用thread::spawn并赋予所有线程相同的闭包:将计数器移动到线程中,通过调用lock 方法获得Mutex<T>上的锁,然后将互斥锁中的值加1。当一个线程结束它的闭包时,num将超出作用域并释放锁,以便另一个线程可以获得它。

main线程中,我们收集所有的连接句柄。然后,正如我们在示例16-2中所做的那样,我们在每个句柄上调用join,以确保所有线程都完成。此时,主线程将获取锁并打印此程序的结果。

我们暗示过这个示例不会编译。现在让我们来看看为什么!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here, in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error

错误消息声明counter值在之前的循环迭代中被移动了。Rust告诉我们不能将锁counter的所有权转移到多个线程中。让我们用我们在第15章中讨论过的多重所有权方法来修复编译器错误。

16.3.4 多线程的多重所有权

在第15章中,我们通过使用智能指针Rc<T>来创建一个引用计数值,为多个所有者提供了一个值。让我们在这里做同样的事情,看看会发生什么。我们将用在示例16-14中的Rc<T>,包装Mutex<T>,并在将所有权转移到线程之前克隆Rc<T>

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Once again, we compile and get… different errors! The compiler is teaching us a lot.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
   --> src/main.rs:11:22
    |
11  |           let handle = thread::spawn(move || {
    |  ______________________^^^^^^^^^^^^^_-
    | |                      |
    | |                      `Rc<Mutex<i32>>` cannot be sent between threads safely
12  | |             let mut num = counter.lock().unwrap();
13  | |
14  | |             *num += 1;
15  | |         });
    | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
    |
    = help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
    = note: required because it appears within the type `[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error

哇,这个错误消息太啰嗦了!下面是需要关注的重要部分Rc<Mutex<i32>>cannot be sent between threads safely。编译器也告诉我们原因:the trait Send is not implemented for Rc<Mutex<i32>>。我们将在下一节中讨论Send:它是确保我们在线程中使用的类型适合在并发情况下使用的特征之一。

不幸的是,Rc<T>对于跨线程共享是不安全的。当Rc<T>管理引用计数时,它为每次克隆调用添加计数,并在每次克隆被丢弃时从计数中减去。但是它没有使用任何并发原语来确保对计数的更改不会被另一个线程中断。这可能会导致错误的计数——微妙的错误可能会反过来导致内存泄漏或在我们处理完一个值之前就被丢弃。我们需要的是一个完全类似Rc<T>的类型,但它以线程安全的方式更改引用计数。

16.3.5 原子引用计数 Arc<T>

幸运的是,Arc<T>是类似Rc<T>的类型,在并发情况下使用是安全的。a代表atomic,这意味着它是原子引用计数类型( atomically reference counted type)。原子是一种额外的并发原语,我们在这里不会详细介绍:有关更多细节,请参阅std::sync::atomic的标准库文档。此时,您只需要知道原子的工作原理与基本类型类似,但可以安全地跨线程共享。

然后,您可能会想,为什么不是所有的基元类型都是原子类型,为什么标准库类型没有实现为默认使用Arc<T>。原因是线程安全带来了性能损失,只有在真正需要的时候才愿意付出代价。如果您只是在单个线程中对值执行操作,那么如果您的代码不需要强制执行原子提供的保证,那么它可以运行得更快。

让我们回到我们的例子:Arc<T>Rc<T>具有相同的API,因此我们通过更改use行、调用new和调用clone来修复程序。示例16-15中的代码将最终编译并运行:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

This code will print the following:

Result: 10

我们成功了!我们从0数到10,这似乎不是很令人印象深刻,但它确实教会了我们很多关于Mutex<T>和线程安全的知识。您还可以使用这个程序的结构来执行更复杂的操作,而不仅仅是增加计数器。使用此策略,您可以将计算划分为独立的部分,将这些部分跨线程分割,然后使用Mutex<T>让每个线程用其部分更新最终结果。

注意,如果您正在进行简单的数值操作,标准库的std::sync::atomic模块提供了比Mutex<T>类型更简单的类型。这些类型提供了对原语类型的安全的、并发的、原子的访问。在本例中,我们选择使用Mutex<T>和一个原语类型,这样我们就可以专注于Mutex<T>是如何工作的。

16.3.6 RefCell<T>/Rc<T>Mutex<T>/Arc<T>的相似性

你可能已经注意到counter是不可变的,但我们可以获得它内部值的可变引用;这意味着Mutex<T>提供内部可变性,就像Cell家族一样。就像我们在第15章中使用RefCell<T>来改变Rc<T>中的内容一样,我们使用Mutex<T>来改变Arc<T>中的内容。

另一个需要注意的细节是,当您使用Mutex<T>时,Rust不能保护您免受各种逻辑错误的影响。回顾第15章,使用Rc<T>带来了创建引用循环的风险,其中两个Rc<T>值彼此引用,导致内存泄漏。类似地,Mutex<T>也有创建死锁的风险。当一个操作需要锁定两个资源,而两个线程各自获得了其中一个锁时,就会发生这种情况,这导致它们彼此永远等待。如果你对死锁感兴趣,试着创建一个有死锁的Rust程序;然后研究任何语言互斥锁的死锁缓解策略,并尝试在Rust中实现它们。 Mutex<T>MutexGuard的标准库API文档提供了有用的信息。

我们将讨论Send Sync 特性以及如何在自定义类型中使用它们。

16.4 具有SyncSend特性的可扩展并发

有趣的是,Rust语言只有很少的并发特性。到目前为止,我们在本章中讨论的几乎所有并发特性都是标准库的一部分,而不是语言的一部分处理并发性的选项不局限于语言或标准库;您可以编写自己的并发特性,也可以使用其他人编写的并发特性

然而,该语言中嵌入了两个并发概念:std::marker traits SyncSend

16.4.1 使用Send允许在线程之间转移所有权

Send标记特征 (marker trait )表明实现Send的类型的值的所有权可以在线程之间转移。几乎每个Rust类型都是Send,但也有一些例外,包括Rc<T>:这不能被Send,因为如果您克隆了一个Rc<T>值并试图将克隆的所有权转移到另一个线程,两个线程可能同时更新引用计数。因此,Rc<T>被实现为在单线程情况下使用,在这种情况下,您不希望付出线程安全性能损失。

因此,Rust的类型系统和trait边界确保您永远不会不安全地跨线程发送Rc<T>值。当我们在示例16-14中尝试这样做时,我们得到了the trait Send is not implemented for Rc<Mutex<i32>>。当我们切换到Arc<T>(即Send)时,代码编译完成。

任何完全由Send类型组成的类型也会自动标记为Send。除了我们将在第19章讨论的原始指针外,几乎所有的原始类型都是Send

16.4.2 使用Sync 允许多线程访问

Sync标记特征表明,从多个线程引用实现Sync的类型是安全的。换句话说,如果&T(对T的不可变引用)是Send,则任何类型T都是Sync,这意味着引用可以安全地发送到另一个线程。与Send类似,基本类型是Sync,完全由Sync类型组成的类型也是Sync

智能指针Rc<T>也不是Sync的原因与它不是Send的原因相同。RefCell<T>类型(我们在第15章讨论过)和相关的Cell<T>类型家族是不Sync的。RefCell<T>在运行时执行的借用检查的实现不是线程安全的。智能指针Mutex<T>Sync的,可以用于与多个线程共享访问,正如您在“在多个线程之间共享互斥锁”一节中看到的。

16.4.3 手动实现SyncSend是不安全的

因为由SyncSend特征组成的类型也会自动SyncSend,所以我们不需要手动实现这些特征。作为标记特征,它们甚至没有任何方法来实现。它们只是用于强制执行与并发性相关的不变量。

手动实现这些特性需要实现不安全的Rust代码。我们将在第19章中讨论使用不安全的Rust代码;就目前而言,重要的信息是,构建不由发送和同步部分组成的新并发类型需要仔细考虑以维护安全保证。“Rustonomicon”有更多关于这些保证以及如何维护它们的信息。

十七、Rust的面向对象编程特性

面向对象编程(Object-oriented programming,OOP)是一种对程序建模的方法。对象作为一个编程概念在20世纪60年代的编程语言Simula中被引入。这些对象影响了Alan Kay的编程体系结构,在该体系结构中,对象之间传递消息。为了描述这种体系结构,他在1967年创造了术语面向对象编程。许多相互竞争的定义描述了什么是OOP,根据其中一些定义,Rust是面向对象的,但另一些则不是。在本章中,我们将探讨一些通常被认为是面向对象的特征,以及这些特征如何转化为惯用的Rust。然后,我们将向您展示如何在Rust中实现面向对象的设计模式,并讨论这样做与使用Rust的一些优点来实现解决方案之间的权衡。

17.1 面向对象语言的特点

对于一门语言必须具有哪些面向对象的特性,编程界没有达成共识。Rust受到许多编程范式的影响,包括面向对象编程;例如,我们在第13章探讨了函数式编程的特性。可以说,OOP语言具有某些共同的特征,即对象、封装和继承(objects, encapsulation, and inheritance)。让我们看看每个特征的含义,以及Rust是否支持这些特征。

17.1.1 对象包含数据和行为

由Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides (Addison-Wesley Professional, 1994)所著的《设计模式:可重用的面向对象软件的元素》,通俗地称为“he Gang of Four ”书,是面向对象设计模式的目录。它是这样定义OOP的:

面向对象程序由对象组成。对象既打包了数据,也打包了对数据进行操作的过程。过程通常称为方法或操作。

使用这个定义,Rust是面向对象的:结构和枚举有数据,而impl块提供结构和枚举的方法。即使结构和带有方法的枚举不称为对象,但根据 Gang of Four 对对象的定义,它们提供了相同的功能。

17.1.2 隐藏实现细节的封装

与OOP通常相关的另一个方面是封装(encapsulation),这意味着使用该对象的代码无法访问对象的实现细节。因此,与对象交互的唯一方法是通过它的公共API;使用对象的代码不应该能够进入对象的内部并直接更改数据或行为这使程序员可以更改和重构对象的内部结构,而不需要更改使用对象的代码。

我们在第7章讨论了如何控制封装:我们可以使用pub关键字来决定代码中的哪些模块、类型、函数和方法应该是公共的,默认情况下其他的都是私有的。例如,我们可以定义一个结构AveragedCollection,它的字段包含i32个值的向量。该结构还可以有一个字段,其中包含向量中值的平均值,这意味着平均值不必在任何人需要它时按需计算。换句话说,AveragedCollection将为我们缓存计算的平均值。示例17-1给出了AveragedCollection结构的定义:
17-1

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

该结构被标记为pub,以便其他代码可以使用它,但结构中的字段保持私有。这在本例中很重要,因为我们希望确保每当从列表中添加或删除一个值时,平均值也会更新。我们通过在结构上实现addremoveaverage方法来实现这一点,如示例17-2所示:
17-2

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

公共方法addremoveaverage是访问或修改AveragedCollection实例中的数据的惟一方法。当使用add方法将项添加到list 或使用remove方法删除项时,每个项的实现都调用私有update_average方法,该方法也处理平均字段的更新。

我们将list average字段保留为私有,因此外部代码无法直接向列表字段添加或删除项;否则,当list 更改时,平均字段可能会不同步。average方法返回平均值字段中的值,允许外部代码读取平均值但不修改它。

因为我们封装了结构AveragedCollection的实现细节,所以将来可以很容易地更改方面,比如数据结构。例如,对于list 字段,我们可以使用HashSet<i32>而不是Vec<i32>。只要addremoveaverage公共方法的签名保持不变,使用AveragedCollection的代码就不需要更改。如果我们将list设为public,情况就不一定是这样了:HashSet<i32>Vec<i32>有不同的添加和删除项的方法,因此如果要直接修改list,外部代码可能必须更改。

如果封装是一门语言被视为面向对象所必需的方面,那么Rust满足了这一要求。对代码的不同部分使用pub或不使用pub的选项支持对实现细节的封装。

17.1.3 继承,作为类型系统和代码共享

继承是一种机制,通过这种机制,一个对象可以从另一个对象的定义中继承元素,从而获得父对象的数据和行为,而不必重新定义它们。

如果一种语言必须具有继承才能成为面向对象语言,那么Rust就不是。如果不使用宏,就无法定义继承父结构的字段和方法实现的结构。

然而,如果您习惯于在编程工具箱中使用继承,则可以在Rust中使用其他解决方案,这取决于您最初获得继承的原因。

选择继承主要有两个原因。一个是代码的重用:您可以为一种类型实现特定的行为,继承使您能够为另一种类型重用该实现。您可以在Rust代码中使用默认特征方法实现以有限的方式做到这一点,您可以在示例10-14中看到这一点,当时我们在Summarytrait上添加了summarize方法的默认实现。任何实现Summarytrait的类型都可以在其上使用summarize方法,而不需要任何进一步的代码。这类似于父类具有方法的实现,继承子类也具有方法的实现。我们还可以在实现Summarytrait时覆盖summarize方法的默认实现,这类似于子类覆盖从父类继承的方法的实现。

使用继承的另一个原因与类型系统有关:允许在与父类型相同的位置使用子类型。这也称为多态性(polymorphism),这意味着如果多个对象共享某些特征,则可以在运行时相互替换它们。

多态性

对许多人来说,多态性就是继承的同义词。但它实际上是一个更通用的概念,指的是可以处理多种类型数据的代码。对于继承,这些类型通常是子类。

Rust反而使用泛型来抽象不同的可能类型和 trait bounds,从而对这些类型必须提供的内容施加约束。这有时称为有界参数多态性( bounded parametric polymorphism)。

在许多编程语言中,继承作为一种编程设计解决方案最近已不再受欢迎,因为它常常存在共享过多代码的风险。子类不应该总是共享父类的所有特征,但可以通过继承来实现。这可能会降低程序设计的灵活性。它还引入了在子类上调用方法的可能性,这些方法没有意义,或者因为方法不应用于子类而导致错误。此外,有些语言只允许单继承(意味着一个子类只能从一个类继承),这进一步限制了程序设计的灵活性。

出于这些原因,Rust采用了使用特征对象而不是继承的不同方法。让我们看看trait对象(trait objects )如何在Rust中启用多态性。

17.2 使用Trait对象允许不同类型值的

在第8章中,我们提到了向量的一个限制是它们只能存储一种类型的元素。我们在示例8-9中创建了一个变通方法,其中定义了一个SpreadsheetCell枚举,其中包含用于保存整数、浮点数和文本的变量。这意味着我们可以在每个单元格中存储不同类型的数据,并且仍然有一个表示一行单元格的向量。当可交换项是编译代码时知道的固定类型集时,这是一个非常好的解决方案。

但是,有时我们希望库用户能够扩展在特定情况下有效的类型集。为了演示如何实现这一点,我们将创建一个示例图形用户界面(GUI)工具,该工具遍历项目列表,对每个项目调用一个draw方法将其绘制到屏幕上——这是GUI工具的一种常见技术。我们将创建一个名为gui的库 crate,其中包含gui库的结构。这个crate可能包括一些供人们使用的类型,例如ButtonTextField。此外,gui用户希望创建自己的可绘制类型:例如,一个程序员可能添加一个Image,另一个可能添加一个SelectBox

我们不会为这个示例实现一个完整的GUI库,但将展示如何将各个部分组合在一起。在编写库时,我们不可能知道和定义其他程序员可能想要创建的所有类型。但是我们知道gui需要跟踪许多不同类型的值,并且它需要对每个不同类型的值调用一个draw方法。它不需要知道当我们调用draw方法时会发生什么,只需要知道值会有那个方法可供我们调用。

要在具有继承的语言中做到这一点,我们可以定义一个名为Component的类,该类上有一个名为draw的方法。其他类,如ButtonImageSelectBox,将继承自Component,因此继承了绘制方法。它们都可以覆盖draw方法来定义它们的自定义行为,但框架可以将所有类型视为组件实例,并对它们调用draw。但是因为Rust没有继承,我们需要另一种方式来构造gui库,以允许用户使用新类型来扩展它。

17.2.1 使用 Trait定义共同行为

为了实现我们希望gui拥有的行为,我们将定义一个名为Draw的trait,它将有一个名为draw的方法。然后我们可以定义一个带有trait对象的向量。trait对象既指向实现我们指定trait的类型实例,又指向用于在运行时查找该类型的trait方法的表。我们通过指定某种类型的指针来创建trait对象,例如&引用或Box<T>智能指针,然后是dyn关键字,然后指定相关的trait。(我们将在第19章“Dynamically Sized Types and the Sized Trait.”一节中讨论trait对象必须使用指针的原因)我们可以使用trait对象来代替泛型或具体类型无论我们在哪里使用trait对象,Rust的类型系统都将在编译时确保在该上下文中使用的任何值都将实现trait对象的trait。因此,我们不需要在编译时知道所有可能的类型。

我们已经提到过,在Rust中,我们避免将结构和枚举称为“对象”,以将它们与其他语言的对象区分开来。在结构或枚举中,结构字段中的数据和impl块中的行为是分离的,而在其他语言中,数据和行为组合成一个概念通常被标记为对象。然而**,trait对象更像其他语言中的对象,因为它们结合了数据和行为。但特征对象与传统对象的不同之处在于,我们不能向特征对象添加数据**。Trait对象并不像其他语言中的对象那样普遍有用:它们的特定目的是允许跨公共行为进行抽象。

示例17-3展示了如何定义:一个叫做Draw的trait,它有一个叫做draw的方法:
17-3

pub trait Draw {
    fn draw(&self);
}

这种语法应该与我们在第10章中关于如何定义trait的讨论很相似。接下来是一些新的语法:示例17-4定义了一个名为Screen的结构体,它保存了一个名为components的向量。这个向量的类型是Box<dyn Draw>,这是一个trait对象;它是Box中实现了Draw特性的任何类型的替身。
17-4

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Screen结构体上,我们将定义一个名为run的方法,它将在每个组件上调用draw方法,如示例17-5所示:
17-5

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这与定义使用带 trait bounds的泛型类型参数的结构不同。泛型类型参数一次只能用一个具体类型替换,而特征对象允许在运行时为特征对象填充多个具体类型。例如,我们可以使用泛型类型和 trait bounds来定义Screen结构,如示例17-6所示:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这将我们限制在一个Screen实例中,该实例具有所有类型为Button或全部类型为TextField的组件列表。如果您只使用同构集合,那么使用泛型和trait边界是更好的选择,因为定义将在编译时进行单一化,以使用具体的类型。

另一方面,通过使用trait对象的方法,一个Screen实例可以保存Vec<T>,其中包含Box<Button>Box<TextField>。让我们看看这是如何工作的,然后讨论运行时性能影响。

17.2.2 实现 trait

现在我们将添加一些实现Draw特性的类型。我们将提供Button类型。同样,实际实现GUI库超出了本书的范围,因此draw方法在其主体中没有任何有用的实现。为了想象实现的样子,Button结构体可能有width, heightlabel字段,如示例17-7所示:
17-7

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Button上width, heightlabel字段将不同于其他组件上的字段;例如,TextField类型可能有相同的字段加上一个placeholder字段。我们想要在屏幕上绘制的每个类型都将实现Drawtrait ,但将在draw方法中使用不同的代码来定义如何绘制特定的类型,就像Button在这里所做的那样(如前所述,没有实际的GUI代码)。例如,Button类型可能有一个附加的impl块,其中包含与用户单击按钮时发生的事情相关的方法。这类方法不适用于TextField之类的类型。

如果使用我们库的人决定实现一个具有width, heightlabel字段的SelectBox结构体,他们也会在SelectBox类型上实现Drawtrait ,如示例17-8所示:

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

库的用户现在可以编写他们的main函数来创建Screen实例。对于Screen实例,他们可以添加一个SelectBox和一个Button,将它们放在Box<T>中以成为一个trait对象。然后,它们可以调用Screen实例上的run方法,该方法将调用每个组件上的draw。示例17-9显示了这个实现:

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

当我们编写库时,我们不知道有人可能会添加SelectBox类型,但我们的Screen实现能够操作新类型并绘制它,因为SelectBox实现了draw特性,这意味着它实现了绘制方法。

这个概念——只关心值响应的消息而不关心值的具体类型——类似于动态类型语言中的鸭子类型(duck typing )的概念:如果它像鸭子一样走路,像鸭子一样嘎嘎叫,那么它一定是一只鸭子!在示例17-5中 Screenrun 的实现中,run不需要知道每个组件的具体类型。它不检查组件是否是ButtonSelectBox的实例,它只是调用组件上的draw方法。通过指定Box<dyn Draw>作为components向量中的值的类型,我们已经定义Screen需要可以调用draw 方法的值。

使用trait对象和Rust的类型系统来编写类似于duck类型的代码的好处是,我们不必在运行时检查一个值是否实现了一个特定的方法,也不必担心如果一个值没有实现一个方法但我们仍然调用它会出错。如果值没有实现特征对象需要的特征,Rust就不会编译我们的代码。

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

We’ll get this error because String doesn’t implement the Draw trait:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast to the object type `dyn Draw`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` due to previous error

这告诉了我们,要么是我们传递了并不希望传递给 Screen 的类型并应该提供其他类型,要么应该在 String 上实现Draw以便 Screen 可以调用其上的 draw

17.2.3 Trait对象执行动态调度

回想第10章“使用泛型的代码性能”一节中,我们对在泛型上使用trait边界时编译器执行的单一化过程的讨论:编译器为我们用来代替泛型类型参数的每个具体类型生成函数和方法的非泛型实现。由单态化产生的代码正在执行静态调度(static dispatch),也就是当编译器知道您在编译时调用什么方法时。这与动态调度(dynamic dispatch)相反,动态调度是指编译器在编译时无法判断调用的是哪个方法。在动态调度情况下,编译器发出的代码将在运行时找出要调用的方法。

当我们使用trait对象时,Rust必须使用动态调度。编译器不知道使用trait对象的代码可能使用的所有类型,因此它不知道调用哪个类型上实现的哪个方法。相反,在运行时,Rust使用trait对象中的指针来知道调用哪个方法。这种查找会产生静态调度不会产生的运行时成本。动态调度还会阻止编译器选择内联方法的代码,这反过来又会阻止一些优化。然而,我们在示例17-5中编写的代码中获得了额外的灵活性,并且能够在示例17-9中提供支持,因此这是需要考虑的权衡。

17.3 实现面向对象的设计模式

状态模式(state pattern)是一种面向对象的设计模式。该模式的关键在于,我们在内部定义一个值可以拥有的一组状态。状态由一组状态对象( state objects)表示,值的行为根据其状态而变化。我们将通过一个博客帖子结构的例子,它有一个字段来保存它的状态,它将是来自集合“draft”、“review”或“published”的状态对象。

状态对象共享功能:当然,在Rust中,我们使用结构体和traits ,而不是对象和继承。每个状态对象都对自己的行为负责,并负责在应该改变为另一种状态时进行治理。保存状态对象的值不知道状态的不同行为,也不知道何时在状态之间转换。

使用状态模式的优点是,当程序的业务需求发生变化时,我们不需要更改保存状态的值的代码或使用该值的代码。我们只需要更新一个状态对象内部的代码来更改它的规则,或者添加更多的状态对象。

首先,我们将以更传统的面向对象的方式实现状态模式,然后我们将使用Rust中更自然的方法。让我们深入研究如何使用状态模式逐步实现博客文章工作流。

最终的功能是这样的:

  • 博客文章一开始都是空的草稿(draft)。
  • 草稿完成后,要求对该文章进行审查(review )。
  • 当文章被批准后,它就会被发表(published)。
  • 只有已经发布的博客文章才会返回打印内容,所以未经批准的文章不会意外发布。

任何其他对文章的修改都不会产生任何效果。例如,如果我们试图在请求审查之前批准一篇博客文章的草稿,那么该文章应该保持为未发表的草稿。

示例17-11以代码形式展示了这个工作流:这是我们将在名为blog的库crate 中实现的API的示例用法。这还不能编译,因为我们还没有实现blog crate 。
17-11

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

我们希望允许用户使用post::new创建一个新的博客文章草稿。我们希望允许将文本添加到博客文章中。如果我们试图立即获得帖子的内容,在批准之前,我们不应该得到任何文本,因为帖子仍然是一个草案。我们添加了assert_eq!在代码中进行演示。对此,一个很好的单元测试是断言博客文章草稿从content方法返回一个空字符串,但我们不打算为这个示例编写测试。

接下来,我们希望启用对文章进行审查的请求,并希望content 在等待审查时返回一个空字符串。当文章获得批准时,它应该被发布,这意味着当内容被调用时,文章的content 将被返回。

注意,我们在crate 中与之交互的唯一类型是Post类型。该类型将使用状态模式并保存一个值,该值将是三个状态对象之一,表示一篇文章可能处于起草阶段、等待审查或发布的各种状态。从一种状态到另一种状态的更改将在Post类型内部进行管理。状态变化是响应我们的库的用户在Post实例上调用的方法的,但是他们不必直接管理状态变化。此外,用户不能在状态上犯错误,比如在评论之前发布帖子。

17.3.1 定义Post并在Draft状态创建一个新实例

让我们开始库的实现!我们知道我们需要一个公共Post结构体来保存一些内容,因此我们将从该结构的定义和一个相关的公共new函数开始,以创建Post的实例,如示例17-12所示。我们还将创建一个私有Statetrait ,它将定义Post的所有状态对象必须具有的行为。

然后Post将在名为state 的私有字段中保存Box<dyn State>的trait对象在Option<T>中以保存状态对象。稍后您将看到为什么Option<T>是必要的。

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

State trait 定义了由不同post状态共享的行为。状态对象是DraftPendingReviewPublished,它们都将实现state trait 。目前,trait还没有任何方法,我们将从只定义Draft状态开始,因为这是我们希望post开始的状态。

当我们创建一个新的Post时,我们将其状态字段设置为包含BoxSome值。此Box指向Draft结构的一个新实例。这确保了每当我们创建Post的新实例时,它将以草稿的形式开始。因为Post的状态字段是私有的,所以无法在任何其他状态中创建Post !在Post::new函数中,我们将内容字段设置为一个新的空String

17.3.2 存储文章内容的文本

我们在示例17-11中看到,我们希望能够调用一个名为add_text的方法并向它传递一个&str,然后将该&str添加为博客文章的文本内容。我们将其作为一个方法来实现,而不是将content 字段公开为pub,以便稍后我们可以实现一个方法来控制如何读取content 字段的数据。add_text方法非常简单,所以让我们将示例17-13中的实现添加到impl Post块中:

impl Post {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

add_text方法有一个对self的可变引用,因为我们正在改变调用add_textPost实例。然后在content中的String上调用push_str函数,并传递要添加到保存的内容中的文本参数。此行为不依赖于帖子所处的状态,因此它不是状态模式的一部分。add_text方法完全不与状态字段交互,但它是我们希望支持的行为的一部分。

17.3.3 确保草稿的内容是空的

即使在调用add_text并向post添加了一些内容之后,我们仍然希望content方法返回一个空字符串片,因为post仍然处于草稿状态,如示例17-11的第7行所示。现在,让我们用最简单的方法来实现content方法,以满足这一要求:总是返回一个空字符串片。一旦我们实现了改变帖子状态的功能,我们将在后面修改它。到目前为止,文章只能处于草稿状态,所以文章内容应该总是空的。示例17-14显示了这个占位符实现:

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        ""
    }
}

使用这个添加的content 方法,示例17-11到第7行中的所有内容都可以正常工作。

17.3.4 请求审查文章会改变其状态

接下来,我们需要添加请求审查文章的功能,它应该将其状态从Draft更改为PendingReview。示例17-15显示了这段代码:

impl Post {
    // --snip--
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

我们为Post提供了一个名为request_review的公共方法,它将接受self的一个可变引用。然后在Post的当前状态上调用一个内部的request_review方法,第二个request_review方法使用当前状态并返回一个新状态。

我们将request_review方法添加到State特征中;所有实现trait的类型现在都需要实现request_review方法。注意,我们没有使用self&self&mut self作为方法的第一个参数而是使用self: Box<self>。这种语法意味着该方法只有在对包含该类型的Box调用时才有效此语法获得Box<Self>的所有权,使旧状态无效,以便Post的状态值可以转换为新状态。

要使用旧状态,request_review方法需要获得状态值的所有权。这就是Post的状态字段中的Option发挥作用的地方:我们调用take方法从状态字段中取出Some值并在其位置上留下None,因为Rust不允许我们在结构中有未填充的字段。这允许我们将状态值移出Post,而不是借用它。然后我们将把poststate值设置为这个操作的结果。

我们需要暂时将state 设置为None,而不是直接用self.state = self.state.request_review();这样的代码设置它,来获得state 值的所有权。这确保Post在将旧状态值转换为新状态后不能使用旧状态值。

Draft上的request_review方法返回一个新的PendingReview结构体的新盒装实例,该结构体表示文章等待审核时的状态。PendingReview结构也实现了request_review方法,但不做任何转换。相反,它返回自己,因为当我们请求一个已经处于PendingReview状态的帖子的评论时,它应该保持在PendingReview状态。

现在我们可以开始看到状态模式的优点了:Post上的request_review方法无论其状态值如何都是相同的。每个状态都要为自己的规则负责。

我们将保持Post上的content方法不变,返回一个空字符串片。我们现在可以让Post处于PendingReview状态和Draft状态,但我们希望在PendingReview状态中也有相同的行为。示例17-11现在可以工作到第10行!

17.3.5 添加approve更改内容的行为approve

方法将类似于request_review方法:它将state设置为当前状态表示当该状态被批准时它应该拥有的值,如示例17-16所示:
17-16:

impl Post {
    // --snip--
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

我们将approve方法添加到State trait 中,并添加实现State(Published )的新结构体。

类似于PendingReview上的request_review的工作方式,如果我们在Draft上调用approve方法,它将没有效果,因为approve将返回self。当我们在PendingReview上调用approve时,它返回Published结构体的一个新的boxed 实例。Published结构体实现了State trait,并且对于request_review方法和approve方法,它都返回自己,因为在这两种情况下,post应该保持Published状态。

现在我们需要更新Post上的content方法。我们希望从content返回的值依赖于Post的当前状态,因此我们将Post委托给一个根据其状态定义的内容方法,如示例17-17所示:

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--
}

因为目标是将所有这些规则保存在实现State的结构体中,所以我们对State中的值调用content方法,并将post实例(即self)作为参数传递。然后返回使用状态值的content方法返回的值。

我们在Option上调用as_ref方法,因为我们想要Option内的值的引用,而不是该值的所有权。因为stateOption<Box<dyn state >>,所以当我们调用as_ref时,返回的是Option<&Box<dyn state >>。如果不调用as_ref,就会出现错误,因为无法将状态移出函数形参的借来的&self

然后调用unwrap方法,我们知道它永远不会出错,因为我们知道Post上的方法确保当这些方法完成时,状态总是包含一个Some值。这是我们在第9章“你比编译器拥有更多信息的情况”一节中谈到的情况之一,我们知道None值永远不可能,即使编译器无法理解这一点。

此时,当我们在&Box<dyn State>上调用content时,deref coercion将在&Box上生效,因此最终将在实现Statetrait的类型上调用content方法。这意味着我们需要向Statetrait定义添加内容,这也是我们将根据所拥有的状态将返回什么内容的逻辑放在哪里的地方,如示例17-18所示:

trait State {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--
struct Published {}

impl State for Published {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

我们为返回空字符串片的content方法添加一个默认实现。这意味着我们不需要实现DraftPendingReview结构体上的内容。Published结构体将覆盖content方法并返回post.content中的值。

注意,这个方法需要生命周期注释,正如我们在第10章中讨论的那样。我们将post的引用作为参数并返回该post的一部分的引用,因此返回的引用的生存期与post参数的生存期相关。

我们已经完成了—示例17-11现在都可以工作了!我们已经使用博客文章工作流的规则实现了状态模式。与规则相关的逻辑存在于状态对象中,而不是分散在Post中。

为什么不是Enum?

您可能想知道为什么我们不使用带有不同post状态的枚举作为变量。这当然是一个可能的解决方案,试试它,并比较最终的结果,看看你更喜欢哪一个!使用枚举的一个缺点是,每个检查枚举值的地方都需要一个匹配表达式或类似表达式来处理每个可能的变量。这可能比trait对象解决方案更加重复。

17.3.6 状态模式的权衡

我们已经展示了Rust能够实现面向对象的状态模式来封装一个帖子在每个状态下应该具有的不同类型的行为。Post上的方法对各种行为一无所知。根据我们组织代码的方式,我们只需查看一个地方就可以知道一个已发布的帖子的不同行为方式:State trait在published结构上的实现。

如果要创建一个不使用状态模式的替代实现,则可以在Post上的方法中使用match 表达式,甚至在检查Post状态并更改这些地方的行为的主代码中使用match 表达式。这意味着我们必须查看多个地方,以理解处于已发布状态的帖子的所有含义!这只会增加我们添加的更多状态:每个匹配表达式都需要另一个 arm。

有了状态模式,Post方法和我们使用Post的位置不需要匹配表达式,要添加一个新的状态,我们只需要添加一个新的结构,并在这个结构上实现trait方法。

使用状态模式的实现很容易扩展以添加更多功能。要了解维护使用状态模式的代码的简单性,可以尝试以下建议:

  • 添加一个reject方法,将post的状态从PendingReview更改回Draft
  • 在将状态更改为Published之前,需要两次调用进行批准。
  • 只允许用户在文章处于Draft状态时添加文本内容。提示:让状态对象负责内容可能发生的变化,但不负责修改Post

状态模式的一个缺点是,因为状态实现了状态之间的转换,所以有些状态是相互耦合的。如果我们在PendingReviewPublished之间添加另一个状态,例如Scheduled,我们将不得不更改PendingReview中的代码,以转换到Scheduled。如果PendingReview不需要通过添加新状态进行更改,那么工作量会更少,但这将意味着切换到另一种设计模式。

另一个缺点是我们复制了一些逻辑。为了消除一些重复,我们可以尝试在返回selfState特征上为request_reviewapprove方法设置默认实现;然而,这将违反对象安全性,因为trait 不知道具体的self 到底是什么。我们希望能够将State用作trait对象,因此我们需要它的方法是对象安全的。

其他的重复包括Post上的request_reviewapprove方法的类似实现。这两个方法都将Option state 字段中的值委托给相同方法的实现,并将state 字段的新值设置为结果。如果Post上有很多方法遵循这种模式,我们可以考虑定义一个宏来消除重复(参见第19章的“宏”一节)。

通过完全按照面向对象语言定义的方式实现状态模式,我们没有充分利用Rust的优势。让我们看看可以对blogcrate 进行的一些更改,这些更改可以将无效状态和转换转化为编译时错误。

将状态和行为编码为不同类型

我们将向您展示如何重新考虑状态模式,以获得一组不同的权衡。我们不是完全封装状态和转换,以便外部代码不了解它们,而是将状态编码为不同的类型。因此,Rust的类型检查系统将通过发出编译器错误来防止只允许使用已发布帖子的的地方使用草稿帖子。

让我们考虑一下示例17-11中main的第一部分:

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
}

我们仍然可以使用Post::new在草稿状态下创建新帖子,并且可以向帖子的内容添加文本。但是,我们不会在草稿文章中使用返回空字符串的content方法,而是使草稿文章完全没有content方法。这样,如果我们试图获取一个草稿文章的内容,我们将得到一个编译器错误,告诉我们方法不存在。因此,我们不可能在生产中意外地显示草稿帖子内容,因为那些代码甚至不会编译。示例17-19显示了Post结构和DraftPost结构的定义,以及它们各自的方法:

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

PostDraftPost结构都有一个私有content 字段,用于存储博客文章文本。结构不再有状态字段,因为我们将状态的编码移到了结构的类型。Post结构将表示已发布的文章,它有一个返回contentcontent方法。

我们仍然有一个Post::new函数,但是它返回的不是Post的实例,而是DraftPost的实例。因为内容是私有的,并且没有任何返回Post的函数,所以现在不可能创建Post的实例。

DraftPost结构体有一个add_text方法,所以我们可以像以前一样将文本添加到内容中,但请注意,DraftPost没有定义内容方法!因此,现在该程序确保所有帖子都是从草稿帖子开始的,并且草稿帖子的内容不能显示。任何绕过这些约束的尝试都将导致编译器错误。

将转换实现为不同类型的转换

那么,我们如何获得一篇发表的文章呢?我们想要执行这样的规则:一篇文章的草稿必须经过审查和批准才能发表。处于pending review状态的帖子仍然不应该显示任何内容。让我们通过添加另一个结构PendingReviewPost来实现这些约束,在DraftPost上定义request_review方法以返回PendingReviewPost,并在PendingReviewPost上定义approve方法以返回Post,如示例17-20所示:

impl DraftPost {
    // --snip--
    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

request_reviewapprove方法拥有self的所有权,因此使用DraftPostPendingReviewPost实例,并将它们分别转换为PendingReviewPostpublished Post。这样,在调用了DraftPost实例的request_review之后,就不会有任何滞留的实例,等等。PendingReviewPost结构体上没有定义内容方法,因此试图读取其内容会导致编译器错误,就像使用DraftPost一样。因为获得一个定义了内容方法的已发布的Post实例的唯一方法是调用PendingReviewPost上的approve方法,而获得PendingReviewPost的唯一方法是调用DraftPost上的request_review方法,所以我们现在已经将博客文章工作流编码到类型系统中。

但我们也必须对main做一些小的改变。request_reviewapprove方法返回新实例,而不是修改调用它们的结构,因此我们需要添加更多的let post =遮蔽赋值来保存返回的实例。我们也不能让关于草稿和等待审阅帖子的内容的断言为空字符串,我们也不需要它们:我们不能再编译试图在这些状态下使用帖子内容的代码。main中更新的代码如示例17-21所示:
17-21:

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

我们需要对main进行更改以重新分配post,这意味着该实现不再完全遵循面向对象的状态模式:状态之间的转换不再完全封装在post实现中。然而,我们的收获是无效状态现在是不可能的,因为类型系统和编译时发生的类型检查!这确保了某些错误,例如未发布的帖子的内容显示,在它们投入生产之前就能被发现。

在示例17-21之后的blog crate 上尝试本节开头建议的任务,看看您对这个版本代码的设计有什么看法。注意,在这个设计中,有些任务可能已经完成了。

我们已经看到,即使Rust能够实现面向对象的设计模式,其他模式(如将状态编码到类型系统中)也可以在Rust中使用。这些模式有不同的取舍。尽管您可能非常熟悉面向对象的模式,但是重新考虑这个问题以利用Rust的特性可以带来好处,例如在编译时防止一些错误。面向对象模式在Rust中并不总是最好的解决方案,这是因为面向对象语言不具备某些特性,比如所有权。

看完本章,不管你是否认为Rust是一种面向对象的语言,你现在都知道可以使用trait对象在Rust中获得一些面向对象的特性。动态分派可以为代码提供一些灵活性,以换取一些运行时性能。您可以使用这种灵活性来实现面向对象的模式,这有助于代码的可维护性。Rust还有一些面向对象语言不具备的特性,比如所有权。面向对象的模式并不总是利用Rust优势的最佳方式,但它是一种可用的方式