• Rust 基础(六)


    十六、无畏并发

    安全且高效的处理并发编程是 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));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    注意,当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!
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在句柄上调用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!
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    两个线程继续交替,但主线程会等待,因为调用了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));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    主线程会等待衍生线程完成,然后运行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!
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    一些小细节,比如在哪里调用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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    闭包使用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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    如果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 || {
      |                                ++++
    
    • 1
    • 2
    • 3
    • 4

    通过在闭包之前添加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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    我们可能会尝试用同样的方法来修复示例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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    我们使用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();
        });
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    同样,我们使用thread::spawn创建一个新线程,然后使用movetx移动到闭包中,因此衍生线程拥有tx。衍生线程需要拥有发送者,以便能够通过通道发送消息。发送者有一个send方法,它接受我们想要发送的值。send方法返回Result类型,因此,如果接收者已经被丢弃,且无处发送值,则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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

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

    try_recv方法不会阻塞,而是会立即返回Result:如果有可用的消息,则返回一个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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在这里,我们尝试在通过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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    我们的并发性错误导致了编译时错误。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);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    这一次,衍生线程有一个字符串向量,我们希望将其发送给主线程。我们遍历它们,分别发送它们,并通过调用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
    
    • 1
    • 2
    • 3
    • 4

    因为在主线程的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--
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    这一次,在创建第一个衍生线程之前,我们在发送者上调用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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    您可能会以另一种顺序看到这些值,这取决于您的系统。这就是并发性既有趣又困难的原因。如果您尝试使用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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

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

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

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

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

    16.3.3 在多个线程之间共享Mutex

    现在,让我们尝试使用Mutex在多个线程之间共享一个值。我们将快速创建10个线程,并让它们每个线程增加一个计数器值1,因此计数器从0增加到10。示例16-13中的下一个示例将出现一个编译器错误,我们将使用该错误了解更多关于使用Mutex的信息,以及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());
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    我们创建一个counter 变量来在Mutex中保存i32,如示例16-12所示。接下来,我们通过在一组数字上迭代创建10个线程。我们使用thread::spawn并赋予所有线程相同的闭包:将计数器移动到线程中,通过调用lock 方法获得Mutex上的锁,然后将互斥锁中的值加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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

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

    16.3.4 多线程的多重所有权

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

    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());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

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

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

    16.3.5 原子引用计数 Arc

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

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

    让我们回到我们的例子:ArcRc具有相同的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());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    This code will print the following:

    Result: 10
    
    • 1

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

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

    16.3.6 RefCell/RcMutex/Arc的相似性

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

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

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

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

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

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

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

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

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

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

    16.4.2 使用Sync 允许多线程访问

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

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

    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,
    }
    
    • 1
    • 2
    • 3
    • 4

    该结构被标记为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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

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

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

    因为我们封装了结构AveragedCollection的实现细节,所以将来可以很容易地更改方面,比如数据结构。例如,对于list 字段,我们可以使用HashSet而不是Vec。只要addremoveaverage公共方法的签名保持不变,使用AveragedCollection的代码就不需要更改。如果我们将list设为public,情况就不一定是这样了:HashSetVec有不同的添加和删除项的方法,因此如果要直接修改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智能指针,然后是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);
    }
    
    • 1
    • 2
    • 3

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

    pub struct Screen {
        pub components: Vec<Box<dyn Draw>>,
    }
    
    • 1
    • 2
    • 3

    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();
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这与定义使用带 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();
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

    另一方面,通过使用trait对象的方法,一个Screen实例可以保存Vec,其中包含Box

  • 相关阅读:
    【图解IO与Netty系列】Reactor模型
    多路径来源页面导航高亮以及面包屑导航修改
    PAT 1059 Prime Factors(建立素数表)
    解决 MacOS Sonoma 14 系统下修改用户名无法进入系统的历史Bug
    MySQL筑基篇之增删改查
    Mathtype问题汇总
    ffmpeg把RTSP流分段录制成MP4,如果能把ffmpeg.exe改成ffmpeg.dll用,那音视频开发的难度直接就降一个维度啊
    pnpm ERR_PNPM_ADDING_TO_ROOT
    VoLTE基础自学系列 | 什么是被叫域选择T-ADS?
    web前端期末大作业——用HTML+CSS做一个漂亮简单的电影主题网站
  • 原文地址:https://blog.csdn.net/chinusyan/article/details/128163187