Skip to content

필사 모드: Rust 的 trait、泛型与 trait 对象

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — Rust 的抽象化工具

面向对象语言用继承和接口来组织抽象。Rust 没有类继承。取而代之,它靠trait(trait)和泛型(generic)这两个轴获得代码复用与多态性。理解了这两者,Rust 库的签名会突然变得可读 — T: Display + Clone 这样的写法是什么意思,impl IteratorBox<dyn Iterator> 为什么不同,都能看懂了。

核心直觉是这样的。trait 定义"这个类型能做什么",泛型编写"要求这种能力的代码"。 而执行这一要求的方式有两种(静态分派 / 动态分派),这正是本文的亮点。让我们一点点搭起来。

trait — 接口,以及更多

trait 是类型可以实现的一组方法的集合。到这里为止,它和 Java、C# 的接口,以及 Go 的接口很相似。

trait Summary {
    fn summarize(&self) -> String; // 仅签名(无实现)

    fn preview(&self) -> String {   // 提供默认实现
        format!("{}...", &self.summarize()[..5.min(self.summarize().len())])
    }
}

struct Article {
    title: String,
    body: String,
}

impl Summary for Article { // 为 Article 实现 Summary
    fn summarize(&self) -> String {
        format!("{}: {}", self.title, self.body)
    }
    // preview 直接使用默认实现
}

trait Summary 定义了契约,impl Summary for ArticleArticle 满足这个契约。能够提供默认实现(preview),这一点比普通接口更进一步(类似 Java 的 default 方法)。

与接口的决定性差异有两点。

第一,定义类型的地方和实现 trait 的地方是分开的。 在 Java 中,声明类的时候就得预先写好 implements Comparable。而在 Rust 中,可以为已经存在的类型(包括不是自己创建的类型)之后再实现 trait。甚至可以为标准库的 i32 实现自己写的 trait。

第二,这种自由带着一个约束,叫孤儿规则(orphan rule)。要实现一个 trait,trait 或者类型二者之中必须有一个归属于自己的 crate。在别人的类型上实现别人的 trait 是被禁止的。原因是一致性 — 如果两个 crate 各自为 Vec 实现了 Display,该用哪一个就会产生冲突。这条规则保证了"每一对(trait, 类型)最多只有一个实现"。

泛型与 trait 约束

现在来写要求某个 trait 的代码。泛型函数写的是"接受任意类型 T",但通常会要求 T 具备特定的能力。这个要求就是 trait 约束(trait bound)。

use std::fmt::Display;

// T 可以是任何实现了 Display 的类型
fn announce<T: Display>(item: T) {
    println!("公告: {item}"); // 多亏 Display,才能用 {} 格式化
}

fn main() {
    announce(42);              // i32 实现了 Display
    announce("hello");         // &str 也实现了 Display
    announce(3.14);            // f64 也是
}

<T: Display> 就是"T 必须实现 Display"这条约束。没有它,就无法在 announce 内部用 {item} 输出(编译器会拒绝,因为它"不知道 T 是不是 Display")。约束向编译器(也向读者)传达了泛型代码可以对 T 做出什么假设

多个约束用 + 连接,多了之后用 where 子句清爽地拆出来。

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

// 内联约束 — 短的时候
fn show<T: Display + Clone>(x: T) { /* ... */ }

// where 子句 — 约束多时更易读
fn process<T, U>(t: T, u: U) -> String
where
    T: Display + Clone,
    U: Debug + Default,
{
    format!("{t} {u:?}")
}

这里还有一个与 Java 泛型的根本差异。Java 泛型采用类型擦除(type erasure),运行时类型信息会被抹去,约束大多靠强制转换处理。Rust 泛型则会被单态化(monomorphization)。编译器会为 announce(42)announce("hello") 分别生成各自的函数,一份给 i32 用,一份给 &str 用。所以虽然是泛型,运行时成本却是零。这就是下一节要讲的"静态分派"。

静态分派 — 零成本的多态性

单态化的结果是,泛型函数调用会被静态分派(static dispatch)。调用哪个实现在编译期就已确定,编译器会为每种类型生成特化代码,函数甚至可能被内联。运行时不需要花费成本去查找"该调用哪个方法"。

以 trait 作为参数的惯用写法是 impl Trait 语法,它是泛型的语法糖。

use std::fmt::Display;

