1.1 指针概览(上):什么是指针、指针和引用的区别
1.1.1. 什么是指针
指针是计算机引用无法立即直接访问的数据的一种方式。
一个非常形象的类比就是书的目录,目录相当于指针,目录里面存的是对应内容所在的页码;在计算机中,指针存的是一个地址。在书里面我们通过目录的页码就可以找到具体的内容,在计算机里,我们通过指针里存的地址来找到我们想要访问的数据。下图是对指针一个形象的描述图:

数据在物理内存中(Random Access Memory,简称RAM)是分散着存储的。而为了找到具体的数据,我们需要一个检索系统,叫做地址空间。
指针会被编码为内存地址,使用usize类型(使用usize的原因在讲虚拟内存时会讲,详见 1.7.3. 虚拟内存)的整数表示。一个地址会指向地址空间中的某个地方。
地址空间的范围是系统和CPU提供的外观界面(facade,指个系统或组件对外呈现的简化界面,隐藏了复杂的内部细节)。程序只知道有序的字节序列,并不会考虑系统中实际RAM的数量。
1.1.2. 名词解释
- 内存地址(又叫地址):内存中指代单个字节的一个数。内存地址是汇编语言提供的抽象。
- 指针(又叫原始指针):指向某种类型的一个内存地址。指针是高级语言提供的抽象。
- 引用:这里的引用指的是Rust中的引用(详见 【Rust指南】4.4. 引用与借用)。它就是指针,但如果它指向的数据是动态大小的类型(比如
str或[T]),那么引用会提供一个整数来保证知道数据的边界在哪里以防止越界。引用是Rust语言提供的抽象。
1.1.3. Rust中的引用
Rust的引用相比于原始指针有很多的好处:
-
引用始终引用的是有效的数据
-
引用与
usize的倍数是对齐的,如果不对齐CPU的操作就会变慢。Rust通过填充字节来保证引用能在内存上对齐。 对齐(Alignment) 是指数据在内存中的存储地址必须是某个特定数值的倍数,以满足硬件的访问要求并提高效率。在Rust中,引用(如&T或&mut T)的地址总是对齐到usize的倍数,这意味着它的起始地址是系统字长(例如32位系统为4字节,64位系统为8字节)的整数倍。例如,在64位系统中,如果一个变量的地址是0x1001,它不是8的倍数,因此不能作为引用的地址;而0x1000或0x1008则是有效的引用地址。 -
引用可为动态大小的类型提供上述的保障。对于在内存中没有固定长度的类型,Rust会保证它的长度会被保存在内部指针的路径,这样就能知道知道数据的边界在哪里以防止越界。
1.1.4. Rust的引用和指针
看个例子:
static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108];
static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0];
fn main() {
let a:i32 = 42;
let b:&[u8;10] = &B;
let c:&[u8;11] = &C;
println!("a = {}, b = {:p}, c = {:p}", a, b, c);
}
b是B的引用,c是C的引用{:p}指的是打印出变量的内存地址
输出:
a = 42, b = 0x1021d97e0, c = 0x1021d97ea
a、b和c在内存空间中的局部视图如下:

- 变量
b和c是引用,在32位CPU上占4字节,在64位上占8字节(这里是4字节) a是i32类型,所以在内存上占4字节- 静态变量
B和C是两个数组,数组里的元素是u8,所以每个元素只占1字节
这这个代码想要实现的是b模拟智能指针,c模拟原始指针。这个例子还模拟得不够接近,后续会有更逼真的例子,现在就将就着吧。

这是一个我们虚构的拥有49个字节的地址空间,表示的是我们最终想要达到的效果,所以会和上文的代码有出入。我们一点一点来解析:
a是一个整数,但由于图是我们假想的理想情况,所以说图里的类型是i16(占2字节),不是原来的i32b是一个智能指针,总长度是4字节,地址字段的长度只占2字节,也就是u16。长度字段占2字节,由于B是有10个元素的数组,所以在长度字段存储的值就是10。地址字段存储的32表示数据所在的起始位置,也就是0x20(32用16进制表示就是0x20)这个地方,长度是10,所以就是从0x20到0x29这块数据c是一个原始指针,占2个字节,字节里存的就是地址。这里存的是16,换成16进制就是0x10,所以c指向的数据从0x10开始,里面有11个元素,所以指向的是0x10到0x1A的数据块。- 0x0是空字节(NULL byte),是程序的死区。如果指针指向这个地方再进行解引用就会崩溃。
其他知识:
- 变量
c是以0结尾的buffer,其实这是C语言中字符串的内部表示形式(C语言中的字符串是一个数组,以0结尾)。了解如何将这些类型转化为Rust中的类型对于通过外部函数接口(Foreign Function Interface,简称FFI,后面会详细地讲) 处理外部代码是非常有用的。 - 变量
c和C在一起就叫做Rust里的CStr类型 - 变量
b实际上是一个长度为10,固定长度的buffer,但这个buffer不带终止符(不以0结尾)。当这个buffer在指针类型后面使用时会被称作后备数组 - 变量
b和B在一起几乎可以创建出Rust中的字符串类型,但字符串类型还包含一个容量(capacity)字段。也就是说,组成Rust字符串类型需要长度、地址和容量三个字段。
1.2 指针概览(下):原始指针及Rust里的各类指针
1.2.1. 一点回顾
上一节中我们使用了引用的例子来模拟指针,但模拟的效果差了很多,我们想要区分原始指针(raw pointer)和智能指针(smart pointer)在内部的区别,具体想要的效果如下:

这个图我在上一篇文章 1.1. 指针概览(上) 中有过详细解释,这里就不再作介绍。
1.2.2. Rust的引用和指针
这篇文章我们会换一个更逼真的例子,使用更复杂的类型展示指针内部的区别:
use std::mem::size_of;
static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108];
static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0];
fn main() {
let a: usize = 42;
let b: Box<[u8]> = Box::new(B);
let c: &[u8; 11] = &C;
println!("a (unsigned 整数)");
println!("地址: {}", &a);
println!("大小: {:?} bytes", size_of::<usize>());
println!("值: {:?}\n", a);
println!("b (装在Box里)");
println!("地址: {:p}", &b);
println!("大小: {:?} bytes", size_of::<Box<[u8]>>());
println!("指向: {:p}\n", b.as_ptr());
println!("c (C的引用)");
println!("地址: {:p}", &c);
println!("大小: {:?} bytes", size_of::<&[u8; 11]>());
println!("指向: {:p}\n", c);
println!("B (10 bytes的数组):");
println!("地址: {:p}", &B);
println!("大小: {:?} bytes", size_of::<[u8; 10]>());
println!("值: {:?}\n", B);
println!("C (11 bytes的数组):");
println!("地址: {:p}", &C);
println!("大小: {:?} bytes", size_of::<[u8; 11]>());
println!("值: {:?}\n", C);
}
- 使用了
std::mem::size_of函数,用于获取各类型所占的内存空间(以字节为单位) - 静态变量
B和C的大小和内容与上一篇文章一样 a是usize类型,值为42b使用了智能指针Box<T>来包裹的B,这时候Box<T>里面值的所有权就转移到Box<T>上了c是一个普通的引用- 每个变量都打印了地址,使用取地址符号
&来实现;每个变量也都打印了它们在内存中和所占的字节数,使用std::mem::size_of函数来实现 - 我们打印出了
a、b、c、B和C,但是由于类型不同其表示的意义也不一样:a、B和C会打印实际存储的值;b和c的“指向”行会打印它们所引用数据的地址(b.as_ptr()以及对c使用{:p})
输出:
a (unsigned integer)
address: 42
size: 8 bytes
value: 42
b (inside a Box)
address: 0x16d1aa5f0
size: 16 bytes
points to: 0x1031f1c10
c (reference to C)
address: 0x16d1aa600
size: 8 bytes
points to: 0x102c8aeba
B (10-byte array):
address: 0x102c8aeb0
size: 10 bytes
value: [99, 97, 114, 114, 121, 116, 111, 119, 101, 108]
C (11-byte array):
address: 0x102c8aeba
size: 11 bytes
value: [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0]
- 我的电脑是64位的所以
usize的a所占的内存是8字节 b的类型是Box<T>,一个智能指针,所以它要占16字节——两个usize类型所占的大小(一个usize字段用于存储指针,另一个用于存储长度)c是一个普通的引用,一个指针,所以占8字节——一个usize类型的大小(用于存储指针)B是一个有10个元素的数组,元素类型是u8,一个u8占一字节,所以10个u8就占10字节C是一个有11个元素的数组,元素类型是u8,一个u8占一字节,所以11个u8就占11字节
我们真正需要注意的是c和b所存储的指针:
c存储指向C的指针,输出中我们可以看到c存储的指针是0x102c8aeba,而C所处的地址正是0x102c8aeba,对应上了b存储指向堆上B字节副本的指针(由Box::new(B)创建)。b存储的指针是0x1031f1c10,但是静态变量B所在的地址是0x102c8aeb0,并没有对应上,是为什么呢? 这是因为B是放在静态内存里的,而Box<[u8]>会在堆上另分配一块缓冲区并把数组拷过去。由于b并不指向静态的B,而是指向堆上的那块分配,所以地址对不上。
我们再讲一个例子,还是刚才的B和C两个静态变量,我们在前一篇文章讲过B和C实际上是文本内容,但是没有进行解码,所以是以u8的格式存储在变量里的,这里我们就来实现解码操作。这样做同时还能创建啊一个与理想状态(在 1.2.1. 一点回顾 中出现的那张图)更加相似的内存地址布局:
use std::borrow::Cow;
use std::ffi::CStr;
use std::os::raw::c_char;
static B: [u8; 10] = [99, 97, 114, 114, 121, 116, 111, 119, 101, 108];
static C: [u8; 11] = [116, 104, 97, 110, 107, 115, 102, 105, 115, 104, 0];
fn main() {
let a = 42;
let b: String;
let c: Cow<str>;
unsafe {
let b_ptr = &B as *const u8 as *mut u8;
b = String::from_raw_parts(b_ptr, 10, 10);
let c_ptr = &C as *const u8 as *const c_char;
c = CStr::from_ptr(c_ptr).to_string_lossy();
}
println!("a: {}, b: {}, c: {}", a, b, c);
}
-
std::borrow::Cow是一个智能指针,Cow指的是Clone on write,意思就是需要写入时才进行克隆,平时只需要读的时候就不用了 -
std::ffi::CStr类似于C语言的字符串类型,它允许Rust读取以0结尾的字符串 -
std::os::raw::c_char是平台上 C 语言char类型的别名(常常是i8,有时是u8)。现代代码更推荐使用std::ffi::c_char。 -
main函数中的变量a、b和c分别是i32、String和Cow<str>类型 -
由于下面的操作需呀使用到原始指针(例如可变原始指针
*mut T和不可变原始指针*const T),所以得写在unsafe块里:-
第一步想要获得
B的可变原始指针,也就是转化为*mut u8,但肯定不能直接获得,所以我们得先写&B获得B的引用,然后再使用as *const u8转化为指向的数据为u8的不可变原始指针,再使用as *mut u8转化为可变原始指针。 -
为什么要获得可变的原始指针呢?因为我们需要使用
String::from_raw_parts函数把数字解码为文本。它有三个参数:buf、length和capacity(对应String类型这个智能指针的三个字段)。buf处我们写数据的原始引用也就是b_ptr,length和capacity都写10因为我们知道里面存了10个元素。这步之后B所对应的字符串就解码出来了。 -
对
C也进行类似的操作,不同之处在于C以元素0结尾,是C语言存储字符串的方式,所以代码与对B进行解码稍有不同:我们得获得C的不可变原始指针,类型还得从u8变到c_char(平台相关,常常是i8),所以先写&C获得引用,再写as *const u8获得原始指针,最后写as *const c_char将类型转为c_char。 -
使用
CStr::from_ptr函数,把c_ptr这个C的c_char类型不可变原始指针传进去,再使用to_string_lossy方法即可以得到解码后的字符串。
-
-
最后我们把
a、b和c都打印出来。
输出:
a: 42, b: carrytowel, c: thanksfish
-
打印出来的这一行是:
a的值是42可以直接打印b解码出来的值是carrytowelc解码出来是thanksfish
-
打印之后,当
b被丢弃时进程仍会异常退出。在某些平台上你可能还会看到分配器诊断信息(例如 macOS 的malloc: *** error for object ...: pointer being freed was not allocated);本次本地运行中 abort 没有产生额外的 stderr 文本。
这是因为String::from_raw_parts会接管一块必须由分配器分配出来的内存;这里我们却把它指向了静态数据,于是分配器会尝试释放并不属于它的内存。
1.2.3. 原始指针(raw pointer)
什么是原始指针
unsafe Rust提供了两种类似于引用的新型指针,它们叫做原始指针或者裸指针,英文是raw pointer。只有使用原始指针时才需要放到unsafe块里,因为可能会出现问题,单创建一个原始指针并不会产生问题,故不需要放到unsafe块里。
和引用类似,这种原始指针要么是可变的要么是不可变的:
- 可变的:
*mut T - 不可变的:
*const T,意味着不能通过该指针修改其指向的值(除非先把它转换成*mut T)。 注意:这里面的*是类型的一部分不代表解引用,*const T这三个标记放在一起才是一个类型,比如*const String
*const T和*mut T的差别很小,可以相互自由的转换。Rust的引用(不论是&mut T还是&T)在编译阶段都会被编译器转为原始指针,这意味着无需进入unsafe块就能获得原始指针的性能。
引用和原始指针的不同之处在于:
- 允许通过同时具有不可变和可变指针或多个指向同一位置的可变指针来忽略借用规则(借用规则详见 【Rust指南】4.4. 引用与借用)
- 原始指针无法保证能指向合理的内存,而引用可以。
- 原始指针允许为
null - 原始指针不实现任何自动清理功能
我们看一个转化为原始指针的简单例子(刚才的例子稍微又些复杂):
fn main(){
let a: i64 = 42;
let a_ptr: *const i64 = &a as *const i64;
println!("a: {}({:p})", a, a_ptr);
}
输出:
a: 42(0x16f30a620)
解引用(dereference)
解引用指的是指针从RAM内存提取数据的过程叫做对指针进行解引用(dereferencing a pointer)。
我们再看一个把引用转化为原始指针的例子:
fn main() {
let a: i64 = 42;
let a_ptr: *const i64 = &a as *const i64;
let a_addr: usize = unsafe { std::mem::transmute(a_ptr) };
println!("a: {}({:p}..0x{:x})", a, a_ptr, a_addr + 7);
}
a是i64类型,值是42a_ptr是a的不可变原始指针a_addr在unsafe块里(涉及使用原始指针的代码需要放到unsafe块里)使用std::mem::transmute函数把a_ptr转化为usize类型- 输出时先输出
a的值,再输出指向a的原始指针,最后输出addr + 7的值
输出:
a: 42(0x16d9ea608..0x16d9ea60f)
关于原始指针的一些提醒
- 在底层,引用(
&mut T和&T)最终会被实现为原始指针。但引用带有额外的保障,应该始终作为首选项使用。 - 访问原始指针的值总是不安全的
- 原始指针不拥有值的所有权,在访问时编译器不会检查数据的合法性
- Rust允许多个原始指针指向同一数据,但无法保证共享数据的合法性
使用原始指针的情况
有的时候原始指针不得不被使用,比如:
- 某些系统或第三方库需要使用,例如与C交互
- 共享对某些内容的访问至关重要,运行时性能要求高
1.2.4. Rust指针生态
- 原始指针是unsafe的
- 智能指针倾向于包装原始指针,附加更多的能力(语义)。也就是不仅仅是对内存地址解引用,还有其它能力,具体如下:
| 名称 | 简介 | 强项 | 弱项 |
|---|---|---|---|
| 原始指针Raw Pointer | *mut T 和 *const T,自由基,闪电般快,极其 unsafe | 速度、与外界交互 | Unsafe |
Box<T> | 可把任何东西都放在 Box 里。可接受几乎任何类型的长期存储。新的安全编程时代的主力军。 | 将值集中存储在 Heap | 大小增加 |
Rc<T> | 是 Rust 的能干而睿智的簿记员。它知道谁借了什么,何时借了什么。 | 对值的共享访问 | 大小增加;运行时成本;线程不安全 |
Arc<T> | 是 Rust 的大使。它可以跨线程共享值,保证这些值不会相互干扰。 | 对值的共享访问;线程安全 | 大小增加;运行时成本 |
Cell<T> | 变”态“专家,具有改变不可变值的能力 | 内部可变性;与T同等大小 | 线程不安全;不能直接拿到内部值的引用 |
RefCell<T> | 对不可变引用执行改变,但有代价 | 内部可变性;可与 Rc、Arc 嵌套使用 | 大小增加;运行时成本;线程不安全;缺乏编译时保障 |
Cow<T> | 封闭并提供对借用数据的不可变访问,并在需要修改或所有权时延迟克隆数据 | 只读访问时避免写入 | 大小可能会增大 |
String | 可处理可变长度的文本,展示了如何构建安全的抽象。 | 动态按需增长;运行时保证正确编码 | 过度分配内存大小 |
Vec<T> | 程序最常用的存储系统;它在创建和销毁值时保持拥有序。 | 动态按需增长 | 过度分配内存大小 |
RawVec<T> | Vec<T> 和其动态大小类型的基石;知道如何按需给数据提供一个家。 | 动态按需增长;与内存分配器一起配合寻找空间 | 不直接适用于你的代码 |
Unique<T> | 作为值的唯一所有者,可保证拥有完全控制权。 | 需要独占值的类型(如String)的基础 | 不适合直接用于应用程序代码 |
NonNull<T> | 许多标准库智能指针内部使用的非空原始指针包装 | 对T协变;可用Option<NonNull<T>>做niche优化 | 解引用仍不安全;通常不直接用于应用程序代码 |
1.3 内存 Pt.1:各类概念的定义及变量的高级模型和低级模型
1.3.1. 值
在讲内存之前我们得先讲三个概念,第一个是值。值指的是类型 + 类型值域中的一个元素。
例如true,它的类型是bool类型,它的值域里就两个值——一个true,一个false。值指的是类型 + 类型值域中的一个元素,true的值就是bool类型 + 值域中的元素true。
通过值的类型表示可以转化为字节序列:
- 举个例子,
u8类型的6,在内存中的表示就是0x06 - 再看一个例子,
str的“Hello World“字符串,它就是字符串值域里的一个值,它的表示是utf-8编码 注意:值的含义是独立于存储它字节的位置的,也就是值和存储它的位置没有直接关系,它们是独立的两个概念
1.3.2. 变量
值会存储到一个地方(或者说这个地方可以容纳值),具体就是堆内存、栈内存或者其他地方。最常见的存储值的地方就是变量,它是栈内存上一个被命名的槽(用于存储值)。

1.3.3. 指针
指针是一个值,值里面存放的是一块内存的地址,指针指向某个地方(地址所对应的内存)。
指针可以被 解引用(dereference) 来访问它指向的内存里存放的值。
可以把同一个指针放在不同的变量里,也就是说多个变量可以间接地引用内存上的同一块区域,也就是相同的底层的值。

1.3.4. 深入变量
变量可以被分为两种模型:
- 高级模型:生命周期、借用…
- 低级模型:不安全代码、原始指针…
变量的高级模型
变量实际上就是给值一个名称。 当值被赋给变量时,这个值从那时起就由该变量命名了。
举个例子:
#![allow(unused)]
fn main() {
let variable = 1234;
}

如图,1234这个数据此时就由variable这个变量命名了。
当变量被访问时,可以从变量的上次访问到这次访问画一条线,从而在两次访问中建立依赖关系。如果变量被移动了,就不能从它那里画线。
这么说你可能不明白,看个图你就懂了:

声明a(第2行)算一次访问,把a赋给b(第4行)算另一次,但是和打印出a(第8行)之间不能画线,因为在此之前变量已经移动了(第4行)。
在变量的高级模型中,变量只会在它持有合法值的时候才存在。如果变量的值没有初始化,或者已经被移动了,那就无法使用画线的方法。
使用这种模型,整个程序会由许多依赖线组成,这些线叫flow。每个flow都在追踪一个值的特定实例的生命周期。当有分支存在时,flow可以分叉或合并,并且每个分叉都在追踪该值的不同的生命周期。
在程序中的任何给定点,编译器可以检查所有的flow是否可以互相兼容,并行存在。例如:
- 一个值不可能有两个具有可变访问的并行flow
- 一个flow借用了一个值,但却没有flow拥有该值
变量的低级模型
变量会给那些可能(不)存储合法值的内存地址进行命名。
可以把变量想象为值的槽:当你赋值时槽就装满了,而它里面原来的值(如果有的话)就被丢弃或替换了。当访问它时,编译器会检查槽是不是空的。如果是,那么就说嘛变量未初始化或它的值被移动了。
指向变量的指针其实是指向这个变量的幕后内存,通过解引用可以获得它的值。
注意:在本例中,我们忽略CPU寄存器,并将其视为优化。实际上,如果变量不需要内存地址,编译器可以使用寄存器而不是内存区域来存放变量。
1.4 内存 Pt.2:栈内存、栈帧(stack frame)、栈指针(stack pointer)
1.4.1. 内存区域
程序有很多的内存区域,并不都是在DRAM上的。三个比较重要的区域是栈内存stack、堆内存heap和静态内存staic。
栈内存和堆内存相对比,栈内存更快堆内存更慢。
1.4.2. 栈内存(stack)
有这么一个公理,叫做:“有疑问时,首选Stack”。但是如果想把数据放在栈内存,编译器就必须知道类型的大小。换句话说:“有疑问时,首选实现了Sized的类型”(关于Sized trait的详细内容见【Rust指南】19.5.4. 动态大小类型与Sized trait)。
Stack是一段内存,程序把它作为一个暂存空间,用于函数调用。
为什么会叫stack呢?因为在stack上的条目是LIFO(Last In First Out,后进先出)

Stack Frame
每个函数被调用,在stack的顶部都会分配一个连续的内存块,叫做stack frame(栈帧)。

在接近stack底部附近是main函数的frame,随着函数的调用,其余的frames都推到了stack上。
函数的frame包含着函数里的所有变量,以及函数所带的参数。当函数返回时,它的frame就被回收了。
构成函数本地变量值的那些字节不会被立即擦除,但访问它们是不安全的,因为它们可能会被后续的函数调用所重写(如果后续函数调用的frame与回收的这个有重合的话)。但即使没有被重写,它们也可能包含无法使用的值。例如函数返回后被移动的值。
Stack Frame也叫activation frames或allocation record。只有activation frames被分配在stack上时才叫stack frame。
每个stack frame的大小是不同的。在函数调用期间,stack frame会包含函数的状态。当一个函数在另外一个函数内调用时,原来的函数的值会被及时冻结。
stack fram为函数参数,指向原来调用栈的指针,以及本地变量(不包括在堆内存上分配的数据)提供空间。
stack的主要任务在于为本地变量创造空间,原因在于stack里所有变量都是紧挨着的,找起来更快。
看个例子:
fn main() {
let pw = "justok";
let is_string = is_strong(pw);
}
fn is_strong(password: String) -> bool {
password.len() > 5
}
输出:
error[E0308]: mismatched types
--> src/main.rs:3:31
|
3 | let is_string = is_strong(pw);
| --------- ^^ expected `String`, found `&str`
| |
| arguments to this function are incorrect
|
note: function defined here
--> src/main.rs:6:4
|
6 | fn is_strong(password: String) -> bool {
| ^^^^^^^^^ ----------------
help: try using a conversion method
|
3 | let is_string = is_strong(pw.to_string());
| ++++++++++++
问题很明显,pw是&str类型的,is_strong函数的参数是String类型。
我们的目标就是让is_strong函数兼容&str和String类型。这个目标看起来挺简单但其实有点麻烦:String拥有堆上的缓冲区,而&str只是指向某段UTF-8字节的胖指针,这些字节可能在静态内存、堆上或其他地方,这两个类型的转换不简单。
看看修改后的代码:
#![allow(unused)]
fn main() {
fn is_strong<T: AsRef<str>>(password: T) -> bool {
password.as_ref().len() > 5
}
}
这么写就是把传进来的参数作为到str的引用。
也可以这么改:
#![allow(unused)]
fn main() {
fn is_strong<T: Into<String>>(password: T) -> bool {
password.into().len() > 5
}
}
这么写就是把传进来的参数转化为String。但是这些写法都涉及到了比较多的转化。
还可以这么写:
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn is_strong<T: Display>(password: T) -> bool {
password.to_string().len() > 5
}
}
通过to_string方法把参数转化成String类型再操作。这个操作比上一个更慢。
Stack Pointer
随着程序的执行,CPU里有一个游标会随着更新,它反映当前stack frame的当前地址,这个游标就叫做stack pointer(stack指针)。

随着函数内不断地调用函数,stack就会增长(stack pointer从stack frame开始),而stack pointer的值就会减少(越接近stack frame的地方内存地址更大);当函数返回,stack pointer的值会增加(当函数返回时,它的frame就被回收了,游标向stack frame接近,值就变大)。
Stack Frame的消失
stack frames会最终消失的这个事实与Rust生命周期的概念是密切相关的。任何存储在stack上的变量在frame消失后就无法访问了。
所以,任何到stack上的变量的生命周期最多只能与frame的生命周期一样长。
1.5 内存 Pt.3:深入探究Rust堆内存底层实现
1.5.1. 堆内存(Heap)
- Heap意味着混乱,而stack则相对比较整齐。
- Heap是一个内存池,并没有绑定到当前程序的调用栈,而stack绑定到当前程序的调用栈。
- Heap是为在编译时没有已知大小的类型准备的,而stack上的数据在编译时大小必须已知。

