《Rust语言圣经》Rust教程笔记13:2.Rust基础入门(2.4复合类型)2.4.4Rust枚举(枚举类型枚举值、不同枚举成员可拥有不同数据类型、同一化类型、Option<T>Some<T>)

2. Rust 基础入门

2.4. 复合类型

2.4.4. 枚举

枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:

enum PokerSuit {
    
    
  Clubs,
  Spades,
  Diamonds,
  Hearts,
}

如果在此之前你没有在其它语言中使用过枚举,那么可能需要花费一些时间来理解这些概念,一旦上手,就会发现枚举的强大,甚至对它爱不释手,枚举虽好,可不要滥用哦。

再回到之前创建的 PokerSuit,扑克总共有四种花色,而这里我们枚举出所有的可能值,这也正是 枚举 名称的由来。

任何一张扑克,它的花色肯定会落在四种花色中,而且也只会落在其中一个花色上,这种特性非常适合枚举的使用,因为枚举值只可能是其中某一个成员。抽象来看,四种花色尽管是不同的花色,但是它们都是扑克花色这个概念,因此当某个函数处理扑克花色时,可以把它们当作相同的类型进行传参。

细心的读者应该注意到,我们对之前的 枚举类型枚举值 进行了重点标注,这是因为对于新人来说容易混淆相应的概念,总而言之:
枚举类型是一个类型,它会包含所有可能的枚举成员,而枚举值是该类型中的具体某个成员的实例。

1. 枚举类型和枚举值
示例1:创建枚举类型成员

现在来创建 PokerSuit 枚举类型的两个成员实例:

let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;

我们通过 :: 操作符来访问 PokerSuit 下的具体成员,从代码可以清晰看出,heartdiamond 都是 PokerSuit 枚举类型的,接着可以定义一个函数来使用它们:

fn main() {
    
    
    let heart = PokerSuit::Hearts;
    let diamond = PokerSuit::Diamonds;

    print_suit(heart);
    print_suit(diamond);
}

fn print_suit(card: PokerSuit) {
    
    
    // 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug
    println!("{:?}",card);
}

总代码:

// 测试代码
#[derive(Debug)]
enum PokerSuit {
    
    
    Clubs,
    Spades,
    Diamonds,
    Hearts,
}

fn main() {
    
    
    let heart = PokerSuit::Hearts;
    let diamond = PokerSuit::Diamonds;

    print_suit(heart);
    print_suit(diamond);
}

fn print_suit(card: PokerSuit) {
    
    
    // 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug
    println!("{:?}", card);
}

在这里插入图片描述

print_suit 函数的参数类型是 PokerSuit,因此我们可以把 heartdiamond 传给它,虽然 heart 是基于 PokerSuit 下的 Hearts 成员实例化的,但是它是货真价实的 PokerSuit 枚举类型。

示例2:创建带值的枚举类型成员

