阶段五 · 泛型与特征

特征 Trait(上):定义,实现与约束

一句话总结

特征(Trait)是 Rust 的接口系统,定义"类型能做什么".与 Go interface 目标相同,但两点关键不同:实现必须显式声明(impl Trait for Type),且 Trait 可提供默认方法.
Trait 还是泛型约束的基石--上一篇那个无法比较的 T,本篇就能解决.

Trait 是什么

Trait 定义一组方法签名,任何类型只要实现这些方法,就"拥有"了这个特征:

trait Summary {
fn summarize(&self) -> String;
}

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

impl Summary for Article {
fn summarize(&self) -> String {
format!("{}: {}...", self.title, &self.content[..10])
}
}

let a = Article { title: "Rust".into(), content: "所有权是核心概念".into() };
println!("{}", a.summarize());

Trait 约等于 Go 的 interface.
trait Summary { fn summarize() } 对应 Go 的 type Summary interface { Summarize() string }.两者都是"行为契约":规定类型必须具备哪些方法.

关键差异一:显式实现

这是 Go 程序员最需要调整的一点.Go 是结构化类型(隐式实现):类型只要恰好拥有 interface 要求的方法,就自动满足该 interface,无需声明.Rust 是名义类型(显式实现):必须写出 impl Summary for Article,编译器才认.

Go interface Rust Trait
实现方式 隐式,有对应方法即满足 显式 impl Trait for Type
意图表达 "碰巧能用" "明确声明我实现了它"
误实现风险 方法签名巧合即被当作实现 不会,必须主动声明

显式的好处

显式实现让"这个类型实现了哪些特征"一目了然,也杜绝了"方法名碰巧相同就被误认为实现某接口"的意外.

代价是稍啰嗦,但意图更清晰--符合 Rust "显式优于隐式"的一贯哲学.

关键差异二:默认方法

Trait 可为方法提供默认实现.实现该 Trait 的类型可直接复用,也可覆盖.Go interface 做不到这点(只能定义签名):

trait Summary {
fn summarize_author(&self) -> String;

// 默认方法:可调用本 trait 的其他方法
fn summarize(&self) -> String {
format!("(作者:{})", self.summarize_author())
}
}

struct Tweet { username: String }

impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
// 不实现 summarize,直接用默认版本
}

默认方法类似面向对象里"抽象基类提供的通用方法":只需实现少数核心方法,其余通用逻辑由 Trait 默认提供--既减少重复,又保留覆盖的自由.
标准库大量使用这一手法(如 Iterator 只要实现 next,就白送几十个默认方法).

特征约束:让泛型强大起来

现在解决上一篇的遗留问题.要让泛型函数能调用某些方法,就用 Trait 约束(bound) 类型参数,告诉编译器"这个 T 保证实现了某 Trait":

fn notify<T: Summary>(item: &T) {
println!("速报!{}", item.summarize()); // 因为约束了 Summary,才能调 summarize
}

回到那个"求最大值"的例子.比较大小需要 PartialOrd,取值复制需要 Copy,加上约束后就能编译:

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut max = list[0];
for &item in list {
if item > max { // PartialOrd 保证了 > 可用
max = item;
}
}
max
}

T: PartialOrd + Copy 读作"T 必须同时实现 PartialOrd 和 Copy",+ 叠加多个约束.和 Go 泛型约束如出一辙:Go 写 [T constraints.Ordered],Rust 写 <T: PartialOrd>.

where 子句:约束多了更清晰

约束复杂时,挤在尖括号里可读性差.用 where 子句把约束移到签名后面:

// 难读
fn process<T: Summary + Clone, U: Clone + std::fmt::Debug>(t: &T, u: &U) { }

// 用 where,清晰
fn process<T, U>(t: &T, u: &U)
where
T: Summary + Clone,
U: Clone + std::fmt::Debug,
{ }

impl Trait:参数与返回值的简写

作为参数时,impl Trait 是泛型约束的语法糖:

// 与 fn notify<T: Summary>(item: &T) 等价
fn notify(item: &impl Summary) {
println!("{}", item.summarize());
}

作为返回值时,它表示"返回某个实现了该 Trait 的具体类型,但不写出类型名"--第 10 篇返回闭包用的 impl Fn(...) 正是这个机制.

孤儿规则:实现 Trait 的边界

一条迟早会撞上的规则.为类型实现 Trait 时,Trait 或类型至少有一个必须是自己 crate 定义的.不能给"别人的类型"实现"别人的 Trait":

// ✓ 可以:给自己的类型实现标准库 trait
impl std::fmt::Display for Article { /* ... */ }

// ✓ 可以:给标准库类型实现自己的 trait
trait MyTrait { fn mine(&self); }
impl MyTrait for String { fn mine(&self) {} }

// ✗ 不行:Vec 和 Display 都不是自己定义的(孤儿规则禁止)
// impl std::fmt::Display for Vec<i32> { ... }

孤儿规则与绕过它的 Newtype

这条规则(orphan rule)保证 trait 实现的全局一致性--避免两个 crate 给同一对"外部类型 + 外部 trait"提供冲突的实现.

当确实需要给外部类型实现外部 trait 时,用第 05 篇的 Newtype 模式:把外部类型包进自己的元组结构体(如 struct MyVec(Vec<i32>)),再为这个"自己的"类型实现 trait.Go 没有此约束,因为它的接口是隐式满足的.

