Rust(I): Type

Type in Rust

5,577 words 34 min read
目录 27 节
  1. 基本数据类型
  2. 固定宽度的数值类型
  3. 布尔类型
  4. 字符
  5. 元组
  6. 指针类型
  7. 数组、向量和切片
  8. 字符串类型
  9. 结构体
  10. 具名字段结构体
  11. 元组型结构体
  12. 单元型结构体
  13. impl 定义方法
  14. 关联常量
  15. 泛型结构体
  16. 常量泛型
  17. 枚举
  18. 特型与泛型
  19. 使用特型
  20. 定义特型
  21. 两种多态:特型对象与泛型
  22. 限界决定函数体里能做什么
  23. Self、子特型与关联函数
  24. 方法冲突与完全限定调用
  25. 关联类型:一个实现对应一种结果
  26. 使用 impl Trait 隐藏具体类型
  27. 从实现反推限界

在很程度上,Rust语言就是围绕其类型来设计的。Rust有很健全的类型系统,选取合适的类型表示可以获得高性能的代码、 内存和线程安全、灵活性。

基本数据类型

固定宽度的数值类型

Rust 类型系统的根基是一组固定宽度的数值类型,这些类型匹配几乎所有现代处理器已在硬件中实现的类型。

大小(位)无符号整数有符号整数浮点数
8u8i8
16u16i16
32u32i32f32
64u64i64f64
128u128i128
机器字usizeisize

机器字的大小与目标机器上地址空间的大小保持一致,可能是32位,也可能是64位。

Rust中的整型字面量可以带上一个后缀来指示他们的类型:47u8、23.2_f32、29isize。如果整形字面量没有带类型后缀,那么Rust就会延迟确定其类型, 直到找出一处足以认定其类型的使用代码。最后如果有多种候选类型,Rust会默认使用i32(前提是候选类型之一)。如果无法确定类型,Rust 会将此歧义报告为错误。

前缀0x、0o、0b分别表示十六进制字面量、八进制字面量、二进制字面量。

为了让常数值更易读,可以在数字间插入任意下划线。

Rust 会使用u8作为字节值。例如,从二进制文件或套接字中读取数据时会产生一个u8值构成的流。 与C、C++不同,Rust会把字符视为与数值截然不同的类型:char既不是u8,也不是u32(尽管它确实有32 位长)。尽管如此,Rust确实为u8值提供了字节字面量。字面量b’A’与65u8完全等效。只有ASCII字符 才能出现在字节字面量中。

注:Rust在进行整型运算时可能产生溢出,有几种计算方式checked_、wrapping_、saturating_、overflowing_; 分别表示检查运算、回绕运算、饱和运算、溢出运算。

f32、f64类型具有IEEE要求的一些特殊值的关联常量,比如INFINITY(无穷大)、NEG_INFINITY(负无穷大)、NAN(非数值)、 MIN(最小有限值)、MAX(最大有限值)。

与C、C++不同,Rust几乎不会执行任何隐式的数值转换,需要显示使用 as 运算符:i as f64

布尔类型

Rust 的as运算符可以将bool类型转为整型,但无法进行另一个方向的转换。 尽管bool只需要用一个位来表示,但Rust在内存中会使用整字节来表示,因此可以创建指向它的指针。

字符

Rust 的字符类型 char 会以32位值表示单个Unicode字符。Rust会对单独的字符串使用 char 类型, 但对字符串和文本流使用 UTF-8 编码。 String 会将其文本标识为 UTF-8 字节序列,而不是字符数组。

除了直接字面量表示外,还可以直接使用unicode码点表示:‘\u{1f600}’

let x = '\u{1f600}';
println!("{x}");    // 😀
println!("{}", x.escape_unicode()); // \u{1f600}

char 总是包含 0x0000 到 0xD7FF 或 0xE000 到 0x10FFFF 范围内的 Unicode码点。 char永远不会是“半代用区”中的码点(0xD800 ~ 0xDFFF范围内的码点,它们不能单独使用)或 Unicode码点空间之外的值(大于0x10FFFF的值)。Rust 使用类型系统和动态检查确保char值 始终在允许的范围内。

可以使用 as 转换运算符将 char 转换为整型,对于小于32位的类型,该字符值的高位会被截断。 从另一个方向看,u8是唯一能通过 as 运算符转换为 char 的类型,因为Rust刻意让 as 运算符 只执行开销低且可靠的转换,除了u8之外的全部整型都可能包含Unicode码点之外的值,这些转换都要 做运行期检查。

替代方案是标准库函数 std::char::from_u32: u32 -> Option

元组

元组可以是各种类型的多值对(n-元组)。 元组和数组都表示值的有序序列,但元组的每个元素可以是不同的类型,并且元组只允许用常量作为索引, 例如t.0,不能写成t.i、t[i]。

Rust 通常使用元组类型从一个函数返回多个值。 fn split_at(&self, mid: usize) -> (&str, &str)

