1
头图

Hello world! 这篇文章将带你快速回顾一下 Rust 的 trait 和泛型,并实现更高级的 trait 约束及类型签名。

快速复习 Rust trait

编写 Rust trait 就是这么简单:

pub trait MyTrait {
    fn some_method(&self) -> String;
}

只要某类型实现了 MyTrait,它就保证会实现 some_method() 函数。要实现一个 trait,只需实现必须的方法(结尾有分号的)。

struct MyStruct;

impl MyTrait for MyStruct {
    fn some_method(&self) -> String {
        "Hi from some_method!".to_string()
    }
}

也可以在你自己的类型上实现不属于你的 trait,或者在不属于你的类型上实现你的 trait——但不能两者都不属于你!原因在于 trait 的相干性(coherence)[1]。我们要确保 trait 的实现不会发生意外冲突:

// 为 MyStruct 实现不属于我们的 Into<T> trait 
impl Into<String> for MyStruct {
    fn into(self) -> String {
        "Hello world!".to_string()
    }
}

// 为不属于我们的类型实现 MyTrait
impl MyTrait for String {
    fn some_method(&self) -> String {
        self.to_owned()
    }
}

// 不能这样!
impl Into<String> for &str {
   fn into(self) -> String {
       self.to_owned()
   }
}

通常的解决方法是采用 newtype 模式——创建一个单字段元组结构体,封装要扩展的类型。

struct MyStr<'a>(&'a str);

// 注意,实现 From<T> 时也会实现 Into<T> - 因此可以同时使用 .into() 及 String::from()

impl<'a> From<MyStr<'a>> for String {
    fn from(string: MyStr<'a>) -> String {
        string.0.to_owned()
    }
}

fn main() {
    let my_str = MyStr("Hello world!");
    let my_string: String = my_str.into();

    println!("{my_string}");
}

如果多个 trait 具有相同的方法名,则需要手动声明用哪个 trait 的实现来调用该类型:

pub trait MyTraitTwo {
    fn some_method(&self) -> i32;
}

impl MyTraitTwo for MyStruct {
    fn some_method(&self) -> i32 {
        42
    }
}