如图,heap上的数据存放的位置和自身的大小都是不一定的,比较常见的情况是stack有一个指针指向heap上的数据。
什么叫在编译时大小不已知?
- 一些类型会随着时间变大或变小,例如
String、Vec<T>。这些类型本身是Sized的(在栈上是固定大小的结构体),但它们拥有的缓冲区在堆上,可以改变大小。 - 另一些类型的大小不会改变,但是无法告诉编译器需要分配多少内存
- 另一个例子是trait对象(详见 【Rust指南】19.5.4. 动态大小类型与
Sizedtrait),它允许程序员模拟一些动态语言的特性——将多个类型放进一个容器 - 真正的动态大小类型(dynamically sized types,简称DST)——也叫unsized types——包括切片(如
str、[T])以及trait对象(如dyn Trait)。String和Vec<T>本身不是DST;它们是通过一个Sized句柄来管理堆上动态大小数据的。
Heap允许你显示地分配连续的内存块。当这么做时,你就会得到一个指针,它指向内存开始的地方。
Heap中的值会一直有效,直到你对它显式地释放。如果你想让值活得比当前函数frame(详见 1.4. 内存 Pt.2)的生命周期还长,就很有用。如果值是函数的返回值,那么调用函数可以在它的stack上留一些空间给被调用函数让它把值在返回前写入进去。
1.5.2. 堆内存与线程安全
如果想要把值送到另一个线程,当前线程可能根本无法与那个线程共享stack frames,你就可以把它存在堆内存上。因为函数返回时堆内存上的分配不会消失,所以你在一个地方为值分配内存,把指向它的指针传给另一个线程,就可以让那个线程安全地操作于这个值。
换一种说法:当你分配堆内存时,结果指针会有一个无约束的生命周期,你的程序想让数据活多久都行。
1.5.3. 堆内存的交互机制
堆内存上的变量必须通过指针访问。看个例子:
fn main(){
let a: i32 = 40; // Stack
let b: Box<i32> = Box::new(60); // Heap
let result = a + b;
let result = a + *b;
println!("{} + {} = {}", a, b, result);
}
输出:
error[E0277]: cannot add `Box<i32>` to `i32`
--> src/main.rs:4:20
|
4 | let result = a + b;
| ^ no implementation for `i32 + Box<i32>`
|
= help: the trait `Add<Box<i32>>` is not implemented for `i32`
help: consider dereferencing here
|
4 | let result = a + *b;
| +
a是i32类型,放在栈内存上的b是Box<i32>类型,放在堆内存上的
但是这么写肯定是有问题的,问题出在let result = a + b;上:堆内存上的数据必须通过指针来访问,b是指针,a是数字,两者类型不同肯定不能相加。
所以我们删掉这句话把原代码改成:
fn main(){
let a: i32 = 40; // Stack
let b: Box<i32> = Box::new(60); // Heap
let result = a + *b;
println!("{} + {} = {}", a, b, result);
}
let result = a + *b;通过*对b进行了解引用,把指针指向的值60取出来了。
输出:
40 + 60 = 100
Rust与堆内存的交互方式
Rust里与堆内存交互的主要方式就是Box<T>类型。
当我们使用Box::new函数创建类型为Box<T>的实例时,值(传进Box::new函数的参数)就会被放在堆内存上,返回的(BOx<T>)就是指向堆内存上的指针。当Box被丢弃时,内存就会被释放。
如果忘记释放堆内存,就会导致内存泄漏。但有时候程序员会故意让内存泄漏,例如有一个只读的配置,整个程序都需要访问它。这时候就可以通过Box::leak得到一个‘static引用,从而显式地让其进行泄露。
看个例子:
use std::mem::drop;
fn main(){
let a = Box::new(1);
let b = Box::new(1);
let c = Box::new(1);
let result1 = *a + *b + *c;
drop(a);
let d = Box::new(1);
let result2 = *b + *c + *d;
println!("{} {}", result1, result2);
}
- 使用
std::mem::drop函数可以手动释放
我们讲讲这个程序的逻辑:
- 首先声明了变量
a、b和c,值都是存储在堆内存上的1(Box<i32>类型) - 使用
*对三个变量都进行解引用然后相加得到result1 - 得到
result1之后使用drop函数直接抛弃了a - 然后声明了变量
d,值是存储在堆内存上的1(Box<i32>类型) - 使用对
*三个变量(b、c和d)进行解引用然后相加得到result2 - 打印出
result1和result2
我们用一张图来看看这个程序执行中内存的变化:

1.6 内存 Pt.4:静态(static)内存与’static生命周期标注
1.6.1. 静态(static)内存
static内存实际上是一个统称,它指的是程序编译后的文件中几个密切相关的区域。当程序执行的时候,这些区域会自动加载到内存里。
static内存里的值会在程序执行期间一直存活。
程序的static内存是包含程序的二进制代码的(通常映射为只读的)。随着程序的执行,它会在文本段的二进制代码中挨个指令进行遍历,而当函数被调用时就进行跳跃。
static内存会持有使用static声明的变量的内存,也包括某些常量值,例如字符串。
1.6.2. ‘static生命周期标注
‘static是一个特殊的生命周期,它的名字来源于static内存区。它将引用标记为只要static内存还存在(也就是程序关闭之前),那么引用就合法。
static变量的内存在程序开始运行时就分配了。指向static内存中变量的引用,按定义来说,就是'static的,因为在程序关闭之前它不会被释放;但是有'static生命周期标注的引用可以不指向static内存。
既然'static生命周期标注的引用可以不指向static内存,为什么还要把这种生命周期命名为'static呢?有'static标注但不存储在static内存里这不是误导人吗?
'static这个名称仍然适用的原因在于:一旦你创建了一个'static的生命周期的引用,就程序的其余部分而言,它所指向的内存都可能在static内存中,因为程序想要使用它多久都没问题
话句话说:'static这个名字可能会让人误以为所有带有'static生命周期的引用都指向静态内存区(即程序运行期间一直存在的全局变量或常量)。但实际上,'static只是表示这个引用在整个程序生命周期内都是有效的,至于它指向的内存是否真的存储在静态区,并不一定。换句话说,'static生命周期的引用意味着 “这个引用可以一直存在,程序可以随时使用它”,但并不强制要求它的内容必须是静态分配的。
在写Rust代码的时候,遇到更多的会是'staic生命周期标注而不是static内存。'static经常出现在类型参数的trait bounds上。
例如T: 'static就代表类型T可以存活我们想要的任何时长(知道程序关闭),同时这也要求T是拥有所有权的并且是自给自足的。这代表着这个类型要么它不借用其它(非static)值,要么它借用的东西是static的。这样就能保证类型能活到程序结束。
1.6.3. const与static的区别
const关键字会把紧随它的东西声明为常量,例如:
#![allow(unused)]
fn main() {
const X: i32 = 123;
}
X被声明为了常量
常量可在编译的时候完全计算出来。在计算期间,任何引用常量的代码会被替换为常量的计算结果值。
例如:
#![allow(unused)]
fn main() {
const X: i32 = 123;
println!("{}", X);
}
这句话中的打印操作就会在编译时被改为:
#![allow(unused)]
fn main() {
println!("{}", 123);
}
所以常量没有内存或关联其它存储(因为它不是一个地方,“值”“变量”“地方”等概念的定义详见 1.3. 内存 Pt.1)。你可以把常量理解为某个特殊值的方便的名称。
1.7 内存 Pt.5:堆内存vs.栈内存、虚拟内存、数据在RAM中展示的建议
1.7.1. 动态内存分配
在任何时刻,运行中的程序都会占据一部分的内存。有时候程序需要更多的内存,就需要向操作系统请求,这就是动态内存分配(dynamic allocation)。
下面是内存动态分配的步骤示意图:

- 通过系统调用向系统请求内存。在Unix类系统里使用
alloc()函数,在Windows系统中使用HeapAlloc()函数 - 程序使用分配的内存
- 如果内存在使用过后不再需要了,那么程序会把不再需要的内存释放给操作系统。Unix类系统里使用
free()函数,Windows使用HeapFree()函数。
PS:程序和系统之间在请求内存时会有分配器(allocator)。它是嵌入在程序幕后的一个专业的子程序,它会执行一些优化,来避免CPU内和操作系统的大量工作
1.7.2. 为什么栈内存和堆内存之间存在性能差异?
首先需要说明的是栈(stack)内存和堆(heap)内存只是概念而已,内存在物理上并没有这2个区域。
访问栈内存块的原因在于:
- 函数本地变量(都分配在栈内存上)在RAM上彼此相邻(连续布局)。连续布局对于缓冲很友好。
访问堆内存慢的原因:
- 分配在堆内存上的数据不太可能彼此相邻
- 访问堆内存上的数据需要对指针进行解引用(页表查找表和区访问主内存)
栈内存与堆内存的简单对比
| Stack | Heap | 备注 |
|---|---|---|
| 简单 | 复杂 | |
| 安全 | 危险 | 危险指Unsafe Rust |
| 快 | 慢 | |
| 死板 | 灵活 |
- Stack上的数据结构在生命周期内大小不能改变
- Heap上的数据结构更加灵活,因为指针可以改变
1.7.3. 虚拟内存
虚拟内存是程序所见到的内存视图,程序可以访问的所有数据都是由操作系统在其地址空间中提供的。
从直觉上来看,程序的内存就是一系列字节,从开始位置0到结束位置n。例如有个程序汇报书用了100KB的RAM,那么n就应该在100000左右。
看个例子:
fn main() {
let mut n_nonzero = 0;
for i in 0..10000 {
let ptr = i as *const u8;
let byte_at_addr = unsafe { *ptr };
if byte_at_addr != 0 {
n_nonzero += 1;
}
}
println!("{}", n_nonzero);
}
- 这个例子会逐字节地扫描正在运行程序的内存,起始位置是0,终止于9999
let ptr = i as *const u8;把i转化为u8类型(一个u8占一字节)的原始不可变指针(原始指针详见 1.2.3. 原始指针(raw pointer)),目的是检查这个内存地址。这里我们把每一个地址作为一个单元。实际上很多值都不止一个字节,都是跨字节的,但是在这里我们就不考虑- 下一行
let byte_at_addr = unsafe { *ptr };对指针进行了解引用(对原始指针的操作要放到unsafe块里),读取里面的值赋给byte_at_addr - 如果
byte_at_addr的值不等于0,那么n_nonzero的值就加1 - 最后打印出
n_nonzero的值
输出:
segmentation fault
segmentation fault指的是当CPU或操作系统检测到程序试图请求非法(无权访问)的内存地址时所产生的错误。
segment指的是虚拟内存中的块。虚拟内存被划分为块来最小化虚拟和物理地址之间转换所需的空间。
那么到底是访问哪个内存是非法的呢?是0。当i等于0的时候就相当于是一个Null指针,这个指针不能被解引用。这也部分解释了为什么原始指针的操作必须放到unsafe块里。
那我们让循环从1开始:
fn main() {
let mut n_nonzero = 1;
for i in 1..10000 {
let ptr = i as *const u8;
let byte_at_addr = unsafe { *ptr };
if byte_at_addr != 0 {
n_nonzero += 1;
}
}
println!("{}", n_nonzero);
}
输出:
segmentation fault
一样的错误。
这个例子行不通,我们换一个例子:
static GLOBAL: i32 = 1000;
fn noop() -> *const i32 {
let noop_local = 12345;
&noop_local as *const i32
}
fn main() {
let local_str = "a";
let local_int = 123;
let boxed_str = Box::new("b");
let boxed_int = Box::new(789);
let fn_int = noop();
println!("GLOBAL: {:p}", &GLOBAL as *const i32);
println!("local_str: {:p}", local_str.as_ptr());
println!("local_int: {:p}", &local_int as *const i32);
println!("boxed_int: {:p}", Box::into_raw(boxed_int));
println!("boxed_str: {:p}", Box::into_raw(boxed_str));
println!("fn_int: {:p}", fn_int);
}
- 声明了
GLOBAL、local_str和local_int等变量(静态变量也算变量),有的是存储在堆内存上有的是存储在栈内存上 - 把这些变量的内存地址打印出来
local_str.as_ptr()打印的是字符串数据的地址。相比对local_str as *const str使用{:p},更推荐这种写法:*const str是宽指针(详见 1.14.6. 宽指针(Wide Pointer)),当前 Rust 会把它格式化成Pointer { addr: ..., metadata: ... },而不是单纯的十六进制地址。
输出:
GLOBAL: 0x102572ae4
local_str: 0x102572ae0
local_int: 0x16d8c260c
boxed_int: 0x102d89b10
boxed_str: 0x102d89c10
fn_int: 0x16d8c2620
虽然我们的程序很小,但是变量在虚拟内存上的分布很分散。但是还是有一定的分布规律:
GLOBAL和local_str的地址比较接近box_int和box_str的地址比较接近local_int和fn_int的地址比较近
虚拟内存的空间大小大概是2^48,但实际上物理内存肯定没有这么大。而且在虚拟内存空间里有一部分是系统为自己预留的,预留的地址没发使用。
通过例子
通过这些例子我们能得到一些信息:
- 某些内存地址是非法的,访问越界的内存,程序就会关掉你的程序。
- 内存地址并不是随机的,看起来不同类型的值在内存中分布的比较广,但实际上是有规律的。
1.7.4. 翻译虚拟地址到物理地址
程序里访问数据需要虚拟地址(程序只能访问虚拟地址)。虚拟地址会被翻译为物理地址,这就要涉及到程序、系统、CPU、RAM硬件(有时候还会涉及到硬盘及其他设备):
- CPU负责执行翻译,具体来说是CPU里的内存管理单元(MMU) 负责这项工作
- 系统负责存储指令
- 这些指令也存在内存中一个预定义的地址中
最坏的情况下,每次访问内存都会发生两次内存查找:一次是要访问的内存,一次是指令。
CPU会维护一个最近转换地址的缓存。它有自己的快速内存来加速内存的访问。由于历史原因,该内存称为转换后备缓冲区(Translation Lookaside buffe,简称TLB)。
为了提高性能,程序员需要保持数据结构精简,避免深度嵌套。尤其是达到TLB容量(对于x86处理器来说大概是100页)以后。
解释一下专业名词:
- 页(Page):指的是实际内存中固定大小的字块,64位系统通常是4k
- 字(Word):指针大小的任何值,对于CPU寄存器的宽度。Rust中的
usize和isize就是字长类型(Word-length type)。
虚拟地址被分为很多块,叫做页(page),通常4KB大小。分成块的好处在于避免了需要为每个变量都存储转换映射。而且页是统一大小的,有助于避免内存碎片(可用RAM中出现空的、不可用的空间)。
注意:上面所说的只是通用性的指导,例如像微控制器情况就不同了。
1.7.5. 数据在RAM中展示的指导性建议
将程序热工作的部分保持在4KB以内,从而保持快速查找,性能较好。很多程序不能把热工作的部分控制在4KB以内,4KB这个目标对于这种程序不合理,那么下一个目标应该是4KB * 100。这意味着CPU可维护其转换缓存(TLB) 来支持你的程序。
避免深度嵌套数据结构。如果指针指向另一个页,那么性能就会受到影响。
在遍历数组时,访问的顺序会影响缓存的利用率(因为CPU会从RAM读取小块字节,这叫chache line,或者叫缓存行),从而影响程序的性能。C/C++、Rust、Python(NumPy)等语言的二维数组在内存中是按行存储的。例如:
int matrix[3][3] = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
在内存中是这样存储的:
1 2 3 | 4 5 6 | 7 8 9
如果用行优先遍历:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
process(matrix[i][j]);
}
}
matrix[i][j]在内存中是连续的,能很好地利用缓存行,减少 RAM 访问,提高速度。
如果用列优先遍历:
for (int j = 0; j < 3; j++) {
for (int i = 0; i < 3; i++) {
process(matrix[i][j]);
}
}
由于列在内存中是分散的,访问matrix[i][j]时可能跨多个 缓存行。CPU可能会频繁从RAM读取新缓存行,导致缓存未命中(cache miss),影响性能。
总结一下:
- 在 C/C++、Rust、Python(NumPy)等默认使用行主序(Row-Major) 的语言中,尽量按行遍历数组。
- 在 MATLAB、Fortran 等使用列主序(Column-Major) 的语言中,按列遍历更高效。
注意:
虚拟化会让情况更加糟糕,如果在虚拟机内运行应用程序,管理器(Hypervisor) 还必须为其客户端的操作系统转换地址。这就是为什么许多CPU自带虚拟化支持——通过减少转换来减少额外的性能开销。
如果在虚拟机中运行容器,那就是又添加一层间接,同时也就增加了延迟。
所以说,如果想要获得裸机的性能,就必须在裸机上运行程序。
1.8 内存 Pt.6:通过操作系统扫描地址空间
1.8.1. 通过操作系统扫描地址空间(例子)
操作系统提供了接口可让程序发出请求——系统调用(system call)。在Windows里,KERNEL.DLL提供了用于检查和操纵运行进程内存 的功能。
这个例子我们在Windows上进行。为什么以Windows为例呢?
- 函数命名易于理解
- 无需POSIX API的知识
1.8.2. 依赖项
本例使用了 windows-sys 这个 crate,它提供了对 Windows API 的低级绑定。在Cargo.toml中添加如下依赖:
[dependencies]
windows-sys = { version = "0.59.0", features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
] }
- 这里我们使用
windows-sys来调用诸如GetCurrentProcess、K32GetProcessMemoryInfo和VirtualQueryEx等 Windows API。这些模块受 feature 控制,因此必须启用上面的 features。
1.8.3. 主程序
然后在main.rs把需要的东西引入作用域:
#![allow(unused)]
fn main() {
use std::ffi::c_void;
use std::mem;
use windows_sys::Win32::System::Memory::{VirtualQueryEx, MEMORY_BASIC_INFORMATION};
use windows_sys::Win32::System::ProcessStatus::{PROCESS_MEMORY_COUNTERS, K32GetProcessMemoryInfo};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId};
/// Windows API 中的 `PVOID` / `SIZE_T`。
/// 在 `windows-sys` 0.59+ 中,它们不再作为 `Win32::Foundation` 下的具名别名导出,
/// 而是直接用原始 Rust 类型表示。
type PVOID = *mut c_void;
type SIZE_T = usize;
}
• PVOID:代表 void* 指针,用于表示不透明内存地址。这里是本地别名,对应 *mut c_void。
• SIZE_T:对应无符号整型,用来表示内存区域大小。这里是本地别名,对应 usize(与 windows-sys 0.59+ 的函数签名一致)。
• MEMORY_BASIC_INFORMATION:系统内定义的结构,用于描述一段内存区域的基本信息。
• PROCESS_MEMORY_COUNTERS:用于记录进程内存使用情况的结构体。
• K32GetProcessMemoryInfo:通过该函数获取当前进程的内存信息。
• GetCurrentProcess与GetCurrentProcessId:分别获取当前进程的句柄与进程 ID。
为了方便Debug格式的输出,我们将Windows API返回的PROCESS_MEMORY_COUNTERS包装在一个自定义的ProcessInfo结构体中:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct ProcessInfo {
cb: u32,
page_fault_count: u32,
peak_working_set_size: usize,
working_set_size: usize,
quota_peak_paged_pool_usage: usize,
quota_paged_pool_usage: usize,
quota_peak_non_paged_pool_usage: usize,
quota_non_paged_pool_usage: usize,
pagefile_usage: usize,
peak_pagefile_usage: usize,
}
}
-
cb(u32):- 描述:结构体的大小(以字节为单位)。
- 用途:标识该结构体的大小,用于兼容性。
-
page_fault_count(u32):- 描述:进程自启动以来的页面错误总数。
- 用途:页面错误代表内存访问未命中物理内存而触发的处理过程。它包括软故障(从文件缓存中获取数据)和硬故障(需要从磁盘加载)。
-
peak_working_set_size(usize):- 描述:进程使用的工作集(实际驻留在物理内存中的内存)的峰值大小。
- 用途:用于监控进程的内存使用高峰。
-
working_set_size(usize):- 描述:进程当前使用的工作集大小。
- 用途:展示进程当前占用的物理内存量。
-
quota_peak_paged_pool_usage(usize):- 描述:进程使用的分页池(Paged Pool)配额的峰值大小。
- 用途:分页池是指可以分页到磁盘的内核模式内存。
-
quota_paged_pool_usage(usize)- 描述:进程当前使用的分页池配额大小。
- 用途:用于监控当前使用的可分页内核内存量。
-
quota_peak_non_paged_pool_usage(usize):- 描述:进程使用的非分页池(Non-paged Pool)配额的峰值大小。
- 用途:非分页池是指永久驻留在物理内存中的内核模式内存。
-
quota_non_paged_pool_usage(usize):- 描述:进程当前使用的非分页池配额大小。
- 用途:用于监控当前使用的不可分页内核内存量。
-
pagefile_usage(usize):- 描述:进程当前在页面文件中使用的空间大小。
- 用途:表示进程将数据分页到磁盘上的数量。
-
peak_pagefile_usage(usize)- 描述:进程使用的页面文件空间的峰值大小。
- 用途:用于监控进程的页面文件使用高水位标记。
windows-sys 中的 MEMORY_BASIC_INFORMATION 没有实现 Debug,因此我们也把它的字段包装起来以便打印:
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct MemoryBasicInfo {
base_address: *mut c_void,
allocation_base: *mut c_void,
allocation_protect: u32,
region_size: usize,
state: u32,
protect: u32,
type_: u32,
}
}
获取当前进程句柄与进程ID(要放在unsafe块中):
#![allow(unused)]
fn main() {
let this_proc = GetCurrentProcess();
let this_pid = GetCurrentProcessId();
}
获取进程内存信息:
#![allow(unused)]
fn main() {
let mut mem_counters: PROCESS_MEMORY_COUNTERS = mem::zeroed();
let mem_counters_size = mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
K32GetProcessMemoryInfo(this_proc, &mut mem_counters, mem_counters_size);
}
-
let mut mem_counters: PROCESS_MEMORY_COUNTERS = mem::zeroed();- 使用
mem::zeroed()函数创建并初始化一个PROCESS_MEMORY_COUNTERS结构体,使其所有字段值都为零。 PROCESS_MEMORY_COUNTERS是一个 Windows 系统预定义的结构体,用于存储进程的内存统计信息。
- 使用
-
let mem_counters_size = mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;- 通过
mem::size_of函数计算PROCESS_MEMORY_COUNTERS结构体的大小(单位为字节)。 - 该大小会被转换为
u32类型,用以作为后续 API 函数调用的参数。
- 通过
-
K32GetProcessMemoryInfo(this_proc, &mut mem_counters, mem_counters_size);- 参数解释:
this_proc:当前进程的句柄,表示你需要查询哪个进程的内存数据。&mut mem_counters:PROCESS_MEMORY_COUNTERS结构体的可变引用,用于接收从 API 返回的内存统计信息。mem_counters_size:传递结构体的大小,以确保 API 函数能够正确读取并填充结构体数据。
- 作用:
调用K32GetProcessMemoryInfo函数,将当前进程的内存状态(如页面故障数量、工作集大小、页面文件使用量等信息)填充到mem_counters结构体中。
- 参数解释:
将内存信息包装成我们自定义的ProcessInfo:
#![allow(unused)]
fn main() {
let proc_info = ProcessInfo {
cb: mem_counters.cb,
page_fault_count: mem_counters.PageFaultCount,
peak_working_set_size: mem_counters.PeakWorkingSetSize,
working_set_size: mem_counters.WorkingSetSize,
quota_peak_paged_pool_usage: mem_counters.QuotaPeakPagedPoolUsage,
quota_paged_pool_usage: mem_counters.QuotaPagedPoolUsage,
quota_peak_non_paged_pool_usage: mem_counters.QuotaPeakNonPagedPoolUsage,
quota_non_paged_pool_usage: mem_counters.QuotaNonPagedPoolUsage,
pagefile_usage: mem_counters.PagefileUsage,
peak_pagefile_usage: mem_counters.PeakPagefileUsage,
};
}
定义扫描地址的起始与结束地址:
#![allow(unused)]
fn main() {
let min_addr: PVOID = 0 as PVOID;
}
设定一个典型的 64 位用户模式地址空间上限:
#![allow(unused)]
fn main() {
let max_addr: PVOID = 0x00007FFF_FFFF_FFFF as PVOID;
}
输出进程信息和地址区间:
#![allow(unused)]
fn main() {
println!("{:p} @ {:p}", this_pid as *const (), this_proc as *const ());
println!("{:?}", proc_info);
println!("min: {:p}, max: {:p}", min_addr, max_addr);
}
初始化VirtualQueryEx所需参数:
#![allow(unused)]
fn main() {
let MEMINFO_SIZE = mem::size_of::<MEMORY_BASIC_INFORMATION>();
let mut base_addr: PVOID = min_addr;
let mut mem_info: MEMORY_BASIC_INFORMATION = mem::zeroed();
}
-
let MEMINFO_SIZE = mem::size_of::<MEMORY_BASIC_INFORMATION>();- 作用:计算
MEMORY_BASIC_INFORMATION结构体的大小(单位为字节)并存储在MEMINFO_SIZE变量中。 - 原因:
VirtualQueryEx函数要求提供这个结构体缓冲区的大小,以便正确写入查询结果。
- 作用:计算
-
let mut base_addr: PVOID = min_addr;- 作用:初始化虚拟内存扫描的起始地址,将第一个扫描地址设置为
min_addr。 base_addr:表示当前查询的内存起始地址,会在后续循环中逐步增加,遍历整个虚拟地址空间。
- 作用:初始化虚拟内存扫描的起始地址,将第一个扫描地址设置为
-
let mut mem_info: MEMORY_BASIC_INFORMATION = mem::zeroed();- 作用:使用
mem::zeroed()函数创建并初始化一个MEMORY_BASIC_INFORMATION结构体,并将所有字段值置为零。 - 原因:该结构体将用于存储
VirtualQueryEx的结果,即当前查询地址对应内存区域的详细信息。
- 作用:使用
循环调用VirtualQueryEx扫描整个地址空间:
#![allow(unused)]
fn main() {
loop {
let rc: SIZE_T = VirtualQueryEx(this_proc, base_addr, &mut mem_info, MEMINFO_SIZE as SIZE_T);
if rc == 0 {
break;
}
// `windows-sys` 中的 `MEMORY_BASIC_INFORMATION` 没有实现 `Debug`,
// 因此包装我们关心的字段再打印。
let printable = MemoryBasicInfo {
base_address: mem_info.BaseAddress,
allocation_base: mem_info.AllocationBase,
allocation_protect: mem_info.AllocationProtect,
region_size: mem_info.RegionSize,
state: mem_info.State,
protect: mem_info.Protect,
type_: mem_info.Type,
};
println!("{:#?}", printable);
// 累加当前区域的大小,得到下一个查询地址
base_addr = ((base_addr as usize) + mem_info.RegionSize) as PVOID;
if (base_addr as usize) >= (max_addr as usize) {
break;
}
}
}
1.8.4. 完整代码
main.rs:
use std::ffi::c_void;
use std::mem;
use windows_sys::Win32::System::Memory::{VirtualQueryEx, MEMORY_BASIC_INFORMATION};
use windows_sys::Win32::System::ProcessStatus::{PROCESS_MEMORY_COUNTERS, K32GetProcessMemoryInfo};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId};
/// Windows API 中的 `PVOID` / `SIZE_T`。
/// 在 `windows-sys` 0.59+ 中,它们不再作为 `Win32::Foundation` 下的具名别名导出,
/// 而是直接用原始 Rust 类型表示。
type PVOID = *mut c_void;
type SIZE_T = usize;
/// 为了能够用 Debug 格式输出,我们自己包装了 PROCESS_MEMORY_COUNTERS。
#[derive(Debug)]
struct ProcessInfo {
cb: u32,
page_fault_count: u32,
peak_working_set_size: usize,
working_set_size: usize,
quota_peak_paged_pool_usage: usize,
quota_paged_pool_usage: usize,
quota_peak_non_paged_pool_usage: usize,
quota_non_paged_pool_usage: usize,
pagefile_usage: usize,
peak_pagefile_usage: usize,
}
/// `windows-sys` 中的 `MEMORY_BASIC_INFORMATION` 没有实现 `Debug`。
#[derive(Debug)]
struct MemoryBasicInfo {
base_address: *mut c_void,
allocation_base: *mut c_void,
allocation_protect: u32,
region_size: usize,
state: u32,
protect: u32,
type_: u32,
}
fn main() {
unsafe {
// 获取当前进程句柄与进程ID
let this_proc = GetCurrentProcess();
let this_pid = GetCurrentProcessId();
// 获取进程内存信息
let mut mem_counters: PROCESS_MEMORY_COUNTERS = mem::zeroed();
let mem_counters_size = mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
K32GetProcessMemoryInfo(this_proc, &mut mem_counters, mem_counters_size);
// 将内存信息包装成我们自定义的 ProcessInfo
let proc_info = ProcessInfo {
cb: mem_counters.cb,
page_fault_count: mem_counters.PageFaultCount,
peak_working_set_size: mem_counters.PeakWorkingSetSize,
working_set_size: mem_counters.WorkingSetSize,
quota_peak_paged_pool_usage: mem_counters.QuotaPeakPagedPoolUsage,
quota_paged_pool_usage: mem_counters.QuotaPagedPoolUsage,
quota_peak_non_paged_pool_usage: mem_counters.QuotaPeakNonPagedPoolUsage,
quota_non_paged_pool_usage: mem_counters.QuotaNonPagedPoolUsage,
pagefile_usage: mem_counters.PagefileUsage,
peak_pagefile_usage: mem_counters.PeakPagefileUsage,
};
// 定义扫描地址的起始与结束地址
let min_addr: PVOID = 0 as PVOID;
// 这里设定一个典型的 64 位用户模式地址空间上限
let max_addr: PVOID = 0x00007FFF_FFFF_FFFF as PVOID;
// 输出
println!("{:p} @ {:p}", this_pid as *const (), this_proc as *const ());
println!("{:?}", proc_info);
println!("min: {:p}, max: {:p}", min_addr, max_addr);
// 初始化 VirtualQueryEx 所需参数
let MEMINFO_SIZE = mem::size_of::<MEMORY_BASIC_INFORMATION>();
let mut base_addr: PVOID = min_addr;
let mut mem_info: MEMORY_BASIC_INFORMATION = mem::zeroed();
// 循环调用 VirtualQueryEx 扫描整个地址空间
loop {
let rc: SIZE_T =
VirtualQueryEx(this_proc, base_addr, &mut mem_info, MEMINFO_SIZE as SIZE_T);
if rc == 0 {
break;
}
let printable = MemoryBasicInfo {
base_address: mem_info.BaseAddress,
allocation_base: mem_info.AllocationBase,
allocation_protect: mem_info.AllocationProtect,
region_size: mem_info.RegionSize,
state: mem_info.State,
protect: mem_info.Protect,
type_: mem_info.Type,
};
println!("{:#?}", printable);
// 累加当前区域的大小,得到下一个查询地址
base_addr = ((base_addr as usize) + mem_info.RegionSize) as PVOID;
if (base_addr as usize) >= (max_addr as usize) {
break;
}
}
}
}
Cargo.toml:
[package]
name = "RustStudy"
version = "0.1.0"
edition = "2021"
[dependencies]
windows-sys = { version = "0.59.0", features = [
"Win32_Foundation",
"Win32_System_Memory",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
] }
1.8.5. 读取和写入进程内存的步骤
读取和写入进程内存的逻辑还算简单,伪代码如下:
let pid = some_process_id;
OpenProcess(pid);
loop 地址空间 {
调用VirtualQueryEx()来访问下个内存块
通过调用ReadProcessMemory()来访问内存块
寻找某种特定的模式
使用所需的值调用WriteProcessMemory
}
let pid = some_process_id;:获取当前进程的idOpenProcess(pid);:打开这个进程
Linux提供了简单的API:process_vm_readv()、process-vm_writev(),对应Windows中的ReadProcessMemory()、WriteProcessMemory()
1.9 所有权(简单回顾):所有权的核心思想、如何实现 Copy trait、值的删除(丢弃)、值删除的顺序
这篇文章只对所有权进行简单回顾。
1.9.1. 所有权的核心思想
Rust内存模型的核心思想是所有值都只有一个所有者。也就是说只有一个位置(通常是作用域来) 负责释放每个值。
这种效果是通过借用检查器(详见 1.11.2. 借用检查器(Borrow Checker))实现的,如果值移动了(赋值新变量、推到Vec上、置于堆内存上等),其所有者也变成新的位置了。
所有者实际上就是内存上的一个位置,数据所在的位置就是值的所有者。移动指的是数据从一个位置转移到另一个位置,新的位置就是数据的所有者。
但是有些类型不执行这种规则:如果值的类型实现了Copy trait,那么在重新赋值时不是移动而是复制。也就是把值复制一份放到新的位置。
1.9.2. 如何实现Copy trait
实现Copy trait的类型必须可以按位(bit)来复制值。
能实现Copy trait的类型自然不包括:
- 含有
non-copy类型的类型 - 如果一个类型在其值被丢弃时,必须执行某些特殊的资源释放操作
为什么呢?
想象一下,如果Box<T>实现了Copy trait,进行赋值:box1 = box2,这时候这两个变量都认为自己在堆内存上有一块专属于自己的空间,所以当它们走出作用域时,它们都会尝试释放那块内存,导致双重释放(double free),其危害在 【Rust指南】4.2. 所有权规则、内存与分配 中已作介绍,可以点击链接查看,这里不做重复。
1.9.3. 值的删除(丢弃)
当值不再被需要时,其所有者会将其删除。
值的删除(或者叫丢弃)发生于值走出作用域时。类型会递归地将其所包含的值删除。比如说我们要删除一个复杂类型的变量,会导致需要删除很多值。
但Rust不会发生多次删除同一个值的情况(因为所有权设计)。变量若含有对其他值的引用(不拥有该值),当变量被删除时,其它的值不会被删除。
这样说可能难理解,那我们看一个简单的例子:
fn main() {
let x1 = 42;
let y1 = Box::new(x1);
{
let z = (x1, y1);
}
let x2 = x1;
}
x1是i32类型,y1是Box<i32>类型,它拥有堆上的一块分配,里面存的是x1值的副本(因为i32实现了Copy,Box::new(x1)并不会借用x1)- 使用
{}创建了一个新的作用域,在这个小作用域里创建了z z是元组类型,其值是(x1, y1),x1是i32类型,实现了Copytrait,所以x1是把自己的值复制了一份给z;y1是Box<i32>,没有实现Copytrait,所以它不能复制值,而是把所有权转移给了z- 离开了小作用域之后又使用了一次
x1,x1此时还保持有效,因为x1是把自己的值复制了一份给z,自身仍然保持有效。 - 而
y1在给z赋值之后就失效了,因为它把所有权转移给了z
1.9.4. 值删除的顺序
- 变量(包括函数的参数)按照相反的顺序进行删除。
- 嵌套的值按照源代码的顺序进行删除。在上面的例子中,当离开内部作用域时会删除
z:它先删除第一个元素(那个被复制的i32),再删除第二个元素(那个Box)。因为y1的所有权已经移动进z,之后不会再单独删除y1。
注意:Rust暂时不允许在单个值内进行自我引用
1.10 引用及内部可变性(简单回顾):引用、内部可变性、Cell类型及相关操作
这篇文章只对引用与内部可变性进行简单回顾;所有权相关内容见 1.9. 所有权(简单回顾)。
1.10.1. 引用
通过引用,Rust允许将值借用出去,但不放弃所有权。
引用就是带有附加合约的指针(指针与引用的区别详见 1.1. 指针概览(上))。Rust中一共有两种引用类型。
1. 共享的引用
共享的引用,又叫不可变的引用,Rust中写作&T,其中T指代类型。
它的特点在一可以同时(或者叫在同一作用域内)存在任意数量的引用指向同一个值。每个共享的引用都实现了Copy trait。
共享引用背后的值不可变。编译器允许假定共享引用指向的值,在该引用存货期间是不会改变的。
举个例子:一个共享引用的值在某函数内被多次读取,那编译器就有权让其只读取一次,然后重用读取的值。
2. 可变引用
与不可变引用相对的就是可变引用,在Rust中写作&mut T。
可变引用是独占的,意味着在一个作用域内只能有一个可变引用,不能出现第二个可变引用或任意数量的共享引用。所以可变引用没有实现Copy trait(共享/不可变引用则实现了Copy)。
编译器会假定没有其它线程访问可变引用所指向的类型(无论是通过共享引用还是可变引用)。
1.10.2. 拥有值 vs. 拥有到值的可变引用
所有者需要对删除值(丢弃值)负责,除此之外两者的作用基本一样。
注意:如果你移动了可变引用背后的值,则必须在其位置上留下另一个值。如果不这样做,所有者会认为它需要将其删除(丢弃),但其实却没有值可以删除了,导致未定义行为或编译错误。
看个例子:
fn main() {
let mut s = String::from("Hello");
let r = &mut s;
let t = *r; // 试图移动 r 所指向的值
println!("{}", r); // r 变成了悬垂引用
}
输出:
error[E0507]: cannot move out of `*r` which is behind a mutable reference
--> src/main.rs:5:13
|
5 | let t = *r; // 试图移动 r 所指向的值
| ^^ move occurs because `*r` has type `String`, which does not implement the `Copy` trait
|
help: consider removing the dereference here
|
5 - let t = *r; // 试图移动 r 所指向的值
5 + let t = r; // 试图移动 r 所指向的值
|
help: consider cloning the value if the performance cost is acceptable
|
5 - let t = *r; // 试图移动 r 所指向的值
5 + let t = r.clone(); // 试图移动 r 所指向的值
|
我们来梳理一下过程:
r是s的可变引用,而*r的操作试图移动这个值(String类型没有实现Copytrait,意味着s会失去数据)- 由于
s仍然存在,当s作用域结束时,Rust期望可以正常释放它的内存 - 但
s已经被移动走了,导致Rust不知道该如何正确释放它,从而引发编译错误
正确的做法:
fn main() {
let mut s = String::from("Hello");
let r = &mut s;
let t = std::mem::replace(r, String::new()); // 用空字符串替换原值
println!("{}", t); // "Hello"
println!("{}", s); // ""
}
1.10.3. 内部可变性
一些类型提供了内部可变性,这些类型可以通过共享引用修改值。
这些类型通常依赖于额外的机制(如原子CPU指令)或不变量来提供安全的可变形,而不依赖于独占引用的语义。
内部可变性分为两类:
-
通过共享引用获得可变引用:
Mutex、RefCell这类类型提供了保障机制——如果对某个值提供了可变引用,那么同时(或者叫在同一作用域下)只会存在一个可变引用,并且没有共享引用。这种功能依赖于UnsafeCell类型,通过共享引用修改值的唯一正确方式。 -
通过共享引用可以替换值:
std::sync::atomic、std::cell::Cell这类类型没有提供可变引用到内部的值,但是提供了就地操作值的方法——比如说替换/读取一个值。例如:无法获得到usize或i32的直接引用,但是可以读取和替换值。
1.10.4. Cell类型
Cell类型来自于标准库,它通过不变量实现内部可变性。
Cell类型无法跨线程共享,因为内部值不会被并发地修改,即使通过共享引用发生修改- 不会提供到
Cell内部的值的引用(所以可以一直移动它)
Cell提供的方法:
- 对值整体替换(也就是所谓的就地操作)
- 返回值的副本(也就是读取)
1. set(value): 替换值
use std::cell::Cell;
fn main() {
let x = Cell::new(10); // 创建一个 `Cell`,存储 10
x.set(20); // 替换内部值
println!("Updated value: {}", x.get()); // 输出 20
}
set(value)用新值替换Cell内部的值
2. get():返回值的副本
use std::cell::Cell;
fn main() {
let x = Cell::new(5);
let y = x.get(); // 获取 `x` 内部的副本
println!("Value: {}", y); // 输出 5
}
get()不会返回内部值的引用,而是返回值的副本(适用于实现Copytrait 的类型)。- 适用于
i32、bool等实现Copytrait 的类型。
1.11 生命周期(进阶) Pt.1:回顾、借用检查器、泛型生命周期
这篇文章在已有基础上对生命周期这一概念进行补充。
1.11.1. 回顾
在初级教程中我们提到过:Rust里每个引用都有生命周期,它就是引用保持合法的作用域(scope),大多数时候都是隐式并且由编译器推断出来的。
对某个变量取得引用时生命周期开始,当变量移动或离开作用域时生命周期结束。也就是对于某个引用来说,它必须保持合法的一个代码区域的名称。
生命周期通常与作用域重合,但也不一定。
1.11.2. 借用检查器(Borrow Checker)
每当具有某个生命周期'a的引用被使用,借用检查器都会检查'a是否还存活。具体方法是:
- 追踪路径到
'a开始(获得引用)的地方 - 从这开始,检查沿着路径是否存在冲突
- 保证引用指向一个可安全访问的值
这个例子使用了 rand crate。在Cargo.toml中添加如下依赖:
[dependencies]
rand = "0.8"
看个例子:
use rand::random;
fn main() {
let mut x = Box::new(42);
let r = &x;
if random::<f32>() > 0.5 {
*x = 84;
} else {
println!("{}", r);
}
}
-
x是Box<i32>类型 -
把
r声明为x的引用,从这一行(第5行)开始引用的生命周期就开始了 -
第7行对
x进行了解引用修改值的操作,需要一个指向x的可变引用。借用检查器此时就会去出一个指向x的可变引用,并检查它的使用是否存在冲突,这个代码例中没有冲突,所以代码是合法的 -
有人可能会问了:第7行在
r的作用域里,*x需要指向x的可变引用,那在同一个作用域里既有不可变引用r和可变引用*x违反了借用规则不应该报错吗? 事实上Rust很聪明,知道代码如果走了if分支就不能走else分支了,r在if分支下根本就没有使用过,所以在if分支下使用*x这个可变引用是没有问题的。换句话说,r的生命周期并没有延伸到if分支里。这就是生命周期通常不与作用域完全重合的例子
再看一个例子:
fn main() {
let mut x = Box::new(42);
let mut z = &x;
for i in 0..100 {
println!("{}", z);
x = Box::new(i);
z = &x;
}
println!("{}", z);
}
x是Box<i32>类型z是x的引用,从这行(第4行)生命周期就开始了- 第6行,循环中打印了
z,使用到了z这个引用自然就会被借用检查器检查。这里没有什么问题,所以借用检查器不会报错 - 第7行,
x被重新赋值 - 第8行,
z被重新赋值,Rust会把新赋的这个引用视作是另一个引用,所以相当于第8行是新的生命周期,而原本的生命周期到7行就结束了 - 之后的每次循环都是
z = &x;这一行会开启一个新的生命周期。借用检查器因此不会报错
借用检查器的特性
借用检查器是保守的:如果不确定某个借用是否合法,借用检查器就会拒绝该借用。
借用检查器有时候会需要帮助来理解借用为什么是合法的,这就是Unsafe Rust存在的部分原因。
1.11.3. 泛型生命周期
有时候我们需要在自己的类型里储存引用。我们就需要给这些引用标注生命周期,以便借用检查器检查合法性。例如在类型方法中返回引用,且存活时间比self长。
Rust允许你基于一个或多个生命周期将类型的定义泛型化。
两点提醒
-
如果类型实现了
Droptrait,那么丢弃类型时,就被记作是使用了类型所泛型的生命周期或类型。如果类型没有实现Droptrait,那么类型丢弃时不会当作使用了生命周期,可以忽略类型内的引用。 举个例子:某个类型实例要被丢弃了,在丢弃之前,借用检查器会查看是否仍然合法地去使用你类型的泛型生命周期,因为你在drop函数中的代码可能会用到这些引用 -
类型可泛型多个生命周期,但通常没必要让类型签名变得更复杂。只有类型包含多个引用时,你才应该使用多个生命周期参数,并且返回的引用只应绑定到其中一个引用的生命周期。
看个例子:
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
}
'a表示有a这么一个生命周期,x、y以及返回类型都是这个生命周期a,这个时候就表示x、y和返回类型的生命周期是一样的。
1.12 生命周期(进阶) Pt.2:生命周期变型(Lifetime Variance)、协变(covariant)、不变(invariant)、逆变(contravariant)
这篇文章在已有基础上对生命周期这一概念进行补充;上篇见 1.11. 生命周期(进阶) Pt.1。
1.12.1. 生命周期变型(Lifetime Variance)
变型(Variance)是Rust类型系统中的一个概念,它描述了泛型参数(特别是生命周期参数)在类型层次结构中的继承关系。
我们可以简单地理解为变型是用于描述哪些类型是其他类型的“子类”的,这里的“子类”比较类似于Java和C#中的子类。
除此以外,变型还会关注什么时候“子类”能够替换“超类”(反之亦然)。
通常来说,如果A是B的子类,那么A至少和B一样有用。举一个Rust语言的例子:如果函数接收&'a str的参数,那么就可以传入&'static str的参数。因为'static是'a的子类,'static至少跟任何'a存活的时间一样长('static能够在程序运行中一直保持有效)。关于这个生命周期详见 1.6.2. 'static生命周期标注。
1.12.2. 生命周期的3种变型
所有类型都有变型,每个类型所对应的变型定义了哪些类似类型可以用在该类型的位置上。
注意:以下的内容比较难,建议你先回忆一下高中学的充分条件、必要条件等知识
1. 协变(covariant)
协变(covariant)指的是某类型只能用“子类型”来代替。
协变表示:
如果 A <: B(A是B的子类型),那么 F<A> <: F<B>(F<A>也是F<B>的子类型)
这是一个从小到大继承关系的传递,类似于充分条件的推导:如果 A 成立,则 B 一定成立(A 是 B 的充分条件)。
例如&'static T可以代替&'a T,因为&T是对'a这个生命周期的协变,所以'a可以由它的子类(比如说'static)来替代。
2. 不变(invariant)
不变(invariant)意味着必须提供指定的类型。
不变表示:
A <: B 不能推导出 F<A> <: F<B> 也不能 F<B> <: F<A>
这意味着F<A>和F<B>之间没有足够的关系,无法形成推导关系,因此它们既不是充分条件,也不是必要条件,它们是独立的。
比如说&mut T这个可变引用对于T来说就是不变的。
3. 逆变(contravariant)
逆变表示:
如果 A <: B(A是B的子类型),那么 F<B> <: F<A>(F<B>反而是F<A>的子类型)
这里的逻辑是 “想要F<A>成立,B必须满足A的条件”,更像必要条件:如果 B 成立,则 A 也必须成立(A 是 B 的必要条件)。
你可以把逆变理解为“成反比”:函数吧对参数的要求越低,参数可发挥的作用越大。
举两个例子:
-
假如有两个变量
x1和x2,其中x1的生命周期是'static,x2的生命周期是'a。那么毫无疑问x1的作用比x2大,因为它活得更长。 -
假如有两个函数
take_func1和take_func2,其中take_func1接收的参数是&'static str,take_func2接收的参数是&'a str。毫无疑问,take_func1对参数的要求比take_func2严格,这就导致了take_func1没有take_func2作用大。
由上述的两个例子看出,给变量声明一个更长的生命周期会使它的作用更大,但是要求函数的参数是更长的生命周期就会使函数的作用更小。这就是所谓的逆变。
那么这叫谁和谁的逆变呢?就是函数对它里面的参数类型的逆变。
1.12.3. 生命周期变型的作用
我们通过一个例子来看一下生命周期变型的作用:
struct MutStr<'a, 'b> {
s: &'a mut &'b str,
}
fn main() {
let mut s = "hello";
*MutStr { s: &mut s }.s = "world";
println!("{}", s);
}
这个代码令人比较困惑的地方在于MutStr这个结构体,我们来解析一下:
- 这个结构体只有一个字段,但是拥有两个生命周期
&'a mut表示 一个可变引用,这个可变引用的生命周期是'a&'b str表示 一个字符串切片的引用,这个字符串切片的生命周期是'b- 换句话说,
MutStr允许你存储一个可变引用,这个引用指向一个字符串切片的引用。你可以修改s本身,但不能修改&'b str指向的字符串内容
接下来我们来看一下主函数的逻辑:
-
let mut s = "hello";声明了s这个变量,类型是&str,值是“hello“ -
*MutStr { s: &mut s }.s = "world";这其实是好几步被合在了一行,我们分开来看:MutStr { s: &mut s }传递给MutStr结构体s的可变引用,此时MutStr下的s字段的值就是“hello“*MutStr { s: &mut s }.s = "world";的.s表示访问s字段(此时这个字段的值是&mut s)。*解引用s,即获得s这个字符串切片的引用本身。= "world"修改了指向的值——s之前指向“hello“,现在被修改为“world“,即s = "world"。
那如果只有一个生命周期还能这么写吗?
struct MutStr<'a> {
s: &'a mut str,
}
fn main() {
let mut s = "hello";
*MutStr { s: &mut s }.s = "world";
println!("{}", s);
}
输出:
error[E0308]: mismatched types
--> src/main.rs:7:31
|
7 | *MutStr { s: &mut s }.s = "world";
| ----------------------- ^^^^^^^ expected `str`, found `&str`
| |
| expected due to the type of this binding
error[E0277]: the size for values of type `str` cannot be known at compilation time
--> src/main.rs:7:5
|
7 | *MutStr { s: &mut s }.s = "world";
| ^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `str`
= note: the left-hand-side of an assignment must have a statically known size
在这行合写的代码上,rustc 把失败报在赋值处(expected str, found &str,以及 str 不是 Sized)。更深层的类型问题是:&mut s 的类型是 &mut &str,但字段期望的是 &mut str。
具体来说:
- 变量
s的类型是&str(引用一个字符串切片)。 - 当写
&mut s时,其类型实际上是&mut &str,即对变量s的可变引用。然而,结构体MutStr的定义要求字段s的类型是&mut str。 - 如果把构造单独写成
MutStr { s: &mut s },rustc 则会报告:不能把&引用背后的数据再可变借用——这是同一类底层不匹配,只是报错位置不同。
这是所指类型不同,而不是生命周期子类型问题。(另外需要注意:&mut T确实支持 unsizing 强制转换,例如&mut [T; N] → &mut [T];但这种机制仍然不能把&mut &str变成&mut str。)
不变性真正起作用的地方是双生命周期版本:&'a mut &'b str 在 'b 上是不变的,这可以防止你通过可变引用赋值时,以不安全的方式缩短内部借用。
也可以这么理解:
- 字符串字面值(“hello“和“world“就是字符串字面值)是
&str类型,有隐式的'static生命周期标注,也就是说&str实际上是&'static str。原本的结构体里的'b对应它 - 结构体的
'a对应可变引用的生命周期,也就是*MutStr { s: &mut s }.s = "world"这一行中的&mut这个可变引用的生命周期 - 修改之后的这个只有一个生命周期参数的结构体期望的是
&mut str,但&mut s的类型仍是&mut &str,因此类型不匹配
1.13 内存中的类型 Pt.1:对齐(Alignment)、布局(Layout)、repr属性
1.13.1. 类型的基本职责
每个Rust值都有类型,而类型的职责在于告诉你如何解释内存中的比特位(bits)。
例如:0b10111101这串比特(bits)本身并没有意义,但是:
- 用
u8类型来解释就会得到数字189 - 用
i8类型来解释就会得到数字-67
当自定义类型时:编译器决定该类型的各部分在内存表示中的位置
1.13.2. 对齐(Alignment)
对齐(Alignment)决定了类型的字节可以被存储在哪里。
而一旦类型的表示被确定之后,你可能想在内存上随便找个地方存进去就行,这在理论上是可行的。但实际上计算机硬件对给定的类型可以存放的位置是有约束的。
最典型的一个例子是指针,它指向的是字节(bytes),而不是位(bits),1个字节等于8比特。换言之,它并不指向具体的比特。所以如果将某类型的值放在计算机内存中索引为4的位(bits)上,那就无法引用它的地址,因为指针指向的是字节,而不是具体的比特,所以就必须对齐字节,也就是对齐到8比特。
出于这个原因,所有的值(无论什么类型),都必须开始于字节的边界。所有类型必须至少是字节对齐的(byte-aligned)。换言之,存放的地址必须是8bits的整数倍。
1.13.3. 更严格的对齐规则
有一些类型的对齐规则比字节的对齐规则还要严格:在CPU和内存系统里,内存经常按大于单个byte的块进行访问。
例如:在64位的CPU上,大部分的值是按照8bytes的块进行访问的,每个操作都开始于“8bytes对齐”的地址上。(这也叫做CPU的字长,英文是word size)
CPU当然也有办法处理更小值的读写,以及跨越块边界的值。但是我们作为开发者应该尽可能地保证硬件可以操作于它的原生(native)对齐。
举个例子:如果想读取的i64值它开始于8bytes块的中间,那这个时候要读取它就至少需要两次读取。因为i64是8字节,而它开始于两个8字节块中间说明它一定横跨了这两个块。所以在读取时引进就得从这两个块读取数据,第一个块读取i64的前面部分,第二个块读取i64的后面部分,然后再把它们合并到一起。
这种操作是非常低效的,会拖累程序执行的速度,所以我们应该尽可能保证硬件可以操作于它的原生对齐。
1.13.4. 没对齐的操作
CPU访问内存时,数据的地址没有按照架构要求的对齐方式进行访问叫做“misaligned access“。这会导致性能低下和并发问题。
很多CPU操作多要求/强烈建议它们的参数是自然对齐的(naturally aligned)。自然对齐值的对齐是匹配他们值的大小的。
例如我想加载8字节,那么提供的地址就需要8字节对齐。
1.13.5. 编译器会尽可能利用对齐
基于类型包含的内容,编译器通过计算为类型分配一个对齐(或者叫给它分配一个对齐方案):
-
对于内值的值,通常对齐到它们的大小。比如说
u8按1字节对齐,u16按2字节对齐,u32按4字节对齐,u64按8字节对齐。 -
而复杂类型(包含其它类型的类型),通常被赋予所含类型的最大对齐。例如某类型含有
u8、u16和u32这三个类型的字段,那么类型就应该是4字节对齐(u32是最大对齐,为4字节)
1.13.6. 布局(Layout)
类型的布局(Layout)指的是编译器决定这个类型在内存上如何表示。
Rust编译器对于类型如何布局,并没有给出多少保证。
Rust提供了repr属性(attribute):它可以添加到你类型的定义上,来请求特定的类型表示。
1.13.7. repr(C)
repr属性(attribute)最常见的一个是repr(C)。名字里带个C说明跟C语言有关系。
repr(C)布局方式与C/C++编译器对同类型的布局兼容。这对于使用FFI(外部函数接口,英文是Foreign Function Interface)与其它语言交互的Rust代码很有用。
使用FFI与其它语言交互的时候,Rust会生成一个匹配其它语言编译器期望的布局。因为C语言的布局是可预测且不易改变的,所以repr(C)在unsafe(unsafe Rust详见 【Rust指南】19.1. 摆脱安全性限制的unsafe Rust)的上下文是非常有用的。
比如说你使用指向该类型的原始指针时,或者在两个具有相同字段的类型间进行转换时,都可以使用到repr(C)。
1.13.8. repr(transparent)
repr(transparent)中的transparent是透明的意思,它用于 newtype 风格的包装类型,并保证外层类型与其唯一的非零大小字段具有相同的布局。(若还有其它字段,它们必须是零大小类型,例如()或PhantomData。)
这与newtype模式(详见【Rust指南】19.5. 高级类型)结合起来很好用。
我们在这里回顾一下newtype模式:利用元组结构体来构建一个新的类型放在本地,相当于是薄封装。
举个例子:你想操作struct A和struct NewA(A)的内存表示,使用了repr(transparent)之后两者的内存表示就应该是一样的。不使用的话Rust编译器就没发保证了。
1.13.9. 使用repr属性的例子
我们来看一个例子:
| 代码 | 字段类型的大小 | 默认内存表示 | 填充 | 最终对齐 |
|---|---|---|---|---|
#[repr(C)] | ||||
struct Foo { | ||||
tiny: bool, | 1 bit | 1 byte 对齐 | 3 bytes | |
normal: u32, | 4 bytes | 4 bytes 对齐 | (tiny + normal)8 bytes | |
small: u8, | 1 byte | 1 byte 对齐 | 7 bytes | 8 bytes |
long: u64, | 8 bytes | 8 bytes 对齐 | 8 bytes | |
short: u16, | 2 bytes | 2 bytes 对齐 | 6 bytes | 8 bytes |
} | ||||
| 共 32 bytes |
这个表展现了 Rust 结构体在#[repr(C)]下的内存对齐和填充:
-
代码是最左边的这列,使用了
repr(C)注解。结构体里面有好几个字段 -
Rust编译器首先看
tiny字段是bool类型的,占1bit内存,就会对齐到1字节 -
编译器接着看
normal字段是u32类型的,占4字节,所以对齐到4字节即可。这时候Rust发现tiny字段对齐到的是1字节,所以编译器就会填充3字节让tiny字段占4字节 -
由于这个字段刚好占了8字节,是4字节的整数倍,所以已经对齐了
-
small字段是u8类型,占1字节,对齐到1字节。由于上面的两个字段已经对齐了,所以Rust编译器会根据下文的字节来判断给它填充多少字节。判断到这里Rust编译器还得观望一下。 -
long是u64类型,占8字节,自然就是8字节对齐。它的字段是8字节及以上。此时我们看tiny和normal组成了8字节对齐,long也是8字节对齐,Rust明白了现在的情况是应该按8字节对齐。那就只能给small字段填充7个字节补成一个8字节对齐了。 -
short是u16类型,占2字节,由于现在的情况是应该按8字节对齐,所以编译器会给它补6字节合成8字节对齐。
其过程用表格表述就是:
| 字段 | 类型大小 | 需要的对齐 | 填充情况 | 备注 |
|---|---|---|---|---|
tiny: bool | 1 bit | 1 byte | 3 bytes 填充 | 为了对齐下一个 u32 |
normal: u32 | 4 bytes | 4 bytes | 无填充 | 按 u32 对齐 |
small: u8 | 1 byte | 1 byte | 7 bytes 填充 | 为了对齐下一个 u64 |
long: u64 | 8 bytes | 8 bytes | 无填充 | 8 字节对齐 |
short: u16 | 2 bytes | 2 bytes | 6 bytes 填充 | 以 8 字节对齐结构体 |
1.14 内存中的类型 Pt.2:动态大小的类型和宽指针(Wide Pointer)、无填充内存布局、给特定字段或类型更大的对齐、复杂类型的内存表示、repr(Rust)
1.14.1. repr(Rust)
还记得我们上一篇文章的例子吗?那个例子使用repr(C),而C的表示的限制在于需要将所有的字段按原struct定义的顺序放置。
repr(Rust)是默认表示。它有意比repr(C)提供更少的布局保证:编译器可以重排字段,而且即便两个类型拥有相同字段、相同字段类型、相同定义顺序,也不能保证它们有相同的内存布局。
因为编译器可以重排字段(例如把较大的字段放在前面),填充常常可以减少。对于上一篇文章的Foo例子,有一种可能的优化布局就不需要填充。
对布局的保证少了,编译器就有余地进行重新编排,从而产生高效的代码。
如果使用repr(Rust),那么上文的Foo结构体在内存中的一种可能布局是:
| 代码 | 字段类型的大小 | 默认内存表示 | 填充 | 最终对齐 |
|---|---|---|---|---|
#[repr(Rust)] | ||||
struct Foo { | ||||
long: u64, | 8 bytes | 8 bytes 对齐 | 8 bytes | |
normal: u32, | 4 bytes | 4 bytes 对齐 | ||
short: u16, | 2 bytes | 2 bytes 对齐 | ||
small: u8, | 1 byte | 1 byte 对齐 | ||
tiny: bool, | 1 bit | 1 byte 对齐 | ||
} | ||||
| 共计 16 bytes |
- 编译器首先按照字段类型的大小进行排列,把最大的放在前面,这样就可以决定它是按什么对齐的(这个例子中
u64是最大的,占8字节,所以就以8字节对齐) - 编译器往后面一看剩下的所有字段加一起正好是8字节,那么就会让他们连着放在一起,就可以避免填充
- 最终这个例子的结构体只需要16字节就可以了,比使用
repr(C)直接节约了一半的内存空间 - 这样更加高效,但是编译时间可能会长一点
1.14.2. 无填充布局
你可以告诉编译器字段间无需任何填充,但是你需要承担不对齐访问造成的性能损失。
在内存有限,类型实例较多的时候就可以使用无填充布局。或者是通过低带宽网络连接发送内存表示时。
启用无填充布局需要在类型上需要添加*[repr(packed)]注解。
注意,使用无填充布局:
- 可能会导致代码运行速度变慢
- 极端情况下,如果CPU只支持对齐操作,可导致程序崩溃
1.14.3. 给特定字段或类型更大的对齐
使用#[repr(align(n))]注解可以给特定字段或类型更大的对齐,其中n是参数。
例如:保证在内存中连续(相邻)存储(就像数组)的不同值最终位于CPU上不同的缓存上,就可以避免伪共享(false sharing)。
简单解释一下相关的术语:
- 缓存是由缓存行组成的,缓存都是以缓存行作为一个单位来处理的。缓存行(cache line) 是可以映射到缓存中的最小数据部分
- 伪共享指的是两个不同的CPU访问共享同一个缓存行的不同变量时,就发生了伪共享。理论上它们可以并行操作,但最终它们都争相更新缓存中的同一个条目。它可能导致并发类程序中的巨大性能降级
1.14.4. 复杂类型的内存表示
- 元组(Tuple,详见【Rust指南】3.3. 数据类型:复合类型):在内存中的表示就像结构体,其字段类型与元组元素类型按顺序相同
- 数组:所包含的类型的连续序列,元素间没有填充
- Union:对于每一个字段来说,其布局的选择是独立的;对齐就是所有字段里最大的那个
- 枚举:和Union一样,额外有一个隐藏的共享字段,用于存储枚举变体的鉴别符。代码用鉴别符的值来判定给定值所含的是哪一个变体,鉴别符的大小取决于变体的数量
1.14.5. 动态大小的类型和宽指针
Rust中大多数类型自动实现了Sized trait,关于它的详细介绍见【Rust指南】19.5.4. 动态大小类型与Sized trait,这里做一个简单的回顾。
Rust 需要了解有关其类型的某些详细信息,例如为特定类型的值分配多少空间。这使得动态大小类型(dynamically sized types) 这个概念有些迷惑人。它有时被称为DST或unsized types,这些类型允许我们使用只能在运行时知道其大小的值来编写代码。
为了使用动态大小类型,Rust提供了Sized trait来确定类型的大小在编译时是否已知。对于编译时大小已知的所有内容,都会自动实现此trait。此外,Rust隐式地为每个泛型函数添加了Sized trait。默认情况下,泛型函数仅适用于编译时大小已知的类型。但是也可以使用?Sized来放宽此限制。?Sized意味着“ T可能实现也可能没实现Sized trait”,也就是T可能是动态大小类型也可能不是。这种表示方法不需要泛型类型在编译时必须具有已知大小这个默认条件。有这种含义的?Trait语法仅适用于Sized trait ,没有任何其他trait。
当函数需要接受DST(例如trait对象、切片等)作为参数时怎么办呢?我们可以使用宽指针(wide pointer,或者叫fat pointer)。
1.14.6. 宽指针(Wide Pointer)
通过将非Sized类型放在宽指针后面,就可以弥补Sized和非Sized类型之间的差距。
那么到底什么是宽指针呢?宽指针就是普通指针附加一个“字大小(word-size)“的字段。它可以提供给编译器所需要的关于指针的额外信息,以生产使用该指针的合理代码。
当你引用DST时,编译器会自动为你组建一个宽指针。比如说切片(Slice) 的附加信息就是切片的长度。
宽指针是Sized是因为它本质上是指针,大小是固定的(usized的2倍——一个字段用于存储指针,另一个用于存储附带信息,用于“完善该类型”)。
备注:Box<T>和Arc<T>都支持存储宽指针,所以它们都支持?Sized。
1.15 Trait bounds(Trait 约束)的编译与分派
1.15.1. 静态分发(static dispatch)
编译泛型代码时发生了什么?
编译器会针对每个T(每个类型),都将类型或函数复制一部分(每个类型都有自己的函数),这个过程叫单态化(monomorphization)。详见【Rust指南】10.2.6. 泛型代码的性能。(调用dyn Trait上的方法则不同:那是动态分发,见本文后面部分;详见【Rust指南】17.2.3. trait对象执行的是动态派发。)
当你构建Vec<i32>或HashMap<String, bool>时,编译器会复制它的泛型类型以及所有的实现块。例如Vec<i32>就是把Vec<T>的T替换成i32,对Vec做了一个完整的复制,所有遇到的T都换成i32。
也就是说编译器会把实例的泛型参数使用具体类型替换。需要注意的是,编译器其实并不会做完整的复制粘贴,他只复制你用的代码。
看个例子:
#![allow(unused)]
fn main() {
impl String {
pub fn contains(&self, p:impl Pattern) -> bool {
p.is_contained_in(self);
}
}
}
- 这个例子针对
String类型实现了一个contains方法 contains方法的第二个参数p的trait约束是Pattern。p没有实际类型,只有trait约束,所以p就相当于泛型参数
p在实际使用时可能会是不同的类型。针对不同的类型,该方法都会复制一遍,因为我们需要知道is_contained_in方法的地址,以便进行调用。CPU需要知道在哪跳转和继续执行。
对于任何给定的p,编译器知道那个地址的类型是实现了Pattern trait方法的。不存在一个可给任意类型的通用地址。
PS:我知道你在想Python的动态类型,Python变量本质上是对象的引用,而并非直接存储值。
正是因为如此,编译器需要为每个类型复制一个(方法体),每份都有自己的地址来用来跳转。这就是所谓的静态分配(static dispatch),因为对于方法的任何给定副本,我们“分派到”的地址都是静态已知的。
- 静态(static) 在编程中通常指编译中已知的事物(或可被视为此的)
1.15.2. 单态化(monomorphization)
单态化(monomorphization)指的是从一个泛型类型到多个非泛型类型的过程。Rust的trait就有这个特点。
当编译器优化完代码后,就好像根本没有泛型。每个实例都是单独优化的,具有了所有的已知类型,所以上文例子里的is_contained_in方法调用的执行效率就如同trait不存在一样,没有任何性能损失。
编译器对涉及的类型完全掌握,(在合适的情况下)甚至可以将它们进行inline实现。
- “对涉及的类型完全掌握”指的是Rust是静态类型的语言,在编译时就能够确定所有变量和函数的类型,不需要在运行时进行类型推导。
- “将它们进行inline实现”指的是将函数的实现直接展开到调用的地方,避免函数调用的开销。
#[inline(always)]
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = add(2, 3); // 可能被编译器优化为 let x = 2 + 3;
}
1.15.3. 单态化的代价
- 所有实例都需要单独编译,编译时间会因它而增加(如果不能优化编译)
- 每个单态化的函数会有自己的一段机器码,让程序更大
- 指令在泛型方法的不同实例间无法共享,CPU的指令缓存效率降低,因为它需要持有相同指令的多个不同副本
1.15.4. 动态分发(dynamic dispatch)
动态分发(dynamic dispatch)使代码可以调用泛型类型上的trait方法,而无需知道具体的类型。
上面的代码例稍作修改即可实现动态分发:
#![allow(unused)]
fn main() {
impl String {
pub fn contains(&self, p:&impl Pattern) -> bool {
p.is_contained_in(&*self);
}
}
}
这个例子中,实现动态分发需要调用者提供两个信息:
Pattern的地址is_contained_in的地址
为什么impl Pattern前要加&?
- 动态分发依赖于trait 对象,而trait对象本质上是一个宽指针(fat pointer,上一篇文章有讲),所以说传入的数据得是一个引用(因为Rust不能确定动态分发类型的内存大小,所以只能用引用)
1.15.5. vtable
实际上,调用者会提供指向一块内存的指针,它叫做虚方法表(virtual method table,简称vtable)。
上例中,它持有该类型中所有的trait方法实现的地址,其中一个就是is_contained_in这个方法的地址。
当代码想要调用提供类型的一个trait方法时,就会从vtable查询is_contained_in方法的实现地址并调用。这就允许我们使用相同的函数体,而不关心调用者想要使用的类型。
每个vtable还包含具体类型的布局和对齐信息(总是需要这些信息配合使用)。
1.15.6. 对象安全(Object-Safe)
类型实现了一个trait和它的vtable的组合就形成了一个trait object(trait对象)。
大部分trait可转为trait object,但不是所有。例如Clone trait就不行(它的clone方法返回self),Extend trait也不行。这些例子就不是对象安全的(object-safe)。
对象安全的具体要求是:
- trait的所有方法都不能是泛型的,也不可以使用
self - trait不可以拥有静态方法,因为无法知道在那个实例上调用的方法
1.15.7. self: Sized
self: Sized意味着self无法用于trait object(因为它是!Sized)。
将self: Sized用在某个trait,就是要求永远不使用动态分发。
我们也可以将self: Sized用在特定方法上,这时当trait通过trait object访问的时候,该方法就不可用了。
当检查trait对象是否安全的时候,使用了where self: Sized的方法就会免除
1.15.8. 动态分发的优缺点
| 优点 | 缺点 |
|---|---|
| 编译时间减少 | 编译器无法对特定类型优化 |
| 提升 CPU 指令缓存效率 | 只能通过 vtable 调用函数 |
| 直接调用方法的开销增加 | |
| trait object 上的每次方法调用都需要查 vtable |
1.15.9. 如何在静态分发和动态分发间选择
| 静态分发 | 动态分发 |
|---|---|
| 在library中使用静态分发 | 在binary中使用动态分发 |
| 无法知道用户的需求 | binary 是最终代码 |
| 如果使用动态分发,用户也只能如此 | 动态分发使代码更整洁(省去了泛型参数) |
| 如果使用静态分发,用户可自行选择 | 编译更快 |
| 以边际性能为代价 |
1.16 泛型trait:泛型(类型参数)trait、关联类型trait
这篇文章以概念性和建议性的文字偏多,需要你对泛型类型参数和关联类型有一定了解。
1.16.1. trait的泛型方式
trait的泛型方式有两种:
- 泛型类型参数。例:
trait Foo<T> - 关联类型。例:
trait Foo{type Bar;}
两者的区别在于:
- 使用关联类型这种形式的效果就是对于指定类型的trait只有一个实现
- 使用泛型参数类型则可以有多个实现
这里有一个简单的建议:可以的话尽量使用关联类型。
1.16.2. 泛型(类型参数)trait
泛型trait要求必须指定所有的泛型类型参数,并重复写这些参数的约束(bounds)。
这么写维护起来会难一些。比如说如果添加泛型类型参数到某个trait,该trait的所有实现者都必须更新代码。
这种形式还可能会导致针对给定的类型,一个trait有多重实现的问题。编译器会更难推断你想要的到底是trait的哪个实例。有时候不得不调用类似FromIterator::<u32>::from_iter这样可以消除歧义的函数。
这个特性有的时候也会是一个优点,例如:
impl PatialEq<BookFormat> for Book,其中BookFormat就可以是不同的类型- 可以同时实现
FromIterator<T>和FromIterator<&T> where T:Clone
1.16.3. 关联类型trait
我们以一段代码为例:
#![allow(unused)]
fn main() {
trait Contains {
type A;
type B;
//Updates syntax to refer to these new types generically
fn contains(&self, _: &Self::A, _: &Self::B) -> bool;
}
}
使用关联类型:
- 编译器只需要知道实现trait的类型
- 约束(bound) 可以完全位于trait本身,不必重复使用
- 未来再添加关联类型也不影响用户使用
- 具体的类型会决定trait内关联类型的类型,无需使用消除歧义的函数,看个例子:
#![allow(unused)]
fn main() {
impl Contains for Container {
// Specify what types `A` and `B` are. If the `input` type
// is `Container(i32, i32)`, the `output` types are determined
// as `i32` and `i32`.
type A = i32;
type B = i32;
// `&Self::A` and `&Self::B` are also valid here.
fn contains(&self, number_1: &i32, number_2: &i32) -> bool {
(&self.0 == number_1) && (&self.1 == number_2)
}
// Grab the first number.
fn first(&self) -> i32 { self.0 }
// Grab the last number.
fn last(&self) -> i32 { self.1 }
}
}
- 这个例子写了为
Container类型实现Containstrait Container类型的Containstrait中的关联类型通过type A = i32;和type B = i32;这两行代码决定
不可以对多个目标(Target)类型来实现Deref trait
看一下Deref trait的源代码:
#![allow(unused)]
fn main() {
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
}
type Target: ?Sized;中的Target就是我们说的目标(Target)类型
我们随便写一个Deref trait的实现来说明一下:
#![allow(unused)]
fn main() {
use std::ops::Deref;
struct Wrapper {
value: String,
}
impl Deref for Wrapper {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.value
}
}
}
上面代码中,Wrapper只能解引用为String。但如果你想让Wrapper同时解引用为String和str,Rust不允许你再实现Deref,因为Target只能有一个具体类型。
也就是说,这么写是非法的:
#![allow(unused)]
fn main() {
use std::ops::Deref;
struct Wrapper {
value: String,
}
// 第一次实现 Deref,Target = String
impl Deref for Wrapper {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.value
}
}
// 这是非法的,Rust 不允许对同一个类型 `Wrapper` 进行第二次 `Deref` 实现
impl Deref for Wrapper {
type Target = str; // 冲突,Rust 不能推断哪个 `Target` 生效
fn deref(&self) -> &Self::Target {
&self.value
}
}
}
不可以使用多个Item来实现Iterator trait
其原因与不可以对多个目标(Target)类型来实现Deref trait一样,主要涉及关联类型的唯一性和Rust 编译器的推断规则。
1.17 孤儿规则与连贯性(一致性):泛实现(Blanket Implementation)、覆盖实现(Covered Implementation)
1.17.1. 连贯性(一致性)属性
连贯性(或者叫一致性)是指对于给定的类型和方法,只会有一个正确的选择,用于该方法对该类型的实现。
孤儿规则(orphan rule)指的是只要trait或者类型在本地的crate,那就可以为该类型实现该trait。 比如说:
- 你定义在本地的类型可以实现
Debugtrait - 可以为
bool类型实现你定义在本地的trait - 不能为
bool类型实现Debugtrait,因为这两者都不是定义在本地的
这个孤儿规则也有例外,我们下文再讲。
1.17.2. 泛实现(Blanket Implementation)
泛实现(Blanket Implementation),又叫通用实现,它指的是Rust允许为所有符合某个trait约束的类型提供默认实现。
它的模版是:
#![allow(unused)]
fn main() {
impl<T> MyTrait for T where T:
}
它的意思是为所有实现了某个trait的类型实现MyTrait。
例如:
#![allow(unused)]
fn main() {
impl<T: Display> ToString for T {}
}
这句话的意思是为所有实现了Display trait的类型实现了ToString trait。
这个例子的写法还不是模版的写法,换成模版的写法就是:
#![allow(unused)]
fn main() {
impl<T> ToString for T where T: Display {}
}
需要注意的是,只有定义trait的crate允许使用泛实现。添加泛实现到现有trait属于破坏性变化。
1.17.3. 基础类型
有些类型太过于基础了,需要允许任何人在它们上实现trait(即使违反孤儿规则)。这些类型被标记为#[fundamental],目前包括&T、&mut T、Box<T>、Pin<P>。
- 一点补充:
Pin<P>的主要作用是确保某个值无法被移动,即防止Rust代码调用std::mem::replace、std::mem::swap或者std::mem::take之类的操作导致值的物理地址发生变化 - 处于孤儿原则的目的,实际上在孤儿规则检查之前,它们会被抹除
注意:对于基础类型使用泛实现也被认为是破坏性变化。
1.17.4. 覆盖实现(Covered Implementation)
有时候需要为外部类型实现外部trait,这就叫覆盖实现(Covered Implementation)。这样写使用到了孤儿规则制定的一个狭窄的豁免:允许在非常特定的情况下为外来类型实现外部trait。
注意:覆盖实现既可以指Covered Implementation,也可以指Override Implementation(又叫覆写实现),这里指的是Covered Implementation。覆写实现指的是当结构体实现 trait并提供自己的方法时,可以**覆写(Override)**默认实现。
这种写法的模版是:
#![allow(unused)]
fn main() {
impl<P1..=Pn> ForeignTriat<T1..=Tn> for T0
}
P1..=Pn和T1..=T0指的是若干个参数
这种写法只在以下条件被允许:
T1..=Tn里至少有一个是本地类型- 没有
T(T是指泛型类型P1..=Pn中的一个)在第一个这样的本地类型之前 - 泛型类型参数
P允许出现在T0..Ti,只要它们被某种中间(intermediate)类型所包裹- 如果
T作为其他类型(例如Vec<T>)的类型参数出现,那就说明T被包裹了 T只作为本身,或者位于基础类型后(例如&T),就不是包裹
- 如果
举个简单的例子,比如说:
#![allow(unused)]
fn main() {
impl From<MyType> for Vec<i32>
}
需要为外部的Vec<i32>这个类型来实现From<MyType>这个trait
再举一些复杂的例子,你可以对照着规则来理解:
| 实现 | 是否可行 |
|---|---|
impl<T> From<T> for MyType | OK |
impl<T> From<T> for MyType<T> | OK |
impl<T> From<MyType> for Vec<T> | OK |
impl<T> ForeignTrait<MyType, T> for Vec<T> | OK |
-------------------------------------------- | ———— |
impl<T> ForeignTrait for T | Not OK |
impl<T> From<T> for T | Not OK |
impl<T> From<Vec<T>> for T | Not OK |
impl<T> From<MyType<T>> for T | Not OK |
impl<T> From<T> for Vec<T> | Not OK |
impl<T> ForeignTrait<T, MyType> for Vec<T> | Not OK |
判断覆盖实现是否是破坏性变化需要结合实际情况:
- 为现有trait添加新的实现,且至少包含一个新的本地类型,该本地类型满足和面条件,这就是非破坏性的变化
- 为现有的trait添加的实现不满足上述要求,就是破坏性变化
注意:
impl<T> ForeignTrait<MyType, T> for Vec<T>是合法的impl<T> ForeignTrait<T, MyType> for Vec<T>是非法的
2.1. API设计原则之不意外性(unsurprising) Pt.1:命名的技巧、实现常用的trait(Debug、Send、Sync和Unpin)
2.1.1. 什么是不意外(unsurprising)原则
不意外原则也叫做最少意外原则,它的意思是你写的API应该尽可能的直观。
用户一看到接口就应该能猜出来是干什么用的。至少你写的接口不应该让人感到意外。它的核心思想是贴近用户已经知道的东西,这样用户就不需要重学概念。比如说接口名字里有error,那么用户大概就能猜到这是用来做错误处理的。
也就是说,我们需要让我们写的接口可以预测,这就要求在以下几点:
- 命名
- 实现常用的trait
- “人体工程学的(Ergonomic)” trait
- 包装类型(Wrapper Type)
2.1.2. 命名的技巧
接口的名称,应该符合惯例,便于推断功能。惯例指的是Rust标准库和Rust社区常用的惯例。
举几个例子:
- 方法
iter(或者是名字以iter结尾),大概率应将&self作为参数,并应该返回一个迭代器(iterator) - 叫做
into_inner的方法,大概率将self作为参数,并返回某个包装的类型 - 叫做
SomethingError的类型,应该实现std::error::Error,并出现在各类Result类型里
将通用/常用的名称用于相同的目的,有助于用户的理解。这又引出来一个推论:同名的事物应该以相同的方式工作,否则用户大概率会写出错误的代码。
2.1.3. 实现常用的trait
用户通常会假设接口中的一切皆可“按预期地工作”,例如:
- 可以使用
{:?}打印任何类型 - 可发送任何东西到另外的线程(可以跨线程的)
- 每个类型都是
Clone的
所以在写代码时要积极地去实现大部分标准trait,即使不立即能用到。
从另一个方面去想,用户无法为外部的类型实现外部的trait(因为违背了孤儿规则,详见 1.17.1. 连贯性(一致性)属性),这使得他们很难为你的类型实现他们想要的trait。所以你应该积极地去实现大部分标准trait,让你的类型能够实现大部分用户想要的trait。
2.1.4. 建议实现Debug trait
几乎所有的类型,都能且应该实现Debug trait。
最简单的、最佳的实现方式是使用#[derive(Debug)]注解。需要注意的是,派生的trait会为任意的泛型参数添加相同的约束(bound)。
看个例子就明白了:
use std::fmt::Debug;
#[derive(Debug)]
struct Pair<T> {
a: T,
b: T,
}
fn main() {
let pair = Pair { a: 5, b: 10 };
println!("{:?}", pair);
}
Pair结构体使用了派生的方式实现了Debugtrait,所以说它对泛型参数T自动添加了限定条件:T得实现了Debugtraitmain函数里Pair的字段的类型是i32,实现了Debugtrait,所以打印的出来
输出:
Pair { a: 5, b: 10 }
那如果我把字段类型改成没有实现Debug trait的呢:
use std::fmt::Debug;
struct Person {
name: String,
}
#[derive(Debug)]
struct Pair<T> {
a: T,
b: T,
}
fn main() {
let pair = Pair {
a: Person { name: "Dave".to_string() },
b: Person { name: "Nick".to_string() },
};
println!("{:?}", pair);
}
输出:
error[E0277]: `Person` doesn't implement `Debug`
--> src/main.rs:18:22
|
18 | println!("{:?}", pair);
| ---- ^^^^ `Person` cannot be formatted using `{:?}` because it doesn't implement `Debug`
| |
| required by this formatting parameter
|
= help: the trait `Debug` is not implemented for `Person`
= note: add `#[derive(Debug)]` to `Person` or manually `impl Debug for Person`
help: the trait `Debug` is implemented for `Pair<T>`
--> src/main.rs:7:10
|
7 | #[derive(Debug)]
| ^^^^^
note: required for `Pair<Person>` to implement `Debug`
--> src/main.rs:8:8
|
7 | #[derive(Debug)]
| ----- in this derive macro expansion
8 | struct Pair<T> {
| ^^^^ - type parameter would need to implement `Debug`
= help: consider manually implementing `Debug` to avoid undesired bounds
help: consider annotating `Person` with `#[derive(Debug)]`
|
3 + #[derive(Debug)]
4 | struct Person {
|
另外我们也可以使用标准库里的fmt::Formatter提供的各种debug_xxx辅助方法手动实现:
debug_structdebug_tupledebug_listdebug_setdebug_map
看例子:
use std::fmt;
struct Pair<T> {
a: T,
b: T,
}
impl<T: fmt::Debug> fmt::Debug for Pair<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Pair")
.field("a", &self.a)
.field("b", &self.b)
.finish()
}
}
fn main() {
let pair = Pair { a: 1, b: 2 };
println!("{:?}", pair);
}
- 我们手动实现
Debugtrait而不是使用#[derive(Debug)]标注 fmt是实现fmt::Debug时必须定义的方法f: &mut fmt::Formatter<'_>:提供了格式化的上下文和工具f.debug_struct("Pair"):声明格式化为一个带有字段的调试结构体,并指定结构体的名称为"Pair".field("a", &self.a)和.field("b", &self.b):为Pair添加两个字段a和b,并关联各自的值.finish():完成格式化的构建,并返回将被打印的结果
输出:
Pair { a: 1, b: 2 }
2.1.5. 建议实现Send、Unpin和Sync trait
如果你的类型没有实现Send trait,就不能把它移动到另一个线程(例如 thread::spawn 要求 T: Send)。把 !Send 的值包进 Mutex<T> 也无济于事:只有当 T: Send 时 Mutex<T> 才是 Send/Sync,仍然无法跨线程共享。
看一个例子:
use std::rc::Rc;
fn main() {
let x = Rc::new(42);
std::thread::spawn(move || {
println!("{:?}", x);
});
}
Rc<T>没有实现Sendtrait所以不能在多线程间使用
我们可以自己写一个简单的元组结构体(当然就没有Rc<T>的引用计数功能了)来实现:
#[derive(Debug)]
struct MyBox(*mut u8);
unsafe impl Send for MyBox {}
fn main() {
let mb = MyBox(Box::into_raw(Box::new(42)));
std::thread::spawn(move || {
println!("{:?}", mb);
});
}
MyBox实现了Sendtrait所以可以跨线程使用- 像Send trait这种只是作为标记而没有具体的实现的trait叫做标记trait(marker trait)。标记trait 用于 提供编译期的信息,但不会增加具体行为。所以为
MyBox实现Sendtrait就不用写任何实现 - 手动实现
Sendtrait是不安全的,我们得在impl块前添加unsafe标注。Rust 的类型系统默认会自动推导 Send,确保线程安全,而手动实现 Send 可能会绕过 Rust 的安全检查。
没有实现Sync trait的类型无法通过Arc<T>(原子引用计数指针,Rc<T>的多线程版)跨线程共享,也无法被放到需要 Sync 的静态变量中。
看个例子:
use std::cell::RefCell;
use std::sync::Arc;
fn main() {
let x = Arc::new(RefCell::new(42));
std::thread::spawn(move || {
let mut x = x.borrow_mut();
*x += 1;
});
}
RefCell<T>没有实现Synctrait所以不能用Arc<T>跨线程共享
Unpin trait表示取消固定。Unpin是一个 标记trait(marker trait),用于指示某个类型是否可以安全地从 Pin 中移出,即 是否能绕过Pin<P>的限制。
大多数类型默认是Unpin。自引用类型通常通过嵌入 std::marker::PhantomPinned(或其它 !Unpin 字段)来变成 !Unpin。Rust 不会仅仅因为“包含指向自身内部数据的指针”就自动取消 Unpin;若没有这类标记,类型仍然是 Unpin,移动它就可能让内部指针失效。
如果你的类型没有实现上述任一trait都建议在文档中说明。
2.2. API设计原则之不意外性(unsurprising) Pt.2:实现Clone、Default、PartialEq、PartialOrd、Hash、Eq和Ord
2.2.1. 建议实现Clone trait和Default trait
Clone trait
Rust的Clone trait允许实现者通过clone方法显式创建自身的深拷贝,以区别于Copy trait提供的按值复制。
看一个Clone例子:
#[derive(Debug, Clone)]
struct Person {
name: String,
age: u32,
}
impl Person {
fn new(name: String, age: u32) -> Self {
Self { name, age }
}
}
fn main() {
let person1 = Person::new("John".to_owned(), 25);
let person2 = person1.clone();
println!("{:?}", person1);
println!("{:?}", person2);
}
Person这个结构体实现了Clonetrait- 主函数中
person2克隆了person1的数据,因为它实现了Clone
输出:
Person { name: "John", age: 25 }
Person { name: "John", age: 25 }
Default trait
Rust的Default trait允许类型定义一个默认值,通过default()方法返回该类型的默认实例。
看一个Default的例子:
#[derive(Default)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point::default();
println!("Point is at ({}, {})", p.x, p.y);
}
输出:
Point is at (0, 0)
2.2.2. 建议实现PartialEq、PartialOrd、Hash、Eq和Ord trait
PartialEq trait
PartialEq提供 == 和 != 操作符支持,允许自定义类型进行部分相等性比较
看个例子:
#[derive(Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let point1: Point = Point { x: 1, y: 2 };
let point2: Point = Point { x: 1, y: 2 };
let point3: Point = Point { x: 3, y: 4 };
println!("point1 == point2: {}", point1 == point2);
println!("point1 == point3: {}", point1 == point3);
}
- 通过实现
PartialEq,我们就可以实现比较结构体是否相等的操作
输出:
point1 == point2: true
point1 == point3: false
PartialOrd、PartialEq、 Eq、Ord trait
PartialOrd:提供 <、<=、> 和 >= 操作符支持,允许自定义类型进行部分排序比较,可能存在无法比较的情况
Eq:是 PartialEq 的更严格版本,要求相等性满足自反性(a == a 总是 true)。实现Eq必须要先实现PartialEq。
Ord:是 PartialOrd 的更严格版本,要求实现全序关系,使类型支持完整的排序逻辑(如 BTreeMap、BTreeSet) 。实现Ord必须要先实现PartialOrd。
看个例子:
use std::collections::BTreeMap;
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Clone)]
struct Person {
name: String,
age: u32,
}
fn main() {
let mut ages = BTreeMap::new();
let person1 = Person {
name: String::from("Alice"),
age: 25,
};
let person2 = Person {
name: String::from("Bob"),
age: 30,
};
let person3 = Person {
name: String::from("Charlie"),
age: 20,
};
ages.insert(person1.clone(), "Alice's Age");
ages.insert(person2.clone(), "Bob's Age");
ages.insert(person3.clone(), "Charlie's Age");
for (person, description) in &ages {
println!("{}: {} - {:?}", person.name, person.age, description);
}
}
BTreeMap实现了有序存储。它需要根据键的值来排序,所以它要求键类型必须实现了Ord(全局比较,用于排序)和Eq(用于判断键是否相等)。- 实现
Ord又需要先实现PartialOrd - 实现
Eq有需要先实现PartialEq
- 实现
Hash trait
Hash:允许类型实现 hash() 方法,以支持 HashMap、HashSet 等哈希集合。Hash trait 本身并不以 Eq 为超 trait,但哈希集合要求 K: Eq + Hash,且相等的值必须产生相同的哈希。所以在实践中,只要类型会用作哈希表/集合的键,就应一并实现 PartialEq 和 Eq。
看个例子:
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
#[derive(Debug, PartialEq, Eq, Clone)]
struct Person {
name: String,
age: u32,
}
impl Hash for Person {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.age.hash(state);
}
}
fn main() {
let mut persons = HashSet::new();
let person1 = Person {
name: "Alice".to_string(),
age: 25,
};
let person2 = Person {
name: "Bob".to_string(),
age: 30,
};
let person3 = Person {
name: "Charlie".to_string(),
age: 20,
};
persons.insert(person1.clone());
persons.insert(person2.clone());
persons.insert(person3.clone());
println!("Persons: {:#?}", persons);
}
HashSet用于存储唯一的元素集合,而HashMap用于存储键值对。
输出:
Persons: {
Person {
name: "Bob",
age: 30,
},
Person {
name: "Charlie",
age: 20,
},
Person {
name: "Alice",
age: 25,
},
}
Eq与PartialEq、Ord与PartialOrd
Eq相对于PartialEq、Ord相对于PartialOrd都有额外的语义要求。只应在这些语义适用你的类型时才实现它们。
我们看一下Eq相对于PartialEq的额外语义:
- 自反性(Reflexivity):对于所有
a,必须有a == a恒成立
(PartialEq 本身已要求对称性和传递性;Eq 是“相等性也自反”的标记,因此不存在诸如 f32::NAN != f32::NAN 这类“部分相等”。)
我们看一下Ord相对于PartialOrd的额外语义:
- 全序 / 可比性:对所有
a和b,a < b、a == b、a > b恰好成立其一(等价地,partial_cmp从不返回None) - 自反性(Reflexivity):对于所有
a,必须有a <= a和a >= a恒成立。 - 反对称性(Antisymmetry):如果
a <= b且b <= a,则必须保证a == b。 - 传递性(Transitivity):如果
a <= b且b <= c,则必须保证a <= c。
2.3. API设计原则之不意外性(unsurprising) Pt.3:实现serde下的Serialize和Deserialize trait、不建议实现Copy trait
2.3.1. 建议实现serde下的Serialize和Deserialize trait
serde是Rust中用于序列化(serialization)和反序列化(deserialization) 的核心库:
- 序列化(Serialization):把Rust结构体或枚举转换为JSON、YAML等格式的字符串或二进制数据。
- 反序列化(Deserialization):从JSON、YAML等格式的字符串或二进制数据解析回Rust结构体或枚举。
Serialize和Deserialize都是serde库下的trait。
Serialize trait
Serialize trait允许一个类型转换为可序列化的数据格式(如JSON、YAML、TOML)。
其主要方法有:
serialize_boolserialize_i32serialize_strserialize_struct
这些是 Serialize 实现会调用的 Serializer(及相关)trait 上的方法;Serialize trait 本身只要求实现 serialize。
其定义是:
#![allow(unused)]
fn main() {
pub trait Serialize {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer;
}
}
我们用一个例子来介绍如何手动实现Serialize trait:
#![allow(unused)]
fn main() {
use serde::ser::{Serialize, SerializeStruct, Serializer};
struct Point {
x: i32,
y: i32,
}
impl Serialize for Point {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("Point", 2)?;
state.serialize_field("x", &self.x)?;
state.serialize_field("y", &self.y)?;
state.end()
}
}
}
serializer.serialize_struct("Point", 2)?创建一个结构体序列化对象,2 代表字段数量state.serialize_field("x", &self.x)?依次序列化结构体字段state.end()结束序列化
Deserialize trait
Deserialize trait允许从各种数据格式解析Rust类型。
其主要方法有:
deserialize_booldeserialize_i32deserialize_stringdeserialize_struct
这些是 Deserialize 实现通过 Visitor 驱动的 Deserializer(及相关)trait 上的方法;Deserialize trait 本身只要求实现 deserialize。
其定义是:
#![allow(unused)]
fn main() {
pub trait Deserialize<'de>: Sized {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>;
}
}
deserialize方法的作用是:使用deserializer从数据格式解析Rust类型
我们用一个例子来介绍如何手动实现Deserialize trait:
#![allow(unused)]
fn main() {
use serde::de::{self, Deserialize, Deserializer, Visitor, MapAccess};
use std::fmt;
struct Point {
x: i32,
y: i32,
}
impl<'de> Deserialize<'de> for Point {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct PointVisitor;
impl<'de> Visitor<'de> for PointVisitor {
type Value = Point;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a struct Point with fields x and y")
}
fn visit_map<M>(self, mut map: M) -> Result<Point, M::Error>
where
M: MapAccess<'de>,
{
let mut x = None;
let mut y = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"x" => x = Some(map.next_value()?),
"y" => y = Some(map.next_value()?),
_ => {}
}
}
let x = x.ok_or_else(|| de::Error::missing_field("x"))?;
let y = y.ok_or_else(|| de::Error::missing_field("y"))?;
Ok(Point { x, y })
}
}
deserializer.deserialize_struct("Point", &["x", "y"], PointVisitor)
}
}
}
PointVisitor结构体用于解析JSON字段visit_map解析x和y的值,确保字段存在
使用#[derive(Serialize, Deserialize)]标注自动实现Serialize和Deserialize
手动实现Serialize和Deserialize非常繁琐,通常我们会使用serde_derive宏:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 10, y: 20 };
let serialized = serde_json::to_string(&p).unwrap();
println!("Serialized: {}", serialized); // {"x":10,"y":20}
let deserialized: Point = serde_json::from_str(&serialized).unwrap();
println!("Deserialized: {:?}", deserialized); // Point { x: 10, y: 20 }
}
使用了Serialize和Deserialize的例子
use serde::{Serialize, Deserialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
}
fn main() {
let user = User {
name: "Alice".to_string(),
age: 30,
};
// 序列化
let json_str = serde_json::to_string(&user).unwrap();
println!("Serialized JSON: {}", json_str);
// 反序列化
let deserialized: User = serde_json::from_str(&json_str).unwrap();
println!("Deserialized: {:?}", deserialized);
}
输出:
Serialized JSON: {"name":"Alice","age":30}
Deserialized: User { name: "Alice", age: 30 }
其他注意事项
serde下的serde_derive crate提供了机制,可以覆盖单个字段或枚举变体的序列化。由于serde是第三方库,你可能不希望强制添加对它的依赖。
大多数库都选择了提供了serde功能(feature),只有当用户选择启用该功能时才添加对serde的支持。
也就是说,你要在你的crate里这么写:
[dependencies]
serde = { version = "1.0", optional = true }
[features]
serde = ["dep:serde"]
- 在
[dependencies]这一块引入了serde,除了写了语义版本之外,还有optional = true,这代表着是一个可选的依赖。这意味着默认情况下不包含 serde,除非显式启用它。 [features]这一块写了serde = ["dep:serde"],意思是当用户启用serde feature时,才会启用 serde 依赖"dep:serde"这部分表示依赖 serde 这个库(dep:是依赖的前缀,告诉Cargo这个feature依赖serde)
别人要用你的crate(假设my_crate是你的crate的名字)时这么写就可以启用serde:
[dependencies]
my_crate = { version = "0.1", features = ["serde"] }
2.3.2. 不建议实现Copy trait
用户通常不期望类型实现Copy trait。如果用户想要副本的话,通常会调用clone方法。
Copy trait改变了移动给定类型值的语义(Copy trait的语义与实现要求详见 1.9.2. 如何实现Copy trait)。看个例子:
#[derive(Debug,Copy, Clone)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let point1 = Point { x: 10, y: 10 };
let point2 = point1;
println!("{:?}", point1);
println!("{:?}", point2);
}
point1的值赋给了point2。一般来说point1至此之后就会失效,但是由于Point结构体实现了Copy trait,所以point1的值赋给了point2发生的是复制而不是移动,point1至此之后仍然保持有效。这点会让用户感到惊讶,所以不建议实现Copy trait。
除此以外,实现Copy的类型会有很多限制,一个最初简单的类型很容易变得不再满足Copy trait的要求。例如持有String或其他不支持Copy trait的类型都不得不移除Copy trait。
2.4. API设计原则之不意外性(unsurprising) Pt.4:“人体工程学”的trait实现、包装类型(Wrapper Types)、Borrow trait
2.4.1. “人体工程学”的trait实现
Rust不会自动为实现某一trait的类型的引用提供对应的实现。
比如说Bar实现了Trait,但不能将&Bar传递给fn foo<T: Trait>(t: T)。因为为 Bar 实现 Trait 并不会自动为 &Bar 实现 Trait。
看代码例:
trait Trait {
fn name(&self) -> &'static str;
}
struct Bar;
impl Trait for Bar {
fn name(&self) -> &'static str {
"Bar"
}
}
fn foo<T: Trait>(t: T) {
println!("{}", t.name());
}
fn main() {
let bar = Bar;
foo(bar); // OK
let bar_ref = &Bar;
foo(bar_ref); // 报错[E0277]:the trait bound `&Bar: Trait` is not satisfied
}
而用户看到某个trait的方法只接收&self(而不接收self或&mut self)时,仍然可能惊讶于 &Bar 不能满足 T: Trait,不符合不意外(unsurprising)原则。
为了解决这一问题,我们需要在定义新的trait时,(通常)在方法签名允许的情况下(典型是 &self / &mut self 方法)为下列提供相应的全局实现(即泛实现,Blanket Implementation,详见 1.17.2. 泛实现):
&T where T: Trait + ?Sized&mut T where T: Trait + ?SizedBox<T> where T: Trait + ?Sized
接着上面的代码例,为了不让foo(bar_ref);报错,我们需要手动提供&T的Trait实现:
#![allow(unused)]
fn main() {
impl<T: Trait + ?Sized> Trait for &T {
fn name(&self) -> &'static str {
(**self).name()
}
}
}
注意:如果 trait 方法按值接收 self(消耗所有权),一般无法写一个把调用转发给 T 的 impl Trait for &T,因为共享引用不能把 T 移出去。
对于迭代器来说,如果某个类型可以迭代,那么它的引用也应该添加相应的trait实现。也就是说:对于任何可迭代的类型,考虑为&MyType和&mut MyType实现IntoIterator。这样在循环中我们就可以直接使用借用的实例,符合用户预期。
看个代码例:
struct MyCollection {
items: Vec<i32>,
}
// 为 MyCollection 实现 IntoIterator
impl IntoIterator for MyCollection {
type Item = i32;
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.items.into_iter()
}
}
// 为 &MyCollection 实现 IntoIterator
impl<'a> IntoIterator for &'a MyCollection {
type Item = &'a i32;
type IntoIter = std::slice::Iter<'a, i32>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter()
}
}
// 为 &mut MyCollection 实现 IntoIterator
impl<'a> IntoIterator for &'a mut MyCollection {
type Item = &'a mut i32;
type IntoIter = std::slice::IterMut<'a, i32>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter_mut()
}
}
fn main() {
let mut collection = MyCollection { items: vec![1, 2, 3] };
// 使用所有权迭代
for item in collection {
println!("Owned: {}", item);
}
let collection = MyCollection { items: vec![4, 5, 6] };
// 使用不可变借用迭代
for item in &collection {
println!("Borrowed: {}", item);
}
let mut collection = MyCollection { items: vec![7, 8, 9] };
// 使用可变借用迭代
for item in &mut collection {
*item *= 2;
}
// 确保修改生效
for item in &collection {
println!("Modified: {}", item);
}
}
2.4.2. 包装类型(Wrapper Types)
Rust没有面向对象传统意义上的继承,但是Deref和AsRef提供了类似继承的东西。
比如说你有一个类型为T的值,并满足Deref<Target = U>,那就可以在T类型值上直接调用类型U的方法。
看代码例:
use std::ops::Deref;
// 定义一个包装类型 Wrapper,它内部存储一个 String
struct Wrapper(String);
// 实现 Deref,使得 Wrapper 的 Deref 目标是 String
impl Deref for Wrapper {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
fn main() {
let my_wrapper = Wrapper(String::from("Hello, Rust!"));
// 由于 Wrapper 实现了 Deref<Target = String>,
// 这里可以直接调用 String 的方法,而不需要手动解引用
let len = my_wrapper.len();
let uppercased = my_wrapper.to_uppercase();
println!("Length: {}", len);
println!("Uppercased: {}", uppercased);
}
输出:
Length: 12
Uppercased: HELLO, RUST!
如果你提供了相对透明的类型(例如Arc<T>),那么实现Deref允许你的包装类型在使用点运算符时自动解引用为内部类型,从而可以直接调用内部类型的方法。
如果访问内部类型不需要任何复杂或潜在的低效逻辑,应考虑实现AsRef,这样用户就可以轻松将&WrapperType作为&InnerType使用。
对于大多是包装类型,还应在可能的情况下为包装类型实现From<InnerType>,并为内部类型实现From<Wrapper>(这样会免费得到Into),以便用户可以轻松地添加或移除包装。
看代码例:
use std::ops::Deref;
use std::sync::Arc;
// 定义一个包装类型
struct Wrapper(Arc<String>);
// 实现 Deref 以允许透明地访问内部 String
impl Deref for Wrapper {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// 实现 AsRef<String>,允许用户获取 `&String`
impl AsRef<String> for Wrapper {
fn as_ref(&self) -> &String {
&self.0
}
}
// 实现 From<String> 以便用户轻松创建 Wrapper
impl From<String> for Wrapper {
fn from(s: String) -> Self {
Wrapper(Arc::new(s))
}
}
// 实现 From<Wrapper> for String,允许用户将 Wrapper 转回 String(克隆字符串)
impl From<Wrapper> for String {
fn from(w: Wrapper) -> Self {
(*w).clone() // Deref 使 Wrapper 可以当作 String 使用
}
}
fn main() {
let wrapped = Wrapper::from("Hello, Rust!".to_string());
// 由于实现了 Deref,我们可以直接调用 String 的方法
println!("Length: {}", wrapped.len());
println!("Uppercased: {}", wrapped.to_uppercase());
// 使用 AsRef,可以获取 &String 引用
let str_ref: &String = wrapped.as_ref();
println!("AsRef: {}", str_ref);
// 通过 Into 获取 String(克隆)
let original: String = wrapped.into();
println!("Converted back: {}", original);
}
输出:
Length: 12
Uppercased: HELLO, RUST!
AsRef: Hello, Rust!
Converted back: Hello, Rust!
2.4.3. Borrow trait
Borrow trait与Deref和AsRef有些类似,不过它针对的是更为狭窄的使用情况,并且进行了定制。
Borrow trait允许调用者提供统一类型的多个本质上相同的变体中的任意一个,这些变体叫做Equivalent。
注意:Borrow trait仅适用于当你的类型本质上与另一个类型等价时(Equivalent)。 也就是说Borrow适用于“等价”的情况,而AsRef和Deref适用于“充当”的情况。
比如说对于一个HashSet<String>,Borrow允许调用者提供&str或&String。
看一个代码例:
use std::collections::HashSet;
fn main() {
let mut set: HashSet<String> = HashSet::new();
set.insert("hello".to_string());
set.insert("world".to_string());
// 直接使用 &str 进行查找,而不需要创建 String。
// 这能工作是因为标准库已经提供了 `impl Borrow<str> for String`。
let exists = set.contains("hello");
let not_exists = set.contains("rust");
println!("Contains 'hello': {}", exists);
println!("Contains 'rust': {}", not_exists);
}
输出:
Contains 'hello': true
Contains 'rust': false
与AsRef的对比
当然使用AsRef也是可以实现上面的代码效果的:
use std::collections::HashSet;
// 泛型函数,接受任何 `AsRef<str>` 的类型,如 `&str` 和 `&String`
fn contains<S: AsRef<str>>(set: &HashSet<String>, value: S) -> bool {
set.contains(value.as_ref()) // `AsRef<str>` 使 `value` 转换为 `&str`
}
fn main() {
let mut set: HashSet<String> = HashSet::new();
set.insert("hello".to_string());
set.insert("world".to_string());
// 直接使用 &str 进行查找
let exists = contains(&set, "hello");
// 也可以使用 &String 进行查找
let string_value = "world".to_string();
let exists_string = contains(&set, &string_value);
println!("Contains 'hello': {}", exists);
println!("Contains 'world': {}", exists_string);
}
使用AsRef能达到同样的效果,但是如果没有Borrow的额外要求,这种实现对哈希表查找是不安全的,因为Borrow要求借用形式的Hash、Eq和Ord必须与拥有类型保持一致。
潜在的问题是:AsRef<U>甚至不会在文档层面要求源类型与U之间的Hash、Eq和Ord保持一致。
比如说:
use std::collections::HashSet;
#[derive(Hash, Eq, PartialEq)]
struct CustomType {
value: String,
}
// 实现 AsRef<str>,但这本身并不能让 HashSet 用 &str 查找合法
impl AsRef<str> for CustomType {
fn as_ref(&self) -> &str {
&self.value
}
}
fn main() {
let mut set: HashSet<CustomType> = HashSet::new();
set.insert(CustomType { value: "hello".to_string() });
// 这里不会通过编译(error[E0308]):`contains` 依赖 `Borrow` 而不是 `AsRef`,
// 因此没有 `CustomType: Borrow<str>` 时 `&str` 无法匹配
let exists = set.contains("hello");
println!("Exists: {}", exists);
}
contains的类型是按Borrow写的,所以AsRef<str>并不参与- 即便你写了借助
AsRef再查找的辅助函数,类型系统也不会强制CustomType的Hash/Eq与str一致
相比之下,Borrow<U> 才是 HashMap/HashSet 查找使用的 trait,其文档要求类型与借用形式之间的Hash、Eq和Ord保持一致(编译器不会证明这一点;实现者必须遵守):
use std::collections::HashSet;
use std::borrow::Borrow;
#[derive(Hash, Eq, PartialEq)]
struct CustomType {
value: String,
}
// `Borrow<str>` 才让 `contains("hello")` 成为可能,并且你必须让 Hash/Eq 与 `str` 对齐
impl Borrow<str> for CustomType {
fn borrow(&self) -> &str {
&self.value
}
}
fn main() {
let mut set: HashSet<CustomType> = HashSet::new();
set.insert(CustomType { value: "hello".to_string() });
let exists = set.contains("hello"); // 在 Hash/Eq 与 str 一致时,查找是安全的
println!("Exists: {}", exists);
}
其余特性
Borrow trait还为Borrow<T>、&T和&mut T提供了通用实现。这使得在trait约束中使用它来给接收给定类型的拥有值或引用值非常方便。
Rust 标准库中为所有 T 提供了以下Borrow<T>的通用实现,这些实现意味着:
- 类型
T本身可以Borrow<T>,即T可以直接作为Borrow<T>的参数 &T也可以Borrow<T>,这允许我们用一个不可变引用来满足Borrow<T>的约束&mut T也可以Borrow<T>,这允许我们用一个可变引用来满足Borrow<T>的约束
假设我们有一个find_item函数,它在HashMap<K, V>中查找某个键:
use std::borrow::Borrow;
use std::collections::HashMap;
use std::hash::Hash;
fn find_item<'a, K, V, Q>(map: &'a HashMap<K, V>, key: &Q) -> Option<&'a V>
where
K: Eq + Hash + Borrow<Q>, // 关键点:K 可以借用为 Q
Q: ?Sized + Eq + Hash,
{
map.get(key)
}
fn main() {
let mut map: HashMap<String, i32> = HashMap::new();
map.insert("hello".to_string(), 42);
// 由于 `String: Borrow<str>`,我们可以用 &str 直接查找 HashMap<String, i32>
let value = find_item(&map, "hello");
println!("Value: {:?}", value); // Output: Value: Some(42)
}
这里的Borrow<T>提供的便利有这些:
String可以作为str的Borrow<T>实现,因此HashMap<String, i32>允许用&str作为键进行查找find_item(&map, "hello")直接传入&str,而不需要转换成Stringfind_item(&map, &"hello".to_string())也可以工作,因为&String也满足Borrow<str>
2.5. API设计原则之灵活性(flexible) Pt.1:代码的契约(Contract)、使用泛型参数(generic arguments)让接口更灵活
2.5.1. 代码的契约(Contract)
你写的代码里,不论是显式地还是隐式地,都包含了一种契约。
契约一共有两个方面:
- 契约是一种要求,它是代码使用的限制
- 契约是一种承诺,它是代码行为的保证
在设计API时,有这样一个经验:避免施加不必要的限制,只做能够兑现的承诺。
为什么呢?
- 增加限制或取消承诺需要重大的语义版本更改,可能会导致其它代码出问题
- 在最开始设计API时放宽限制,之后再提供额外的承诺通常是向后兼容的
2.5.2. 限制(Restrictions)与承诺(Promises)
Rust中常见的限制的形式是:
- trait约束(trait bound)
- 参数类型(Argument Types)
承诺的常见形式是:
- trait的实现
- 返回类型
一些例子
我们看一个API历经三个版本的演化:
#![allow(unused)]
fn main() {
fn frobnicate(s: String) -> String
}
- 第一个版本接收的参数是
String类型,返回的也是String类型 - 它的契约是:调用者进行内存分配(因为函数的参数和返回值都是拥有的,所以肯定会有内存分配),承诺返回是拥有的
String - 这个函数的问题是以后无法更改为“无需内存分配的”函数(在不改签名的情况下),因为函数的参数和返回值都是拥有的
#![allow(unused)]
fn main() {
fn frobnicate(s: &str) -> Cow<'_, str>
}
- 第二个版本稍微放宽了一些契约
- 它的契约是:只接受字符串的引用,承诺返回字符串的引用或一个拥有的
String,也就是Cow这个类型(这个类型在 1.2.2. Rust的引用和指针 中有过介绍) - 这个版本还是有一点死板。比如说参数是
&str,如果我传进来的是String,还必须先转化;还比如说Cow作为返回值,就不能返回除了String和&str其它存储字符串的类型(比如说OsString)
#![allow(unused)]
fn main() {
fn frobnicate<T: AsRef<str>>(s: T) -> T
}
- 第三个版本进一步放宽了契约
- 现在这个函数的参数和返回值都只要求实现了
AsRef<str>trait,也就是能产生字符串引用的类型
这三个函数都是接收字符串,返回字符串,只是契约不同。这三者没有优劣之分,只有限制严格与否的区别。在设计API时要仔细规划契约,否则改变契约会引起破坏。
我们来看完整的代码例:
use std::borrow::Cow;
fn frobnicate<T: AsRef<str>>(s: T) -> T {
s
}
fn main() {
let string: String = String::from("example");
let borrowed: &str = "hello";
let cow: Cow<str> = Cow::Borrowed("world");
let result1: &str = frobnicate::<&str>(string.as_ref());
let result2: &str = frobnicate::<&str>(borrowed);
let result3: Cow<str> = frobnicate(cow);
println!("Result1: {:?}", result1);
println!("Result2: {:?}", result2);
println!("Result3: {:?}", result3);
}
- 不论是
String、&str还是Cow<str>(本质上也是&str),这个函数都能接收(String类型要现使用as_ref方法转成&str)并返回值(返回值也可以是不同类型)。
输出:
Result1: "example"
Result2: "hello"
Result3: "world"
2.5.3. 使用泛型参数(generic arguments)让接口更灵活
我们可以通过泛型放宽对函数的要求。大多数情况下,我们是值得使用泛型来代替具体类型的。
使用泛型参数的例子
看个例子:
fn print_as_str<T: AsRef<str>>(s: T) {
println!("{}", s.as_ref());
}
fn main() {
let s: String = String::from("hello");
let r: &str = "world";
print_as_str(s); // 调用 print_as_str::<String>
print_as_str(r); // 调用 print_as_str::<&str>
}
- 函数
print_as_str接受一个实现了AsRef<str>trait 的参数 - 这个函数是泛型的,它对 T 进行了泛型化,这意味着它会对你使用它的每一种实现了
AsRef<str>的类型进行单态化(详见 【Rust指南】10.2.6. 泛型代码的性能)。 例如,如果你用一个String和一个&str来调用它,你就会在你的二进制文件中有两份函数的拷贝print_as_str::<String>和print_as_str::<&str>,传入不同类型时就会调用相应的函数
注意:进行了单态化的好处是避免了运行时的性能开销,缺点是编译器会针对每一种传入的类型生成一份函数,增大文件空间占用。
如果你不想要二进制文件中有多份函数的拷贝,就可以使用动态分发(dynamic dispatch):
fn print_as_str(s: &dyn AsRef<str>) {
println!("{}", s.as_ref());
}
fn main() {
let s: String = String::from("hello");
let r: &str = "world";
print_as_str(&s); // 传递一个类型为 &dyn AsRef<str> 的 trait 对象
print_as_str(&r); // 传递一个类型为 &dyn AsRef<str> 的 trait 对象
}
- 这个函数不再是泛型的,它接受一个 trait 对象,它可以是任何实现了
AsRef<str>的类型。 - 这意味着它会在运行时使用动态分发来调用
as_ref方法。并且你只会在你的二进制文件中有一份函数的拷贝。
动态分发的内容详见 1.15.4. 动态分发(dynamic dispatch)。注意:动态分发相比于单态化会有一定运行时的性能开销,但是非常小。
使用泛型参数别太极端
使用泛型参数也不要太极端,需要结合具体的情况。
判断到底是使用泛型(或是trait对象)还是使用具体的类型取决于用户是否会合理且频繁地使用其他类型代替你最初选定的具体类型,如果是,那么参数定义为泛型更合适。
单态化与动态分发的取舍问题
单态化的好处是避免了运行时的性能开销,缺点是编译器会针对每一种传入的类型生成一份函数,增大文件空间占用。
如果你担心生成的二进制文件过大,那么你可以使用动态分发(dynamic dispatch)。虽然动态分发相比于单态化会有一定运行时的性能开销,但是非常小。
- 在高性能的应用中,在频繁调用的热循环中使用动态分发可能会成为一个致命的问题!
只有在简单的trait约束(一个trait约束)中才能使用动态分发,例如:T: AsRef<str>或impl AsRef<str>。而由于Rust无法为复杂的trait约束(比如两个及以上的trait约束)创建虚方法表(vtable,详见 1.15.5. vtable),所以就无法使用动态分发。
对于以引用方式获取的参数(dyn Trait不是Sized的,需要使用宽指针来使用它们),可以使用动态分发代替泛型参数。
看一下例子:
#![allow(unused)]
fn main() {
//泛型函数,静态分发
fn process<T>(value: T) {
println!("processing T");
}
}
- 这是泛型函数的写法
#![allow(unused)]
fn main() {
// 动态分发
trait Processable {
fn process(&self);
}
struct TypeA;
impl Processable for TypeA {
fn process(&self) {
println!("processing TypeA");
}
}
fn process_trait_object(value: &dyn Processable) {
value.process();
}
}
- 这是动态分发的写法
那如果我把这两者都写在一起,该怎么判断那个是用的静态分发,哪个用的是动态呢:
//泛型函数,静态分发
fn process<T>(value: T) {
println!("processing T");
}
// 动态分发
trait Processable {
fn process(&self);
}
struct TypeA;
impl Processable for TypeA {
fn process(&self) {
println!("processing TypeA");
}
}
struct TypeB;
impl Processable for TypeB {
fn process(&self) {
println!("processing TypeB");
}
}
fn process_trait_object(value: &dyn Processable) {
value.process();
}
fn main() {
let a = TypeA;
let b = TypeB;
process_trait_object(&a); // 动态分发
process_trait_object(&b); // 动态分发
process(&a); // 静态分发
process(&b); // 静态分发
process(&a as &dyn Processable); // 静态分发
process(&b as &dyn Processable); // 静态分发
}
- 使用了
process_trait_object都是动态分发 - 使用了
process都是静态分发
最后的两个process语句有一点特殊。传进去的参数是&dyn Processable而不是具体的类型(因为使用了as &dyn Processable),编译器会把它视作为一种类型单态化,也就是编译器会把代码单态化为:
#![allow(unused)]
fn main() {
fn process(value: &dyn Processable) {
println!("processing T");
}
}
这一部分仍然是静态分发,因为T = &dyn Processable是在编译时确定的。
在运行时,由于&dyn Processable这个胖指针背后没有具体的静态类型,若通过该 trait 对象调用方法,Rust会通过虚方法表查找运行时类型的实现。不过在这个 process 例子里,函数体根本没有对 value 调用任何方法,因此不会发生虚表分发;单态化仍然只是在编译期生成一份 process::<&dyn Processable> 特化。
但总的来说,我们仍然把对泛型 process 函数本身的调用看作静态分发。
输出:
processing TypeA
processing TypeB
processing T
processing T
processing T
processing T
使用泛型参数时,调用者始终可以通过传递一个trait对象来选择动态分发(process(&a as &dyn Processable);)。
反过来不成立:如果你接受一个trait对象作为参数,那么调用者必须提供trait对象,而无法使用静态分发。
API应该如何考虑泛型参数?
我们可以从具体的类型开始编写接口,然后逐渐将它们转换为泛型。这样写是可行的,但不一定是向下兼容的。
看代码例:
fn foo(v: &Vec<usize>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect());
}
- 主函数中通过
into_iter方法把Vector转化成IntoIter<usize>类型 iter.collect()将iter从IntoIter<usize>又转化为了Vec<usize>,在前面加了&就能完全符合foo函数传入参数的类型要求。 这里collect方法知道要把iter收集为一个Vec<usize>类型是因为编译器知道这里foo函数接收的是&Vec<usize>类型
好的下面我们使用trait约束来写foo函数:
fn foo(v: impl AsRef<[usize]>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect());
}
- 这个程序不能通过编译,因为编译器不知道
collect方法应该把iter收集为什么类型。编译器只知道foo的参数是AsRef<[usize]>,但是有很多类型都满足这一条件,比如Vec<usize>和&[usize]
输出:
error[E0283]: type annotations needed
--> src/main.rs:7:15
|
7 | foo(&iter.collect());
| ^^^^^^^ cannot infer type of the type parameter `B` declared on the method `collect`
|
= note: the type must implement `FromIterator<i32>`
help: consider specifying the generic argument
|
7 | foo(&iter.collect::<Vec<_>>());
| ++++++++++
为了解决这个问题,只能让调用者显式地写出collect方法把iter收集为什么类型:
fn foo(v: impl AsRef<[usize]>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect::<Vec<usize>>());
}
2.6. API设计原则之灵活性(flexible) Pt.2:对象安全(Object Safety)、对象安全与API设计、trait的泛型方法与API设计
2.6.1. 对象安全(Object Safety)
在定义trait时,它是否是对象安全的,也是契约(详见 2.5.1. 代码的契约(Contract))未写明的一部分。
对象安全(Object Safety)是Rust中与Trait 对象(Trait Object) 相关的一个概念,它决定了某个 Trait 是否可以被动态分发(dynamic dispatch),即能否用作dyn Trait形式的Trait对象。
对象安全的 Trait 需要满足以下条件(基于 RFC 255)
-
所有的 supertrait 也必须是对象安全的
如果某个trait继承了其他trait(supertrait,详见 【Rust指南】19.2.5. 使用supertrait要求额外的trait功能),那么这些supertrait也必须是对象安全的。 -
不能要求
Sized
Trait 不能有Sized(详见 【Rust指南】19.5.4. 动态大小类型与Sizedtrait)作为supertrait(即不能包含Self: Sized限制),因为 Trait 对象的大小在编译期未知。 -
不能有任何关联常量(Associated Constants)。
-
不能有任何带有泛型的关联类型(Associated Types)。
-
所有的关联函数(methods)必须符合以下规则之一:
-
可分发函数(Dispatchable functions):
- 不能有任何类型参数(但生命周期参数是允许的)。
- 必须是方法,并且
Self只能出现在接收器(receiver)的位置,例如:&self&mut selfBox<Self>Rc<Self>Arc<Self>Pin<P>(其中P是上述类型之一)
- 不能要求
Self: Sized,否则会限制 Trait 只能用于已知大小的类型,破坏对象安全。
-
显式不可分发函数(Non-dispatchable functions):
- 允许
Self作为方法的返回值,但这些函数必须要求Self: Sized,这样它们在 Trait 对象上就无法被调用(只能用于具体类型)。
- 允许
-
如果上面的内容你记不住,你就记住 对象安全(object safety)描述一个trait是否能被安全的包装成trait对象(trait object) 即可
对象安全的作用
如果某个trait是对象安全的(也就是满足上述的所有条件),那么我们就可以使用dyn Trait将实现该trait的不同类型视为单一通用类型。
如果不是对象安全的,编译器会禁止你使用dyn Trait。
对象安全与API设计
在设计API时,建议把trait写成是对象安全的(即使会稍微降低使用的便利度),因为它提供了新的使用方式和灵活性。
看个例子:
假设我们有一个
Animaltrait,它有两个方法:name和speak。name方法返回&str,表示动物的名字。speak方法打印动物声音的拟声词,没有返回值。我们有两个结构体:Dog和Cat,都要实现这个trait。
trait Animal {
fn name(&self) -> &str;
fn speak(&self);
}
struct Dog {
name: String,
}
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn speak(&self) {
println!("Woof!");
}
}
struct Cat {
name: String,
}
impl Animal for Cat {
fn name(&self) -> &str {
&self.name
}
fn speak(&self) {
println!("Meow!");
}
}
fn main() {
let dog = Dog { name: String::from("George") };
let cat = Cat { name: String::from("Hamilton") };
let animals: Vec<&dyn Animal> = vec![&dog, &cat];
for animal in animals {
println!("The name of this animal is {}", animal.name());
animal.speak();
}
}
Animaltrait是对象安全(object-safe)的,因为它没有返回Self类型或是使用泛型参数- 所以我们可以用它来创建一个trait对象:
let animals: Vec<&dyn Animal> = vec![&dog, &cat];,这个Vector的类型就相当于一个trait
输出:
The name of this animal is George
Woof!
The name of this animal is Hamilton
Meow!
接下来我们小改一下之前的代码例:
我们给
Animaltrait添加一个新的方法clone,它返回一个Self类型
trait Animal {
fn name(&self) -> &str;
fn speak(&self);
fn clone(&self) -> Self;
}
struct Dog {
name: String,
}
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn speak(&self) {
println!("Woof!");
}
fn clone(&self) -> Self {
todo!()
}
}
struct Cat {
name: String,
}
impl Animal for Cat {
fn name(&self) -> &str {
&self.name
}
fn speak(&self) {
println!("Meow!");
}
fn clone(&self) -> Self {
todo!()
}
}
fn main() {
let dog = Dog { name: String::from("George") };
let cat = Cat { name: String::from("Hamilton") };
let animals: Vec<&dyn Animal> = vec![&dog, &cat];
for animal in animals {
println!("The name of this animal is {}", animal.name());
animal.speak();
}
}
输出:
error[E0038]: the trait `Animal` is not dyn compatible
--> src/main.rs:47:27
|
47 | let animals: Vec<&dyn Animal> = vec![&dog, &cat];
| ^^^^^^ `Animal` is not dyn compatible
|
note: for a trait to be dyn compatible it needs to allow building a vtable
for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
--> src/main.rs:4:24
|
1 | trait Animal {
| ------ this trait is not dyn compatible...
...
4 | fn clone(&self) -> Self;
| ^^^^ ...because method `clone` references the `Self` type in its return type
= help: consider moving `clone` to another trait
= help: the following types implement `Animal`:
Dog
Cat
consider defining an enum where each variant holds one of these types,
implementing `Animal` for this new enum and using it instead
添加了clone方法之后Animal就不再是对象安全的了,因为clone方法违反了规则——返回类型不能是Self,因为dyn Trait需要通过指针调用,而Self代表具体实现类型,在编译时无法确定具体大小。
比如说:
fn main() {
let dog = Dog {name: "Ver".to_string()};
let dog2 = dog.clone(); // 这里没问题,因为 Self = Dog
let animal: Box<dyn Animal> = Box::new(Dog { name: "Ver".to_string() });
let animal2 = animal.clone(); // 编译错误,编译时无法确定具体大小
}
那如果我想保留Animal的对象安全,同时保留clone方法该怎么写呢?
回到本文第一小节,看显式不可分发函数(Non-dispatchable functions):允许Self作为方法的返回值,但这些函数必须要求 Self: Sized,这样它们在 Trait 对象上就无法被调用(只能用于具体类型)。
根据它的要求我们这么改代码:
#![allow(unused)]
fn main() {
trait Animal {
fn name(&self) -> &str;
fn speak(&self);
fn clone(&self) -> Self
where
Self: Sized;
}
// ...其余代码不变
}
输出:
The name of this animal is George
Woof!
The name of this animal is Hamilton
Meow!
这时候就不会报错了。
这么写需要注意的是clone方法就只能在具体的类型下调用,否则会报错:
fn main() {
let dog = Dog { name: String::from("George") };
let cat = Cat { name: String::from("Hamilton") };
let animals: Vec<&dyn Animal> = vec![&dog, &cat];
for animal in animals {
println!("The name of this animal is {}", animal.name());
animal.speak();
animal.clone(); // 会报错,因为aniaml是&dyn Animal而不是具体的类型
}
}
输出:
error: the `clone` method cannot be invoked on a trait object
--> src/main.rs:54:16
|
6 | Self: Sized;
| ----- this has a `Sized` requirement
...
54 | animal.clone(); // 会报错,因为aniaml是&dyn Animal而不是具体的类型
| ^^^^^
由于在写trait的方法时写到了clone的返回值实现了Sized trait,而&dyn Animal是不清楚具体类型大小的,所以不能调用。
当然在具体类型上是肯定可以的:
fn main() {
let dog = Dog { name: String::from("George") };
let dog_clone = dog.clone(); // 能够通过编译
}
trait的泛型方法与API设计
把泛型参数放到trait上
如果trait必须有泛型方法,那么考虑把泛型参数放到trait上(泛型(类型参数)trait详见 1.16.2. 泛型(类型参数)trait)。
看个例子:
use std::collections::HashSet;
use std::hash::Hash;
trait Container<T> {
fn contains(&self, item: &T) -> bool;
}
impl<T> Container<T> for Vec<T>
where
T: PartialEq,
{
fn contains(&self, item: &T) -> bool {
self.iter().any(|x| x == item)
}
}
impl<T> Container<T> for HashSet<T>
where
T: Eq + Hash,
{
fn contains(&self, item: &T) -> bool {
HashSet::contains(self, item)
}
}
fn main() {
// 创建`Vec<T>`和`HashSet<T>`的实例
let vec_container: Box<dyn Container<i32>> = Box::new(vec![1, 2, 3]);
let hashset_container: Box<dyn Container<i32>> =
Box::new(vec![4, 5, 6].into_iter().collect::<HashSet<_>>());
// 调用contains方法
println!("Vector contains 2: {}", vec_container.contains(&2));
println!("HashSet contains 4: {}", hashset_container.contains(&4));
}
- 有一个trait叫
Container,它有一个方法叫做contains,这个contains方法的实现肯定会需要泛型参数。但是为了实现对象安全我们不能在方法上添加类型参数。 - 所以我们把泛型参数移到trait上而不是在trait的方法上,也就是
Container<T>,其中T是泛型参数。 - 这样我们就可以为不同的容器类型实现
Containertrait,每个实现都有自己特定的元素类型 - 例如代码例中我们为
Vec<T>和HashSet<T>实现了Containertrait
输出:
Vector contains 2: true
HashSet contains 4: true
使用动态分发
不仅可以这么写,另一个选择是考虑这个泛型参数是否可以使用动态分发,来保证trait的对象安全。
看个例子:
假设我们有一个 Foo trait,其中包含一个泛型方法 bar,它接受一个泛型参数 T:
#![allow(unused)]
fn main() {
trait Foo {
fn bar<T>(&self, x: T);
}
}
这个 trait不是对象安全的,因为对象安全要求trait的方法不含泛型参数。原因是泛型方法依赖单态化(monomorphization),Rust 需要在编译时确定T的具体类型,并为不同的T生成不同的代码,而dyn Foo允许运行时动态分发,编译器无法为dyn Foo预先生成所有可能T的代码。
但有一个方法可以“曲线救国”——把泛型参数换成动态分发的表述,比如说这样:
#![allow(unused)]
fn main() {
trait Foo {
fn bar(&self, x: &dyn Debug);
}
}
这样,bar方法可以通过 动态分发(vtable调用)来调用x的Debug方法,而不需要在编译时知道具体类型,从而保持Foo的对象安全性。
看例子:
trait Foo {
fn bar<T>(&self, x: T); // 泛型方法,导致 trait 不是对象安全的
}
struct MyStruct;
impl Foo for MyStruct {
fn bar<T>(&self, x: T) {
println!("Received a value!");
}
}
fn main() {
let obj = MyStruct;
let obj_ref: &dyn Foo = &obj; // 编译错误:the trait `Foo` is not dyn compatible
obj_ref.bar(42); // 这里无法调用,因为 `T` 需要在编译时确定
}
这么写肯定不行,所以要换成动态分发的写法:
use std::fmt::Debug;
trait Foo {
fn bar(&self, x: &dyn Debug); // 这里用 trait 对象替代泛型,使其对象安全
}
struct MyStruct;
impl Foo for MyStruct {
fn bar(&self, x: &dyn Debug) {
println!("Received a value: {:?}", x);
}
}
fn main() {
let obj = MyStruct;
let obj_ref: &dyn Foo = &obj; // 现在可以作为 trait 对象
obj_ref.bar(&42); // 输出:Received a value: 42
obj_ref.bar(&"Hello"); // 输出:Received a value: "Hello"
}
实现对象安全的代价
为了实现对象安全,我们需要做出多大的牺牲呢?
- 考虑你的trait会被用户怎么使用,如果用户会想把它当作trait对象,那就尽力实现对象安全
2.7. API设计原则之灵活性(flexible) Pt.3:借用 vs. 拥有、Cow类型、可失败和阻塞的析构函数及解决办法
2.7.1. 借用(Borrowed) vs. 拥有(Owned)
针对Rust中几乎每一个函数、trait和类型,我们都需要决定:
- 应该拥有数据
- 还是持有对数据的引用
如果你的代码需要数据的所有权,那么就必须要存储拥有的数据。当你的代码拥有数据时,必须让调用者提供拥有的数据,而不是引用或克隆。这样就可以让调用者控制内存分配,并且可以清楚地看到使用相关接口的成本。
如果代码不需要拥有数据,那么就应用数据的引用来执行操作。但是有例外:像i32、bool和f64这类“小类型”,直接存储和复制的成本与通过引用存储的成本基本相同。
这种小类型基本都实现了Copy trait,但不是所有实现了Copy trait的类型都可以被称作“小类型”:比如[u8; 114514],它实现了Copy trait,但是由于它的元素太多了,所以存储和复制操作的开销太大,建议传引用。
Cow类型
有的时候我们还会无法确定代码是否拥有数据,因为它取决于运行时的情况。Cow类型(在1.2.2. Rust的引用和指针 有过介绍)非常适合这种场景。
Cow允许在需要时持有引用或拥有值。如果在只有引用的情况下要求生成拥有的值,Cow将使用ToOwned trait在后台创建一个拥有的值,通常是通过克隆。一般情况下我们会在返回类型中使用Cow来表示有时会分配内存的函数。
也就是说:
- 如果数据无需修改,
Cow可以借用现有数据,避免额外的内存分配。 - 如果数据需要修改,
Cow会克隆数据,以获得所有权,并进行修改。
看一个例子:
use std::borrow::Cow;
fn process_data(data: Cow<str>) {
if data.contains("invalid") {
// 包含了"invalid"就要进行修改,进行修改就得先获得所有权
let owned_data = data.into_owned();
// ...一些修改操作
println!("{}", owned_data); // 最后输出
}
else {
// 这里不包含修改操作,所以只需要读取它
println!("Data: {}", data);
}
}
fn main() {
let input1 = "Hello, world!";
process_data(Cow::Borrowed(input1));
let input2 = "This is invalid data".to_string();
process_data(Cow::Owned(input2));
}
process_data函数的逻辑我写在注释里了- 主函数中,
input1不包含“invalid“,没有修改操作,只需要读取。所以不需要传入持有的值,传入引用(Cow::Borrowed(input1))即可 input2包含“invalid“,需要进行修改操作,所以要传入持有的值(Cow::Owned(input2))即可
什么时候该考虑获得数据所有权
有时候引用生命周期会让接口特别复杂,难以使用。如果用户在使用的过程中遇到了编译问题,这表明我们需要(即使不必要)拥有数据的所有权。
如果要这样做,第一步该考虑把容易克隆或不涉及性能敏感的数据换成拥有的值,而不是直接对大块数据的内容机械能堆分配。这样做可以避免性能问题并提高接口的可用性。
2.7.2. 可失败和阻塞的析构函数(Fallible and Blocking Destructors)
析构函数(Destructors,也就是Drop trait)是在对象生命周期结束时自动调用的特殊方法(值何时被丢弃详见 1.9.3. 值的删除(丢弃)),用于释放资源。
析构函数一般由Drop trait来实现,Drop trait定义了一个drop方法,通过在一个类型的生命周期结束时自动调用drop trait来释放资源。
析构函数通常是不允许失败的,并且是非阻塞执行的,但有时会有例外:
- 释放资源时,可能需要关闭网络连接或写入日志文件,这些操作都有可能发生错误
drop方法可能需要执行阻塞任务,例如等待一个线程结束或等待一个异步任务的完成
I/O操作与析构函数的问题
在 I/O(输入/输出)相关的类型(如文件、网络连接等)中,资源管理非常重要,而Drop机制(析构函数)可以确保在对象被丢弃时,正确地执行清理操作,避免资源泄漏。
更具体地说:
- 文件操作:在文件对象被丢弃时,Drop需要确保所有数据已写入磁盘(防止数据丢失)。
- 网络连接:在
TcpStream或UdpSocket被丢弃时,Drop需要确保连接正确关闭,防止资源泄露。 - 数据库连接:在数据库连接对象超出作用域时,Drop需要断开连接,释放服务器端的资源。
问题在于:在Rust的Drop机制(析构函数)中,如果执行清理操作时发生了错误,没有直接的方式返回Result让调用者处理,唯一能做的就是触发panic!让程序崩溃。
异步代码与析构函数的问题
异步代码也有类似的问题——在Rust的异步编程 (async/await) 中,通常希望在Drop(析构函数)中执行清理操作,比如:
- 关闭数据库连接
- 刷新并关闭文件
- 关闭 WebSocket 或 TCP 连接
- 释放锁或资源
然而,异步代码执行时,可能会遇到 其他任务仍在等待(pending),比如:
- 网络I/O操作未完成
- 其他async任务仍然在等待信号
- 当前任务需要await,但Drop不能await
问题就出在Rust Drop trait不能await,因为drop()不是异步的:
#![allow(unused)]
fn main() {
trait Drop {
fn drop(&mut self);
}
}
drop()不能await,意味着它无法执行异步清理任务(如async关闭数据库连接)。- 但异步清理通常需要await,比如:
#![allow(unused)]
fn main() {
async fn close_connection() {
// 模拟关闭数据库连接
println!("Closing database connection...");
}
}
这段代码无法在Drop里直接调用,因为 Drop不能await。
一种常见的做法是在drop()里启动另一个异步执行器(executor) 来运行清理代码,例如:
#![allow(unused)]
fn main() {
impl Drop for MyAsyncResource {
fn drop(&mut self) {
tokio::spawn(async {
self.close().await;
});
}
}
}
- 这样可以在
drop()里执行async任务。 - 但问题是:如果
drop()发生在main()结束或其他async任务完成后,可能还没执行完drop()里的任务,程序就退出了。
针对这两类问题
针对这两类问题,没有完美的解决方案,只能是通过Drop来尽力清理。如果清理出错误了,至少我们尝试了,就只能让程序忽略错误并继续了。
如果还有可用的执行器,我们可以尝试生成一个Future来做清理,但如果Future永不会允许,那也没办法。
一点拓展:关于Future
在Rust的异步模型中,Future代表的是一个异步计算的值:
#![allow(unused)]
fn main() {
async fn cleanup() {
println!("Cleaning up...");
}
}
- 这个
cleanup()方法返回的是一个Future,它不会立即执行,而是需要执行器(executor)去轮询它。
#![allow(unused)]
fn main() {
struct MyResource;
impl Drop for MyResource {
fn drop(&mut self) {
let fut = async {
println!("Cleaning up...");
};
// 这里创建了 `Future`,但没人执行它!
}
}
}
- 这里
drop()里创建了一个Future,但它不会自己运行,必须有执行器(executor)来驱动它。 - 如果没有可用的执行器,Future就永远不会执行,导致清理任务无法完成。
解决方案——显式的析构函数
讲完了Future,我们回到解决这两类问题来。
如果用户不想留下“松散的线程”,那么我们可以提供一个显式的析构函数。这种析构函数通常是一个方法,它获得self的所有权并暴露任何的错误(使用Result<T,E>)或异步性(使用async fn),这些都是与销毁相关的。
“松散的线程”(dangling threads)指的是:
- 资源(如线程、数据库连接、文件句柄等)未正确清理,导致进程退出时仍然存在占用
- 例如,某些后台任务未正常终止,可能会继续运行、泄露资源或阻碍进程退出
“显式的析构函数”指的是:
- 由于Rust的
Drop不能返回Result<T, E>,也不能async(因为drop()不能await),所以无法处理异步清理或错误。- 因此,我们可以提供一个显式的
close()或shutdown()方法,让用户手动调用,确保资源被正确释放,并支持Result或async处理错误。
看例子:
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: String,
/// 文件描述符
fd: i32,
}
impl File {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 取得文件描述符的所有权(不要用 as_raw_fd:
// 否则 StdFile 被 drop 时会关掉 fd,而我们手里还留着一份拷贝)
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: name.to_string(),
fd,
})
}
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(self) -> Result<(), Error> {
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(self.fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
}
}
fn main() {
// 创建一个名为 "test.txt" 的文件,并写入一些内容
std::fs::write("test.txt", "Hello, world!").unwrap();
// 打开文件并获取 File 实例
let file: File = File::open("test.txt").unwrap();
// 打印文件名和 fd
println!("File name: {}, fd: {}", file.name, file.fd);
// 关闭文件并处理任何错误
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// 检查关闭后的文件大小
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
- 重要信息我都写在代码的注释里了
close就是一个显式的析构函数,它会关闭文件并返回任何错误,其参数是self,返回的是Result- 在主函数中我们显式地调用了析构函数,使用
match来进行模式匹配
一点注意
显式的析构函数需要在文档中突出显示。
2.8. API设计原则之灵活性(flexible) Pt.4:显式析构函数的问题及3种解决方案
2.8.1. 显式析构函数的问题
添加显式析构函数时会遇到问题:
- 当某一类型实现了
Drop,在析构函数中无法将该类型的任何字段移出。因为在显式析构函数运行后,drop()仍会被调用,它接受&mut self,要求self的所有部分都没被移动。 Drop接收的是&mut self而不是self,因此Drop无法实现简单地调用显式析构函数并忽略其结果(因为Drop不拥有self)
以上一篇文章的例子为基础,如果我们加上既实现了Drop trait,又写了close方法:
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: String,
/// 文件描述符
fd: i32,
}
impl File {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: name.to_string(),
fd,
})
}
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(self) -> Result<(), Error> {
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(self.fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
}
}
//实现drop trait
impl Drop for File {
fn drop(&mut self) {
let _ = self.close(); //调用close方法来丢弃
println!("File dropped");
}
}
fn main() {
// 创建一个名为 "test.txt" 的文件,并写入一些内容
std::fs::write("test.txt", "Hello, world!").unwrap();
// 打开文件并获取 File 实例
let file: File = File::open("test.txt").unwrap();
// 打印文件名和 fd
println!("File name: {}, fd: {}", file.name, file.fd);
// 关闭文件并处理任何错误
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// 检查关闭后的文件大小
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
输出:
error[E0507]: cannot move out of `*self` which is behind a mutable reference
--> src/main.rs:59:17
|
59 | let _ = self.close(); //调用close方法来丢弃
| ^^^^ ------- `*self` moved due to this method call
| |
| move occurs because `*self` has type `File`, which does not implement the `Copy` trait
|
note: `File::close` takes ownership of the receiver `self`, which moves `*self`
--> src/main.rs:33:14
|
33 | fn close(self) -> Result<(), Error> {
| ^^^^
note: if `File` implemented `Clone`, you could clone the value
--> src/main.rs:6:1
|
6 | struct File {
| ^^^^^^^^^^^ consider implementing `Clone` for this type
...
59 | let _ = self.close(); //调用close方法来丢弃
| ---- you could clone this value
报错信息显示无法从*self中移出值,因为它位于&mut self后面。
2.8.2. 解决方案
首先需要说明的是没有完美的解决方案,我们只能尽力弥补。
解决方案1:把结构体包装Option<T>里然后再套一层结构体
我们可以将顶层方案作为包装了Option<T>的新类型,这样Option<T>内部持有一个类型,这个类型包含所有的字段。
这个时候我们就需要两个析构函数,外边一个里面一个。在这两个析构函数中使用Option::take函数来获取数据所有权从而移除值。
由于内部类型没有实现Drop,所以你可以获取所有字段的所有权。
缺点:想在顶层类型上提供的所有方法,现在都必须添加通过Option<T>这层壳来获取内部类型上字段的代码。
我们在上文的代码例基础上进行修改:
步骤1:改掉File的定义,套一层壳
首先我们得把两个字段移到另一个结构体里,套在Option<T>中作为File结构体的字段
#![allow(unused)]
fn main() {
/// 一个表示文件句柄的类型
struct InnerFile {
/// 文件名
name: String,
/// 文件描述符
fd: i32,
}
/// 给InnerFile套了一个壳
struct File {
/// 把InnerFile包装在Option<T>中
inner: Option<InnerFile>,
}
}
步骤2:修改File上的方法
File上有两个方法,我们都需要添加通过Option<T>这层壳来获取内部类型上字段的代码。
首先是open方法:
#![allow(unused)]
fn main() {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
inner: Some( InnerFile {
name: name.to_string(),
fd,
})
})
}
}
- 由于这个代码只有返回值设计了
File结构体,所以也只有返回值需要改
接下来是close方法:
#![allow(unused)]
fn main() {
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> { // 记得self要变为mut self,否则take不了
// 使用模式匹配提取出字段的值
if let Some(inner) = self.inner.take() {
let name = inner.name;
let fd = inner.fd;
println!("Closing file: {} with fd: {}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
} else {
// 如果inner字段是None,说明文件已经被关闭或丢弃,返回错误
Err(Error::new(
std::io::ErrorKind::Other,
"File is already closed",
))
}
}
}
- 参数传进去之后先得通过模式匹配来获取字段的值
- 如果如果
inner字段是None,也就是模式匹配不成功的情况下,我们需要自己写一个错误返回
步骤3:修改Drop trait的实现
Drop::drop方法需要修改:
#![allow(unused)]
fn main() {
fn drop(&mut self) {
// 使用模式匹配获取字段值
if let Some(inner) = self.inner.take() {
let name = inner.name;
let fd = inner.fd;
println!("Dropping file: {} (fd: {})", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 丢弃file实例
drop(file);
} else {
// 如果inner字段是None的话,说明文件已经被丢弃或关闭,不做任何操作
}
}
}
- 参数传进去之后先得通过模式匹配来获取字段的值
- 如果
inner字段是None的话,说明文件已经被丢弃或关闭,不做任何操作
步骤4:微修主函数
主函数中需要提取字段值的地方需要修改:
fn main() {
// ...前文无修改,已省略
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}",
file.inner.as_ref().unwrap().name,
file.inner.as_ref().unwrap().fd
);
// ...后文无修改,已省略
}
- 原始类型是
Option<InnerFile>,调用.as_ref()后变成Option<&InnerFile> - 变成了
Option<&InnerFile>再使用unwrap提取出来的值就是引用而不是所有的值 file.inner是一个Option<InnerFile>类型。而直接访问Option的值需要转移所有权或匹配处理(例如通过take()或unwrap()),这会销毁Option的内部值,所以得要as_ref
整体代码
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
/// 一个表示文件句柄的类型
struct InnerFile {
/// 文件名
name: String,
/// 文件描述符
fd: i32,
}
/// 给InnerFile套了一个壳
struct File {
/// 把InnerFile包装在Option<T>中
inner: Option<InnerFile>,
}
impl File {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
inner: Some( InnerFile {
name: name.to_string(),
fd,
})
})
}
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> { // 记得self要变为mut self,否则take不了
// 使用模式匹配提取出字段的值
if let Some(inner) = self.inner.take() {
let name = inner.name;
let fd = inner.fd;
println!("Closing file: {} with fd: {}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
} else {
// 如果inner字段是None,说明文件已经被关闭或丢弃,返回错误
Err(Error::new(
std::io::ErrorKind::Other,
"File is already closed",
))
}
}
}
// 实现drop trait,用于在值离开作用域时运行的一些代码
impl Drop for File {
fn drop(&mut self) {
// 使用模式匹配获取字段值
if let Some(inner) = self.inner.take() {
let name = inner.name;
let fd = inner.fd;
println!("Dropping file: {} (fd: {})", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 丢弃file实例
drop(file);
} else {
// 如果inner字段是None的话,说明文件已经被丢弃或关闭,不做任何操作
}
}
}
fn main() {
// 创建一个名为 "test.txt" 的文件,并写入一些内容
std::fs::write("test.txt", "Hello, world!").unwrap();
// 打开文件并获取 File 实例
let file: File = File::open("test.txt").unwrap();
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}",
file.inner.as_ref().unwrap().name,
file.inner.as_ref().unwrap().fd
);
// 关闭文件并处理任何错误
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// 检查关闭后的文件大小
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
解决方案2:把字段包装在Option<T>里
我们也可以保持结构体不变,把每个字段的值都套在Option<T>里,需要获取所有权使用时用Option::take就可以,需要引用时用.as_ref()加.unwrap()就可以。
如果类型具有合理的空值,那么效果很好。
缺点:如果你必须将几乎每个字段都包装在Option中,然后对这些字段的每次访问都进行匹配的unwrap就会使代码变得很繁琐。
我们在上文的代码例基础上进行修改:
步骤1:改掉File的定义
为每一个字段添加一层Option<T>:
#![allow(unused)]
fn main() {
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: Option<String>,
/// 文件描述符
fd: Option<i32>,
}
}
步骤2:修改File上的方法
File上有两个方法,我们都需要添加通过Option<T>这层壳来获取内部类型上字段的代码。
首先是open方法:
#![allow(unused)]
fn main() {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: Some(name.to_string()),
fd: Some(fd),
})
}
}
open方法的参数没有涉及到File结构体,所以接收参数部分不用修改open方法的返回值涉及到了File,得为每个字段添上Some变体
其次是close方法:
#![allow(unused)]
fn main() {
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> {
// 模式匹配,并使用std::mem::take取出name字段的值
if let Some(name) = std::mem::take(&mut self.name) {
//模式匹配,并使用std::mem::take取出fd字段的值
if let Some(fd) = std::mem::take(&mut self.fd) {
// 打印
println!("Closing file: {} with fd: {}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
} else {
// 如果fd字段是None,说明文件已经被关闭或丢弃,返回一个错误
Err(Error::new(
std::io::ErrorKind::Other,
"File descriptor already dropped or taken",
))
}
} else {
// 如果name字段是None,说明文件已经被关闭或丢弃,返回一个错误
Err(Error::new(
std::io::ErrorKind::Other,
"File name already dropped or taken",
))
}
}
}
- 参数要先经过模式匹配,并使用
std::mem::take取出里面的值 - 如果任意字段是
None,说明文件已经被关闭或丢弃,返回一个错误
步骤3:修改Drop trait的实现
#![allow(unused)]
fn main() {
fn drop(&mut self) {
// 使用模式匹配获取字段值
if let Some(name) = self.name.take() {
if let Some(fd) = self.fd.take() {
println!("Dropping file: {} (fd: {})", name, fd);
// 使用 FromRawFd 将 fd 转换回
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 丢弃file实例
drop(file);
} else {
// 如果fd字段是None,说明文件已经被关闭或丢弃,不做任何操作
}
} else {
// 如果name字段是None,说明文件已经被关闭或丢弃,不做任何操作
}
}
}
- 参数要先经过模式匹配,并使用
std::mem::take取出里面的值 - 如果任意字段是
None,说明文件已经被关闭或丢弃,不做任何操作
步骤4:微修主函数
fn main() {
// ...前文无修改,已省略
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}",
file.name.as_ref().unwrap(),
file.fd.as_ref().unwrap()
);
// ...后文无修改,已省略
}
- 原始类型被
Option<T>包裹,调用.as_ref()后获得里面值的引用 - 变成了引用之后再使用
unwrap提取出来的值就是引用而不是所有的值 - 而直接访问
Option的值需要转移所有权或匹配处理(例如通过take()或unwrap()),这会销毁Option的内部值,所以得要as_ref
整体代码
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: Option<String>,
/// 文件描述符
fd: Option<i32>,
}
impl File {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: Some(name.to_string()),
fd: Some(fd),
})
}
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> {
// 模式匹配,并使用使用std::mem::take取出name字段的值
if let Some(name) = std::mem::take(&mut self.name) {
//模式匹配,并使用使用std::mem::take取出fd字段的值
if let Some(fd) = std::mem::take(&mut self.fd) {
// 打印
println!("Closing file: {} with fd: {}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
} else {
// 如果fd字段是None,说明文件已经被关闭或丢弃,返回一个错误
Err(Error::new(
std::io::ErrorKind::Other,
"File descriptor already dropped or taken",
))
}
} else {
// 如果name字段是None,说明文件已经被关闭或丢弃,返回一个错误
Err(Error::new(
std::io::ErrorKind::Other,
"File name already dropped or taken",
))
}
}
}
// 实现drop trait,用于在值离开作用域时运行的一些代码
impl Drop for File {
fn drop(&mut self) {
// 使用模式匹配获取字段值
if let Some(name) = self.name.take() {
if let Some(fd) = self.fd.take() {
println!("Dropping file: {} (fd: {})", name, fd);
// 使用 FromRawFd 将 fd 转换回
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(fd)
};
// 丢弃file实例
drop(file);
} else {
// 如果fd字段是None,说明文件已经被关闭或丢弃,不做任何操作
}
} else {
// 如果name字段是None,说明文件已经被关闭或丢弃,不做任何操作
}
}
}
fn main() {
// 创建一个名为 "test.txt" 的文件,并写入一些内容
std::fs::write("test.txt", "Hello, world!").unwrap();
// 打开文件并获取 File 实例
let file: File = File::open("test.txt").unwrap();
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}",
file.name.as_ref().unwrap(),
file.fd.as_ref().unwrap()
);
// 关闭文件并处理任何错误
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// 检查关闭后的文件大小
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
方法3:将数据持有在ManuallyDrop类型内
将数据持有在ManuallyDrop类型内,它会解引用内部类型,不必再使用unwrap。
在drop中进行销毁时,可使用ManuallyDrop::take来获取所有权。
缺点:ManuallyDrop::take是不安全的,需要放在unsafe块中。
我们在上文的代码例基础上进行修改:
步骤1:改掉File的定义
为每一个字段添加一层ManuallyDrop:
#![allow(unused)]
fn main() {
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: ManuallyDrop<String>,
/// 文件描述符
fd: ManuallyDrop<i32>,
}
}
步骤2:修改File上的方法
File上有两个方法,我们都需要添加通过ManuallyDrop这层壳来获取内部类型上字段的代码。
首先是open方法:
#![allow(unused)]
fn main() {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 取得文件描述符的所有权
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: ManuallyDrop::new(name.to_string()),
fd: ManuallyDrop::new(fd),
})
}
}
open方法的参数没有涉及到File结构体,所以接收参数部分不用修改open方法的返回值涉及到了File,每个字段都得用ManuallyDrop::new来传值
其次是close方法:
#![allow(unused)]
fn main() {
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> {
// 使用std::mem::replace将name字段替换为一个空字符串,把原来的值给name
let name =
std::mem::replace(&mut self.name, ManuallyDrop::new("".to_string()));
// 使用std::mem::replace将fd字段替换为一个无效的值(-1),把原来的值给fd
let fd =
std::mem::replace(&mut self.fd, ManuallyDrop::new(-1));
// 打印
println!("Closing file: {:?} with fd: {:?}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(*fd) //这里fd要先解引用
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
}
}
- 使用
std::mem::replace将name字段替换为一个空字符串,把原来的值给name - 使用
std::mem::replace将fd字段替换为一个无效的值(-1),把原来的值给fd std::fs::File::from_raw_fd(*fd)中参数要先解引用,也就是写*fd
步骤3:修改Drop trait的实现
#![allow(unused)]
fn main() {
fn drop(&mut self) {
// 使用ManuallyDrop::take取出name字段的值,并检查是否是空字符串
let name = unsafe { ManuallyDrop::take(&mut self.name) };
// 使用ManuallyDrop::take取出fd字段的值,并检查是否是无效的值
let fd = unsafe { ManuallyDrop::take(&mut self.fd) };
//打印
println!("Dropping file: {:?} (fd: {:?})", name, fd);
// 如果fd字段不是无效的值,说明文件还没有被关闭或丢弃,需要执行丢弃操作
if fd != -1 || !name.is_empty() {
let file = unsafe { std::fs::File::from_raw_fd(fd) };
// 丢弃
drop(file);
}
}
}
- 使用
ManuallyDrop::take取出name和fd字段的值,并检查是否是空字符串或无效的值 - 如果
fd字段不是无效的值(不是-1),或是name字段不是空字符串,就说明文件还没有被关闭或丢弃,需要执行丢弃操作 - 其实这里不用两个判断条件(
fd != -1 || !name.is_empty()),一个就够了,因为name和fd字段的值的变化是一起的,一个无效就代表着整个结构体都还未被清理。
步骤4:微修主函数
fn main() {
// ...前文无修改,已省略
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}", *file.name, *file.fd);
// ...后文无修改,已省略
}
- 使用解引用来打印值
整体代码
use std::os::fd::{FromRawFd, IntoRawFd};
use std::fs::{File as StdFile, OpenOptions, metadata};
use std::io::Error;
use std::mem::ManuallyDrop;
/// 一个表示文件句柄的类型
struct File {
/// 文件名
name: ManuallyDrop<String>,
/// 文件描述符
fd: ManuallyDrop<i32>,
}
impl File {
/// 一个构造函数,打开一个文件并返回一个 File 实例
fn open(name: &str) -> Result<File, Error> {
// 使用 OpenOptions 打开文件,具备读写权限
let file: StdFile = OpenOptions::new()
.read(true)
.write(true)
.open(name)?;
// 获取文件描述符
let fd: i32 = file.into_raw_fd();
// 返回一个 File 实例
Ok(File {
name: ManuallyDrop::new(name.to_string()),
fd: ManuallyDrop::new(fd),
})
}
/// 一个显式的析构函数,关闭文件并返回任何错误
fn close(mut self) -> Result<(), Error> {
// 使用std::mem::replace将name字段替换为一个空字符串,把原来的值给name
let name =
std::mem::replace(&mut self.name, ManuallyDrop::new("".to_string()));
// 使用std::mem::replace将fd字段替换为一个无效的值(-1),把原来的值给fd
let fd =
std::mem::replace(&mut self.fd, ManuallyDrop::new(-1));
// 打印
println!("Closing file: {:?} with fd: {:?}", name, fd);
// 使用 FromRawFd 将 fd 转换回 File
let file: std::fs::File = unsafe {
std::fs::File::from_raw_fd(*fd) //这里fd要先解引用
};
// 刷新文件数据到磁盘
file.sync_all()?;
// 将文件截断为 0 字节
file.set_len(0)?;
// 再次刷新文件
file.sync_all()?;
// 丢弃文件实例,它会自动关闭文件
drop(file);
// 返回成功
Ok(())
}
}
// 实现drop trait,用于在值离开作用域时运行的一些代码
impl Drop for File {
fn drop(&mut self) {
// 使用ManuallyDrop::take取出name字段的值,并检查是否是空字符串
let name = unsafe { ManuallyDrop::take(&mut self.name) };
// 使用ManuallyDrop::take取出fd字段的值,并检查是否是无效的值
let fd = unsafe { ManuallyDrop::take(&mut self.fd) };
//打印
println!("Dropping file: {:?} (fd: {:?})", name, fd);
// 如果fd字段不是无效的值,说明文件还没有被关闭或丢弃,需要执行丢弃操作
if fd != -1 || !name.is_empty() {
let file = unsafe { std::fs::File::from_raw_fd(fd) };
// 丢弃
drop(file);
}
}
}
fn main() {
// 创建一个名为 "test.txt" 的文件,并写入一些内容
std::fs::write("test.txt", "Hello, world!").unwrap();
// 打开文件并获取 File 实例
let file: File = File::open("test.txt").unwrap();
// 打印文件名和 fd (这里需要修改)
println!("File name: {}, fd: {}", *file.name, *file.fd);
// 关闭文件并处理任何错误
match file.close() {
Ok(()) => println!("File closed successfully"),
Err(e) => println!("Error closing file: {}", e),
}
// 检查关闭后的文件大小
let metadata = metadata("test.txt").unwrap();
println!("File size: {} bytes", metadata.len());
}
三种方案的选择
这三种方案的选择要根据实际情况,通常第二个方案。但是如果真的字段太多要写的unwrap太多的话就需要考虑其他的方案。
如果代码足够简单,可以轻松检查代码的安全性,那么第三种ManuallyDrop方案也是非常好的。
2.9. API设计原则之显然性(obvious) Pt.1:文档与类型系统、语义化类型、使用“零大小”类型
2.9.1. 文档与类型系统
用户可能不会完全理解API的所有规则和限制。所以你写的API应该让你的用户易于理解,并且难以用错。
通过Rust的文档与类型系统,我们可以尽量实现这个需求。
2.9.2. 文档
让API透明化的第一步就是写出好的文档。
写出好的文档有这么几点要求:
1. 清楚的记录
清楚的记录可能出现的意外情况,或它依赖于用户执行超出类型签名要求的操作。
例如:何时会发生panic、何时返回错误。如果使用了unsafe函数,那么需要写明用户需要什么条件才能安全地调用这个函数。
看个例子:
#![allow(unused)]
fn main() {
/// 除法运算,返回两个数的结果
///
/// # Panics
///
/// 如果除数为0,该函数会发生 panic。
///
/// # 示例
///
/// ```
/// let result = divide(10, 2);
/// assert_eq!(result, 5);
/// ```
pub fn divide(dividend: i32, divisor: i32) -> i32 {
// ...此处省略
}
}
- 这里我们把会发生恐慌的情况写进去了
2. 包含端到端的用例
在crate或module级别,要包含端到端的用例,而不是针对特定的类型或方法。
这么做的好处是让用户了解这些内容是如何组合到一起的,对API的整体结构有一个相对清晰的理解,从而让开发者快速了解到各方法和类型的功能,以及在哪里使用。
在你提供了端到端的用例之后,用户就可以把这段代码复制粘贴到自己的项目里,相当于给用户提供了一个定制化使用的起点。
举个例子:
假设我们有一个
math_utilscrate,它提供了一些数学运算功能,包括基本的加法、减法和一个复杂的计算函数。这里每个函数的文档注释我就只简单写功能了,但是你自己在写的时候一定要写好每个函数的文档注释。
// lib.rs (crate 根模块)
pub mod math_utils {
/// 计算两个数的和
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 计算两个数的差
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
/// 执行复杂的数学运算(如 a * b + (a - b))
pub fn complex_calculation(a: i32, b: i32) -> i32 {
(a * b) + subtract(a, b)
}
}
// --- 端到端用例(crate 级别文档测试) ---
/// ```
/// use my_crate::math_utils;
///
/// fn main() {
/// let sum = math_utils::add(10, 5);
/// let difference = math_utils::subtract(10, 5);
/// let result = math_utils::complex_calculation(10, 5);
///
/// println!("Sum: {}", sum); // 15
/// println!("Difference: {}", difference); // 5
/// println!("Complex Calculation Result: {}", result); // 55
/// }
/// ```
3. 组织好文档
利用模块来将语义相关的项目进行分组。然后使用内部文档链接将这些项相互连接起来。
有时候你可以考虑使用#[doc(hidden)]这个注解标记那些不打算公开但出于遗留的原因需要的接口部分,避免弄乱文档。
看个例子:
#![allow(unused)]
fn main() {
/// 一个简单的模块,包含一些用于内部使用的函数和结构体。
pub mod internal {
/// 一个用于内部计算的辅助函数。
#[doc(hidden)]
pub fn internal_helper() {
// 内部计算的具体实现...
}
/// 一个仅用于内部使用的结构体。
#[doc(hidden)]
pub struct InternalStruct {
// 结构体的字段和方法...
}
}
}
internal_helper()函数和InternalStruct结构体都是只供内部使用的。- 给它们标注了
#[doc(hidden)],它们的文档注释就不会出现在生成的文档注释中
4.尽可能地丰富文档
有时候需要解释一些内容和概念,你就可以添加链接到外部资源。比如:相关的规范文件(RFC)、博客、白皮书…
在顶层文档中需要引导用户了解常用的模块、trait、类型和方法。
一些有关文档内容的注解:
- 使用
#[doc(cfg(..))]突出显示仅在特定配置下可用的项,这样用户就能快速了解为什么在文档中列出的某个方法不可用。 - 使用
#[doc(alias = "...")]可以让用户以其他名称搜索到类型和方法
例子1:
#![allow(unused)]
fn main() {
//! 这是一个用于处理图像的库。
//!
//! 这个库提供了一些常用的图像处理功能,例如:
//! - 读取和保存不同格式的图像文件 [`Image::load`] [`Image::save`]
//! - 调整图像的大小、旋转和裁剪 [`Image::resize`] [`Image::rotate`] [`Image::crop`]
//! - 应用不同的滤镜和效果 [`Filter`] [`Effect`]
//!
//! 如果您想了解更多关于图像处理的原理和算法,您可以参考以下的资源:
//! - [数字图像处理](https://book.douban.com/subject/5345798/),一本经典的教科书,介绍了图像处理的基本概念和方法。
//! - [Learn OpenCV](https://learnopencv.com/),一个网站,提供了很多用OpenCV实现图像处理功能的教程和示例代码。
//! - [Awesome Computer Vision](https://github.com/jbhuang0604/awesome-computer-vision),一个GitHub仓库,收集了很多计算机视觉相关的资源和项目。
/// 一个表示图像的结构体
#[derive(Debug, Clone)]
pub struct Image {
// ...
}
// ...
}
- 这里使用到了外部链接,可以看到外部链接的格式是
[你想展示在文档中的字](链接),这就是标准的markdown格式,只要是写过自述文件的人肯定都非常熟悉。
例子2:
#![allow(unused)]
fn main() {
impl Image {
// ...
// ...
#[doc(alias = "读取")]
#[doc(alias = "打开")]
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
// ...
}
// ...
}
}
- 使用了
#[doc(alias = "读取")]和#[doc(alias = "打开")]这两个注释,这样在文档中搜索“读取”和“打开”时就能搜到这个函数。
例子3:
/// 一个只在启用了 `foo` 特性时才可用的结构体。
#[cfg(feature = "foo")]
#[doc(cfg(feature = "foo"))]
pub struct Foo;
impl Foo {
/// 一个只在启用了 `foo` 特性时才可用的方法。
#[cfg(feature = "foo")]
#[doc(cfg(feature = "foo"))]
pub fn bar(&self) {
// ...
}
}
fn main() {
println!("Hello, world!");
}
#[cfg(feature = "foo")]:只有当启用了“foo“特性时,Foo结构体及其方法bar才会包含在最终的编译产物中。#[doc(cfg(feature = "foo"))]:在API说明中标注该结构体和方法依赖foo特性,让使用者知道它们并非默认可用。
2.9.3. 类型系统
我们使用Rust的类型系统可以确保:
- 接口明显
- 自我描述
- 难以被误用
语义化类型
有一些值具有超过它表面的意义的,比如说1和0可以代表男和女。这时候我们就可以添加类型来表示值的意义。
看例子:
#![allow(unused)]
fn main() {
fn processData(dryRun: bool, overwrite: bool, validate: bool) {
// 处理数据的逻辑
}
}
- 这个函数的3个参数都是布尔类型,很容易记混,用户极有可能错误地使用
为了解决这个问题,我们可以创建3个类型,并让参数是3个不同的类型:
#![allow(unused)]
fn main() {
enum DryRun {
Yes,
No,
}
enum Overwrite {
Yes,
No,
}
enum Validate {
Yes,
No,
}
fn processData(dryRun: DryRun, overwrite: Overwrite, validate: Validate) {
// 处理数据的逻辑
}
}
- 把3个布尔类型变成3个枚举类型
用户在调用的时候就会写:
#![allow(unused)]
fn main() {
processData(DryRun::Yes, Overwrite::No, Validate::Yes)
}
这样更加的清晰明了。
使用“零大小”类型来表示关于类型实例的特定事实
举个例子:
假设我们有一个结构体
Rocket,它有方法launch用于发射,这个火箭没有处于已发射状态时调用这个方法肯定是没有问题的。但是如果火箭已经处于已发射状态了就不能再使用发射方法了。同样的,在火箭发射后我们能控制火箭加速或减速,但在地面不行。
#![allow(unused)]
fn main() {
// 定义不同的火箭状态
struct Grounded;
struct Launched;
// 颜色枚举
enum Color {
White,
Black,
}
// 质量结构体,使用 newtype 模式封装 u32
struct Kilograms(u32);
// 泛型火箭结构体,带有默认状态 Grounded
struct Rocket<Stage = Grounded> {
stage: std::marker::PhantomData<Stage>,
}
// 为 Grounded 状态的 Rocket 实现 Default
impl Default for Rocket<Grounded> {
fn default() -> Self {
Self {
stage: Default::default(),
}
}
}
// 为 Grounded 状态的 Rocket 实现方法
impl Rocket<Grounded> {
pub fn launch(self) -> Rocket<Launched> {
Rocket {
stage: Default::default(),
}
}
}
// 为 Launched 状态的 Rocket 实现方法
impl Rocket<Launched> {
pub fn accelerate(&mut self) {}
pub fn decelerate(&mut self) {}
}
// 为所有状态的 Rocket 实现通用方法
impl<Stage> Rocket<Stage> {
pub fn color(&self) -> Color {
Color::White
}
pub fn weight(&self) -> Kilograms {
Kilograms(0)
}
}
}
-
Grounded和Launched这两个结构体没有任何字段,因此它们的大小为零,Rust编译器不会为它们分配内存空间。它们仅用于标记Rocket处于哪种状态,而不需要额外的存储开销。 -
我们定义了
Rocket结构体,它带有一个泛型参数Stage,该参数默认是Grounded。在定义中我们还使用了std::marker::PhantomData<T>,它是零大小类型 (ZST, Zero-Sized Type),它在编译期影响类型系统,但运行时不会占用内存。 -
launch方法仅在Rocket<Grounded>实例上可用。 -
launch()被调用后,会返回一个Rocket<Launched>,表示火箭已经进入发射状态。Rocket<Launched>不再有launch()方法,确保无法重复发射。 -
accelerate方法代表加速,decelerate方法代表减速,这些方法只对Rocket<Launched>实例有效,防止在Grounded状态下加速或减速。 -
有些方法在任何状态下都可以使用,我们就写在
impl<Stage> Rocket<Stage>这个块里即可。
#[must_use]注解
将#[must_use]注解添加到类型、trait或函数中之后,如果用户的代码接收到该类型或trait的元素,或调用了该函数,并且没有明确处理它,编译器将发出警告。
看一个例子:
#![allow(unused)]
fn main() {
#[must_use]
fn process_data(data: Data) -> Result<(), Error> {
// ...
Ok(())
}
}
- 我们使用
#[must_use]注解将process_data函数标记为必须使用其返回值 - 如果用户在调用该函数后没有显式处理返回的
Result类型,编译器将发出警告 - 这有助于提醒用户在处理潜在的错误情况时要小心,并减少可能的错误
2.10. API设计原则之受约束性(constrained) Pt.1:对类型进行修改
2.10.1. 接口的更改要三思
如果你的接口要做出对用户可见的更改,那么一定要三思而后行。
你需要确保你做出的变化:
- 不会破坏现有用户的代码
- 这次变化应该保留一段时间
频繁推送向后不兼容的更改(主版本增加),会导致用户的不满。
2.10.2. 向后不兼容的更改
有些向后不兼容的更改是显而易见的,比如说你改变公共类型的名称,或从中移除一个公共项。
有些向后不兼容的更改则很微妙,这与Rust的工作方式息息相关。这篇文章主要讲的就是这种更改,以及你作为开发者应该如何为其制定修改计划。
在这个过程中,有时候你就需要在接口的灵活性上做出权衡与妥协。
2.10.3. 对类型进行修改
如果你移除或重命名一个公共类型几乎肯定会破坏用户的代码,解决办法就是尽可能利用可见性修饰符。比如说:
pub(crate):对当前这个crate可见pub(in path):对指定的路径可见
看个例子:
#![allow(unused)]
fn main() {
pub mod outer_mod {
pub mod inner_mod {
// 该函数仅对 `outer_mod` 可见
pub(in crate::outer_mod) fn outer_mod_visible_fn() {}
// 该函数对整个 crate 可见
pub(crate) fn crate_visible_fn() {}
// 该函数仅对 `outer_mod` 可见(使用 `super` 指向外部模块)
pub(super) fn super_mod_visible_fn() {
// 由于 `inner_mod_visible_fn` 在相同模块内可见,可以正常调用
inner_mod_visible_fn();
}
// 该函数仅对 `inner_mod` 内部可见,相当于 `private`
pub(self) fn inner_mod_visible_fn() {}
}
pub fn foo() {
inner_mod::outer_mod_visible_fn();
inner_mod::crate_visible_fn();
inner_mod::super_mod_visible_fn();
// 该函数不再可见,因为我们已经在 `inner_mod` 之外
// Error! `inner_mod_visible_fn` 是私有的
inner_mod::inner_mod_visible_fn();
}
}
fn bar() {
// 这个函数仍然可见,因为我们在同一个 crate 内
outer_mod::inner_mod::crate_visible_fn();
// 这个函数在 `outer_mod` 之外不再可见
// Error! `super_mod_visible_fn` 是私有的
outer_mod::inner_mod::super_mod_visible_fn();
// 这个函数在 `outer_mod` 之外也不可见
// Error! `outer_mod_visible_fn` 是私有的
outer_mod::inner_mod::outer_mod_visible_fn();
outer_mod::foo();
}
}
inner_mod模块中函数的可见性控制:
outer_mod_visible_fn(): 仅在outer_mod内部可见,外部无法访问。crate_visible_fn(): 整个crate可见,即bar()仍然可以访问它。super_mod_visible_fn(): 仅outer_mod内部可见,bar()无法访问。inner_mod_visible_fn(): 私有,仅inner_mod内部可见。
你写的API中公共类型越少,更改时就越自由(自由指保证不会破坏现有代码)。
#[non_exhaustive]注解
用户的代码不仅仅通过名称依赖于你的类型。看个例子:
一个破坏性变更的例子
最开始在lib.rs中我写了一个结构体名叫Unit:
#![allow(unused)]
fn main() {
pub struct Unit;
}
然后我在main.rs中使用了Unit:
fn main() {
let u = constrained::Unit;
}
- 这没有任何问题。
后来呢,我对Unit进行了一些修改,因为用户要用:
#![allow(unused)]
fn main() {
pub struct Unit {
pub field: bool,
}
}
在main.rs中代码也会变:
fn is_true(u: constrained::Unit) -> bool {
matches!(u, constrained::Unit { field: true })
}
fn main() {
let u = constrained::Unit {
field: true,
};
}
is_true这个函数用到了修改后Unit的字段- 但是
main函数中本来的代码就会报错
这种情况也会在Unit的field是私有字段时发生。因为编译器知道Unit有字段,而你没有填写这个字段的值。
解决方案
针对这种情况,Rust提供了#[non_exhaustive]注解来缓解这些问题。它可以引用于struct、enum和enum的变体。这个注解表示类型或枚举在将来可能会添加更多字段或变体。
如果你使用了它,那么别人在使用你的crate时,编译器会:
- 禁止显式的构造,比如:
lib::Unit { field: true } - 禁止非穷尽模式的匹配(即没有尾随
..的模式)
如果你的接口比较稳定,就应该避免使用这个注解。
看例子:
lib.rs:
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct Config {
pub window_width: u16,
pub window_height: u16,
}
fn some_function() {
let config: Config = Config {
window_width: 640,
window_height: 480,
};
// Non-exhaustive structs can be matched on exhaustively within the defining crate.
if let Config {
window_width,
window_height,
} = config
{
// ...
}
}
}
- 标注了
#[non_exhaustive],lib.rs里仍然可以使用显式的构造,仍然可以使用穷尽模式的匹配,因为这些代码与定义这个结构体的代码属于同一crate之内
那么我在main.rs这么写呢:
use constrained::Config;
fn main() {
let config: Config = Config {
window_width: 640,
window_height: 480,
};
if let Config {
window_width,
window_height,
} = config {}
}
- 这样写就会报错,因为这里的代码属于外部crate,编译器就会禁止上面所说的两种操作
输出:
error[E0639]: cannot create non-exhaustive struct using struct expression
--> src/main.rs:4:26
|
4 | let config: Config = Config {
| __________________________^
5 | | window_width: 640,
6 | | window_height: 480,
7 | | };
| |_____^
error[E0638]: `..` required with struct marked as non-exhaustive
--> src/main.rs:9:12
|
9 | if let Config {
| ____________^
10 | | window_width,
11 | | window_height,
12 | | } = config {}
| |_____^
我们可以稍微改一下代码使main.rs中的匹配变成带 .. 的非穷尽匹配:
#![allow(unused)]
fn main() {
if let Config {
window_width,
window_height,
.. // 它用于忽略结构体、元组或枚举中的其余字段或变体
} = config {}
}
2.11. API设计原则之受约束性(constrained) Pt.2:封闭trait(sealed trait)、重新导出(re-exports)、自动trait(auto-trait)
2.11.1. trait实现
Rust的一致性规则禁止了对某个trait为某类型进行多重实现。
通常情况下,以下与trait相关的操作会产生破坏性变更:
- 为现有trait添加Blanket Implementation(详见 1.17.2. 泛实现)通常是破坏性变更
- 为现有类型实现外部trait,或为外部类型实现现有trait
- 移除trait实现(为新的类型实现trait不会产生破坏性变更)
大多数到现有trait的更改也是破坏性变更,例如:
- 为现有的trait改变方法的签名
- 添加新方法(如果新方法有默认实现就不产生破坏性变更)
为任何类型实现任何trait都要小心
多提一嘴,为任何类型实现任何trait都要小心。
看个例子:
lib.rs:
#![allow(unused)]
fn main() {
pub struct Unit;
// 定义 trait
pub trait Foo1 {
fn foo(&self);
}
impl Foo1 for Unit {
fn foo(&self) {
println!("foo1");
}
}
}
main.rs:
use constrained::{Foo1, Unit};
// 定义 trait
trait Foo2 {
fn foo(&self);
}
// 为 Unit 实现 Foo2 trait
impl Foo2 for Unit {
fn foo(&self) {
println!("foo2");
}
}
// 运行主函数
fn main() {
Unit.foo();
}
输出:
error[E0034]: multiple applicable items in scope
--> src/main.rs:14:10
|
14 | Unit.foo();
| ^^^ multiple `foo` found
|
= note: candidate #1 is defined in an impl of the trait `Foo1` for the type `Unit`
note: candidate #2 is defined in an impl of the trait `Foo2` for the type `Unit`
--> src/main.rs:8:5
|
8 | fn foo(&self) {
| ^^^^^^^^^^^^^
help: disambiguate the method for candidate #1
|
14 - Unit.foo();
14 + Foo1::foo(&Unit);
|
help: disambiguate the method for candidate #2
|
14 - Unit.foo();
14 + Foo2::foo(&Unit);
|
这么写是会报错的,注意到错误在哪里了吗?问题出在foo方法,main.rs和lib.rs各有一个Foo2和Foo1 trait,而两者都有foo这个方法,Unit结构体既实现了Foo1也实现了Foo2。在main.rs中使用foo方法时编译器就不清楚到底改使用哪个trait上的foo方法。
这就是为什么为任何类型实现任何trait都要小心——实现trait有可能在不经意间造成的破坏性变更。
封闭trait(sealed trait)
刚才我的用词都是“大多数”、“通常情况下”,这是因为Rust有封闭trait(sealed trait)。
它的特点是它只能被其它crate使用,而不能在其它crate中被实现。它可以防止trait添加新方法时造成破坏性的变更
封闭trait(sealed trait) 并不是内建的功能,有几种实现方式。
封闭trait(sealed trait) 常用于派生trait。具体来讲就是为实现特定其它trait的类型提供blanket implementation的trait。
看例子:
mod sealed {
pub trait Sealed {} // 私有 trait,不对外公开
}
// 只有 `i32` 和 `f64` 类型可以实现 `MyTrait`
impl sealed::Sealed for i32 {}
impl sealed::Sealed for f64 {}
pub trait MyTrait: sealed::Sealed {
fn describe(&self) -> String;
}
// Blanket Implementation:只有 `Sealed` 的实现者才可以使用 `MyTrait`
impl MyTrait for i32 {
fn describe(&self) -> String {
format!("I am an i32: {}", self)
}
}
impl MyTrait for f64 {
fn describe(&self) -> String {
format!("I am an f64: {}", self)
}
}
// 测试
fn main() {
let x: i32 = 42;
let y: f64 = 3.14;
println!("{}", x.describe()); // 输出: I am an i32: 42
println!("{}", y.describe()); // 输出: I am an f64: 3.14
}
Sealed是私有的(因为它在 mod sealed 内),别的crate就不可能用的了它,以此实现了封闭的目的。- 只有
i32和f64被允许实现Sealed
上面的是一个比较简单的例子,我们接下来融入派生trait:
使用
Sealed作为封闭 trait,限制BaseTrait只能被特定类型实现。 派生DerivedTrait,让它继承BaseTrait,并提供额外的行为。
mod sealed {
pub trait Sealed {} // 私有 trait,不对外公开
}
// 只有 `i32` 和 `f64` 可以实现 `BaseTrait`
impl sealed::Sealed for i32 {}
impl sealed::Sealed for f64 {}
/// 基础 trait,只能被 `sealed::Sealed` 的类型实现
pub trait BaseTrait: sealed::Sealed {
fn base_method(&self) -> String;
}
// Blanket implementation for BaseTrait
impl BaseTrait for i32 {
fn base_method(&self) -> String {
format!("I am an i32: {}", self)
}
}
impl BaseTrait for f64 {
fn base_method(&self) -> String {
format!("I am an f64: {}", self)
}
}
/// 派生 trait,扩展 `BaseTrait`
pub trait DerivedTrait: BaseTrait {
fn derived_method(&self) -> String;
}
// Blanket implementation for DerivedTrait
impl DerivedTrait for i32 {
fn derived_method(&self) -> String {
format!("Derived trait: {} squared = {}", self, self * self)
}
}
impl DerivedTrait for f64 {
fn derived_method(&self) -> String {
format!("Derived trait: sqrt({}) = {}", self, self.sqrt())
}
}
fn main() {
let x: i32 = 5;
let y: f64 = 9.0;
println!("{}", x.base_method()); // "I am an i32: 5"
println!("{}", x.derived_method()); // "Derived trait: 5 squared = 25"
println!("{}", y.base_method()); // "I am an f64: 9"
println!("{}", y.derived_method()); // "Derived trait: sqrt(9) = 3"
}
BaseTrait不能被外部类型实现,只能用于i32和f64,因为它继承了sealed::SealedDerivedTrait扩展了BaseTrait,并增加了derived_method()BaseTrait和DerivedTrait只对i32和f64提供实现,外部类型无法实现这些 trait
那么什么时候使用封闭trait呢?只有在外部crate不该实现你的trait时。这种形式会严重限制trait的可用性——下游trait无法为其自己类型实现该trait。
我们可以使用密封trait来限制可用作类型参数的类型。还记得我们之前写的Rocket结构体吗?(在 2.9.3. 类型系统 中)Rocket结构体的Stage泛型参数限制为了仅允许Grounded和Launched类型就使用这种方式。
2.11.2. 隐藏的契约
有时,你对代码的某一部分所做的更改会以微妙的方式影响到接口其他地方的契约。
这种情况主要发生在:
- 重新导出(re-exports)
- 自动trait(auto-trait)
重新导出(re-exports)
重新导出(re-exports)的操作在 【Rust指南】14.3.1. 使用pub use重导出API 中有过介绍。
如果你的接口的某部分暴露了外部类型,那么外部类型的任何更改也将成为你接口的变更。
最好用newtype(详见 【Rust指南】19.2.6. 使用newtype模式在外部类型上实现外部trait)包裹外部类型,仅仅暴露外部类型中你认为有用的部分。
自动trait(auto-trait)
有些trait根据类型的内容,会对其进行实现,比如说Send和Sync。而根据这些trait的特性,它们为接口中几乎每种类型都添加一个隐藏的承诺。
这些特性会传播,无论是具体的类型,还是impl Trait等类型擦除的情况。
这些trait的实现通常是编译器自动添加的,如果情况不适用,则不会自动添加。
举个例子:
- 类型A包含私有类型B,默认A和B都实现了
Send。 - 后来修改了B,让B不再实现
Send,那么A也就实现不了Send。 - 这样的情况就是破坏性的变化,而且这类变化还非常难以追踪和发现
针对这种问题,你可以在你的库里面包含一些简单的测试来检查你所有的类型是否实现了相关的trait。
举个代码例:
这是原本的代码:
use std::thread;
/// 1. 私有类型 B,最初实现了 `Send`
struct B;
/// 2. 公开类型 A,包含 B
struct A {
_b: B, // 依赖 `B` 的特性
}
// 3. 证明 `A` 实现了 `Send`
fn assert_send<T: Send>() {}
fn main() {
assert_send::<A>(); // 通过,A 是 Send 的
// 4. 证明 A 可以在线程间安全传递
let a = A { _b: B };
thread::spawn(move || {
let _ = a; // 运行成功,因为 A 仍然是 Send
}).join().unwrap();
}
后面我们进行修改,使B不再实现Send trait:
use std::rc::Rc;
/// 1. 修改 `B`,让它不再实现 `Send`
/// `Rc<T>` 不是 `Send`,所以 B 也不是 `Send`
struct B {
_data: Rc<i32>,
}
/// 2. A 仍然包含 B
struct A {
_b: B,
}
// 3. 证明 `A` 实现了 `Send`
fn assert_send<T: Send>() {}
fn main() {
assert_send::<A>(); // 编译错误[E0277]:`Rc<i32>` cannot be sent between threads safely(因此 `A: Send` 失败)
let a = A { _b: B { _data: Rc::new(42) } };
thread::spawn(move || {
let _ = a; // 这里会报错,因为 Rc<i32> 不能在线程间安全传递
}).join().unwrap();
}
- B现在包含
Rc<T>,但Rc<T>不是Send。这意味着B也不能Send,因为Rc<T>不能安全在线程间传递。 - A也就不再
Send了,导致assert_send::<A>()编译错误。我们就可以在编译时发现错误。