还可以将元组当作一种超级小巧的结构体类型使用。 fn write(file_name: &str, pixels: &[u8], bounds: (usize, usize)) -> Result<(), std::io::Error>

还有一种常用的元组类型是零元组(),这叫 Unit Type。此类型也只有一个值就是()。例如不返回值的 函数的返回类型为()。

可以在元组的最后一个元素后加上一个逗号:(&str, i32,) 和 (&str, i32) 是等效的。 Rust 始终 允许在所有能使用逗号的地方(函数参数、数组、结构体、枚举定义等等)添加额外的尾随逗号。

在单值元组里值后面的逗号是必须的,以区分单值元组和简单的括号表达式。(&str,)

指针类型

使用指针类型时,安全Rust会对其进行约束,消除未定义行为。指针在Rust中比在C++中更容易正确使用。

引用

&T类型的值是对T类型值的引用。最简单的方式是将引用视为Rust中的基本指针类型。在运行期间,对i32的引用 是一个保存着i32值的地址的机器字,这个地址可能位于栈或者堆中。&x会生成一个对x的引用,在Rust术语中, 我们会说它借用了对x的引用。给定一个引用r,*r会引用r指向的值。

然而与C指针不同,Rust的引用永远不会为空:在安全的Rust中根本没有办法生成空引用。同时Rust会跟踪值的 所有权和生命周期,因此早在编译期就排除了悬空指针、双重释放和指针失效等错误。

Rust引用有两种形式: &T:一个不可变的共享引用。可以同时拥有多个对给定值的共享引用,但它们是只读的:禁止修改它们所指向的值, 就像C中的const T*一样。

&mut T:一个可变、独占的引用。你可以读取和修改它指向的值,就像C中的T*一样。但是只要该引用还在,就 不能对该值有任何类型的其他引用。

Box

在堆中分配值最简单的方式是使用Box::new(),当值超出作用域时,内存会立即被释放,除非已经被移动。

裸指针

Rust 也有裸指针类型 *mut T 和 *const T。裸指针时间实际上和C++中的指针很像。使用裸指针是不安全的, 因为Rust不会跟踪它指向的内容。C++所有的经典指针错误都可能会出现。

对于裸指针的解引用只能在unsafe块中操作。如果没有unsafe块,Rust强调的安全保证就仍然有效。

数组、向量和切片

Rust 用 3 种类型来表示内存中的值序列。

  1. 类型 [T; N] 表示 N 个值的数组,每个值的类型为 T。数组的大小是在编译期就已确定的常量,并且是类型的一部分,不能追加新元素或缩小数组。
  2. 类型 Vec 可称为 T 的向量,它是一个动态分配且可增长的 T 类型的值序列。向量的元素存在于堆中,因此可以随意调整向量的大小:压入新元素、追加其他向量、删除元素等。
  3. 类型 &[T] 和 &mut [T] 可称为 T 的共享切片和 T 的可变切片,它们是对一系列元素的引用,这些元素是某个其他值(比如数组或向量)的一部分。可以将切片视为指向其第一个元素的指针,以及从该点开始允许访问的元素数量的计数。可变切片 &mut [T] 允许读取元素和修改元素,但不能共享;共享切片 &[T] 允许在多个读取者之间共享访问权限,但不允许修改元素。

数组

数组的长度是数组类型的一部分,并会在编译期确定下来。

let arr: [u32; 6] = [0, 1, 2, 3, 4, 5];

let buffer = [0u8; 1024]; // 1kb缓冲区,用0填充

Rust没有任何能定义未初始化数组的写法。(一般来说,Rust会确保代码永远无法访问任何种类的未初始化值。)

数组上的使用方法都是作为切片而非数组的方法提供的,Rust会隐式地将对数组的引用转换为切片,因此可以在数组上 调用任何切片方法。

let mut chaos = [3, 5, 4, 1, 2];
chaos.sort();
// [1, 2, 3, 4, 5]

向量

向量 Vec<T> 是一个可调整大小的T类型元素的数组,它是在堆上分配的。

// vec![]
let mut primes = [2, 3, 5, 7];
primes.iter().product::<i32>(); // 210

// Vec::new()
let mut pal = Vec::new();
pal.push("step");
pal.push("on");

// iter
let mut v: Vec<i32> = (0..5).collect(); // [0, 1, 2, 3, 4]
v.pop(); // Some(4)
for e in v {
   println!("{e}");
}

Vec 由 3 个值组成:

  1. 指向元素在堆中分配的缓冲区(该缓冲区由 Vec 创建并拥有)的指针
  2. 缓冲区能够存储的元素数量,
  3. 现在实际包含的数量(也就是它的长度)。 当缓冲区达到其最大容量时,往向量中添加另一个元素需要分配一个更大的缓冲区,将当前内容复制到其中, 更新向量的指针和容量以指向新缓冲区,最后释放旧缓冲区。

