第九章 Trait 进阶:当行为需要在运行时决定

上一章我们把 trait 当成”编译期就焊死的行为契约”,泛型 + 静态分发干得漂亮。可你迟早会撞上一个墙:有时你直到运行时才知道要处理的是哪一种具体类型,而且你还想把它们装进同一个集合、用同一段代码去调用。

打个比方。餐厅晚市,前厅收到一堆”待处理单”:有的要炒菜、有的要烧烤、有的要煮汤。你在写代码的那一刻,并不知道今晚具体来的是哪些单子、各来几张——它们是运行时根据用户点餐动态冒出来的。你可没法给”今晚所有单子”写一个泛型函数,因为泛型要求编译期定死类型。这时候你需要的是:把”各种单子”当成”一堆长得不同、但都能被 cook() 的东西”,塞进一个名单里,挨个 cook()。Rust 给这种需求准备的工具,叫 trait object(dyn Trait)。这一章就拆它,以及和它配套的关联类型、默认方法、自动 Derive、孤儿规则。

先说结论:trait 在 Rust 里既是”编译期约束”(静态分发),也能变成”运行时的统一接口”(动态分发,靠 dyn Trait)。前者零开销但类型必须已知,后者灵活但要付一次查表的成本。而 Derive、孤儿规则这些,是保证这套体系既好用又不乱套的护栏。

一、trait object 与 dyn Trait:把”不同种类”装进同一个盒子

要理解动态分发,先看清问题。假设有几个类型都 impl 了 Draw:

trait Draw {
    fn draw(&self);
}
 
struct Circle { radius: f64 }
struct Square { side: f64 }
 
impl Draw for Circle { fn draw(&self) { println!("画一个圆,半径 {}", self.radius); } }
impl Draw for Square { fn draw(&self) { println!("画一个正方形,边长 {}", self.side); } }

你想写一个函数,把”一堆图形”都画出来。静态分发写不了”一堆类型不同的东西”,因为 Vec<T> 要求元素类型统一是 T。但 Circle 和 Square 不是同一个 T。怎么办?用 trait object:

// 一个元素都是"某种能 Draw 的东西"的向量
fn render(shapes: &[&dyn Draw]) {
    for s in shapes {
        s.draw();   // 运行时才决定调 Circle::draw 还是 Square::draw
    }
}
 
fn main() {
    let c = Circle { radius: 2.0 };
    let q = Square { side: 3.0 };
    let scene: Vec<&dyn Draw> = vec![&c, &q];  // 不同类型,统一当成 dyn Draw
    render(&scene);
}

&dyn Draw 这玩意儿叫 trait object。它背后其实是个胖指针(fat pointer):一个指针指向真实数据(Circle 或 Square),另一个指针指向一张虚函数表(vtable)——表里存着”这个具体类型对 Draw 各方法的实现地址”。调用 s.draw() 时,运行时先查 vtable,找到正确的函数地址再跳过去。这就是动态分发(dynamic dispatch)。

对比第八章的静态分发:静态分发是”编译期生成多份、直接调”;动态分发是”运行时查表再调”。代价你一眼能看出——每次调用多一次间接跳转、且无法内联。收益是:类型在编译期不必统一,运行时的异构集合成为可能。

墨叔给你一句选型口诀:类型集合在编译期已知且固定、追求极致性能 → 用泛型静态分发;类型要到运行时才确定、或要塞进异构集合 → 用 dyn Trait 动态分发。 两者不是谁取代谁,是各管各的场景。

有个细节别漏:dyn Trait 要求这个 trait 是”对象安全的(object safe)“——简单说,trait 里不能出现”返回 Self”或”带泛型参数方法”这类会让 vtable 无法规整化的东西。对象安全的细节你不必现在背,知道”不是所有 trait 都能变成 dyn”、编译器会告诉你哪不行即可。

二、关联类型:让 trait 自己”记住”一个配套类型

第八章提过关联类型是 trait 的硬货之一,这里拆开。看标准库的 Iterator:

trait Iterator {
    type Item;                 // 关联类型:遍历出来的元素类型
    fn next(&mut self) -> Option<Self::Item>;
}

type Item 不是泛型参数,它是”trait 内部绑定一个类型”的声明。一个 impl Iterator for MyVec 必须同时定下 Item 是什么:

