Rust(I): Type
Type in Rust
目录
在很程度上,Rust语言就是围绕其类型来设计的。Rust有很健全的类型系统,选取合适的类型表示可以获得高性能的代码、 内存和线程安全、灵活性。
基本数据类型
固定宽度的数值类型
Rust 类型系统的根基是一组固定宽度的数值类型,这些类型匹配几乎所有现代处理器已在硬件中实现的类型。
| 大小(位) | 无符号整数 | 有符号整数 | 浮点数 |
|---|---|---|---|
| 8 | u8 | i8 | |
| 16 | u16 | i16 | |
| 32 | u32 | i32 | f32 |
| 64 | u64 | i64 | f64 |
| 128 | u128 | i128 | |
| 机器字 | usize | isize |
机器字的大小与目标机器上地址空间的大小保持一致,可能是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 种类型来表示内存中的值序列。
- 类型 [T; N] 表示 N 个值的数组,每个值的类型为 T。数组的大小是在编译期就已确定的常量,并且是类型的一部分,不能追加新元素或缩小数组。
- 类型 Vec
可称为 T 的向量,它是一个动态分配且可增长的 T 类型的值序列。向量的元素存在于堆中,因此可以随意调整向量的大小:压入新元素、追加其他向量、删除元素等。 - 类型 &[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
- 指向元素在堆中分配的缓冲区(该缓冲区由 Vec
创建并拥有)的指针 - 缓冲区能够存储的元素数量,
- 现在实际包含的数量(也就是它的长度)。 当缓冲区达到其最大容量时,往向量中添加另一个元素需要分配一个更大的缓冲区,将当前内容复制到其中, 更新向量的指针和容量以指向新缓冲区,最后释放旧缓冲区。
如果事先知道向量所需的元素数量,就可以调用 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 = "ಠ_ಠ";

可以将 String 视为 Vec
其他类似字符串的类型
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
单元型结构体
这种类型的值不占用内存,很像单元类型 ()。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
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 表示“能够接收字节”,所以 File、TcpStream 和 Vec<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_batch。Iterator 采用了相同的设计:实现者主要提供 next,map、filter、enumerate 等组合方法由 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 {}
这段实现会为 File、TcpStream 和 Vec<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,具体类型要到运行期才能确定。这个引用是一个胖指针,其中包含数据地址和虚表地址;调用 write、flush 等方法时,通过虚表找到对应实现。Box<dyn Write>、Rc<dyn Trait> 采用相同的方式保存类型信息。
dyn Write 本身没有编译期可知的大小,因此不能直接声明为局部变量,必须通过指针使用。常见形式包括 &dyn Trait、&mut dyn Trait 和 Box<dyn Trait>。
一个特型对象只能包含一个提供方法的主 trait,但可以附加 Send、Sync 等自动 trait,例如 Box<dyn Write + Send>。若需要同时组合 Read 和 Write,可以定义一个子特型:
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 会分别生成适用于 File、Vec<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,但不能调用 load 和 merge。
这是判断一个 trait 是否支持 dyn Trait 时需要重点检查的部分:如果方法返回 Self,或者在接收者之外的参数中使用 Self,编译器就需要知道背后的具体类型。为这些方法单独添加 Self: Sized,可以让其余方法继续支持动态派发。
子特型只是把几项要求叠在一起:
trait Audited: std::fmt::Display + Send {
fn revision(&self) -> u64;
}
实现 Audited 的类型必须同时实现 Display 和 Send。它可以看作 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> 也允许左操作数与多种右操作数相乘。但在 Self 和 Rhs 确定后,乘法结果只有一种,因此 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;从切片中按值读取元素需要 Copy;fold 的初始值来自 Default。实际编写时,可以先完成具体类型的版本,再根据编译错误逐步补充限界。
这里仍需区分类型能力与数学语义。Default 只表示“存在默认值”,并不保证该值是加法零元;Add 也不保证满足结合律。书中的写法用于演示如何补齐限界,在实际数值代码中,更适合使用能够明确提供零值的数值 trait。类型检查可以证明操作存在,但不能证明这些操作满足特定的数学规律。