接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A(1)-K(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。

目前来说,枚举值还不能带有值,因此先用结构体来实现:

enum PokerSuit {
    
    
    Clubs,
    Spades,
    Diamonds,
    Hearts,
}

struct PokerCard {
    
    
    suit: PokerSuit,
    value: u8
}

fn main() {
    
    
   let c1 = PokerCard {
    
    
       suit: PokerSuit::Clubs,
       value: 1,
   };
   let c2 = PokerCard {
    
    
       suit: PokerSuit::Diamonds,
       value: 12,
   };
}

这段代码很好的完成了它的使命,通过结构体 PokerCard 来代表一张牌,结构体的 suit 字段表示牌的花色,类型是 PokerSuit 枚举类型,value 字段代表扑克牌的数值。

可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:

// 测试代码
#[derive(Debug)]
#[allow(dead_code)] // 忽略编译器警告
enum PokerCard {
    
    
    Clubs(u8),
    Spades(u8),
    Diamonds(u8),
    Hearts(u8),
}

fn main() {
    
    
    let c1 = PokerCard::Spades(5);
    let c2 = PokerCard::Diamonds(13);
    println!("c1: {:?}", c1);
    println!("c2: {:?}", c2);
}

在这里插入图片描述

直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?

示例3:不同枚举成员可拥有不同数据类型

不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13 的字样,另外的花色打印上 A-K 的字样:

// 测试代码
#[derive(Debug)]
#[allow(dead_code)] // 忽略编译器警告
enum PokerCard {
    
    
    Clubs(u8),
    Spades(u8),
    Diamonds(char),
    Hearts(char),
}

fn main() {
    
    
    let c1 = PokerCard::Spades(5);
    let c2 = PokerCard::Diamonds('A');
    println!("c1: {:?}", c1);
    println!("c2: {:?}", c2);
}

在这里插入图片描述

回想一下,遇到这种不同类型的情况,再用我们之前的结构体实现方式,可行吗?也许可行,但是会复杂很多。

示例4:任何类型的数据都可以放入枚举成员中(比如特定的结构体类型)

再来看一个来自标准库中的例子:

struct Ipv4Addr {
    
    
    // --snip--
}

struct Ipv6Addr {
    
    
    // --snip--
}

enum IpAddr {
    
    
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

这个例子跟我们之前的扑克牌很像,只不过枚举成员包含的类型更复杂了,变成了结构体:分别通过 Ipv4AddrIpv6Addr 来定义两种不同的 IP 数据。

从这些例子可以看出,任何类型的数据都可以放入枚举成员中:例如字符串、数值、结构体甚至另一个枚举。

示例5:更加复杂的枚举类型

增加一些挑战?先看以下代码:

// 测试代码
#[derive(Debug)]
#[allow(dead_code)]
enum Message {
    
    
    Quit,
    Move {
    
     x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    
    
    let m1 = Message::Quit;
    let m2 = Message::Move {
    
     x: 1, y: 1 };
    let m3 = Message::Write(String::from("Hello Rust!"));
    let m4 = Message::ChangeColor(255, 255, 0);

    println!("m1: {:?}", m1);
    println!("m2: {:?}", m2);
    println!("m3: {:?}", m3);
    println!("m4: {:?}", m4);
}

在这里插入图片描述

该枚举类型代表一条消息,它包含四个不同的成员:

  • Quit 没有任何关联数据
  • Move 包含一个匿名结构体
  • Write 包含一个 String 字符串
  • ChangeColor 包含三个 i32

当然,我们也可以用结构体的方式来定义这些消息:

struct QuitMessage; // 单元结构体
struct MoveMessage {
    
    
    x: i32,
    y: i32,
}
struct WriteMessage(String); // 元组结构体
struct ChangeColorMessage(i32, i32, i32); // 元组结构体

由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。

而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。

2. 同一化类型(允许不同的数据类型共享相同的接口)

最后,再用一个实际项目中的简化片段,来结束枚举类型的语法学习。

例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStreamTlsStream,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:

fn new (stream: TcpStream) {
    
    
  let mut s = stream;
  if tls {
    
    
    s = negotiate_tls(stream)
  }

  // websocket是一个WebSocket<TcpStream>或者
  //   WebSocket<native_tls::TlsStream<TcpStream>>类型
  websocket = WebSocket::from_raw_socket(
    s, ......)
}

此时,枚举类型就能帮上大忙:

enum Websocket {
    
    
  Tcp(Websocket<TcpStream>),
  Tls(Websocket<native_tls::TlsStream<TcpStream>>),
}
解释

这里的同一化类型(或者说抽象化)的概念通过使用 Rust 的枚举类型来实现,允许不同的数据类型共享相同的接口。在你提供的示例中,Websocket 枚举用来统一处理不同的底层连接类型,无论它们是 TcpStream 还是经过 TLS 协商的 TlsStream。这种做法允许编写更为通用的代码,同时简化对不同类型的连接的处理。

解释枚举和实际用途

在 Rust 中,枚举(enum)不仅用于表示一组命名的常数,而且可以包含不同类型的数据。在这个案例中,Websocket 枚举有两个变体:

  1. Tcp(Websocket<TcpStream>) — 代表一个基于普通 TCP 连接的 WebSocket。
  2. Tls(Websocket<native_tls::TlsStream<TcpStream>>) — 代表一个基于通过 TLS 加密的 TCP 连接的 WebSocket。

通过这种方式,无论是普通的 TCP 连接还是加密的 TLS 连接,都被封装在同一个 Websocket 枚举下,从而可以在应用中通过一个统一的接口来处理。这样,你就可以写一个函数或方法,它接受一个 Websocket 枚举类型的参数,而无需关心具体的连接类型。

实际应用

在实际使用中,你可能会遇到需要根据不同条件处理不同类型连接的情况。例如,当需要处理加密和非加密数据传输时,通过这种方式可以有效地隐藏实现细节,使得上层的业务逻辑更加清晰和简洁。此外,通过枚举处理不同类型的连接,可以减少代码重复并增加代码的可维护性。

例如,你可能会有一个处理函数,它接受 Websocket 枚举类型的实例,并通过模式匹配来决定如何处理不同类型的连接:

fn handle_websocket(ws: Websocket) {
    
    
  match ws {
    
    
    Websocket::Tcp(socket) => handle_tcp_websocket(socket),
    Websocket::Tls(socket) => handle_tls_websocket(socket),
  }
}

这种模式匹配允许对枚举中的不同变体执行不同的操作,同时保持高层逻辑的统一性和清晰度。

一个更简单的代码示例

我们用一个更基础的例子来展示 Rust 中枚举如何实现同一化类型的概念。让我们考虑一个简单的动物园管理程序,其中包括猫和狗两种动物,虽然它们是不同的类型,但我们希望通过统一的方式来管理它们。

// 定义一个枚举来代表动物园中的动物
enum Animal {
    
    
    Dog(String),
    Cat(String),
}

// 为 Animal 枚举实现一个简单的行为:make_noise
impl Animal {
    
    
    fn make_noise(&self) -> String {
    
    
        match self {
    
    
            Animal::Dog(name) => format!("{} says Woof!", name),
            Animal::Cat(name) => format!("{} says Meow!", name),
        }
    }
}

// 示例函数,展示如何使用 Animal 枚举
fn main() {
    
    
    let dog = Animal::Dog(String::from("Buddy"));
    let cat = Animal::Cat(String::from("Whiskers"));

    println!("{}", dog.make_noise());
    println!("{}", cat.make_noise());
}

在这里插入图片描述

  • 代码解释
    1. 枚举定义Animal 枚举有两个变体,DogCat,每个变体都持有一个 String 类型的名字。

    2. 行为实现:我们为 Animal 枚举实现了 make_noise 方法,该方法使用模式匹配来确定动物的类型,并返回相应的叫声。这样,不同的动物可以发出各自特有的声音,但调用方式是统一的。

    3. 使用示例:在 main 函数中,我们创建了一个狗和一只猫的实例,并分别调用它们的 make_noise 方法来打印它们的叫声。

这个例子展示了如何用枚举封装不同类型的数据并通过统一的方法来操作它们,简单地体现了同一化类型的概念。

使用枚举相比面向对象的方法实现同一化类型有何优势?

在 Rust 中使用面向对象的方法与使用枚举来实现同一化类型确实都能达到类似的目的,但它们在实现细节和用途上有一些区别。理解这些差异可以帮助我们更好地选择适合特定情况的工具。

1)面向对象方法

在 Rust 中,虽然没有传统意义上的面向对象语言中的类(class)概念,但可以通过结构体(struct)和特征(trait)来实现面向对象的编程模式。使用特征可以定义一组方法,这些方法可以被不同的结构体实现,类似于其他语言中的接口。

// 测试代码
// #[derive(Debug)]
// #[allow(dead_code)]
trait Animal {
    
    
    fn make_noise(&self) -> String;
}

struct Dog {
    
    
    name: String,
}

struct Cat {
    
    
    name: String,
}

impl Animal for Dog {
    
    
    fn make_noise(&self) -> String {
    
    
        format!("{} says Woof!", self.name)
    }
}

impl Animal for Cat {
    
    
    fn make_noise(&self) -> String {
    
    
        format!("{} says Meow!", self.name)
    }
}

fn print_noise(animal: &dyn Animal) {
    
    
    println!("{}", animal.make_noise());
}

fn main() {
    
    
    let dog = Dog {
    
    
        name: String::from("Buddy"),
    };
    let cat = Cat {
    
    
        name: String::from("Whiskers"),
    };

    print_noise(&dog);
    print_noise(&cat);
}

在这里插入图片描述

2)使用枚举的方法

使用枚举的方法可以直接在枚举中定义数据和行为,使得相关的变体和行为紧密结合在一起。

enum Animal {
    
    
    Dog(String),
    Cat(String),
}

impl Animal {
    
    
    fn make_noise(&self) -> String {
    
    
        match self {
    
    
            Animal::Dog(name) => format!("{} says Woof!", name),
            Animal::Cat(name) => format!("{} says Meow!", name),
        }
    }
}

fn main() {
    
    
    let dog = Animal::Dog(String::from("Buddy"));
    let cat = Animal::Cat(String::from("Whiskers"));

    println!("{}", dog.make_noise());
    println!("{}", cat.make_noise());
}
3)区别
  1. 类型封装:枚举将所有可能的变体封装在一个类型中,适合于变体数量有限且已知的情况。面向对象方法中的特征允许未来添加更多实现该特征的类型,适合于需要扩展性和灵活性的场景。

  2. 内存布局:枚举在内存中占用的空间是其所有变体中最大者的大小加上必要的标记信息,而特征对象通常是通过指针(如 Box<dyn Animal>)来处理,涉及动态分配和可能的运行时开销。

  3. 类型安全与匹配:枚举可以利用 Rust 强大的模式匹配功能,编译器可以确保所有情况都被处理,这提供了额外的类型安全。面向对象方法中,调用是通过动态分派(dynamic dispatch),这可能带来性能上的轻微影响。