impl Iterator for MyVec {
    type Item = i32;
    fn next(&mut self) -> Option<i32> { /* ... */ }
}

为什么不用泛型参数、写成 trait Iterator<T>?因为”一个类型能遍历出什么元素”,通常只有唯一一种答案。Vec<i32> 遍历出来只能是 i32,不可能同时是 i32 又是 String。如果用泛型参数 Iterator<T>,你理论上能写出 impl Iterator<i32> for MyVec 又写 impl Iterator<String> for MyVec,制造”同一个类型挂多种遍历元素类型”的歧义。关联类型把这种唯一性焊进语言:一个类型 impl 某 trait,关联类型只能定一次。

打个比方。图书馆每张借书卡绑定一个”持卡人姓名”(关联类型),不是”持卡人可以是任意人”(泛型参数)。一张卡对应一个确定的持卡人,清清楚楚;要是借书卡允许”持卡人任意填”,同一张卡今天说是张三明天说是李四,系统就乱套。关联类型就是”把配套角色钉死”的语法。

关联类型和泛型参数怎么选?墨叔给你标准:如果”这个类型对每个参数组合都能各自成立”(比如 Vec<T> 的 T 可以任意),用泛型参数;如果”一个类型 impl 这个 trait 时,配套类型唯一确定”,用关联类型。 Iterator 的 Item 明显是唯一确定的,所以它是关联类型,不是泛型参数。

三、默认方法:trait 里的”通用实现”

默认方法第八章提过,这里补一层”为什么它比继承里的默认方法更干净”。trait 的默认方法,是给 impl 方一个”现成能用、可选覆盖”的实现:

trait Notifiable {
    fn channel(&self) -> String;      // 必须自己实现
 
    fn send(&self, msg: &str) {        // 默认方法:多数类型直接复用
        println!("[{}] {}", self.channel(), msg);
    }
}

类型只需实现 channel,send 白捡。这和继承里”父类给默认”长得很像,但关键区别:这里没有继承链、没有 super 调用、没有多态层级。 一个类型 impl Notifiable,只是”接上了这组行为”,默认方法就是 trait 自带的一份实现,类型选择覆不覆盖。它解决的是”给一组类型一个公共的、通常合适的默认”,而非”靠血缘传递行为”。这是组合式复用,不是层级式复用——和第八章”Rust 没有继承”一脉相承。

默认方法还有个妙用:给已有的 trait 加方法而不破坏老代码。因为老类型没实现的新方法可以用默认实现兜底,已存在的 impl 块不用改。这是 trait 版本演进的安全阀(代价是不能删已用的方法,否则老 impl 编译失败)。

四、自动 Derive:Debug / Clone / Copy 这些是”编译器帮你写 impl”

你肯定见过这种写法:

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

#[derive(...)] 是什么?它是 Rust 的过程宏(第十六章细拆),作用一句话:让编译器自动为你生成某个 trait 的 impl 代码,省得你手写那些千篇一律、纯机械的实现。

比如 #[derive(Clone)],编译器给你生成:

impl Clone for Point {
    fn clone(&self) -> Self {
        Point { x: self.x, y: self.y }   // 逐字段拷一份
    }
}

Debug 同理,生成”按 {:?} 格式打印各字段”的实现;Copy 生成”标记此类型可按位复制”的(注意 Copy 是标记 trait,没有方法体,它只是给编译器一个许可信号)。PartialEq、Hash、Default 等也都能 derive。

为什么需要它?因为这些实现大多是”逐字段套用同样逻辑”的机械活——你手写一百个 struct 的 Clone,模式完全一样,纯属浪费生命。Derive 把这类样板交给编译器批量生成。

但 derive 不是万能的,它有条件。 一个 #[derive(Clone)] 的 struct,要求它的每个字段都得是 Clone,否则编译器不知道怎么 clone 那个字段,derive 失败。同理 #[derive(Debug)] 要求各字段都能 Debug。这其实是 trait 约束的一致性在 derive 上的体现:能让编译器帮你写,是因为”组合的安全性”能被自动验证。要是某个字段不可 Clone,你得自己写 impl Clone,手动决定”那个字段怎么办”。

