聊一聊标准库中的那些 trait

楔子

上一篇文章我们花费相当大的笔墨复习并补充了 trait 的知识,本次来介绍一下标准库中的 trait(前面文章已经详细说过的,这里就不赘述了)。Rust 标准库中包含大量的 trait 定义,甚至 Rust 自身的某些语言特性就是在这些 trait 的帮助下实现的。这些 trait 和标准库里的各种类型一起,构成了整个 Rust 生态的根基,只有了解它们才算真正了解 Rust。

Default

我们来看 Default trait 的定义,以及对 Default trait 的实现和使用。

trait Default {
    fn default() -> Self;
}

显然这个 trait 用于创建指定类型的默认值。

#[derive(Debug)]
struct Color(u8, u8, u8);
impl Default for Color {
    fn default() -> Self {
        Color(0, 0, 0)
    }
}

fn main() {
    let n1: i32 = Default::default();
    let n2 = i32::default();
    let n3 = <i32 as Default>::default();
    println!("{} {} {}", n1, n2, n3);  // 0 0 0

    let s: String = Default::default();
    println!("'{}'", s);  // ''

    let arr: [u8; 3] = Default::default();
    println!("{:?}", arr);  // [0, 0, 0]

    let c1: Color = Default::default();
    // 如果 Color 实现了多个 trait,并且存在多个 default,那么这种方法会产生歧义
    let c2: Color = Color::default();
    let c3: Color = <Color as Default>::default();
    println!("{:?}", c1);  // Color(0, 0, 0)
    println!("{:?}", c2);  // Color(0, 0, 0)
    println!("{:?}", c3);  // Color(0, 0, 0)
}

所以任何实现了 Default trait 的类型,调用 default 方法都会返回自身的实例,或者也可以使用 Default 本身来调用,具体的返回值类型则通过类型标注来指定。

另外还有其它一些地方用到了 Default,比如 Option 的 unwrap_or_default(),在类型参数上调用 default() 函数。

#[derive(Debug)]
struct Color(u8, u8, u8);
impl Default for Color {
    fn default() -> Self {
        Color(0, 0, 0)
    }
}

fn main() {
    let s = Some(123);
    // 如果是 Some(T),返回 T,否则 panic
    println!("{}", s.unwrap());  // 123

    // 如果是 Some(T),返回 T,否则返回指定的默认值
    let s: Option<&str> = None;
    println!("{}", s.unwrap_or("嘿嘿"));  // 嘿嘿

    // 如果是 Some(T),返回 T,否则返回 Default::default()
    let s: Option<[&str; 3]> = None;
    println!("{:?}", s.unwrap_or_default());  // ["", "", ""]

    let s: Option<Color> = None;
    println!("{:?}", s.unwrap_or_default());  // Color(0, 0, 0)
}

对于这些标准库中的 trait,我们没有必要通过 impl 的方式,因为不太方便。Rust 标准库实际给我们提供了一个标注,也就是 #[derive()],方便我们为结构体自动实现指定的 trait。

#[derive(Default)]
struct Color(u8, u8, u8);

#[derive(Default)]
struct Point {
    x: i32,
    y: i32,
}

注意这里的细节,我们用 #[derive()] 在两个结构体上作了标注,这里面出现的 Default 不是 trait,它是一个同名的派生宏(我们后面会讲到),这种派生宏标注帮助我们实现了 Default trait。Rustc 能正确区分 Default 到底是宏还是 trait,因为它们出现的位置不一样。

那为什么可以自动实现 Default trait 呢?因为 Color 里面的类型是基础类型 u8,而 u8 是实现了 Default trait 的,默认值为 0。

Display

我们看 Display trait 的定义。

trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}

Display trait 对应于格式化符号 "{}",比如 println!("{}", s),用于决定一个类型如何显示,其实就是把类型转换成字符串表达。

use std::fmt::{Display, Formatter};

struct Point {
    x: i32,
    y: i32,
}
impl Display for Point {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        let s = format!("x = {}, y = {}", self.x, self.y);
        f.write_str(&s)
    }
}
fn main() {
    let p = Point{x: 11, y: 22};
    println!("{}", p);  // x = 11, y = 22
}

上面说了 #[derive()] 里面的不是 trait,而是同名的派生宏,它会帮我们实现指定的 trait。但是 Display 没有相应的派生宏,需要我们自己手动通过 impl 去实现。

ToString

来看 ToString trait 定义。

trait ToString {
    fn to_string(&self) -> String;
}

它提供了一个 to_string() 方法,方便把各种类型实例转换成字符串。但实际上不需要自己去给类型实现 ToString trait,因为标准库已经帮我们做了实现,像下面这个样子。

impl<T: Display> ToString for T

也就是说,凡是实现了 Display 的就实现了 ToString,这两个功能本质是一样的,就是把类型转换成字符串表达。只不过 Display 侧重于展现,ToString 侧重于类型转换。所以把一个符合条件的类型实例转换成字符串有两种常用方法:

let s = format!("{}", obj);
// 或
let s = obj.to_string();

比较简单。

Debug

Debug 跟 Display 很像,也主要是用于调试打印。打印就需要指定格式,区别在于 Debug trait 是配对 "{:?}" 格式的,Display 是配对 "{}" 的,它们本身都是将类型表示或转换成 String 类型。一般来说,Debug 的排版信息比 Display 要多一点,因为它是给程序员调试用的,不是给最终用户看的。Debug 还配套了一个美化版本格式 "{:#?}",用来把类型打印得更具结构化一些,适合调试的时候查看,比如 json 结构会展开打印。

Rust 标准库提供了 Debug 宏,一般来说,我们都是以这个宏为目标类型自动生成 Debug trait,而不是由我们自己手动去实现。这一点和 Display 正好相对,std 标准库里并没有提供一个 Display 宏,来帮助我们自动实现 Display trait,需要我们手动实现它。

再次提醒一下,Rust 的结构体类型能够自动被 derive 的条件是,它里面的每个元素都能被 derive。比如下面这个结构体里的每个字段,都是 i32 类型的,这种基础类型在标准库里已经被实现过 Debug trait 了,所以可以直接在 Point 上做 derive 为 Point 类型实现 Debug trait。这个原则适用于所有 trait,后面不再赘述。

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

一般来说,实现了 Display 的类型也实现了 Debug,换句话说,能用 {} 打印的也可以用 {:?} 打印,但反过来则不行。

PartialEq 和 Eq

如果一个类型上实现了 PartialEq,那么它就能比较两个值是否相等,这种可比较性满足数学上的对称性和传递性:

  • 对称性(symmetry):a == b 导出 b == a
  • 传递性(transitivity):a == b && b == c 导出 a == c

而 Eq 定义为 PartialEq 的 subtrait(实现 Eq 的同时也必须实现 PartialEq),在 PartialEq 的对称性和传递性的基础上,又添加了自反性,也就是对所有 a 都有 a == a。最典型的就是 Rust 中的浮点数只实现了 PartialEq,没实现 Eq,因为根据 IEEE 的规范,浮点数中存在一个 NaN,它不等于自己,也就是 NaN ≠ NaN。而对整数来说,PartialEq 和 Eq 都实现了。

如果一个类型,它的所有字段都实现了 PartialEq,那么使用标准库中定义的 PartialEq 派生宏,我们可以为目标类型自动实现可比较能力,用 == 号,或者用 assert_eq!() 做判断。

#[derive(PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p1 = Point{x: 11, y: 22};
    let p2 = Point{x: 11, y: 22};
    println!("{}", p1 == p2);  // true
}

如果每个字段的值都相等,那么结构体实例就相等。

PartialOrd 和 Ord

PartialOrd 和 PartialEq 差不多,PartialEq 只判断相等或不相等,PartialOrd 在这个基础上进一步判断是小于、小于等于、大于还是大于等于。可以看到,它就是为排序功能准备的。

另外 PartialOrd 被定义为 PartialEq 的 subtrait,它们在类型上可以用过程宏一起 derive 实现。

#[derive(PartialEq, PartialOrd)]
struct Point {
    x: i32,
    y: i32,
}

#[derive(PartialEq, PartialOrd)]
enum Stoplight {
    Red,
    Yellow,
    Green,
}

类似的,Ord 定义为 Eq + PartialOrd 的 subtrait。如果我们为一个类型实现了 Ord,那么对那个类型的所有值,我们可以做出一个严格的总排序,比如 u8,我们可以严格地从 0 排到 255,形成一个确定的从小到大的序列。同样的,浮点数实现了 PartialOrd,但是没实现 Ord。

由于 Ord 严格的顺序性,如果一个类型实现了 Ord,那么这个类型可以被用作 BTreeMap 或 BTreeSet 的 key。