如果事先知道向量所需的元素数量,就可以调用 Vec::with_capacity 而不是 Vec::new 来创建一个向量, 它的缓冲区足够大,可以从一开始就容纳所有元素。然后,可以逐个将元素添加到此向量中,而不会导致任何重新分配。 vec! 宏就使用了这样的技巧,因为它知道最终向量将包含多少个元素。请注意,这只会确定向量的初始大小, 如果大小超出了你的预估,则向量仍然会正常扩大其存储空间。

切片

切片(写作不指定长度的 [T])是数组或向量中的一个区域。由于切片可以是任意长度,因此它不能直接存储在变量中或作为函数参数进行传递。 切片总是通过引用传递。

对切片的引用是一个胖指针:一个双字值,包括指向切片第一个元素的指针和切片中元素的数量。

普通引用是指向单个值的非拥有型指针,而对切片的引用是指向内存中一系列连续值的非拥有型指针。

使用范围值对数组或向量进行索引,以获取一个切片的引用,该引用既可以指向数组或向量,也可以指向一个既有切片:

let v = vec![0, 1, 2];
&v[0..];
&v[0..2];
&v[..1];

字符串类型

字符串字面量

字符串字面量要用双引号括起来,它们使用与 char 字面量相同的反斜杠转译序列:

let speech = "\"Ouch!\" said the well.\n";

但与 char 字面量不同,在字符串字面量中单引号不需要用反斜杠转义,而双引号需要。

一个字符串可能跨越多行:

println!("In the room the women come and go,
Singing of Mount Abora");

该字符串字面量中的换行符是字符串的一部分,因此也会包含在输出中。第 2 行开头的空格也是如此。

如果字符串的一行以反斜杠结尾,那么就会丢弃其后的换行符和前导空格:

println!("It was a bright, cold day in April, and \
there were four of us—\
more or less.");

原始字符串用小写字母 r 进行标记。原始字符串中的所有反斜杠和空白字符都会逐字包含在字符串中。原始字符串不识别任何转义序列:

let default_win_install_path = r"C:\Program Files\Gorillas";
let pattern = Regex::new(r"\d+(\.\d+)*");

不能简单地在双引号前面放置一个反斜杠来包含原始字符串——别忘了,前面说过它不识别转义序列。但是,仍有办法解决。 可以在原始字符串的开头和结尾添加 # 标记:

println!(r###"
This raw string started with 'r###"'.
Therefore it does not end until we reach a quote mark ('"')
followed immediately by three pound signs ('###'):
"###);

可以根据需要添加任意多个井号,以标明原始字符串的结束位置。 本质上是在自定义原始字符串的结束分隔符,使字符串内部能够包含双引号以及较短的 ”# 组合。

字节串

带有 b 前缀的字符串字面量都是字节串。这样的字节串是 u8 值(字节)的切片而不是 Unicode 文本:

let method = b"GET";
assert_eq!(method, &[b'G', b'E', b'T']);

method 的类型是 &[u8; 3]:它是对 3 字节数组的引用,没有刚刚讨论过的任何字符串方法,最像字符串的地方就是其书写语法,仅此而已。 字节串不能包含任意 Unicode 字符,它们只能使用 ASCII 和 \xHH 转义序列。

内存中的字符串

Rust 字符串是 Unicode 字符序列,但它们并没有以 char 数组的形式存储在内存中,而是使用了 UTF-8(一种可变宽度编码)的形式。 字符串中的每个 ASCII 字符都会存储在单字节中,而其他字符会占用多字节。

let noodles = "noodles".to_string();
let oodles = &noodles[1..];
let poodles = "ಠ_ಠ";

img.png

可以将 String 视为 Vec,它可以保证包含格式良好的 UTF-8,实际上,String 就是这样实现的。 &str 是对别人拥有的一系列 UTF-8 文本的引用,即它“借用”了这个文本。与其他切片引用一样,&str 也是一个胖指针,包含实际数据的地址及其长度。可以认为 &str 就是 &[u8],但它能保证包含的是格式良好的 UTF-8。 字符串字面量是指预分配文本的 &str,它通常与程序的机器码一起存储在只读内存区,它在程序开始执行时就已创建并一直存续到程序退出。

其他类似字符串的类型

Rust 保证字符串是有效的 UTF-8。有时程序确实需要处理并非有效 Unicode 的字符串。 这种情况通常发生在 Rust 程序不得不与不强制执行此类规则的其他系统进行互操作时, 例如,在大多数操作系统中,很容易创建一个名字不符合 Unicode 规则的文件。

Rust 的解决方案是为这些情况提供一些类似字符串的类型。

  • 对于 Unicode 文本,坚持使用 String 和 &str。
  • 当使用文件名时,请改用 std::path::PathBuf 和 &Path。
  • 当处理根本不是 UTF-8 编码的二进制数据时,请使用 Vec 和 &[u8]。
  • 当使用操作系统提供的原生形式的环境变量名和命令行参数时,请使用 OsString 和 &OsStr。
  • 当和使用 null 结尾字符串的 C 语言库进行互操作时请使用 std::ffi::CString 和 &CStr。

结构体

Rust 中的结构体(struct/structure)类似于 C 和 C++ 中的 struct 类型、Python 中的类和 JavaScript 中的对象。 结构体会将多个不同类型的值组合成一个单一的值,以便你能把它们作为一个单元来处理。给定一个结构体,你可以读取和修改它的各个组件。 结构体也可以具有关联的方法,以对其组件进行操作。

Rust 有 3 种结构体类型:具名字段型结构体、元组型结构体和单元型结构体。

具名字段结构体

// 由8位灰度像素组成的矩形
struct GrayscaleMap {
   pixels: Vec<u8>,
   size: (usize, uzise)
}

它声明了一个 GrayscaleMap 类型,其中包含两个给定类型的字段,分别名为 pixels 和 size。 Rust 中的约定是,所有类型(包括结构体)的名称都将每个单词的第一个字母大写(如 GrayscaleMap),这称为大驼峰格式(CamelCase 或 PascalCase)。 字段和方法是小写的,单词之间用下划线分隔,这称为蛇形格式(snake_case)。

可以使用结构体表达式构造出此类型的值:

let width = 1024;
let height = 576;
let size = (width, height);

let image = GrayscaleMap {
   pixels: vec![0; width * height],
   size
};

你可以对某些字段使用 key: value 语法,而对同一结构体表达式中的其他字段使用简写语法。

创建具名字段结构体的值时,可以使用另一个相同类型的结构体为省略的那些字段提供值。在结构体表达式中,如果具名字段后面跟着 .. EXPR, 则任何未提及的字段都会从 EXPR(必须是相同结构体类型的另一个值)中获取它们的值。

// 在这个游戏中,怪物是一些扫帚。你会看到:
struct Broom {
    name: String,
    height: u32,
    health: u32,
    position: (f32, f32, f32),
    intent: BroomIntent
}

/// `Broom`可以支持的两种用途
#[derive(Copy, Clone)]
enum BroomIntent { FetchWater, DumpWater }


// 按值接收输入的Broom(扫帚),并获得所有权
fn chop(b: Broom) -> (Broom, Broom) {
    // 主要从`b`初始化`broom1`,只修改`height`。由于`String`
    // 不是`Copy`类型,因此`broom1`获得了`b`中`name`的所有权
    let mut broom1 = Broom { height: b.height / 2, .. b };

    // 主要从`broom1`初始化`broom2`。由于`String`不是`Copy`类型,
    // 因此我们显式克隆了`name`
    let mut broom2 = Broom { name: broom1.name.clone(), .. broom1 };

    // 为每一半扫帚分别起一个名字
    broom1.name.push_str(" I");
    broom2.name.push_str(" II");

    (broom1, broom2)
}

元组型结构体

构造此类型的值与构造元组非常相似,只是必须包含结构体名称:

struct Bounds(usize, usize);

let image_bounds = Bounds(1024, 768);

元组型结构体的单个元素可以是公共的,也可以不是:

pub struct Bounds(pub usize, pub usize);

元组型结构体适用于创造新类型(newtype),即建立一个只包含单组件的结构体,以获得更严格的类型检查。 如果你正在使用纯 ASCII 文本,那么可以这样定义一个新类型:

struct Ascii(Vec<u8>);

将此类型用于 ASCII 字符串比简单地传递 Vec 缓冲区并在注释中解释它们的内容要好得多。 在将其他类型的字节缓冲区传给需要 ASCII 文本的函数时,这种新类型能帮 Rust 捕获错误。

单元型结构体

这种类型的值不占用内存,很像单元类型 ()。Rust 既不会在内存中实际存储单元型结构体的值,也不会生成代码来对它们进行操作, 因为仅通过值的类型它就能知道关于值的所有信息。但从逻辑上讲,空结构体是一种可以像其他任何类型一样有值的类型。 或者更准确地说,空结构体是一种只有一个值的类型。

struct Onesuch;

let o = Onesuch;

impl 定义方法

Rust 方法不会像 C++ 或 Java 中的方法那样出现在结构体定义中,而是会出现在单独的 impl 块中。 impl 块只是 fn 定义的集合,每个定义都会成为块顶部命名的结构体类型上的一个方法。 在 impl 块中定义的函数称为 ** 关联函数 ** ,因为它们是与特定类型相关联的。 与关联函数相对的是自由函数,它是未定义在 impl 块中的语法项。

use std::collections::VecDeque;

#[derive(Debug, Default)]
pub struct Queue {
    jobs: VecDeque<String>,
}

impl Queue {
    pub fn push(&mut self, job_id: String) {
        self.jobs.push_back(job_id);
    }

    pub fn pop(&mut self) -> Option<String> {
        self.jobs.pop_front()
    }

    pub fn peek(&self) -> Option<&str> {
        self.jobs.front().map(String::as_str)
    }

    pub fn len(&self) -> usize {
        self.jobs.len()
    }

    pub fn is_empty(&self) -> bool {
        self.jobs.is_empty()
    }
}

Rust 会将调用关联函数的结构体值作为第一个参数传给方法,该参数必须具有特殊名称 self。由于 self 的类型显然就是在 impl 块顶部命名的类型或对该类型的引用, 因此 Rust 允许你省略类型,并以 self、&self 或 &mut self 作为 self: Queue、self: &Queue 或 self: &mut Queue 的简写形式。 如果你愿意,也可以使用完整形式,但如前所述,几乎所有 Rust 代码都会使用简写形式。

给定类型的 impl 块还可以定义根本不以 self 为参数的函数。这些函数仍然是关联函数,因为它们在 impl 块中,但它们不是方法,因为它们不接受 self 参数。 为了将它们与方法区分开来,我们称其为类型关联函数。它们通常用于提供构造函数:

impl Queue {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_capacity(capacity: usize) -> Self {
        Self {
            jobs: VecDeque::with_capacity(capacity),
        }
    }
}

“在 Rust 中,构造函数通常按惯例命名为 new,例如 Vec::new、Box::new、HashMap::new 等。但是 new 这个名字并没有什么特别之处,它不是关键字。 类型通常还有其他关联函数作为构造函数,比如 Vec::with_capacity。

关联常量

Rust 在其类型系统中的另一个特性也采用了类似于 C# 和 Java 的思想,有些值是与类型而不是该类型的特定实例关联起来的。在 Rust 中,这些叫作关联常量。

pub struct Vector2 {
    x: f32,
    y: f32,
}

impl Vector2 {
    const ZERO: Vector2 = Vector2 { x: 0.0, y: 0.0 };
    const UNIT: Vector2 = Vector2 { x: 1.0, y: 0.0 };
    const NAME: &'static str = "Vector2";
    const ID: u32 = 18;
}

泛型结构体

Rust 结构体可以是泛型的,这意味着它们的定义是一个模板,你可以在其中插入任何自己喜欢的类型。 在泛型结构体定义中,尖括号(<>)中的类型名称叫作类型参数。

use std::{collections::VecDeque, num::NonZeroUsize};

#[derive(Debug, PartialEq, Eq)]
pub enum PushError<T> {
    Full(T),
}

#[derive(Debug)]
pub struct BoundedQueue<T> {
    items: VecDeque<T>,
    capacity: NonZeroUsize,
}

impl<T> BoundedQueue<T> {
    pub fn new(capacity: NonZeroUsize) -> Self {
        Self {
            items: VecDeque::with_capacity(capacity.get()),
            capacity,
        }
    }

    pub fn try_push(&mut self, item: T) -> Result<(), PushError<T>> {
        if self.items.len() >= self.capacity.get() {
            return Err(PushError::Full(item));
        }

        self.items.push_back(item);
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        self.items.pop_front()
    }

    pub fn peek(&self) -> Option<&T> {
        self.items.front()
    }

    pub fn len(&self) -> usize {
        self.items.len()
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    pub fn capacity(&self) -> NonZeroUsize {
        self.capacity
    }
}

下面的 impl 块标头表明“这里有一些专门用于 BoundedQueue 的关联函数”。这为 BoundedQueue 提供了一个 sum 方法,不过该方法在其他类型的 Queue 上不可用。

impl BoundedQueue<f64> {
   fn sum(&self) -> f64 {
      ...
   }
}

常量泛型

Rust 引入常量泛型,使数组长度、矩阵维度、容量等编译期常量也能参与泛型编程,从而把原本只能在运行时检查的数值约束变成编译期类型约束。 例如,你可以定义一个表示任意次数多项式的类型,如下所示:

/// N - 1次多项式
struct Polynomial<const N: usize> {
    /// 多项式的系数
    ///
    /// 对于多项式a + bx + cx2 + ... + zxn-1,其第`i`个元素是xi的系数
    coefficients: [f64; N]
}

也可以在类型的关联函数中使用参数N:

impl<const N: usize> Polynomial<N> {
    fn new(coefficients: [f64; N]) -> Polynomial<N> {
        Polynomial { coefficients }
    }

    /// 计算`x`处的多项式的值
    fn eval(&self, x: f64) -> f64 {
        // 秦九韶算法在数值计算上稳定、高效且简单:
        // c0 + x(c1 + x(c2 + x(c3 + ... x(c[n-1] + x c[n]))))
        let mut sum = 0.0;
        for i in (0..N).rev() {
            sum = self.coefficients[i] + x * sum;
        }

        sum
    }
}

常量泛型参数可以是任意整数类型、char 或 bool。不允许使用浮点数、枚举和其他类型。 如果结构体还接受其他种类的泛型参数,则生命周期参数必须排在第一位,然后是类型,接下来是任何 const 值。例如,一个包含引用数组的类型可以这样声明:

struct LumpOfReferences<'a, T, const N: usize> {
   the_lump: [&'a T; N]
}

枚举

Rust 枚举可以包含数据,甚至是不同类型的数据,更像是 C 的联合体,但不同之处在于它是类型安全的。 使用枚举的“代价”是你必须通过模式匹配安全地访问数据,模式中还可以包含引用、移动和 if 条件。。 Rust 模式有点儿像针对所有数据的正则表达式。它们用于测试一个值是否具有特定的目标形态,可以一次从结构体或元组中把多个字段提取到局部变量中。

与结构体一样,编译器能为你实现 == 运算符等特性,但你必须明确提出要求:

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum TimeUnit {
    Seconds, Minutes, Hours, Days, Months, Years,
}

Rust 有 3 种枚举变体,没有数据的变体对应于单元型结构体。元组型变体的外观和功能很像元组型结构体。结构体型变体具有花括号和具名字段。

enum RelationshipStatus {
    Single,
    InARelationship,
    ItsComplicated(Option<String>),
    ItsExtremelyComplicated {
        car: DifferentialEquation,
        cdr: EarlyModernistPoem,
    },
}

枚举可以是泛型的。Rust 标准库中的两个例子是该语言中最常用的数据类型:

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

enum Result<T, E> {
    Ok(T),
    Err(E),
}

特型与泛型

初次接触 trait 时,可以先把它理解为类似 Java interface 的接口:定义一组方法,再由具体类型实现。不过 trait 在 Rust 中承担的职责更多,泛型限界、迭代器和运算符重载都建立在它之上。

Write 为例,文件、TCP 连接和内存缓冲区是不同的数据目标,但它们都实现了 Write,因此可以交给同一段输出代码处理。

使用特型

std::io::Write 表示“能够接收字节”,所以 FileTcpStreamVec<u8> 虽然存储位置和行为完全不同,却能被同一段输出逻辑使用。 类似地,Iterator 表示能够持续产生值,Clone 表示能够显式复制自身,Debug 表示能够生成面向开发者的调试表示。

给一个类型实现特型不会改变这个类型的内存布局,也不会平白多出一次动态派发。像 buffer.write_all(...) 这样的普通调用,编译器知道 buffer 的具体类型,照样可以内联。

需要注意的是,特型只有进入作用域后,它提供的方法才会参与方法查找。Vec<u8> 虽然实现了 Write,调用 write_all 前仍然需要导入该特型:

use std::io::{self, Write};

fn encode_status() -> io::Result<Vec<u8>> {
    let mut encoded = Vec::new();
    encoded.write_all(b"status=ready\n")?;
    Ok(encoded)
}

这种导入机制也为“扩展特型”提供了命名空间上的隔离。第三方 crate 可以为现有类型增加方法,而使用者通过 use 明确启用这些方法;如果出现同名方法,可以用完全限定语法指定具体实现。

定义特型

定义特型时,通常只保留少数必须由实现者完成的基本操作,其余行为可以提供默认实现。下面的事件存储只要求实现单条写入,批量写入则复用这个基本操作:

use std::io;

trait EventStore {
    fn append(&mut self, event: &[u8]) -> io::Result<()>;

    fn append_batch(&mut self, events: &[Vec<u8>]) -> io::Result<()> {
        for event in events {
            self.append(event)?;
        }
        Ok(())
    }
}

一般的实现可以直接沿用默认版本。如果数据库支持事务批量写入,或者远程服务提供批量接口,也可以覆盖 append_batchIterator 采用了相同的设计:实现者主要提供 nextmapfilterenumerate 等组合方法由 trait 提供。

trait 的 impl 块只能包含该 trait 声明的关联项。只服务于当前类型的辅助方法,需要放在单独的 impl Type 块中。这样可以直接区分 trait 要求的行为和具体类型的内部实现。

实现 trait 还要遵守孤儿规则:trait 和类型至少有一个必须定义在当前 crate。可以为自定义类型实现标准 trait,也可以为标准类型实现自定义 trait;但不能直接为 u8 实现标准库的 Write。这避免了不同 crate 为同一组 Trait + Type 提供互相冲突的实现。

扩展 trait 经常会配合毯式实现,一次覆盖一整类类型:

use std::io::{self, Write};

trait WriteTsvExt: Write {
    fn write_tsv_row(&mut self, fields: &[&str]) -> io::Result<()> {
        if fields.iter().any(|field| {
            field.bytes().any(|byte| matches!(byte, b'\t' | b'\n' | b'\r'))
        }) {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "TSV field contains a separator",
            ));
        }

        for (index, field) in fields.iter().enumerate() {
            if index > 0 {
                self.write_all(b"\t")?;
            }
            self.write_all(field.as_bytes())?;
        }
        self.write_all(b"\n")
    }
}

impl<W: Write + ?Sized> WriteTsvExt for W {}

这段实现会为 FileTcpStreamVec<u8> 提供 write_tsv_row。其中 ?Sized 用于放宽泛型参数默认的 Sized 限界,使该实现也能覆盖 dyn Write

两种多态:特型对象与泛型

假设监控程序要导出接口调用量。正式环境写文件或网络连接,测试时写进 Vec<u8>,输出逻辑本身并不需要知道是哪一种:

use std::io::{self, Write};

struct Usage<'a> {
    route: &'a str,
    requests: u64,
}