有个深坑要标:Copy 和 Clone 不是一个东西。Clone 是”显式调 .clone() 复制一份”(可能贵);Copy 是”赋值/传参时自动按位拷贝且原值仍有效”(只给便宜的纯栈值)。Copy 隐含要求类型同时是 Clone。别以为 derive 了 Copy 就什么都拷贝无成本——Copy 只能用于”按位复制安全”的类型,含堆数据的 String 永远不能 Copy。这又绕回第二章所有权那个”堆”的分水岭。

五、孤儿规则:为什么不能随便给别人的类型 impl 别人的 trait

第八章的思考题里你其实已经推过孤儿规则的直觉,这里正式讲。规则原文:

若要写 impl Trait for Type,则 Trait 和 Type 至少有一个是在当前 crate(当前这个包)里定义的。二者都是别人的(外部 crate 的),不允许。

为什么存在?根子是一致性(coherence)。Rust 要求:对于任意一对 (类型, trait),全局只能有唯一一份实现。如果放开”谁都能给任意类型 impl 任意 trait”,那么:

  • crate A 给 String impl 了 Display(按它的想法);
  • crate B 也给 String impl 了 Display(按它的想法);
  • 你的程序同时依赖 A 和 B,编译器懵了:到底用哪份?

孤儿规则用”定义权归属”一刀切:只有”类型是你家的”或”trait 是你家的”,你才有权 impl。String 和标准库 Display 都是标准库的,所以任何第三方 crate 都不能给 String impl Display——这就保证了 (String, Display) 这份实现全局唯一、在标准库里、无歧义。

但规则也留了活路,最常见的两种绕过:

其一,定义自己的 trait,给任意类型 impl。 因为 trait 是你家的,孤儿规则放行。第八章你给 i32 impl 自己的 Greeting 就是这招——完全合法。

其二,newtype 模式(wrapper)。 你想给标准库类型”加能力”,又不想动它的定义,就包一层:

struct WrappedString(String);   // 这是你定义的类型
impl SomeTrait for WrappedString { /* ... */ }  // 类型是你家的,合法

外层的 WrappedString 是你定义的,于是你有权给它 impl 任意 trait。代价是调用时要多包一层、取值时多解一层,但换来”在不侵犯孤儿规则的前提下扩展能力”。这又是组合优于继承的体现:想加点行为,包一层、impl 上,而不是去改原类型的”基类”。

墨叔点一句:孤儿规则看着是限制,实则是”能力归属的可追责性”。它让每个 (类型, trait) 实现的来源清清楚楚——要么类型作者负责,要么 trait 作者负责,绝不允许路人甲随便塞一份实现搅局。没有它,trait 系统会在多 crate 协作时崩成意大利面。

六、trait 作为”能力 / 约束”的心智

走到这,墨叔想帮你把 trait 的整个心智收口。别再把 trait 当成”接口”这个词的同义词,它在 Rust 里更像是一张能力证书。

  • 一个类型 impl Clone,等于声明”我具备被复制的能力”。
  • 一个类型 impl Iterator,等于声明”我能一个一个产出 Item”。
  • 一个泛型 fn f<T: Read>(r: T),等于声明”我只接受具备’读取’能力的参数”。

trait 既是抽象(把”能读”这种行为从具体类型里抽出来),又是约束(把”能用在这”的门槛焊进签名)。当你写代码时,你会越来越多地想:“这个函数需要调用方提供什么能力?“——然后把这些能力列成 trait bound。这就是 Rust 鼓励的”按行为设计,而非按数据类型设计”的思维方式。

如果你写过靠继承建模的语言,这里的心智要重装最后一次:不要问”这两个类型是不是一家人”,要问”这两个类型是不是都考了同一张证”。 重塑完这个提问方式,trait 就从”绕弯的接口”变成你手里最趁手的抽象工具。

【思考题】

  1. 静态分发(fn notify<T: Draw>(x: T))和动态分发(fn notify(x: &dyn Draw))都能调 draw()。现在请你推:如果我有一个 Vec,里面既有 Circle 又有 Square,我想写一个函数把整个 Vec 遍历画一遍。为什么泛型静态分发在这里直接写不出来(会出什么类型的错误)?而 &dyn Draw 是怎么绕过这个限制的?把”编译期类型必须单一”与”运行时异构集合”这对矛盾讲透。

  2. 我们讲 Iterator 用了关联类型 type Item。现在假设 Rust 当年没发明关联类型,改用泛型参数写成 trait Iterator<T> { fn next(&mut self) -> Option<T>; }。请推:这会导致什么歧义或麻烦?比如一个 Vec<i32> 能不能同时 impl Iterator<i32> 和 impl Iterator<String>?这种”一个类型挂多种元素类型”在语义上为什么是荒谬的?反过来,关联类型怎么消除这个荒谬?

  3. 孤儿规则说”类型和外 trait 都得至少一个是你定义的才能 impl”。现在你用了标准库的 String,想让它也能被你的 Greet trait 调用(你定义了 Greet)。这符合孤儿规则吗?如果不符合,你该用哪种写法(newtype 还是自定义 trait)来合法地达成”让 String 也能 Greet”?写出关键代码并讲清为什么那种写法合法。

