掘金 后端 ( ) • 2024-04-19 16:14

Rust trait和泛型简介

在这篇文章中,我们将提供有关 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的一致性。我们希望确保不会意外地出现冲突的trait实现:

// implementing Into<T>, a trait we don't own, on MyStruct
impl Into<String> for MyStruct {
    fn into(self) -> String {
        "Hello world!".to_string()
    }
}

// implementing MyTrait for a type we don't own
impl MyTrait for String {
    fn some_method(&self) -> String {
        self.to_owned()
    }
}

// You can't do this!
impl Into<String> for &str {
   fn into(self) -> String {
       self.to_owned()
   }
}

一种常见的解决方法是使用newtype模式 - 即封装我们想要扩展的类型的单字段元组结构。

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

impl<'a> Into<String> for MyStr<'a> {
    fn into(self) -> String {
        self.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为例:

trait Error: Debug + Display {
    // .. re-implement the provided methods here if you want
}

在这里,我们明确告诉编译器我们的类型必须同时实现 DebugDisplay trait,然后才能实现 Error

marker traits 简介

标记(marker) trait用作编译器的“标记”,以了解当为类型实现标记trait时,可以维护某些保证。它们没有方法或特定属性,但通常用于确保编译器执行某些行为。

您需要标记trait有几个原因:

  • 编译器需要知道是否可以保证做某事
  • 它们是实现级别的细节,您也可以手动实现

两个特别标记trait,与其他较少使用的标记trait结合使用,对我们来说非常重要: SendSyncSendSync 手动实现是不安全的 - 这通常是因为您需要手动确保其安全实现。 Unpin 也是另一个例子。您可以在 here.详细了解为什么手动实现这些trait不安全。

除此之外,标记trait(一般来说)也是自动trait。如果一个结构体具有全部实现 auto trait的字段,则该结构体本身也将实现 auto trait。例如:

  • 如果结构中的所有字段类型均为 Send ,则编译器现在会自动将结构标记为 Send ,无需用户实现。
  • 如果您的结构体字段中除一个之外的所有字段都实现了 Clone ,但有一个字段没有实现,则您的结构体现在无法再派生 Clone 。您可以通过将相关类型包装在 ArcRc 中来解决此问题 - 但这取决于您的用例。在某些情况下,这是不可能的,您可能需要考虑替代解决方案。

为什么标记trait在 Rust 中很重要?

Rust 中的标记trait构成了生态系统的核心,使我们能够提供其他语言中可能无法实现的保证。例如,Java 具有类似于 Rust 标记trait的标记接口。然而,Rust 中的标记trait不仅仅适用于 CloneableSerializable 这样的行为;例如,它们还确保类型可以跨线程发送。这是 Rust 生态系统中一个微妙但影响深远的差异。以 Send 类型为例,我们可以确保通过线程发送类型始终是安全的。这使得并发问题更容易处理。标记trait还会影响其他事情:

  • Copy trait需要通过执行按位复制来复制内容(尽管这需要Clone)。尝试按位复制指针只会返回地址!这也是 String 无法复制而必须Clone的原因:Rust 中的字符串是智能指针。
  • Pin 特性允许我们将值“固定”到内存中的静态位置
  • Sized trait允许我们将类型定义为在编译时具有固定大小。

还有诸如 ?Sized!Send!Sync 之类的标记trait。与 SizedSendSync 相比,它们是 反向 trait界限,并且作用完全相反:

  • ?Sized 允许调整类型的大小(或者换句话说,动态大小)
  • !Send 告诉编译器一个对象绝对不能发送到其他线程
  • !Sync 告诉编译器一个对象的引用绝对不能在线程之间共享

标记trait还可以改善library crate 的工效学设计。例如,假设您有一个实现 Pin 的类型,因为您的应用程序或库需要它(Futures 就是一个很好的例子)。这很棒,因为您现在可以安全地使用该类型,但是将 Pin 类型用于不关心固定的事物要困难得多。实现 Unpin 允许您将该类型与不关心固定的事物一起使用,从而使您的开发人员体验更好。

trait 对象和动态派发

除了上述所有之外,trait还可以利用动态调度。动态调度本质上是选择在运行时使用多态函数的实现的过程。虽然出于性能原因,Rust 确实倾向于静态分派,但通过trait对象使用动态分派也有好处。

使用trait对象的最常见模式是 Box<dyn MyTrait> ,其中我们需要将trait对象包装在 Box 中以使其实现 Sized trait。因为我们将多态性过程移至运行时,所以编译器无法知道类型的大小。将类型包装在指针中(或“装箱”)会将其放在堆上而不是栈上。

// a struct with your object trait in it
struct MyStruct {
     my_field: Box<dyn MyTrait>
}

// this works!
fn my_function(my_item: Box<dyn MyTrait>) {
     // .. some code here
}

// this doesn't!        ^^^ 
fn my_function(my_item: dyn MyTrait) {
     // .. some code here
}

// an example of a trait with a Sized bound
trait MySizedTrait: Sized {
    fn some_method(&self) -> String {
        "Boo!".to_string()
    }
}

// an illegal struct that won't compile because of the Sized bound
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

here了解更多信息。

以下是一些例子:

// Examples of object safe methods.
trait TraitMethods {
    fn by_ref(self: &Self) {}
    fn by_ref_mut(self: &mut Self) {}
    fn by_box(self: Box<Self>) {}
    fn by_rc(self: Rc<Self>) {}
    fn by_arc(self: Arc<Self>) {}
    fn by_pin(self: Pin<&Self>) {}
    fn with_lifetime<'a>(self: &'a Self) {}
    fn nested_pin(self: Pin<Arc<Self>>) {}
}
// This trait is object-safe, but these methods cannot be dispatched on a trait object.
trait NonDispatchable {
    // Non-methods cannot be dispatched.
    fn foo() where Self: Sized {}
    // Self type isn't known until runtime.
    fn returns(&self) -> Self where Self: Sized;
    // `other` may be a different concrete type of the receiver.
    fn param(&self, other: Self) where Self: Sized {}
    // Generics are not compatible with vtables.
    fn typed<T>(&self, x: T) where Self: Sized {}
}

struct S;
impl NonDispatchable for S {
    fn returns(&self) -> Self where Self: Sized { S }
}
let obj: Box<dyn NonDispatchable> = Box::new(S);
obj.returns(); // ERROR: cannot call with Self return
obj.param(S);  // ERROR: cannot call with Self parameter
obj.typed(1);  // ERROR: cannot call with generic type
// Examples of non-object safe traits.
trait NotObjectSafe {
    const CONST: i32 = 1;  // ERROR: cannot have associated const