fn write_usage_report(
    out: &mut dyn Write,
    rows: &[Usage<'_>],
) -> io::Result<()> {
    writeln!(out, "route\trequests")?;
    for row in rows {
        writeln!(out, "{}\t{}", row.route, row.requests)?;
    }
    out.flush()
}

&mut dyn Write 是一个特型对象。编译器只知道 out 实现了 Write,具体类型要到运行期才能确定。这个引用是一个胖指针,其中包含数据地址和虚表地址;调用 writeflush 等方法时,通过虚表找到对应实现。Box<dyn Write>Rc<dyn Trait> 采用相同的方式保存类型信息。

dyn Write 本身没有编译期可知的大小,因此不能直接声明为局部变量,必须通过指针使用。常见形式包括 &dyn Trait&mut dyn TraitBox<dyn Trait>

一个特型对象只能包含一个提供方法的主 trait,但可以附加 SendSync 等自动 trait,例如 Box<dyn Write + Send>。若需要同时组合 ReadWrite,可以定义一个子特型:

use std::io::{Read, Write};

trait ReadWrite: Read + Write {}

impl<T: Read + Write + ?Sized> ReadWrite for T {}

之后就可以用 &mut dyn ReadWrite 表示双工流。

同一个函数也可以写成泛型形式:

fn write_usage_report<W: std::io::Write>(
    out: &mut W,
    rows: &[Usage<'_>],
) -> std::io::Result<()> {
    writeln!(out, "route\trequests")?;
    for row in rows {
        writeln!(out, "{}\t{}", row.route, row.requests)?;
    }
    out.flush()
}

在泛型版本中,W 在编译期会被确定为具体类型。Rust 会分别生成适用于 FileVec<u8> 等类型的代码,这个过程称为单态化。静态分派没有虚表跳转,也更容易内联;相应地,同一个泛型函数可能生成多份机器码。

选择这两种写法时,可以先判断是否需要保存不同的具体类型。Vec<Box<dyn Plugin>> 这类异构集合,或者根据运行期配置切换后端的场景,适合使用特型对象。如果函数只接收一种 writer,并且调用处能够确定具体类型,泛型通常更直接。只有在单态化明显增加二进制体积时,才需要进一步考虑是否改用动态分派。

限界决定函数体里能做什么

一个没有限界的 T,除了移动、借用和放进容器,能做的事情很少。想把它打印出来就加 Debug,想当哈希表的键就需要 Hash + Eq。函数体里每多用一种操作,签名上通常就会多一条限界。

当限界较长时,可以将它们移到 where 子句中:

fn count_by_key<T, K, F>(values: &[T], mut key_of: F)
    -> std::collections::HashMap<K, usize>
where
    K: std::hash::Hash + Eq,
    F: FnMut(&T) -> K,
{
    let mut counts = std::collections::HashMap::new();
    for value in values {
        *counts.entry(key_of(value)).or_insert(0) += 1;
    }
    counts
}

这里的 T 可以是日志、请求或任何业务类型。函数只用到了两件事:key_of 能从 T 取出 K,而 K 能放进 HashMap。这些要求正好都写在 where 里。

泛型参数不只有类型。生命周期参数只参与借用检查,不会因为生命周期不同就多生成一份代码;常量泛型则能把数组长度这类编译期常量带进类型。

Self、子特型与关联函数

trait 里的 Self 指实现它的那个具体类型。构造器要返回当前类型,或者某个方法只接受同类型的另一个值,都可以用 Self

use std::io;
use std::path::Path;

trait Snapshot {
    fn load(path: &Path) -> io::Result<Self>
    where
        Self: Sized;

    fn revision(&self) -> u64;
    fn merge(&mut self, newer: Self)
    where
        Self: Sized;
}

load 没有 self 参数,是一个类型关联函数;merge 的参数也必须和接收者是同一种快照。两者都要知道 Self 的具体大小,所以加上 where Self: Sized。这样一来,&dyn Snapshot 还能调用 revision,但不能调用 loadmerge

这是判断一个 trait 是否支持 dyn Trait 时需要重点检查的部分:如果方法返回 Self,或者在接收者之外的参数中使用 Self,编译器就需要知道背后的具体类型。为这些方法单独添加 Self: Sized,可以让其余方法继续支持动态派发。

子特型只是把几项要求叠在一起:

trait Audited: std::fmt::Display + Send {
    fn revision(&self) -> u64;
}

实现 Audited 的类型必须同时实现 DisplaySend。它可以看作 where Self: Display + Send 的简写,并不表示类继承关系。

trait 里也能放关联常量,由各个实现给出自己的值,泛型代码再通过 T::CONSTANT 读取。这个值要在编译时确定,所以不能从 trait object 上动态读取。

方法冲突与完全限定调用

一般情况下,value.method() 已足以完成方法查找。当方法重名、类型无法推断,或者需要把方法本身传给 map 等高阶函数时,可以使用限定调用。

最明确的形式是:

use std::io::{self, Write};

fn persist_exact<W: Write>(out: &mut W, payload: &[u8]) -> io::Result<()> {
    <W as Write>::write_all(out, payload)?;
    <W as Write>::flush(out)
}

这行代码把实现类型 W 和 trait Write 都写出来了。完整格式是 <Type as Trait>::method(receiver, arguments...);只写 Trait::method(...)Type::method(...) 也可以,只是少限定一层。

完全限定语法明确指定了要调用的实现。迭代器中常见的 .map(ToString::to_string) 则说明,方法也可以作为普通函数值传递。

关联类型:一个实现对应一种结果

Iterator 除了有方法,还得说明自己会产生哪种值:

trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

Item 是关联类型。每个迭代器实现都必须确定唯一的 Item,后续代码通过 I::Item 引用它。如果将其设计成 Iterator<T> 的类型参数,同一个迭代器就可能分别实现多个 Iterator<T>,无法表达“每个迭代器只产生一种条目”的约束。

处理日志时,可以把条目类型直接写进限界:

use std::io::{self, Write};

fn write_numbered_lines<I, W>(lines: I, out: &mut W) -> io::Result<()>
where
    I: IntoIterator<Item = io::Result<String>>,
    W: Write,
{
    for (index, line) in lines.into_iter().enumerate() {
        writeln!(out, "{}\t{}", index + 1, line?)?;
    }
    Ok(())
}

这里不仅要求 I 可以迭代,还将每一项限定为 io::Result<String>。特型对象同样需要写出关联类型,即 dyn Iterator<Item = io::Result<String>>;否则调用方无法确定 next 的返回类型。

判断应使用关联类型还是泛型 trait,可以考察同一个实现是否需要对应多个类型。From<T> 使用泛型参数,因为同一个目标类型可以分别实现 From<&str>From<String>Mul<Rhs = Self> 也允许左操作数与多种右操作数相乘。但在 SelfRhs 确定后,乘法结果只有一种,因此 Output 使用关联类型。

使用 impl Trait 隐藏具体类型

参数位置的 impl Trait 相当于匿名泛型参数。下面两种写法具有相近的语义:

use std::io::{self, Write};

fn persist(out: &mut impl Write, payload: &[u8]) -> io::Result<()> {
    out.write_all(payload)
}

fn persist_named<W: Write>(out: &mut W, payload: &[u8]) -> io::Result<()> {
    out.write_all(payload)
}

如果需要表达“两个参数必须是同一类型”,则应使用具名泛型参数。多个 impl Trait 参数彼此独立,调用者也不能通过 ::<W> 显式指定类型。因此,它更适合参数之间没有类型关系的简单签名。

返回位置的 impl Trait 具有不同含义:具体类型由函数实现决定,调用方只知道它满足哪些 trait 限界。被隐藏的仍是一个具体类型,因此这里使用静态分派,不需要 Box<dyn Trait> 的堆分配和虚表调用。

use std::io::{self, BufRead};

fn error_lines<R: BufRead>(
    reader: R,
) -> impl Iterator<Item = io::Result<String>> {
    reader.lines().filter(|line| match line {
        Ok(text) => text.contains("ERROR"),
        Err(_) => true,
    })
}

Lines<R>Filter 组合后的完整类型没有必要暴露在函数签名中,使用 impl Iterator 可以只保留调用方关心的能力。但所有返回路径必须产生同一个具体类型。如果需要根据运行期配置返回不同的迭代器,可以使用 Box<dyn Iterator<Item = ...>>,或者用枚举统一不同实现。

从 Rust 1.75 起,返回位置 impl Trait 已可用于特型定义和实现。不过,这类方法目前仍不能通过 dyn Trait 动态派发,而且公共特型要谨慎设计调用方可能需要的额外限界。参见 Rust 1.75 的官方说明

从实现反推限界

将具体的点积函数改成泛型后,编译器会逐项报告缺失的限界:元素是否支持乘法,运算结果是什么类型,元素能否复制,以及如何得到初始值。补全这些要求后,函数如下:

use std::ops::{Add, Mul};

fn dot_product<N>(left: &[N], right: &[N]) -> Option<N>
where
    N: Copy + Default + Add<Output = N> + Mul<Output = N>,
{
    if left.len() != right.len() {
        return None;
    }

    let total = left
        .iter()
        .zip(right)
        .fold(N::default(), |sum, (&a, &b)| sum + a * b);
    Some(total)
}

这些限界都可以从函数体中的操作推导出来:a * b 需要 Mul,累加需要 Add,两个 Output = N 保证运算结果仍为 N;从切片中按值读取元素需要 Copyfold 的初始值来自 Default。实际编写时,可以先完成具体类型的版本,再根据编译错误逐步补充限界。

这里仍需区分类型能力与数学语义。Default 只表示“存在默认值”,并不保证该值是加法零元;Add 也不保证满足结合律。书中的写法用于演示如何补齐限界,在实际数值代码中,更适合使用能够明确提供零值的数值 trait。类型检查可以证明操作存在,但不能证明这些操作满足特定的数学规律。