第八章 泛型与 Trait:没有继承的多态

你有没有过这种别扭:你想写一个”能比较大小、能打印出来”的函数,结果为了适配 i32、f64、还有你自己定义的 Point,你不得不把同一段逻辑抄三遍,或者干脆退化成”先转成字符串再比”的野路子?

墨叔跟你说,这种别扭,在 Rust 里是用”泛型 + trait”这对搭档一次性解决的。但这一章有个大坑要提前标出来:Rust 的多态,走的不是你熟悉的”类继承”那条老路。 如果你写过靠继承组织代码的语言,这里的心智要重装——不是小修小补,是整条思路得换轨。先把”父类子类”那个词从脑子里请出去,我们重新讲”行为”是怎么被抽象、被复用、被分发掉的。

先说结论:泛型解决”同一份逻辑作用于多种类型”,trait 解决”多种类型都能提供同一组行为”。二者合起来,就是 Rust 实现多态的方式——而且是在编译期就定死的、零运行时开销的多态。

一、泛型:把”类型”也变成参数

泛型(generic)最朴素的样子,就是”函数或结构体,有一个位置不写死具体类型,而是写一个占位符 T”。T 在调用时由编译器根据实际传进来的类型填进去。

// 一个返回切片里最大元素的函数,对 i32、f64、u8…… 都通用
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut max = list[0];
    for &item in list {
        if item > max {       // PartialOrd 保证"能比大小"
            max = item;
        }
    }
    max                        // Copy 保证"能按位拷出来返回"
}

注意那个 <T: PartialOrd + Copy>,它叫 trait bound(约束),意思是”这个 T 不是随便什么类型都行,它得能比大小、得能拷贝”。墨叔待会儿专门讲 bound,这里先混个眼熟。

结构体也能泛型:

struct Point<T> {
    x: T,
    y: T,
}
// 用法:Point<i32> 是整数坐标,Point<f64> 是浮点坐标
let int_p = Point { x: 1, y: 2 };
let float_p = Point { x: 1.5, y: 2.5 };

打个比方。餐厅后厨做套餐,套餐盒上印着”主菜位”和”配菜位”,至于今天塞牛排还是塞鱼,是装盒时定的。泛型就是这个”套餐盒模板”——Point<T> 是”坐标盒模板”,T 是”装什么数的位”,装盒那一刻(Point<i32>)才定死。你不用为”整数坐标盒""浮点坐标盒”各开一套模具,一套模板复用到底。

泛型的根本好处:代码写一遍,类型安全检查却对每一种实例化都单独做。 你写 largest::<i32>,编译器就按 i32 校验一遍;写 largest::<f64>,按 f64 再校验。既省了你抄代码,又不丢任何安全性。这点很 Rust——偷懒可以,安全不退让。

二、trait:Rust 的”接口”,但更强

trait 是你给”一类类型”约定的”一组行为”。语法上它确实像别的语言的 interface,但 Rust 的 trait 多了两件接口没有的硬货,墨叔重点讲。

// 定义一个 trait:凡是"可总结"的类型,都得能 summarize
trait Summary {
    fn summarize(&self) -> String;   // 必须实现的方法(没有方法体)
 
    // 默认方法:有实现,类型可以选择重写或不重写
    fn footer(&self) -> String {
        String::from("—— 来自硬核修炼手册")
    }
}

一个类型要实现这个 trait,就 impl 它:

struct Article { title: String, author: String }
 
impl Summary for Article {
    fn summarize(&self) -> String {
        format!("《{}》 by {}", self.title, self.author)
    }
    // footer 我不重写,用 trait 里给的默认版本
}

trait 强在哪?两处:

第一,可以有默认方法。 别的语言里接口方法必须全实现,Rust 的 trait 能给你一个”现成实现”,类型按需覆盖。这看着像继承里”父类给默认实现”,但记住——这里没有类、没有继承链,纯粹是”trait 提供默认,impl 方决定覆不覆盖”。它解决的是”给一组类型一个公共的、大多数情况下都合适的实现,少数特殊的自己改”。

第二,可以有关联类型(associated type)。 这是接口通常没有的(第九章细拆),这里先埋个伏笔:Iterator trait 里那个 type Item 就是关联类型,它让”遍历出来的元素类型”成为 trait 的一部分,而不是又一个泛型参数。为什么这比”再来个泛型参数”好?因为”一个类型只能有一种遍历元素类型”,用关联类型能表达这种唯一性,避免 Iterator<T> 那样一个类型挂好几种 T 的歧义。

如果你写过靠”类 + 接口”组织代码的语言,这里的心智要重装:在那些语言里,复用靠”子类继承父类、顺带继承行为”;Rust 里没有父类子类,复用靠”一个类型自己去 impl 某个 trait,把行为接上”。类型之间没有血缘,只有”我承诺提供这组行为”的契约。一个类型可以同时 impl 多个互不相干的 trait,像同时持有几张资格证,而不是”认一个爹”。

三、trait bound:给泛型戴上”能力准入”

回到第一节那个 <T: PartialOrd + Copy>。它的全称是 trait bound:约束 T 必须实现 PartialOrd 和 Copy 这两个 trait。为什么需要它?因为 largest 函数体里写了 item > max,这隐含要求 T 得”能比较大小”。如果 T 是个没实现 PartialOrd 的类型,那行比较在语义上就无意义——编译器在泛型还没实例化时就拦下你,逼你声明”我只接受能比大小的类型”。

trait bound 有三种常见写法,挑顺手的用:

// 写法一:impl Trait(最简洁,适合单个参数)
fn notify(item: &impl Summary) {
    println!("{}", item.summarize());
}
 
// 写法二:泛型 + bound(等价,但多个参数时更清楚)
fn notify<T: Summary>(item: &T) {
    println!("{}", item.summarize());
}
 
// 写法三:where 子句(bound 多的时候不挤在 <> 里)
fn notify<T>(item: &T)
where
    T: Summary + std::fmt::Debug,
{
    println!("{:?} -> {}", item, item.summarize());
}

你品这个设计哲学。trait bound 不是给编译器看的装饰,它是把”函数对类型的要求”写进签名、变成可检查的合同。调用方一看签名就知道:“哦,我这个类型得 impl Summary 才能喂进去。” 这又回到第七章那句——Rust 把原本隐形的约束,变成了明面上的类型。

打个比方。图书馆借书,有些珍本区的书,规定”只借给持研究生证的人”。“持研究生证”就是一道 bound。泛型函数 borrow<T: GradStudent> 表达的就是:我不关心你是哪个院的研究生(类型是谁),但你得有那张证(impl 了那个 trait),否则门禁不放行。trait 是那张证,bound 是门禁规则。

四、静态分发:编译期就把多态”焊死”

这是本章最值钱、也最反直觉的一块。当你写 fn notify<T: Summary>(item: &T),编译器在背后干了什么?

它做的是单态化(monomorphization):对每一个你实际用到的 T,编译器生成一份”专属于该类型”的函数副本。你用 Article 调了 notify,它就生成 notify_Article;用 Tweet 调了,生成 notify_Tweet。每份副本里,item.summarize() 直接内联成对应类型那个具体实现——没有任何运行时的查表、没有任何虚函数指针跳转。

// 你写的泛型
fn notify<T: Summary>(item: &T) { println!("{}", item.summarize()); }
 
// 编译器在背后大致生成(示意,非真实输出):
// fn notify_Article(item: &Article) { println!("{}", item.summarize()); }
// fn notify_Tweet(item: &Tweet)    { println!("{}", item.summarize()); }

这种”编译期确定、运行时零开销”的多态,叫静态分发(static dispatch)。它的代价是:代码体积会膨胀(每个类型一份副本)。它的红利是:性能拉满——调用就是一次直接的函数调用,还能被内联优化,和手写为每种类型单独写函数一模一样快。

这就是 BRIEF 里反复提的”零成本抽象”的又一个实例:你享受了”写一遍复用多类型”的抽象便利,运行时却不必为这份抽象付任何代价。

对比一下你熟悉的语言:靠虚函数/接口引用的多态,运行时靠一张虚表(vtable)查方法地址,每次调用多一次间接跳转。Rust 的静态分发连这张表都省了。但——凡事有取舍,运行时查表那条路 Rust 也有,留给第九章的”trait object / dyn”讲。第八章你先吃透静态分发,因为它是 Rust 的默认值、也是性能担当。

五、和”接口 / 继承”的根本差异:Rust 没有类继承

墨叔把这一节单独拎出来,是因为这里最容易栽跟头。你脑子里那套”父类定义共性、子类继承并扩展、is-a 关系串成一颗树”的建模习惯,在 Rust 里整体失效。

Rust 的差异,墨叔给你拆成三刀:

第一,没有类,也没有继承。 Rust 里类型就是 struct 和 enum,它们不”继承”任何东西。你想复用行为,不是写个子类去”认爹”,而是给这个类型 impl 一个或多个 trait。一个 struct 可以同时 impl Summary、Display、Clone 等一堆 trait,像一个人同时考了厨师证、驾照、会计证——互不冲突,也不存在”谁是谁的爹”。

第二,没有”is-a”的层级树,只有”行为契约”。 继承语言里 Dog is an Animal,靠的是血缘。Rust 里没有这种血缘。Dog 若 impl Walk,意思是”Dog 承诺能提供 Walk 这组行为”,仅此而已。两个不同的类型,哪怕毫无关系,只要都 impl 了同一个 trait,就能被同一个泛型函数接纳。多态的基础从”血缘”变成了”契约”——这更松耦合,也更灵活。

第三,复用靠组合,不靠继承。 你想要”把 A 的功能嵌进 B”,在 Rust 里是”B 里包含一个 A 字段,并给 B impl 相关 trait 时把调用转给 A”,而不是”让 B 继承 A”。这就是组合优于继承(composition over inheritance)被 Rust 焊进语言:你没法用继承偷懒,就逼着自己把复用设计得更清晰。

为什么 Rust 刻意不要继承?因为继承那棵树,越长大越容易长出”为了复用一点功能,被迫继承一整坨不想要的行为”的畸形枝杈,还带来脆弱基类、菱形继承等一地鸡毛。Rust 选择:行为用 trait 抽象、数据用 struct 组合、复用靠 impl 显式接上。没有隐式继承链,每一份能力的来源都清清楚楚写在 impl 块里。

所以,当你以后再想”这俩类型有共性,搞个父类吧”——停。改成:“这俩类型都该有某组行为,定义个 trait,分别 impl。” 这一步想通,Rust 的面向对象替代品才算真正进门。

【思考题】

  1. 我们写了 fn largest<T: PartialOrd + Copy>(list: &[T]) -> T。现在请你推:如果去掉 Copy 这个 bound,只留 PartialOrd,函数体里的 let mut max = list[0]; 和 max = item; 会出什么编译错误?根子在哪?要想保留”返回最大值”且不要求 T 是 Copy,你该怎么改函数签名与实现?(提示:返回引用而非值。)

  2. 假设你给 i32 自己 impl 了一个叫 Greeting 的 trait(提供 fn hello(&self))。但 i32 是标准库的类型,Greeting 是你自己定义的 trait。这在 Rust 里允许吗?结合”孤儿规则”的直觉(第九章细讲,这里先想)猜一下:为什么语言要限制”给一个既不是你定义的类型、也不是你定义的 trait 去 impl”?把这个限制的用意讲通。

  3. 静态分发(单态化)让泛型函数为每个类型生成一份副本,换来零运行时开销。请你权衡:什么情况下这种”代码膨胀”会成为一个真实问题?反过来,为什么对大多数程序它根本不是事?再用一句话讲清”零成本抽象”在这里到底免了哪一份成本。

【参考答案】

  1. 去掉 Copy 后,list[0] 的类型是 T,而 T 若是 String 这类非 Copy 类型,let mut max = list[0]; 会把 list[0] move 出来,导致 list 后续不能再被借用(而且 max = item; 也是把 item move 进 max,循环里第二次迭代 list 早已残缺)。更关键:largest 返回 T,意味着它要把某个元素 move 出去,这要求元素可 move——非 Copy 类型本就可 move,所以”返回值”本身没问题;真正的问题是 max = item 在比较循环里反复 move,会把切片里的元素一个个搬走,破坏切片。根子:largest 的语义是”挑出最大元素”,但我们并不需要拥有它,只需要”指向它”。改法是让函数返回引用、并在 bound 上允许非 Copy:
fn largest<T: PartialOrd>(list: &[T]) -> &T {   // 返回引用,不搬值
    let mut max = &list[0];
    for item in list {
        if item > max {
            max = item;          // 只换指针,不 move 元素
        }
    }
    max
}

这样 T 只需 PartialOrd(能比大小),不需要 Copy;返回 &T 也不夺取元素所有权。这正是标准库 slice::iter().max() 的思路:返回引用,避免对元素类型的 Copy 要求。

  1. 结论先给:允许。i32 是别人(标准库)的类型,Greeting 是你定义的 trait——这不触发孤儿规则,因为孤儿规则限制的是”类型和方法(trait)双双重都不是你定义的”。只要 trait 是你自己的,给任意类型(包括标准库类型)impl 它都合法;反之若给标准库类型 impl 标准库 trait(如给 i32 impl 标准库的 Display)才被禁止。限制的用意在于一致性(coherence):如果允许任何人给任意类型 impl 任意标准 trait,那么同一对”(类型, trait)“可能出现两份不同实现,编译器不知道用哪份,而且不同 crate 各自 impl 会冲突。孤儿规则把”定义类型”或”定义 trait”至少占其一作为 impl 的资格门槛,保证每个 “(类型, trait)” 组合全局唯一、无歧义。它本质是”谁能对这组行为负责”的归属裁决,免得全世界的 crate 互相覆盖彼此的实现、把类型系统搅乱。

  2. 代码膨胀成问题的场景:泛型函数被几十上百种具体类型调用,且函数体巨大——这时单态化生成的副本会显著撑大二进制体积,对嵌入式固件、对体积敏感的可执行文件是真实负担。但对绝大多数程序它”不是事”的原因:现代编译器的链接期优化(如 function merging、相似副本合并)会削掉雷同的副本;而且相对换来”每次调用零间接跳转、可被内联”的性能,多占的那点体积通常微不足道。“零成本抽象”在这里免掉的成本,是运行时的分发开销——没有 vtable 查表、没有虚函数指针跳转、没有为抽象付出的每次调用额外指令。你写的是抽象代码,跑的是和手写专用代码一样快的具体机器码。代价(体积)在编译期一次性付完,运行时一分不收,这正是”零成本”的准确定义:不为你没用到的抽象付运行时账。

小结

泛型把”类型”当参数复用逻辑,trait 把”行为”当契约被多种类型实现;trait bound 给泛型戴上限入证,静态分发靠单态化在编译期焊死零开销多态。Rust 没有类继承——复用靠组合、能力靠 impl,多态的根基从”血缘”换成了”契约”。