derive:自动派生常用 Trait

第 05 篇用过 #[derive(...)],这里说清它的本质:很多 Trait 的实现是机械的(如"如何打印""如何比较相等"),derive 让编译器自动生成,免去手写:

常用可派生 Trait 提供的能力
Debug {:?} 调试打印
Clone / Copy 深拷贝 / 按位复制
PartialEq / Eq == 比较相等
PartialOrd / Ord < > 比较,排序
Default Type::default() 默认值
Hash 作为 HashMap 的键

最佳实践:派生而非手写

这些机械实现一律用 derive 自动生成,不要手写--手写既啰嗦又容易出错.只有当默认派生行为不满足需求时(如自定义相等逻辑),才手动 impl.

derive 本质是过程宏(procedural macro),宏的原理在进阶分类展开,这里会用即可.

derive 到底生成了什么

对复杂结构体,derive 的策略很朴素:逐字段处理.以这个结构体为例:

#[derive(Debug, Clone, PartialEq, PartialOrd, Default, Hash)]
struct Person {
name: String,
age: u32,
}

编译器展开后逻辑上等价于:

// Debug: 按 "{类型名 { 字段: 值, ... }}" 格式,每个字段调 Debug
impl fmt::Debug for Person {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("Person")
.field("name", &self.name)
.field("age", &self.age)
.finish()
}
}

// PartialEq: 每个字段用 == 比较,全等才等
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.age == other.age
}
}

// PartialOrd: 按字段声明顺序逐个比,第一个不等即出结果
impl PartialOrd for Person {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match self.name.partial_cmp(&other.name) {
Some(Ordering::Equal) => self.age.partial_cmp(&other.age),
other => other,
}
}
}

// Clone: 逐字段 clone
impl Clone for Person {
fn clone(&self) -> Self {
Person { name: self.name.clone(), age: self.age.clone() }
}
}

// Default: 逐字段取默认值
impl Default for Person {
fn default() -> Self {
Person { name: String::default(), age: u32::default() }
}
}

// Hash: 逐字段喂入 hasher
impl Hash for Person {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.age.hash(state);
}
}

Copy 特殊:它不生成代码,只是标记"这个类型可以按位复制".要求所有字段都是 Copy 的.

Eq 同样不生成新方法:它是标记 trait(marker trait),声明"PartialEq 满足自反性(a == a 永远为真)".derive(Eq) 只是把这个标记加上,不做其他事.

Partial 前缀的含义

PartialEqPartialOrd 里的"Partial"来自数学中的偏序(partial order):并非所有值对都能比较.

最直观的例子是浮点数 f64:

let x = f64::NAN;
println!("{}", x == x); // false -- NaN 不等于任何值,包括它自己

let order = f64::NAN.partial_cmp(&1.0); // None -- 无法比较

所以 f64 只实现了 PartialEqPartialOrd,没有 EqOrd.这引出一个实用结论:

结构体含 f64 时无法 derive(Eq) 和 derive(Ord)

如果结构体包含 f64,编译器只会允许 #[derive(PartialEq, PartialOrd)],拒绝 EqOrd.

要比较浮点字段,常用的折中方案是用 ordered_float crate 的 OrderedFloat 包装类型,或直接手写 impl 定义自己的排序规则.

Trait 返回类型 能否比较 f64 关键区别
PartialEq bool 可以(但 NaN != NaN) 不保证自反性
Eq 无新方法,标记 trait 不可 保证 a == a 始终为真
PartialOrd Option<Ordering> 可以(但 NaN 返回 None) 允许"不可比较"的情况
Ord Ordering(不是 Option) 不可 任意两值都可比较,且结果确定

日常开发中,如果结构体全是整数,字符串,bool 等"正常"类型,写 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 即可--所有比较都正常工作.一旦引入 f64,才需要考虑 Partial 的差异.

下一篇预告

本篇用 <T: Trait> 实现的是静态分发:编译期就确定调用哪个类型的方法.但有时需要"一个集合里放多种不同类型,运行时再决定调谁"--这要用到Trait 对象(dyn Trait)与动态分发.下一篇专讲这组对比,以及关联类型和常用标准库 trait.

快速回顾

  • Trait = 接口:定义类型应具备的行为,对应 Go interface.
  • 显式实现:必须写 impl Trait for Type,不同于 Go 的隐式满足.
  • 默认方法:Trait 可提供默认实现,类型可复用或覆盖,Go interface 做不到.
  • 特征约束:<T: Trait> / where 让泛型获得调用方法的能力,解决"无约束 T 太弱".
  • 孤儿规则:Trait 或类型至少一个属于本 crate;需突破时用 Newtype 模式.
  • derive:自动派生 Debug/Clone/PartialEq 等机械实现,优先用而非手写.

动手练习

  1. 定义与实现:定义 trait Describe { fn describe(&self) -> String; },为两个不同结构体分别实现.
  2. 默认方法:给 Describe 添加一个默认方法,验证未覆盖时使用默认实现.
  3. 约束修复:给上一篇报错的 largest<T> 加上 T: PartialOrd + Copy 约束,让它编译通过.
  4. 孤儿规则绕过:尝试给 Vec<i32> 实现 std::fmt::Display,观察孤儿规则报错,再用 Newtype 模式绕过.
  5. derive 验证:给一个结构体派生 Debug, Clone, PartialEq, Default,分别验证四种能力.