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
之前,类型必须同时实现 Debug
和 Display
。
标记 trait 简介
标记 trait 顾名思义是一种“标记”,编译器通过它可以了解到:当某个类型实现了标记 trait 时,表示该类型做出了特定的承诺。标记 trait 没有方法或特定属性,但通常被编译器用于确保其具有某些行为。
使用标记 trait 的原因有:
- 编译器需要确保是否可以做某事
- 它是实现层面的细节,可以手动实现
有两个标记 trait(结合其他一些较少使用的标记 trait)对我们而言相当重要:Send
和 Sync
。手动实现 Send
和 Sync
是 unsafe 的——通常是因为你需要手动确保它们被安全地实现。Unpin
也是此类 trait 的一个例子。关于为什么手动实现这些 trait 会不安全,请查阅官方文档。
除此之外,(一般来说)标记 trait 也是 auto trait。而如果某结构体的所有字段都实现了 auto trait,则结构体本身也会实现 auto trait。例如:
- 假设结构体中的所有字段类型都是
Send
,编译器就会自动将结构体标记为Send
,用户无需提供任何信息。 - 假设除一个字段外,结构体的所有字段都实现了
Clone
,只有这一个字段没有,那么结构体就不能再推导出Clone
。可以通过Arc
或Rc
封装相关类型来解决该问题,但这取决于使用场景,在某些场景下并不可行,不可行时需要考虑其他解决方案。
Rust 中的标记 trait 为何重要?
Rust 中的标记 trait 构成了其生态系统的核心,并允许我们提供在其他语言中可能无法实现的保证。例如,Java 的标记接口与 Rust 的标记 trait 类似。然而,Rust 中的标记 trait 可不止是用于像 Cloneable
或 Serializable
这样的行为;比方说,它们还能确保类型可以在线程间发送。这是 Rust 生态系统中一个微妙但影响深远的区别。例如有了 Send
类型,就能确保跨线程发送类型始终是安全的,使得并发问题变得更易于处理。标记 trait 还可能影响其他方面:
Copy
trait 要求通过按位复制来复制内容(尽管还需要 Clone)。尝试按位复制一个指针只会返回其地址!这也是 Rust 中的字符串无法被 copy 而必须被 clone 的原因:字符串是智能指针。Pin
trait 允许将某个值“钉”在内存中的固定位置。Sized
trait 允许在编译时将类型定义为具有固定大小——只不过大多数类型已经自动实现。
还有一些标记 trait,如 ?Sized
, !Send
及 !Sync
,较之 Sized
, Send
及 Sync
,它们是“非 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"(无论是
&self
、self
、mut 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
类型使用任何我们想要的值,只要该类型实现了 Send
、Sync
和 Clone
这三个 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
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。