BTreeMap、BTreeSet:相对于 HashMap 和 HashSet,是两种可排序结构。

运算符重载

加减乘除、取余、取模、位运算符等等,都是基于 trait 实现的,通过实现 trait,我们便可重载运算符,我们之前介绍过的。

use std::fmt::{Display, Formatter};
use std::ops::Shl;

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

// 左移操作
impl Shl for Point {
    type Output = Self;
    fn shl(self, rhs: Self) -> Self::Output {
        Point{x: self.x << rhs.x, y: self.y << rhs.y}
    }
}

fn main() {
    let p1 = Point{x: 5, y: 2};
    let p2 = Point{x: 2, y: 3};
    println!("{:?}", Point{x: 5 << 2, y: 2 << 3});  // Point { x: 20, y: 16 }
    println!("{:?}", p1 << p2);  // Point { x: 20, y: 16 }
}

实际上,Rust 标准库提供了一套完整的与运算符对应的 trait,你可以在 std::ops 里面找到可重载的运算符(或者直接去官网查看),然后按照类似的方式练习如何自定义各种运算符。

Clone

定义:

trait Clone {
    fn clone(&self) -> Self;
}

这个 trait 给目标类型提供了 clone() 方法用来完整地克隆实例,使用标准库里面提供的 Clone 派生宏可以方便地为目标类型实现 Clone trait。

#[derive(Clone)]
struct Point {
    x: u32,
    y: u32,
}

因为每一个字段(u32 类型)都实现了 Clone,所以通过 derive,自动为 Point 类型实现了 Clone trait。实现后,Point 的实例 point 使用 point.clone() 就可以把自己克隆一份了。

再看一下方法的签名,可以看到它是不可变引用。

    fn clone(&self) -> Self;

这里面有两种情况。

  • 第一种是已经拿到实例的所有权,clone 一份生成一个新的所有权并被局部变量所持有
  • 第二种是只拿到一个实例的引用,想拿到它的所有权,如果这个类型实现了 Clone trait,那么就可以 clone 一份拿到这个所有权

clone() 是对象的深度拷贝,可能会有比较大的额外负载,但是就大多数情况来说其实还好。不要担心在 Rust 中使用 clone(),先把程序功能跑通最重要。Rust 的代码,性能一般都不会太差,毕竟起点很高。

注:浅拷贝是按值拷贝一块连续的内存,只复制一层,不会去深究这个值里面是否有到其它内存资源的引用。与之相对,深拷贝就会把这些引用对象递归全部拷贝。

实际上在 Rust 生态的代码中,我们经常看到 clone(),因为它把对实例引用的持有转换成了对对象所有权的持有。一旦我们拿到了所有权,很多代码写起来就比较轻松了。

Copy

接下来,我们看 Copy trait 的定义。

trait Copy: Clone {}

Copy 被定义为 Clone 的 subtrait,并且不包含任何内容,仅仅是一个标记(marker)。有趣的是,我们不能自己为自定义类型实现这个 trait。比如下面这个示例就是不行的:

impl Copy for Point {} // 这是不行的

但是 Rust 标准库提供了 Copy 过程宏,可以让我们自动为目标类型实现 Copy trait。

#[derive(Copy, Clone)]
struct SomeType;

因为 Copy 是 Clone 的 subtrait,所以理所当然要把 Clone trait 也一起实现,我们在这里一次性 derive 过来。Copy 和 Clone 的区别是,Copy 是浅拷贝只复制一层,不会去深究这个值里面是否有到其他内存资源的引用。因此对于 Copy 类型来说,它的深拷贝和浅拷贝其实是等价的,我们前面说过。

然后要注意的是,如果你想让结构体是可 Copy 的,那么它内部的字段必须也是可 Copy 的,否则就会报错。

struct Atype {
    num: u32,
    a_vec: Vec<u32>,
}
fn main() {
    let a = Atype {
        num: 100, 
        a_vec: vec![10, 20, 30],
    };
    let b = a;  // 这里发生了移动
}

如果我们给这个结构体实现了 Clone trait 的话,就可以调用 .clone() 来产生一份新的所有权。但如果要给结构体实现 Copy 呢?答案是不能实现 Copy,因为 a_vec 不是可 Copy 的。因为 Vec 是一种所有权结构,如果你在它上面实现了 Copy,那再赋值的时候,就会出现对同一份资源的两个指向,冲突了!

而一旦一个类型实现了 Copy,它就会具备一个特别重要的特性:再赋值的时候会复制一份自身。那么就相当于新创建一份所有权。我们来看下面这个值全在栈上的类型:

#[derive(Clone)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
  let a = Point {x: 10, y: 10};
  let b = a; // 这里发生了所有权 move,a 在后续不能使用了
}

上面只实现了 Clone,因此调用 clone 拷贝一份是没有问题的,但如果直接赋值的话,那么就会发生移动。尽管它的数据都在栈上,但它没有实现 Copy,没有实现的话,在赋值给其它变量时就会移动。

#[derive(Copy, Clone)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
  let a = Point {x: 10, y: 10};
  let b = a; // 这里发生了复制,a 在后续可以继续使用
  let c = a; // 这里又复制了一份,这下有 3 份了
}

仔细体会一下,我们前面所说的复制与移动的语义区别根源。你可能会问,Point 结构体里面的字段其实全都是固定尺寸的,并且 u32 是 copy 语义的,按理说 Point 也是编译时已知固定尺寸的,为什么它默认不实现 copy 语义呢?

这其实是 Rust 设计者故意这么做的,因为 Copy trait 其实关联到赋值语法,仅仅从这个语法(let a = b;),很难一下子看出来这到底是 copy 还是 move,它是一种隐式行为。

而在所有权的第一设计原则框架下,Rust 默认选择了 move 语义。所以方便起见,Rust 设计者就只让最基础的那些类型,比如 u32、bool 等具有 copy 语义,而用户自定义的类型,一概默认 move 语义。如果用户想给自定义类型赋予 copy 语义内涵,那么他需要显式地在那个类型上添加 Copy 的 derive。

我们再回过头来看 Clone,一个类型实现了 Clone 后,需要显式地调用 .clone() 方法才会导致对象克隆,这就在代码里面留下了足迹。而如果一个类型实现了 Copy,那么它在用 = 号对实例再赋值的时候就发生了复制,这里缺少了附加的足迹。这就为潜在的 Bug 以及性能的降低埋下了隐患,并且由于没有附加足迹,导致后面再回头来审查的时候非常困难

试想,如果是.clone(),那么我们只需要用代码搜索工具搜索代码哪些地方出现了 clone 方法就可以了。这个设计,在 Option 和 Result 的 unwrap() 系列函数上也有体现。

显式地留下足迹,是 Rust 语言设计重要的哲学之一。

至于 Copy 为什么要定义成 Clone 的 subtrait,而不是反过来,也是跟这个设计哲学相关。可以这么说,一般情况下,Rust 鼓励优先使用 Clone 而不鼓励使用 Copy,于是让开发者在 derive Copy 的时候,也必须 derive Clone,相当于多打了几个字符,多付出了一点代价。也许开发者这时会想,可能 Clone 就能满足我的要求了,也没必要使用 Copy。

还有一个原因其实是,Clone 和 Copy 在本质上其实是一样的,都是内存的按位复制,只是复制的规则有一些区别。

ToOwned

ToOwned 相当于是 Clone 更宽泛的版本。ToOwned 给类型提供了一个 to_owned() 方法,可以将引用转换为所有权实例。

fn main() {
    let a: &str = "123456";
    let s: String = a.to_owned();
    println!("{} {}", a, s);  // 123456 123456
}

个人一般更习惯使用 clone。

Deref

Deref trait 可以用来把一种类型转换成另一种类型,但是要在引用符号 &、点号操作符 . 或其它智能指针的触发下才会产生转换。比如标准库里最常见的 &String 可以自动转换到 &str,就是因为 String 类型实现了 Deref trait。

use std::ops::Deref;

#[derive(Debug)]
struct Girl {
    name: String,
}

impl Deref for Girl {
    type Target = str;
    // 返回 &str
    fn deref(&self) -> &Self::Target {
        // 或者调用 self.name.as_str() 两者是等价的,都是创建一个字符串切片
        &self.name[..]
    }
}

fn main() {
    let g = Girl { name: "satori".to_string() };
    // 等价于 g.deref()
    println!("{:?}", &g);  // Girl { name: "satori" }
    // *g 也等价于 *(g.deref())
}

比较简单,前面也说过了。

Drop

Drop trait 用于给类型做自定义垃圾清理(回收)。

trait Drop {
    fn drop(&mut self);
}

实现了这个 trait 的类型的实例在走出作用域的时候,触发调用 drop() 方法,这个调用发生在这个实例被销毁之前。

struct A;
impl Drop for A {
  fn drop(&mut self){
    // 可以尝试在这里打印点东西看看什么时候调用
  }
}

一般来说,我们不需要为自己的类型实现这个 trait,除非遇到特殊情况,比如我们要调用外部的 C 库函数,然后在 C 那边分配了资源,由 C 库里的函数负责释放,这个时候我们就要在 Rust 的包装类型(对 C 库中类型的包装)上实现 Drop,并调用那个 C 库中释放资源的函数。后续介绍 FFI 编程时,你会看到 Drop 的具体使用。

From<T> 和 Into<T>

From 和 Into,它们用于类型转换。From 可以把类型 T 转为自己,而 Into 可以把自己转为类型 T。

trait From<T> {
    fn from(T) -> Self;
}
trait Into<T> {
    fn into(self) -> T;
}

可以看到它们是互逆的 trait,实际上,Rust 只允许我们实现 From<T>,因为实现了 From 后,自动就实现了 Into。

struct Point {
    x: i32,
    y: i32,
}
impl From<(i32, i32)> for Point {    // 实现从 (i32, i32) 到 Point 的转换
    fn from((x, y): (i32, i32)) -> Self {
        Point { x, y }
    }
}
impl From<[i32; 2]> for Point {      // 实现从 [i32; 2] 到 Point 的转换
    fn from([x, y]: [i32; 2]) -> Self {
        Point { x, y }
    }
}
fn example() {
    // 使用from()转换不同类型
    let origin = Point::from((0, 0));
    let origin = Point::from([0, 0]);
    // 使用into()转换不同类型
    let origin: Point = (0, 0).into();
    let origin: Point = [0, 0].into();
}

注意:From 是单向的,对于两个类型要互相转的话,需要互相实现 From。

From<T> 和 Into<T> 都隐含了所有权,From<T> 的 Self 是具有所有权的,Into<T> 的 T 也是具有所有权的。Into<T> 有个常用的比 From<T> 更自然的场景是,如果你已经拿到了一个变量,想把它变成具有所有权的值,Into 写起来更顺手。因为 into() 是方法,而 from() 是关联函数。

struct Person {
    name: String,
}
impl Person {
    // 这个方法只接收String参数
    fn new1(name: String) -> Person {
        Person { name }
    }
    // 这个方法可接收 
    // - String
    // - &String
    // - &str
    // - Box<str>
    // - char
    // 这几种参数,因为它们都实现了Into<String>
    fn new2<N: Into<String>>(name: N) -> Person {
        Person { name: name.into() }  // 调用into(),写起来很简洁
    }
}

另外除了 From 和 Into 之外,还有 TryFrom、TryInto,它是可失败版本。

trait TryFrom<T> {
    type Error;
    fn try_from(value: T) -> Result<Self, Self::Error>;
}

trait TryInto<T> {
    type Error;
    fn try_into(self) -> Result<T, Self::Error>;
}

可以看到,调用 try_from() 和 try_into() 后返回的是 Result,你需要对 Result 进行处理。

AsRef

实现 AsRef trait,可以将自身的引用转换成目标类型的引用,这种转换通常用于函数和方法参数,使得你可以传递不同的类型,只要它们能被引用为所需的目标类型。

trait AsRef<T> {
    fn as_ref(&self) -> &T;
}

AsRef 可以让函数参数中传入的类型更加多样化,不管是引用类型还是具有所有权的类型,都可以传递。

struct MyStruct {
    data: String,
}

impl AsRef<str> for MyStruct {
    fn as_ref(&self) -> &str {
        &self.data
    }
}

比如我们之前说过的 Option<T>,它就可以调用 as_ref 将 Option<T> 转成 Option<&T>。

AsRef<T> 还可以让函数参数中传入的类型更加多样化,不管是引用类型还是具有所有权的类型,都可以传递。

fn process<T: AsRef<str>>(input: T) {
    let input_str = input.as_ref();
    // 处理 input_str
}

现在,你可以传递 String、&String、&str 或任何实现了 AsRef<str> 的自定义类型。

未完待续

posted @ 2023-11-13 15:44  古明地盆  阅读(117)  评论(0编辑  收藏  举报