Rust 的那些内置 Trait

@2026年5月20日 2.9k字 §技术 #Rust
目录
  • 什么是 Trait
  • 内置 Trait
  • 让人头疼的 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(FnFnMutFnOnce)也在其中,因为他们实际上是调用运算符 ()

    如果你眼尖的话,还会发现用来给类型提供析构函数的 Drop Trait 同样也在这。

    这类 Trait 没有提供 derive 宏,因此往往你需要手动实现运算符重载。

    用于比较的 Trait

    core::cmp 包中,Rust 定义了处理比较和相等的 Trait,同时还包括了对应的 derive 宏,这意味着在一定程度上 Rust 可以为你节省处理自定义结构体之间比较的代码。

    类型标记 Trait

    这一类的 Trait 来自 core::marker,字面意义上就是对类型进行标记,Rust 会为这些标记 Trait 提供一定程度的语言支持。

    Copy 标记结构体可以进行逐字节复制,SendSync 则标记了类型能不能在不同的线程之间传递或共享,Sized 标记了类型是否可以在编译时计算占用空间,Unpin 标记了类型不需要内存固定的约束。

    让人头疼的 Trait 们

    正因为 Rust 里不少内置 Trait 实际上是跟语言特性强绑定的,因此会产生不少非常让人头疼的区别问题,还有很多配对用法,在这里详细讲讲他们:

    PartialEq 还是 EqPartialOrd 还是 Ord

    这四个 Trait 可以追根溯源到离散数学里的偏序关系,我们先从相等比较说起。

    PartialEq 定义了 ==!= 比较符,而 Eq 则是对 PartialEq 的更高级约束,因此你会发现使用 Eq 时,Rust 会要求你连同 PartialEq 一起加入(毕竟运算符是定义在 Partial Eq 里的)。

    为什么一个等于要拆成两部分?这涉及到一个偏序关系中的 自反性 约束。

    偏序关系

    R\mathrm{R} 是集合 AA 上的一个二元关系,若 R\mathrm{R} 满足:

    1. 自反性:xA,xRx\forall x\in A, x\mathrm{R}x
    2. 反对称性:x,yA,xRyyRxx=y\forall x,y\in A, x\mathrm{R}y\land y\mathrm{R}x\rarr x=y
    3. 传递性:x,y,zA,xRyyRzxRz\forall x,y,z\in A, x\mathrm{R}y\land y\mathrm{R}z\rarr x\mathrm{R}z

    则称 R\mathrm{R} 是集合 AA 上的偏序关系,通常记作 \preccurlyeq(这个符号并不代表一般意义上的“小于或等于”)
    对于 xyx\preccurlyeq y,我们其实应该称为:xx 排在 yy 的前面

    偏序关系实际上已经帮我们把类型比较的地基打好了,因为它告诉了我们什么称之为“相等”,什么又叫做“小于”和“大于”。

    在偏序关系中,又存在 严格偏序非严格偏序,分别用 \prec\preccurlyeq 来表示,因此以上的定义实际上是非严格偏序。

    在严格偏序中,前两个个条件会变为:

    1. 反自反性:xA,xx\forall x\in A, x\nprec x
    2. 非对称性:x,yA,xyyx\forall x,y\in A, x\prec y\rarr y\nprec x

    你可以发现在严格偏序中,“等于”的定义是不存在的,这对于类型比较来说又过于严格了,但是在某些情况下这又是需要的,浮点数的 NaN 就是一个例子,因为 NaN != NaN

    因此,Rust 实际上取巧,只通过 是否适用于自反性 来区分 PartialEqEq,在数学上称为 部分等价关系

    PartialEq    xA,xxEq    xA,x=x\begin{align*} \mathrm{PartialEq}\iff \exist x\in A, x\ne x\\ \mathrm{Eq}\iff \forall x\in A, x=x \end{align*}

    这也是为什么 Eq 的前提是要 PartialEq

    偏序关系也可以解释为什么会出现 PartialOrdOrd 的区别,因为在 PartialEq 中存在着无法自反比较的数值,因此我们无法确定给定的两个值中哪一个更大,体现在 Rust 中,就是只有满足 Ord 的类型才能使用二元 maxmin 方法。

    我们可以总结一下这四种 Trait 的使用:

    如果类型任意数据自己和自己比较能得出相等:使用 PartialEq, EqPartialOrd, Ord
    否则:使用 PartialEqPartialOrd

    Copy 还是 Clone

    Clone Trait 要求实现 clone 方法,即复制一个对象,而 Copy 则是对 Clone 的更高层约束,它干了两件事:

    1. 约束类型可以进行 逐字节 复制
    2. 给类型提供对应的 clone 实现,进行逐字节内存拷贝

    这也是为什么想要用 Copy 时 Rust 会要求你加入 Clone

    什么叫可以“逐字节”复制的类型?很明显基本的数字类型就是可以逐字节复制的,而像字符串和数组这些长度不定,且存储在堆中的数据是没法逐字节复制的,因为你还需要通过指针将堆中的数据复制一份,而不是仅仅复制一个指针数值。

    Rust 对于很多的内置数据类型都为你实现了 Clone,而对于内部所有成员都实现了 Clone 的自定义结构体,你也可以安心地写 #[derive(Clone)],Rust 会自动帮你递归复制。

    同理,只要成员都实现了 Copy,那么结构体也可以放心地写 #[derive(Copy)]

    SendSync

    Rust 的一个核心特征是“无痛并发”,为了让人不需要处理复杂的并发数据,把检查的工作塞给编译器解决,SendSync 就被提出来了。

    这两个 Trait 并没有实现任何功能,而是对类型的标记,Send 表示的是一个类型 是否能被安全地在线程之间转移所有权

    大多数的类型都是可以转移所有权的,除了一些本身就设计用来共享所有权的类型,如:Rc<T>

    Rc<T> 即引用计数(Reference Counter),通过引用技术来检查一个变量的所有权共享情况,且为了性能考虑被设计为非线程安全。如果强行要求 Rc<T> 可传递,那么会出现多个线程之间同时共享一个变量的所有权,从而带来 竞态。为了解决这个问题,Rust 需要用 Arc<T>,其中的 A 表示 Atomic,这是一种线程安全的引用计数器。

    Sync 同样是一个标记,但是用于表示一个类型 是否能安全地在线程之间共享引用

    因为 Sync 需要共享引用,所以它有一个先决条件:类型 T 想要为 Sync 当且仅当 &TSend,即该类型的不可变引用必须可以 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 内置类型天生就同时满足 SendSync,那就是 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,
    }
    正在加载索引……