// 下面两个实际上是等价的(impl Trait 是泛型的简写)
fn v1(item: impl Display) { println!("{item}"); }
fn v2<T: Display>(item: T) { println!("{item}"); }

在返回值位置也可以使用 impl Trait,这一点尤其有用。当要返回像迭代器、闭包这样难以命名的具体类型时,只需声明"某个满足这个 trait 的具体类型"即可。

// 返回类型的真实名字是复杂的 Map<...>,但可以把它藏起来
fn evens(max: u32) -> impl Iterator<Item = u32> {
    (0..max).filter(|n| n % 2 == 0)
}

fn main() {
    for n in evens(10) {
        print!("{n} "); // 0 2 4 6 8
    }
}

静态分派的代价在于代码体积。单态化会为每种类型分别刻出代码,所以二进制文件可能变大(代码膨胀,code bloat)。不过在大多数以速度为首要考量的场景里,这笔代价是划算的。

动态分派 — dyn trait 对象

不过,有时候我们想把不同的类型混合放进同一个集合里 — 比如"可绘制图形的列表"。即便圆形、正方形、三角形都实现了 Draw trait,泛型的 Vec<T> 也只能装一种具体类型。这时候需要的就是 trait 对象(trait object),语法上写作 dyn Trait

trait Draw {
    fn draw(&self) -> String;
}

struct Circle;
struct Square;
impl Draw for Circle { fn draw(&self) -> String { "○".into() } }
impl Draw for Square { fn draw(&self) -> String { "□".into() } }

fn main() {
    // 把不同类型放进同一个 Vec!(装箱后作为 trait 对象)
    let shapes: Vec<Box<dyn Draw>> = vec![
        Box::new(Circle),
        Box::new(Square),
        Box::new(Circle),
    ];

    for s in &shapes {
        print!("{} ", s.draw()); // 运行时才调用实际类型的 draw
    }
}

Box<dyn Draw> 是"堆上某个实现了 Draw某种类型"。在这里调用 s.draw() 会触发动态分派(dynamic dispatch)。编译期并不知道 s 的真实类型,所以要在运行时才能决定。

其运作原理是虚函数表(vtable)。Box<dyn Draw> 实际上是两个指针 — 一个指向真实数据(数据指针),一个指向保存该类型 Draw 方法实现地址的 vtable。s.draw() 会在 vtable 中查找 draw 的地址并跳转过去。这和 C++ 虚函数的机制一样。这也是为什么 dyn Trait 被称作"胖指针(fat pointer)",因为它的大小通常是普通指针的两倍。

静态 vs 动态 — 什么时候用哪个

把两种分派并排放在一起,权衡就一目了然了。

  • 静态分派(泛型 / impl Trait):编译期解决。内联和优化效果好。运行时开销为零。代价是每种类型都会生成一份代码,二进制可能变大,也无法容纳异构集合。
  • 动态分派(dyn Trait):运行时通过 vtable 解决。可以在同一个集合里容纳异构类型,二进制更小(只有一份实现)。代价是每次调用都要付出 vtable 查找的成本(间接调用,难以内联)。

实用的结论是这样的。默认使用静态分派,大多数情况下它更快也更自然。动态分派用在需要异构集合的时候(上面的图形列表)、需要避免代码膨胀的时候,或者像插件那样类型要到运行时才能确定的时候。在大多数应用中,vtable 查找的成本可以忽略不计,所以基本不需要为了性能而回避 dyn — 按表达力和代码结构来选择就好。

还有一个限制:并不是所有 trait 都能变成 trait 对象,它必须满足对象安全(object-safe)规则(大致来说,方法不能是泛型的,也不能按值返回 Self)。如果用违反这条规则的 trait 去写 dyn,编译器会给出原因。

关联类型 — 附属于 trait 的类型

trait 不仅能携带方法,还能携带关联类型(associated type)。典型的例子是标准库的 Iterator

trait Iterator {
    type Item; // 关联类型: 这个迭代器产出的东西的类型

    fn next(&mut self) -> Option<Self::Item>;
}

struct Counter { count: u32 }

impl Iterator for Counter {
    type Item = u32; // 在这里把 Item 定为 u32

