Rust 的那些内置 Trait
目录
什么是 Trait
众所周知,Rust 在 OOP 所推崇的范式是“组合优于继承”,因此没有抽象类和类继承的那一套东西,但又客观上需要实现复用和抽象,因此有了类似于接口的 Trait。
定义 Trait 是一件很简单的事情,在 Trait 中可以定义需要手动实现的方法和提供默认实现的方法:
trait MyTrait {
fn method_1(&self);
fn method_2(&self) {
println!("Hello from MyTrait");
}
}在结构体上实现 Trait 时,拥有默认实现的方法是可以被覆盖的:
struct MyStruct;
impl MyTrait for MyStruct {
fn method_1(&self) {
println!("Hello from MyStruct");
}
fn method_2(&self) {
println!("Hello from MyStruct");
}
}因为 Trait 是类似于接口的抽象,所以也可以像其他语言一样进行存储和传递,但是由于 Trait 背后的结构体是不确定的(它只是一种公共方法的约定),Rust 无法在编译时计算 Trait 的大小,所以往往需要通过 Box<dyn T> 来表示一个 Trait 对象,实际的结构体存储在堆上,而 Box 本身仅仅是一个指针,它的结构大小是确定的,因此可以传递:
fn do_method_1(obj: &Box<dyn MyTrait>) -> &Box<dyn MyTrait> {
obj.method_1();
obj
}当然,在 Rust 中还有一种特殊的 Trait 使用方法 —— 通过 #[derive()]:
#[derive(MyTrait)]
pub struct MyStruct;其实这种写法是通过 Rust 强大的过程宏系统,帮你把需要手动实现的 Trait 方法实现了,对于用户来说很方便,对于库开发者来说就比较麻烦了。
内置 Trait
Rust 提供了很多内置 Trait 来实现语言特性,由相关的 标准定义 所示,在 2024 版本的 Rust 中存在以下特殊的内置 Trait:
运算符类的 Trait
在 core::ops 包中,Rust 为运算符重载定义了大量的 Trait,包括加减乘除、位操作、下标、解引用等,还有很多运算符对应的赋值复合运算符。
用于定义函数指针的各种 Trait(Fn、FnMut、FnOnce)也在其中,因为他们实际上是调用运算符 ()。
如果你眼尖的话,还会发现用来给类型提供析构函数的 Drop Trait 同样也在这。
这类 Trait 没有提供 derive 宏,因此往往你需要手动实现运算符重载。
用于比较的 Trait
在 core::cmp 包中,Rust 定义了处理比较和相等的 Trait,同时还包括了对应的 derive 宏,这意味着在一定程度上 Rust 可以为你节省处理自定义结构体之间比较的代码。
类型标记 Trait
这一类的 Trait 来自 core::marker,字面意义上就是对类型进行标记,Rust 会为这些标记 Trait 提供一定程度的语言支持。
Copy 标记结构体可以进行逐字节复制,Send 和 Sync 则标记了类型能不能在不同的线程之间传递或共享,Sized 标记了类型是否可以在编译时计算占用空间,Unpin 标记了类型不需要内存固定的约束。
让人头疼的 Trait 们
正因为 Rust 里不少内置 Trait 实际上是跟语言特性强绑定的,因此会产生不少非常让人头疼的区别问题,还有很多配对用法,在这里详细讲讲他们:
PartialEq 还是 Eq?PartialOrd 还是 Ord?
这四个 Trait 可以追根溯源到离散数学里的偏序关系,我们先从相等比较说起。
PartialEq 定义了 == 和 != 比较符,而 Eq 则是对 PartialEq 的更高级约束,因此你会发现使用 Eq 时,Rust 会要求你连同 PartialEq 一起加入(毕竟运算符是定义在 Partial Eq 里的)。
为什么一个等于要拆成两部分?这涉及到一个偏序关系中的 自反性 约束。
偏序关系
设 是集合 上的一个二元关系,若 满足:
- 自反性:
- 反对称性:
- 传递性:
则称 是集合 上的偏序关系,通常记作 (这个符号并不代表一般意义上的“小于或等于”)
对于 ,我们其实应该称为: 排在 的前面
偏序关系实际上已经帮我们把类型比较的地基打好了,因为它告诉了我们什么称之为“相等”,什么又叫做“小于”和“大于”。
在偏序关系中,又存在 严格偏序 和 非严格偏序,分别用 和 来表示,因此以上的定义实际上是非严格偏序。
在严格偏序中,前两个个条件会变为:
- 反自反性:
- 非对称性:
你可以发现在严格偏序中,“等于”的定义是不存在的,这对于类型比较来说又过于严格了,但是在某些情况下这又是需要的,浮点数的 NaN 就是一个例子,因为 NaN != NaN。
因此,Rust 实际上取巧,只通过 是否适用于自反性 来区分 PartialEq 和 Eq,在数学上称为 部分等价关系:
这也是为什么 Eq 的前提是要 PartialEq。
偏序关系也可以解释为什么会出现 PartialOrd 和 Ord 的区别,因为在 PartialEq 中存在着无法自反比较的数值,因此我们无法确定给定的两个值中哪一个更大,体现在 Rust 中,就是只有满足 Ord 的类型才能使用二元 max 和 min 方法。
我们可以总结一下这四种 Trait 的使用:
如果类型任意数据自己和自己比较能得出相等:使用 PartialEq, Eq 和 PartialOrd, Ord
否则:使用 PartialEq 和 PartialOrd
Copy 还是 Clone?
Clone Trait 要求实现 clone 方法,即复制一个对象,而 Copy 则是对 Clone 的更高层约束,它干了两件事:
- 约束类型可以进行 逐字节 复制
- 给类型提供对应的
clone实现,进行逐字节内存拷贝
这也是为什么想要用 Copy 时 Rust 会要求你加入 Clone。
什么叫可以“逐字节”复制的类型?很明显基本的数字类型就是可以逐字节复制的,而像字符串和数组这些长度不定,且存储在堆中的数据是没法逐字节复制的,因为你还需要通过指针将堆中的数据复制一份,而不是仅仅复制一个指针数值。
Rust 对于很多的内置数据类型都为你实现了 Clone,而对于内部所有成员都实现了 Clone 的自定义结构体,你也可以安心地写 #[derive(Clone)],Rust 会自动帮你递归复制。
同理,只要成员都实现了 Copy,那么结构体也可以放心地写 #[derive(Copy)]。
Send 和 Sync
Rust 的一个核心特征是“无痛并发”,为了让人不需要处理复杂的并发数据,把检查的工作塞给编译器解决,Send 和 Sync 就被提出来了。
这两个 Trait 并没有实现任何功能,而是对类型的标记,Send 表示的是一个类型 是否能被安全地在线程之间转移所有权。
大多数的类型都是可以转移所有权的,除了一些本身就设计用来共享所有权的类型,如:Rc<T>。
Rc<T> 即引用计数(Reference Counter),通过引用技术来检查一个变量的所有权共享情况,且为了性能考虑被设计为非线程安全。如果强行要求 Rc<T> 可传递,那么会出现多个线程之间同时共享一个变量的所有权,从而带来 竞态。为了解决这个问题,Rust 需要用 Arc<T>,其中的 A 表示 Atomic,这是一种线程安全的引用计数器。
Sync 同样是一个标记,但是用于表示一个类型 是否能安全地在线程之间共享引用。
因为 Sync 需要共享引用,所以它有一个先决条件:类型 T 想要为 Sync 当且仅当 &T 为 Send,即该类型的不可变引用必须可以 Send。
这个标记就如名字 Synchroization 而言,决定了变量能不能在线程之间 共享状态 和 同步数据。因此它和一些访问控制相关的 Rust 内置类型有关,如用于提供 内部可变性 的 RefCell<T>、Mutex<T> 和 RwLock<T>。
RefCell<T> 是典型的非线程安全内部可变性实现,经常和 Rc<T> 配合在一起使用成 Rc<RefCell<T>>,如果强行要求使用 Sync,那么同样会出现多个线程可以同时读写一个变量的竞态。Mutex<T> 和 RwLock<T> 则是其线程安全版本,经常与 Arc<T> 配合一起使用在达成 Send+Sync 的完全线程安全访问,它们通过锁结构来保证了数据不会产生竞态,使得数据同步得以实现。
还有一类特殊的 Rust 内置类型天生就同时满足 Send 和 Sync,那就是 Atomic<T> 原子类型,通过原子访问规则天然就是可以安全地进行所有权传递和数据同步。
Unpin?
假设一个结构体里面有一个指针,这个指针指向了结构体内部的一个变量的地址,那么如果结构体移动到了另外的内存地址,会发生什么?
struct SelfRef {
buf: String,
pstr: *const u8,
}由于发生的是移动,因此整个结构体里所有的数据都没有变,但是此时指针的指向已经指向了一个脏地址 —— 原来的地方已经不是结构体内部了。
解决方法很简单,使用一个 Rust 内置类型 Pin<T> 就行了,它告诉了编译器:这个对象的地址永远也不要改变,直到其被销毁为止。
就如 Pin<T> 的名字所言,这个结构体在内存中就像 被图钉给钉住了,不会进行移动,内部指针也就不会失效了。
那么 Unpin Trait 是用来干什么的?这同样是一个标记,告诉编译器这个类型 不关心自己是否被移动,也可以说这个类型 可以安全地逐字节整体移动。
大部分的类型都自动实现了 Unpin,但是在 Rust 异步中,却有着大量的 !Unpin,即 结构体的移动会影响到内部数据。
一个异步代码块就是 !Unpin 的:
let my_future = async {
let text = "Hello".to_owned();
let text_ref = &text;
do_some_async().await;
println!("{text_ref}");
};在 Rust 中,异步代码实际上会被转换成一个 状态机模型,而状态机里如果出现了内部的引用(如上的 text_ref),那么为了保证引用的指针正确,这个代码块就不能被移动了,所以会产生 !Unpin。
对于自定义结构体,Rust 会默认其 Unpin,对于存在内部自指引用的结构体,仍然需要显式告诉编译器这个结构体是 !Unpin。
Rust 已经为你提供好了方案,只需要插入一个虚拟的结构体标记 PhantomPinned。这个标记是永久 !Unpin 的,因此会导致这个结构体被同样传递为 !Unpin。你也不需要担心它会带来额外的内存占用,这个标记是零内存的。
struct SelfRef {
buf: String,
pstr: *const u8,
_pin: std::marker::PhantomPinned,
}