    fn foo() {}  // ERROR: associated function without Sized
    fn returns(&self) -> Self; // ERROR: Self in return type
    fn typed<T>(&self, x: T) {} // ERROR: has generic type parameters
    fn nested(self: Rc<Box<Self>>) {} // ERROR: nested receiver not yet supported
}

struct S;
impl NotObjectSafe for S {
    fn returns(&self) -> Self { S }
}
let obj: Box<dyn NotObjectSafe> = Box::new(S); // ERROR
// Self: Sized traits are not object-safe.
trait TraitWithSize where Self: Sized {}

struct S;
impl TraitWithSize for S {}
let obj: Box<dyn TraitWithSize> = Box::new(S); // ERROR
// Not object safe if `Self` is a type argument.
trait Super<A> {}
trait WithSelf: Super<Self> where Self: Sized {}

struct S;
impl<A> Super<A> for S {}
impl WithSelf for S {}
let obj: Box<dyn WithSelf> = Box::new(S); // ERROR: cannot use `Self` type parameter

请注意,如果您有一个 不需要 Self: Sized 的trait,但该trait有一个需要它的方法,则您无法在 dyn 对象上调用该方法。

这是因为通过将分派移至运行时,编译器无法猜测类型的大小 - trait对象在编译时没有固定的大小。这也是为什么我们需要像前面提到的那样将动态分派的对象装箱并将它们放在堆上。因此,您的应用程序也会受到性能影响 - 当然这取决于您使用的动态分派对象的数量以及它们的大小!

为了进一步说明这些观点,我想到了两个 HTML 模板库:

  • Askama,它使用宏和泛型进行编译时检查
  • Tera,它使用动态调度在运行时获取过滤器和测试器

虽然这两个库在大多数用例中可以互换使用,但它们具有不同的权衡。 Askama 的编译时间较长,任何错误都会在编译时显示,但 Tera 仅在运行时抛出编译错误,并且由于动态调度而导致性能下降。 Zola(静态站点生成器)专门使用 Tera,因为 Askama 无法满足某些设计条件。您可以在here看到 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的简化来说明这一点):

trait FromRequest<S>
   where S: State
    {
    type Rejection: impl 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 存储在其中,然后在主函数中调用它。然后我们用括号括住该字段以便能够调用它,然后等待未来。请注意,字段末尾没有括号。这是因为在末尾添加 () 实际上会调用该函数!您可以看到我们在声明结构后调用该函数的位置,然后等待它。

在库中的使用

像这样组合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扩展到处理函数。我们可以告诉 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 等其他框架也遵循相同的方法来提供极其工效学的开发人员体验。

更多阅读: