每一个编程语言都有高效处理重复概念的工具。Rust 类型系统提供了两个工具:泛型、Trait。泛型中除了类型标识,还额外包含了生命周期标识,辅助编译器处理一些特殊逻辑。Trait 的地位类似于 TypeScript 的 Interface,不同点是 Trait 在整个语言内核中都被深度使用,许多关键字和语法都提供了相应的 Trait。
泛型
首先简单介绍一下泛型的语法,Rust 的泛型和其他语言(Go)相比,属于比较简单易懂的。它可以在结构体、枚举、函数的声明中使用。比较特别的是,Rust 可以给泛型结构体定义某一个类型的方法。
例子:常见的泛型定义方法
// 函数
fn abs<T>(n: T, m: T) {
if n > m {
n - m
} else {
m - n
}
}
// 结构体
struct Point<T> { x: T, y: T }
// 泛型方法
impl<T> Point<T> {
fn mixup<U>(self, other: Point<U>) -> Point<U> {
Point { x: self.x, y: other.y }
}
}
// 指定类型的方法
impl Point<i32> {
fn add(&self, point: &Point<i32>) -> Point<i32> {
Point {
x: self.x + point.x,
y: self.y + point.y
}
}
}
// 枚举
enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }
单态化
通常单态化指的是在编译期将多态函数展开为单一功能函数,与每个实例一一对应。Rust 会在编译时将泛型代码进行单态化(Monomorphization)。编译器分析 Option 枚举的所有引用,发现存在两种 Option<T>
,分别是 i32 和 f64,接着将泛型定义全部替换为这两种具体的定义。
Rust 单态化的目的是消除泛型语法在运行时的额外开销(C++ 也是这么做的),这是零成本抽象原则的体现。
enum Option_i32 { Some(i32), None }
enum Option_f64 { Some(f64), None }
生命周期标识
在 Rust 中,当某一个定义中添加了生命周期的标识,就会显得比较令人困惑,本身就具有一定复杂度的泛型更是如此。这里我们暂不展开,详情可以参见另一篇文章:[[Rust 生命周期和所有权]],其中列举了很多生命周期和所有权相关的编译规则,有助于理解和解决相关的编译报错。
&i32 // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用
Trait
Trait 类型与 Java/TypeScript 中的 Interface,或是 Haskell 中的 typeclass 很像,可以通过 trait 以一种抽象的方式定义共同行为。任何类型都可以通过实现 trait 获得一些通用行为。
例子:定义和实现 trait
// vec3.rs
// Default trait 定义了创建默认值的行为
// Default trait 派生了 Sized
trait Default: Sized {
fn default() -> Self;
}
// 通过 derive 可以让编译器自动添加这些 trait 的默认实现
#[derive(Debug, Copy, Clone)]
struct Vec3 {
pub x: f64,
pub y: f64,
pub z: f64
}
type Point3 = Vec3;
struct Ray {
origin: Point3,
direction: Vec3,
}
impl Default for Vec3 {
fn default() -> Self {
Self { x: 0.0, y: 0.0, z: 0.0 }
}
}
impl fmt::Display for Vec3 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({},{},{})", self.x, self.y, self.z)
}
}
impl Default for Ray {
fn default() -> Self {
let ray = Self {
origin: Default::default(),
direction: Default::default()
}
// Display trait 让我们可以很轻松地将 Vec3 转成字符串
println!("ray from {} to {}", ray.origin, ray.direction);
ray
}
}
可以看到上面的例子中,主要内容是 Default trait 的声明和实现。这是一个很简单的语言内置 trait,只包含了一个方法,用于获取类型的默认值。Rust 内置的数字、字符串、Option 等类型也实现了 Default,可以自行尝试一下。
除了 Default,上面的代码中还出现了一些别的 trait,他们各自都是 Rust 语言本身的功能或者核心依赖:Debug
、Copy
、Clone
、Sized
。这部分是我们今天的主题,所以先跳过这部分。
trait 本质是一种类型特征,所以他可以用来表示函数的参数和返回值类型,或是充当泛型参数,就像 Java 当中可以用 Interface 作为函数的参数类型一样。
fn my_print(item: &impl Display + Default) {
println!("{}", item);
}
fn my_print_generic<T: Display + Default>(item: &T) {
println!("{}", item);
}
fn my_print_generic_where<T, U>(item: &T, other: &U)
where
T: Display + Default,
U: Display + Clone
{
println!("{} {}", item, other);
}
impl<T: Display> ToString for T {
///
}
从 Traits 入门 Rust
由于 Rust 在各种系统的设计中都使用大量了 Trait 做抽象层,所以我们其实可以反过来,从 Trait 来学习 Rust 各个方面的核心知识,比如:
- 各种运算符重载都使用 Trait 定义
- 所有权会使用到 Copy、Clone
- 类型转换依赖 From、Into
- 容器的借用和可变性涉及 Borrow、Deref
- 并发的基础是 Send、Sync、Pin
重载运算符
上面的示例代码中,除了 Default,我们还提到了 Display:
// Display trait 定义了将任意类型转为字符串的行为
impl fmt::Display for Vec3 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({},{},{})", self.x, self.y, self.z)
}
}
这其实可以看作是一种运算符重载。通过为 Vec3 实现 trait fmt::Display
,我们可以通过 format!("{}", vec3)
的方式将这一类型的数据转为 String。相比其他语言(Python、C++)来说,这种运算符重载的方式是非常优雅的:当我要给某个类型补充一些能力的时候,不需要改动它的定义和实现,可以在与定义分离的额外的代码块中为这个类型添加 trait。
举个例子,我想要给 Vec3 这个类型实现矩阵向量的常用运算,这涉及了+
、-
、*
、/
,还涉及+=
、-=
、*=
、/=
,还需要额外实现点乘、单位化、法线等计算。我可以将这些计算函数放在同一个文件当中,这属于程序设计当中的“关注点分离”原则。
// vec3_operator.rs
impl Add<Vec3> for Vec3 {
type Output = Self;
fn add(self, other: Self) -> Self {
Self {
x: self.x + other.x,
y: self.y + other.y,
z: self.z + other.z
}
}
}
impl AddAssign<Vec3> for Vec3 {
fn add_assign(&mut self, other: Self) {
self.x = self.x + other.x;
self.y = self.y + other.y;
self.z = self.z + other.z;
}
}
impl Neg for Vec3 {
type Output = Self;
fn neg(self) -> Self {
Self {
x: -self.x,
y: -self.y,
z: -self.z
}
}
}
impl Sub for Vec3 {
type Output = Self;
fn sub(self, other: Self) -> Self {
Self {
x: self.x - other.x,
y: self.y - other.y,
}
}
impl Mul<f64> for Vec3 {
type Output = Self;
fn mul(self, other: f64) -> Self {
Self {
x: self.x * other,
y: self.y * other,
z: self.z * other
}
}
}
impl MulAssign<f64> for Vec3 {
fn mul_assign(&mut self, other: f64) {
self.x *= other;
self.y *= other;
self.z *= other;
}
}
impl Mul<Vec3> for Vec3 {
type Output = Self;
fn mul(self, other: Vec3) -> Self {
Self {
x: self.x * other.x,
y: self.y * other.y,
z: self.z * other.z
}
}
}
impl MulAssign<Vec3> for Vec3 {
fn mul_assign(&mut self, other: Self) {
self.x *= other.x;
self.y *= other.y;
self.z *= other.z;
}
}
作为对比,浅述一下 Python 的运算符重载方式,大家可以自行感受其中的区别。由于 Python 语言的设计思路是 Metaclass,所有的 Class 都是 Metaclass 的实例,因此运算符很自然地设计为调用双下划线方法(Dunder Method 或者 Magic Method)。
class Vec3:
def __abs__(self):
return math.sqrt(sum(x for x in self))
def __neg__(self):
return Vector(-x for x in self)
def __repr__(self):
return f"{self.x}, {self.y}, {self.z}"
值拷贝 Copy 和 Clone
Rust 的所有权体系是它的核心语法设计之一,目标是为了让 Rust 能在编译阶段更有效地分析内存资源的使用情况。它有以下三条规则:
- 一个值只能被一个变量所拥有,这个变量被称为所有者(Each value in Rust has a variable that’s called its owner)。
- 一个值同一时刻只能有一个所有者(There can only be one owner at a time),也就是说不能同时存在两个变量拥有同一个值。变量赋值、参数传递、函数返回等行为,旧的所有者会把值的所有权转移给新的所有者,以便保证单一所有者的约束。
- 当所有者离开作用域,其拥有的值被丢弃(When the owner goes out of scope, the value will be dropped),并且释放内存。
这导致了很多时候我们无法向函数传递值,因为我们不希望丢失原本变量的所有权。有两个 trait 可以帮我们做到这一点:Copy 和 Clone。
Copy 的能力是自动栈内存的按位拷贝,Rust 的基本类型都自带这个 trait,比如整数类型 i32、u8、usize,浮点类型 f64 等等。什么是按位拷贝?就是直接将栈内存复制一份,传给被赋值的变量。Copy 依赖于 Clone,并且无法为存放在堆里的类型实现。
Clone 的能力是手动深拷贝,创建一个和当前值完全一样的另一个值,它是 Copy 的基础。有时候我们不需要 Copy 这么强的能力,就可以只用 Clone 去实现手动的值拷贝。
fn main() {
// i32 实现了 Copy,所以不会丢失所有权
let foo: i32 = 0;
let bar = foo;
println!("{}", foo);
// 0,0,0
let origin: Vec3 = Vec3::default();
// 将 origin 深拷贝一份
let black_rgb: Vec3 = origin.clone();
}
类型转换 From 和 Into
在 CRUD 的时候,类型转换是我们非常常用的语法。尤其是不同的数据格式互转、不同的数据源互转,例如从格式化字符串转换为某个 VO 或者 DTO。方便的类型转换语法可以大大缩短代码长度,并提高我们的生产力。
Rust 掌管类型转换的 Trait 有 From、Into、TryFrom、TryInto 这四个,From/TryFrom 负责从 T 到 Self 的转换,Into/TryInto 则是反向的。实际使用中,我们只需要实现 From 就可以直接获得 Into 的默认实现。可以看一下 From 和 Into 的定义:
pub trait From<T> {
fn from(value: T) -> Self;
}
pub trait Into<T> {
fn into(self) -> T;
}
// 实现 From 后自动实现 Into
impl<T, U> Into<U> for T
where
U: From<T>,
{
fn into(self) -> U {
U::from(self)
}
}
实现了 From 之后,就可以得到非常舒适的类型互转能力。比如 String 类型实现了 From<&str>,就可以实现 &str 和 String 的随意互转。
let s = String::from("hello");
let s: String = "hello".into();
通过 From 还可以实现异常类型之间的转换,将多个不同的异常类型转换为同一个类型,简化异常处理。实现了异常类型之间的 From 之后,就可以直接通过 ?
传播异常,Rust 会自动调用 into
,将其转为指定类型。
TryFrom/TryInto 和 From/Into 的差别就是转换过程中可能出现异常,所以返回值使用了 Result 包裹。
pub trait TryFrom<T> {
type Error;
fn try_from(value: T) -> Result<Self, Self::Error>;
}
pub trait TryInto<T> {
type Error;
fn try_into(self) -> Result<T, Self::Error>;
}
借用 Deref 和 DerefMut
讲 Deref 和 DerefMut 之前,需要讲一些借用和智能指针的前置知识。
借用规则和智能指针
Rust 的借用机制建立在所有权之上,规则有以下两条:
- 一个时刻,同一个值只能有一个可变引用或者多个不可变引用(At any given time, you can have either one mutable reference or any number of immutable references.)
- 引用必须永远有效,也就是不能出现悬垂引用(References must always be valid.)
前面我们提到的 Rust 值都是在栈上创建的,也就是调用函数或者声明变量的时候,自动在调用栈内申请内存并创建的值。如果要在堆上创建一个值,最方便的方法是使用智能指针容器 Box 和 Rc(Reference Counter)。它们可以将值放在堆上,然后返回一个指针地址。
例子:通过 Box 和 Rc 申请堆空间
let mut rolling = Box::new("rock");
let gun = Rc::new("rose");
*rolling = "n";
println!("{}", rolling);
println!("{}", gun);
Box 和 Rc 的区别,主要是 Rc 带有引用计数的能力。换句话说,Rc 是一个全功能的指针,但 Box 不是。这里的全功能指的是这个指针可以被复用,可以有多个值访问同一块堆内存。由于指针可以被复用,如果每个指针都能修改这块内存的话,就违反了上面的借用机制规则第一条(同一个值同时只能有一个可变引用),所以 Box 是可以修改的指针,而 Rc 是一个只读指针。
这里指针的读和写分别使用到了 Deref 和 DerefMut,它们重载了解引用操作符 *
,使用时会根据返回值的类型觉得具体调用哪一个 trait。
Deref 还有一个特点是自动解引用。例如下方的 println!
中,我们像使用常规变量一样使用 rolling 和 gun 这两个变量,就是因为 Box 和 Rc 这两个类型都实现了 Deref,自动解包了。
内部可变性 Borrow 和 BorrowMut
如果 Rc 只是一个只读指针,那它几乎就是不可使用的残废工具,除非我们有一种方法给他增加修改的能力,这就是 Cell 和 RefCell。它们的主要能力是带给我们内部可变性。
所谓外部可变性,就是 Rust 中常规的编译器 Mutable 检查,可以一眼从代码中看出这个变量是可变的或是不可变得。比如下面这段代码:
例子:外部可变性
fn main(){
let mut i: i32 = 1;
// 添加这一行会编译错误,因为同时出现了可变和不可变引用
// let i_ref = &i; // i_ref: &i32
// i_ref = 2; // 编译错误,只读引用不可修改
let i_mutable_ref = &mut i; // i_mutable_ref: &mut i32
*i_mutable_ref = 2;
}
内部可变性是什么呢?就是创建变量时不需要表明它是可变的还是不可变的,编译期也不做检查,但会在运行时检查。如果运行时发现这个变量破坏了借用规则就会直接 panic,导致程序退出。可想而知,运行时检查总是会牺牲一点性能。
可以看到下面的例子中 vec3 变量没有添加 mut 标记,但是可以通过 borrow_mut 方法创建一个可变引用,进而对其中的值进行修改。这里的 borrow 和 borrow_mut 函数就来自于 Borrow 和 BorrowMut 这两个 trait。
例子:内部可变性
fn main() {
let vec3 = Rc::new(RefCell::new(Vec3::default()));
let mut vec3_mutable_ref = vec3.borrow_mut();
// 添加这一行不会编译错误,但运行会 panic,因为同时出现了可变和不可变引用
let vec3_ref = vec3.borrow();
vec3_ref.x = 1;
println!("{:?}", vec3_ref);
}
并发 Send 和 Sync
上面提到了智能指针容器 Box 和 Rc,这两个容器是线程不安全的。当需要在多个线程之间复用数据时,可以使用 Arc 容器,全名是 Atomic Rc。Arc 和 Rc 的区别就在于它多了 Send 和 Sync 这两个 trait。这两个 trait 比较抽象,所以我们从定义开始看。
定义
- Send 指的是可以在线程之间安全地发送一个值。
- Sync 指的是可以在线程之间安全地共享(引用)一个值。
简单地理解一下,如果一个值的引用 &T
是 Send 的,那这个值 T
一定是 Sync 的。
定义看起来很复杂,但 Send 和 Sync 其实没有任何实际的声明或者说要求,它们只是两个标记。Rust 已经为绝大多数默认类型实现了 Send 和 Sync,如果一个结构体或者枚举所有的成员都是 Send 和 Sync,它也可以直接 derive Send 和 Sync。
如果我偏要!为特殊的类型添加这两个 trait 呢?那也可以,没人能拦住你,只是要注意,它们是 unsafe 的。
例子:为裸指针添加 Send 实现
struct MyBox(*mut u8);
unsafe impl Send for MyBox {}
fn main() {
let p = MyBox(5 as *mut u8);
let t = thread::spawn(move || {
println!("{:?}",p);
});
t.join().unwrap();
}
推荐的并发实践
一般来说,推荐使用 Arc 和 Mutex/RwLock 组合来实现在并发场景共享数据。Arc 是一个线程安全的引用计数容器,它和 Rc 一样,都需要依赖内部可变性容器来支持修改,也就是我们常见的同步原语 Mutex 互斥锁和 RwLock 读写锁。
同时,Rust 的新版本还提供了许多 Atomic 类型,内部使用 CAS(Compare and Swap) 循环作为自旋锁,尽可能在保证数据安全的同时提供了较优的性能。
结尾
Rust 的 Trait 来自 Haskell 语言,是一种非常强大的类型抽象机制,在强类型的同时实现了类似鸭子类型的使用方法,优势是无需在运行时检查一个值是否实现了特定方法,或者担心在调用时因为值没有实现方法而产生错误。
在学习 Rust 的过程中,不需要惧怕了解细节,可以多阅读 std 源码内的文档和类型定义,其中的代码都非常简洁易读。