【参考答案】

  1. 泛型 fn notify<T: Draw>(x: T) 要求调用时 T 被实例化成某一个具体类型。你要遍历 Vec 画一遍,直觉会写 for s in vec { notify(s); },但 vec 里的元素类型并不统一——它一会儿是 Circle 一会儿是 Square,编译器没法把 T 定成一个固定的具体类型,于是”Vec 的元素类型是什么”本身就过不了泛型这一关(Vec<T> 本就要求元素同型,Circle 与 Square 不是同一 T,连构造这个 Vec 都构造不出来)。静态分发的本质前提是”编译期已知且固定为某类型”,而”运行时的异构集合”恰恰违反了这个前提,所以直接写不出来——不是语法别扭,是根本矛盾。

&dyn Draw 的绕过之道:trait object 把”具体类型”抹平成”某张实现了 Draw 的证”。Vec<&dyn Draw> 的元素不是 Circle 也不是 Square,而是”指向某数据 + 指向该数据对应 vtable 的胖指针”,类型统一就是 &dyn Draw。运行时每调 s.draw(),通过各自 vtable 找到正确实现。于是异构集合得以存在,代价是每次调用查表。一句话收口:静态分发要”编译期同型”,动态分发把类型信息推迟到运行时、用 vtable 兜底,从而允许异构——这是同一对矛盾的两种解法。

  1. 若用泛型参数 Iterator<T>,则 impl Iterator<i32> for Vec<i32> 和 impl Iterator<String> for Vec<i32> 在语法上都被允许——因为 T 只是个参数,可以任取。这就制造了荒谬:“同一个 Vec<i32>,遍历出来的元素既可以是 i32 又可以是 String”。但语义上,Vec<i32> 遍历出什么,是由这个类型唯一决定的事实,不可能同时是两种。这种”一个类型挂多种遍历元素类型”的歧义,让 next() 的返回类型在调用点无法唯一确定,类型系统失去锚点。关联类型 type Item 消除了它:impl Iterator for Vec<i32> 必须且只能写一次 type Item = i32,把”遍历元素类型”钉成该类型的一个固有属性,全局唯一、无歧义。关联类型表达的是”配套角色唯一确定”,泛型参数表达的是”可对任意参数各自成立”——遍历元素明显属于前者,所以关联类型才是对的那个工具。

  2. 结论先给:符合孤儿规则,合法。 因为 Greet 是你自己定义的 trait。孤儿规则要求”类型 和 trait 至少一个是你定义的”。这里类型是 String(标准库的),trait 是 Greet(你的)——trait 归你,满足”至少其一归你”,所以 impl Greet for String 完全允许,无需任何 workaround。我特意把这一问和”不符合”的情形对照,免得你记反:只有当类型和外都是别人的才被禁,比如给标准库 String impl 标准库 Display(类型、trait 都归标准库)才非法,那时才需要 newtype:struct MyString(String); impl Display for MyString { ... }——因为 MyString 是你定义的类型,孤儿规则放行。

所以本问的正确写法是直接 impl Greet for String,不用 newtype。newtype 是”想给别人的类型 impl 别人的 trait”时的逃生舱,不是”想给别人的类型 impl 自己的 trait”时的必需品——后者本就合法。把”谁定义”这个归属想清楚,孤儿规则就从谜语变成常识。

小结

trait 既能静态分发(编译期焊死、零开销)也能动态分发(dyn Trait 运行时查 vtable、容纳异构集合);关联类型把配套类型钉成唯一,默认方法给通用实现,Derive 让编译器代写样板 impl,孤儿规则用”定义权归属”守住实现一致性。把 trait 想成”能力证书”,你就抓住了 Rust 多态的魂。