    fn next(&mut self) -> Option<u32> {
        if self.count < 3 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

type Item 是 trait 要求填写的"空白类型",实现时用 type Item = u32 把它填上。这样一来 next 就会返回 Option<u32>

这里自然会冒出一个疑问:这不就是普通泛型 Iterator<Item> 吗?确实可以那样做,但两者有区别。关联类型会把某个类型的实现固定为唯一一个Counter 只能是 Item = u32 的迭代器,而且只能是这一种。相反,如果是泛型参数,Counter 就可能同时是 Iterator<u32> 又是 Iterator<String>,导致类型推导变得模糊。想表达"对这个类型来说,这种关系是唯一的"时用关联类型,想表达"存在多种可能的关系"时用泛型参数。迭代器产出的元素类型天然只有一种,所以关联类型才是正确的选择。

覆盖实现 — 面向满足条件的所有类型

trait 的威力在覆盖实现(blanket implementation)这一点上彻底爆发。它指的是"为实现了某个 trait 的所有类型,自动实现另一个 trait"。

标准库里的真实例子令人叹服。ToString trait(.to_string() 方法)是这样定义的。

// 标准库的真实实现(简化版)
impl<T: Display> ToString for T {
    fn to_string(&self) -> String {
        // 借助 Display 的格式化能力生成字符串
        format!("{self}")
    }
}

细细品味一下这段代码做的事情:实现了 Display所有类型 T,都会自动实现 ToString。所以只要你为新类型实现了 Display.to_string() 就会免费跟着到手,没有人需要为每个类型手写 ToString。一个带条件的覆盖实现,就覆盖了整个生态系统。

覆盖实现也是表达"能力组合"的一种惯用写法。impl<T: A + B> C for T 声明的是"同时具备 A 和 B 的类型也算作 C"这条规则。当 trait、泛型、覆盖实现相互咬合,即便没有继承层级,也能组装出极具表达力的抽象。

derive — trait 的自动实现

最后是一个实战中每天都会用到的便利功能。许多标准 trait 都可以通过 #[derive(...)] 属性自动实现,编译器会查看字段结构,替你生成那些显而易见的实现。

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

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = p1.clone();          // 多亏 Clone
    println!("{p1:?}");           // 多亏 Debug: Point { x: 1, y: 2 }
    println!("{}", p1 == p2);     // 多亏 PartialEq: true
}

#[derive(Debug, Clone, PartialEq)] 这一行,自动生成了 Debug(调试输出)、Clone(复制)、PartialEq(相等比较)三个 trait 的实现。手写的话会是几十行的样板代码,编译器按字段递归生成。常被 derive 的 trait 有 DebugCloneCopyPartialEqEqHashDefaultPartialOrdOrd 等。

derive 的原理是过程式宏(procedural macro)。也就是说这并非魔法,只是一个在编译期生成代码的宏而已。所以你也可以为自己定义的 trait 附加 derive 宏(例如 serde#[derive(Serialize)]),这也是支撑 Rust 生态便利性的一大支柱。

动手掌握

trait 约束、静态/动态分派、关联类型,都要在读懂签名的直觉建立起来之后才会顺手。在 Rust 学习实验室里定义一个 trait,给泛型函数加上约束,再把同一段代码分别用 impl Trait(静态)和 Box<dyn Trait>(动态)各写一遍,亲手体会两者的差异,库文档里那些复杂的签名就会读起来轻松得多。

结语

Rust 不靠继承也能提供丰富的抽象。trait 定义能力,泛型与 trait 约束要求这种能力,静态分派以零成本执行它。当需要把不同类型汇聚在一起时,dyn trait 对象会通过 vtable 的动态分派带来灵活性。关联类型表达附属于 trait 的类型关系,覆盖实现一次性覆盖满足条件的所有类型,derive 免费生成那些显而易见的实现。

理解了这些拼图是如何咬合的那一刻起,Rust 代码里的签名就不再是噪声,而会读作信息。看到 fn foo<T: Trait>(...),就能立刻明白"这个函数在向 T 要求这种能力";看到 Box<dyn Trait>,就能明白"这里是在用运行时分派处理异构类型"。这就是掌握了 Rust 抽象化能力的信号。

参考资料

현재 단락 (1/142)

面向对象语言用继承和接口来组织抽象。Rust 没有类继承。取而代之,它靠**trait**(trait)和**泛型**(generic)这两个轴获得代码复用与多态性。理解了这两者,Rust 库的签名会...

작성 글자: 0원문 글자: 6,901작성 단락: 0/142