4)结论

选择使用枚举还是面向对象的方法,取决于你的具体需求:

  • 如果变体数量有限且固定,使用枚举可以更简单且类型安全。
  • 如果预期将来需要添加更多类型或强调扩展性,使用特征可能更合适。
3. Option 枚举用于处理空值(Some(T), None)

在其它编程语言中,往往都有一个 null 关键字,该关键字用于表明一个变量当前的值为空(不是零值,例如整型的零值是 0),也就是不存在值。当你对这些 null 进行操作时,例如调用一个方法,就会直接抛出 null 异常,导致程序的崩溃,因此我们在编程时需要格外的小心去处理这些 null 空值。

Tony Hoare, null 的发明者,曾经说过一段非常有名的话:

我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。

尽管如此,空值的表达依然非常有意义,因为空值表示当前时刻变量的值是缺失的。有鉴于此,Rust 吸取了众多教训,决定抛弃 null,而改为使用 Option 枚举变量来表述这种结果。

Option 枚举包含两个成员,一个成员表示含有值:Some(T), 另一个表示没有值:None,定义如下:

enum Option<T> {
    
    
    Some(T),
    None,
}

其中 T 是泛型参数,Some(T)表示该枚举成员的数据类型是 T,换句话说,Some 可以包含任何类型的数据。

Option<T> 枚举是如此有用以至于它被包含在了 prelude(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,你不需要将其显式引入作用域。另外,它的成员 SomeNone 也是如此,无需使用 Option:: 前缀就可直接使用 SomeNone。总之,不能因为 Some(T)None 中没有 Option:: 的身影,就否认它们是 Option 下的卧龙凤雏。

再来看以下代码:

// 测试代码
// #[derive(Debug)]
// #[allow(dead_code)]

fn main() {
    
    
    let some_number = Some(5);
    let some_string = Some("a string");

    let absent_number: Option<i32> = None;
    // let absent_number = None;//报错,因为无法推断出类型
}

如果使用 None 而不是 Some,需要告诉 Rust Option<T> 是什么类型的,因为编译器只通过 None 值无法推断出 Some 成员保存的值的类型。

Option<T> 为什么比空值要好?(能够限制空值的泛滥以增加 Rust 代码的安全性)

当有一个 Some 值时,我们就知道存在一个值,而这个值保存在 Some 中。当有个 None 值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T> 为什么就比空值要好呢?

简而言之,因为 Option<T>T(这里 T 可以是任何类型)是不同的类型,例如,这段代码不能编译,因为它尝试将 Option<i8>(Option<T>) 与 i8(T) 相加:

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

如果运行这些代码,将得到类似这样的错误信息:

error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
 -->
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + std::option::Option<i8>`
  |

很好!事实上,错误信息意味着 Rust 不知道该如何将 Option<i8>i8 相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值,我们可以放心使用而无需做空值检查。只有当使用 Option<i8>(或者任何用到的类型)的时候才需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。

换句话说,在对 Option<T> 进行 T 的运算之前必须将其转换为 T。通常这能帮助我们捕获到空值最常见的问题之一:期望某值不为空但实际上为空的情况。

不再担心会错误的使用一个空值,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T> 中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T> 类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性

当值类型为Option<T>时,如何从 Some 成员中取出 T 的值使用?(match表达式)

那么当有一个 Option<T> 的值时,如何从 Some 成员中取出 T 的值来使用它呢?Option<T> 枚举拥有大量用于各种情况的方法:你可以查看它的文档。熟悉 Option<T> 的方法将对你的 Rust 之旅非常有用。

总的来说,为了使用 Option<T> 值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T) 值时运行,允许这些代码使用其中的 T。也希望一些代码在值为 None 时运行,这些代码并没有一个可用的 T 值。match 表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。

这里先简单看一下 match 的大致模样,在模式匹配中,我们会详细讲解:

// 测试代码
// #[derive(Debug)]
// #[allow(dead_code)]
fn plus_one(x: Option<i32>) -> Option<i32> {
    
    
    match x {
    
    
        None => None,
        Some(i) => Some(i + 1),
    }
}
fn main() {
    
    
    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
    println!("six: {:?}", six);
    println!("none: {:?}", none);
}

在这里插入图片描述

plus_one 通过 match 来处理不同 Option 的情况。

猜你喜欢

转载自blog.csdn.net/Dontla/article/details/143316563