fn main() {
    let my_struct = MyStruct;
    println("{}", MyTraitTwo::some_method(&my_struct);
}

有时可能希望为用户提供一个默认实现,否则会很繁琐。此时可以通过在 trait 中定义方法来实现。

trait MyTrait {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}

trait 也可以依赖其他 trait!以 std::error::Error 为例:

trait Error: Debug + Display {
    // .. 如果需要,在这里重新实现所提供的方法
}

此处,我们明确地告诉编译器,在实现 Error 之前,类型必须同时实现 DebugDisplay

标记 trait 简介

标记 trait 顾名思义是一种“标记”,编译器通过它可以了解到:当某个类型实现了标记 trait 时,表示该类型做出了特定的承诺。标记 trait 没有方法或特定属性,但通常被编译器用于确保其具有某些行为。

使用标记 trait 的原因有:

  • 编译器需要确保是否可以做某事
  • 它是实现层面的细节,可以手动实现

有两个标记 trait(结合其他一些较少使用的标记 trait)对我们而言相当重要:SendSync。手动实现 SendSync 是 unsafe 的——通常是因为你需要手动确保它们被安全地实现。Unpin 也是此类 trait 的一个例子。关于为什么手动实现这些 trait 会不安全,请查阅官方文档。

除此之外,(一般来说)标记 trait 也是 auto trait。而如果某结构体的所有字段都实现了 auto trait,则结构体本身也会实现 auto trait。例如:

  • 假设结构体中的所有字段类型都是 Send,编译器就会自动将结构体标记为 Send,用户无需提供任何信息。
  • 假设除一个字段外,结构体的所有字段都实现了 Clone,只有这一个字段没有,那么结构体就不能再推导出 Clone。可以通过 ArcRc 封装相关类型来解决该问题,但这取决于使用场景,在某些场景下并不可行,不可行时需要考虑其他解决方案。

Rust 中的标记 trait 为何重要?

Rust 中的标记 trait 构成了其生态系统的核心,并允许我们提供在其他语言中可能无法实现的保证。例如,Java 的标记接口与 Rust 的标记 trait 类似。然而,Rust 中的标记 trait 可不止是用于像 CloneableSerializable 这样的行为;比方说,它们还能确保类型可以在线程间发送。这是 Rust 生态系统中一个微妙但影响深远的区别。例如有了 Send 类型,就能确保跨线程发送类型始终是安全的,使得并发问题变得更易于处理。标记 trait 还可能影响其他方面:

  • Copy trait 要求通过按位复制来复制内容(尽管还需要 Clone)。尝试按位复制一个指针只会返回其地址!这也是 Rust 中的字符串无法被 copy 而必须被 clone 的原因:字符串是智能指针。
  • Pin trait 允许将某个值“钉”在内存中的固定位置。
  • Sized trait 允许在编译时将类型定义为具有固定大小——只不过大多数类型已经自动实现。

还有一些标记 trait,如 ?Sized, !Send!Sync,较之 Sized, SendSync ,它们是“非 trait 约束”,起到完全相反的作用:

  • ?Sized 允许类型是未定大小的(或者称为动态大小的)[2]
  • !Send 告诉编译器某对象绝对不能发送到其他线程
  • !Sync 告诉编译器某对象的引用绝对不能在线程之间共享

标记 trait 还可以改善 crates 库。例如,鉴于应用或类库的需要,假设你有个类型实现了 Pin (Future 就是个典型案例)。很棒,因为现在你可以安全地使用该类型,但要将 Pin 类型与不关心 pin 的东西一起使用就变得困难得多。此时你可以为不需要 pin 的类型实现 Unpin,从而大大改善开发体验。

对象 trait 及动态分派

除了上述所有内容外,trait 还可以使用动态分派。动态分派本质上是在运行期选择多态函数的具体实现的过程。虽然 Rust 出于性能考虑倾向于使用静态分派,但通过 trait 对象使用动态分派也确有好处。

使用 trait 对象的最常见模式是 Box<dyn MyTrait>,这里需要将 trait 对象包装在 Box 中以使其实现 Sized。由于我们将多态过程移到了运行期,编译器将无法得知该类型的大小。通过将类型包装在指针中(或“装箱”它)可以将其放在堆上而不是栈上。

// 存放 trait 对象的结构体
struct MyStruct {
     my_field: Box<dyn MyTrait>
}

// 正常工作!
fn my_function(my_item: Box<dyn MyTrait>) {
     // .. 此处有一些代码
}

// 这样不行!
fn my_function(my_item: dyn MyTrait) {
     // .. 此处有一些代码
}

// 使用 Sized 约束的 trait 示例
trait MySizedTrait: Sized {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}

// 非法结构体,由于使用了 Sized 约束而无法编译
struct MyStruct {
    my_field: Box<dyn MySizedTrait>
}

对象类型将在运行期计算得出,这与使用编译期的泛型不同。

动态分派的主要优势在于,函数不需要知道具体的类型;只要类型实现了某个 trait,就可以被当作 trait 对象使用(只需是安全的 trait 对象)。类似于其他语言中鸭子类型的概念,其中对象的可用函数和属性决定了其类型。通常从用户的角度来看,编译器并不关心底层的具体类型是什么——只要它实现了特定 trait 即可。然而,在某些情况下,这点确实又很重要——针对这种情况,Rust 提供了确定其具体类型的方法,尽管使用起来有些棘手。依据你的用法,动态分派也可以做到避免代码膨胀,这可能是其好的一面。

从库用户的角度来看,错误也更容易理解。从库开发者的角度来看,这不是大问题,但若需要使用个重度依赖泛型的库,则可能会遇到非常令人困惑的错误!Axum 和 Diesel 这两个库有时就会犯此错误,它们倒是有相应的解决方案(Axum 通过 #[debug_handler] 宏而 Diesel 依靠文档)。由于将分派过程转移到了运行期,所以还节省了些编译时间。

缺点在于,你需要确保对象 trait 的安全性。满足对象安全性所需要的条件包括:

  • 类型不需要 Self: Sized
  • 类型必须在函数参数中使用某种形式的 "self"(无论是 &selfselfmut self 等...)
  • 类型不能返回 Self

更多内容参见这里[3]。

注意,如果某 trait 不需要 Self: Sized,但它有个方法需要,那你将无法在 dyn 对象上调用那个方法。

这是由于将分派移至运行期后,编译器无法推测类型大小——对象 trait 在编译期并没有固定的大小。这也是为什么需要像之前提到的那样,将动态分派的对象装箱并放在堆上。鉴于该原因,应用程序也将会受到性能影响——当然了,影响程度取决于你使用了多少动态分派的对象以及各对象的大小!

为了进一步阐述这些要点,我想到了两个 HTML 模板库:

  • Askama,它使用宏和泛型进行编译期检查
  • Tera,它使用动态分派来在运行期获取 filter 和 tester

这两个库虽然在大部分使用场景下可以互换使用,但它们有着不同的权衡。Askama 编译时间较长,任何错误都会在编译期显示,而 Tera 只有在运行期才会抛出编译错误,并因动态分派而损失性能。静态站点生成器 Zola 使用了 Tera,因为某些设计条件无法通过 Askama 来满足。从这里[4]你可以看出,Tera 框架使用了 Arc<dyn T>

结合 trait 和 泛型

开始

trait 和泛型可以很好地协同工作,易于使用。你可以轻松地编写一个实现泛型的结构体,像这样:

struct MyStruct<T> {
    my_field: T
}

不过,为了能将结构体与其他 crate 中的类型一起使用,我们需要确保结构体实现了某些行为。这正是要添加 trait 约束的地方:类型必须满足条件才能编译。你可能会遇到的一个常见的 trait 边界是 Send + Sync + Clone

struct MyStruct<T: Send + Sync + Clone> {
    my_field: T
}

现在,可以为 T 类型使用任何我们想要的值,只要该类型实现了 SendSyncClone 这三个 trait!

作为一个更复杂的例子,你可能偶尔需要为自己的类型重新实现使用了泛型的 trait,以 Axum 的 FromRequest trait 为例(下面的代码片段是对原始 trait 的简化,以便于说明):

use axum::extract::State;
use axum::response::IntoResponse;

trait FromRequest<S>
   where S: State
    {
    type Rejection: IntoResponse;

    fn from_request(r: Request, _state: S) -> Result<Self, Self::Rejection>;
}

这里还可以通过使用 where 子句来添加 trait 约束。该 trait 只是告诉我们 S 实现了 State。然而,State 还要求内部对象实现 Clone。通过使用复杂的 trait 约束,我们可以创建出大量使用 trait 的框架系统,以实现一些人可能称之为“trait 魔法”的功能。举个例子,看一下这个 trait 约束:

use std::future::Future;

struct MyStruct<T, B> where
   B: Future<Output = String>,
   T: Fn() -> B
   {
    my_field: T
}

#[tokio::main]
async fn main() {
    let my_struct = MyStruct { my_field: hello_world };
    let my_future = (my_struct.my_field)();
    println!("{:?}", my_future.await);
}

async fn hello_world() -> String {
    "Hello world!".to_string()
}

上述单字段结构体存储了一个返回 impl Future<Output = String> 的函数闭包,我们将 hello_world 存储其中,然后在主函数中调用它。我们给该字段加上括号以便能够调用它,然后等待 future 完成。请注意,我们没在字段末尾加上括号。这是因为在末尾添加 () 实际上会调用这个函数!可以看到我们是在声明结构体后调用函数,然后等待它。

在库中的使用

像这样将 trait 和泛型结合起来是非常强大的。HTTP 框架就是有效利用这点的一个范例。例如,Actix Web 有个名为 Handler<Args> 的 trait,它接受一些参数,调用自身,然后有个名为 call 的函数,该函数产生一个 Future:

pub trait Handler<Args>: Clone + 'static {
     type Output;
     type Future: Future<Output = Self::Output>;

     fn call(&self, args: Args) -> Self::Future;
}

这样就可以将此 trait 扩展为 handler 函数。我们可以告诉 Web 服务,这里有一个函数,它有一个内部函数、一些参数,并实现了Responder(Actix Web 的 HTTP 响应 trait):

pub fn to<F, Args>(handler: F) -> Route where
    F: Handler<Args>,
    Args: FromRequest + 'static,
    F::Output: Responder + 'static {
         // .. the actual function  code here
}

注意,Axum 等其他框架也采用了同样的方法为开发人员提供极其人性化的体验。

总结

感谢阅读!尽管 trait 和泛型可能是个难以理解的主题,但希望这篇关于如何使用 Rust trait 和泛型的指南能够为你带来些许启示!

译注:
[1] 即“孤儿原则”,可参考译者的一篇文章
[2] 可参考译者译的另一篇文章
[3] 地址为:https://doc.rust-lang.org/reference/items/traits.html#object-...
[4] 地址为:https://github.com/Keats/tera/blob/3b2e96f624bd898cc96e964cd63194d58701ca4a/src/tera.rs#L61


Qiang
271 声望25 粉丝

Hello segmentfault