一句话总结
特征(Trait)是 Rust 的接口系统,定义"类型能做什么".与 Go interface 目标相同,但两点关键不同:实现必须显式声明(impl Trait for Type),且 Trait 可提供默认方法.
Trait 还是泛型约束的基石--上一篇那个无法比较的 T,本篇就能解决.
Trait 是什么
Trait 定义一组方法签名,任何类型只要实现这些方法,就"拥有"了这个特征:
|
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 默认提供--既减少重复,又保留覆盖的自由.
标准库大量使用这一手法(如 Iterator 只要实现 next,就白送几十个默认方法).
特征约束:让泛型强大起来
现在解决上一篇的遗留问题.要让泛型函数能调用某些方法,就用 Trait 约束(bound) 类型参数,告诉编译器"这个 T 保证实现了某 Trait":
|
回到那个"求最大值"的例子.比较大小需要 PartialOrd,取值复制需要 Copy,加上约束后就能编译:
|
T: PartialOrd + Copy 读作"T 必须同时实现 PartialOrd 和 Copy",+ 叠加多个约束.和 Go 泛型约束如出一辙:Go 写 [T constraints.Ordered],Rust 写 <T: PartialOrd>.
where 子句:约束多了更清晰
约束复杂时,挤在尖括号里可读性差.用 where 子句把约束移到签名后面:
|
impl Trait:参数与返回值的简写
作为参数时,impl Trait 是泛型约束的语法糖:
|
作为返回值时,它表示"返回某个实现了该 Trait 的具体类型,但不写出类型名"--第 10 篇返回闭包用的 impl Fn(...) 正是这个机制.
孤儿规则:实现 Trait 的边界
一条迟早会撞上的规则.为类型实现 Trait 时,Trait 或类型至少有一个必须是自己 crate 定义的.不能给"别人的类型"实现"别人的 Trait":
|
孤儿规则与绕过它的 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 的策略很朴素:逐字段处理.以这个结构体为例:
|
编译器展开后逻辑上等价于:
|
Copy 特殊:它不生成代码,只是标记"这个类型可以按位复制".要求所有字段都是 Copy 的.
Eq 同样不生成新方法:它是标记 trait(marker trait),声明"PartialEq 满足自反性(a == a 永远为真)".derive(Eq) 只是把这个标记加上,不做其他事.
Partial 前缀的含义
PartialEq 和 PartialOrd 里的"Partial"来自数学中的偏序(partial order):并非所有值对都能比较.
最直观的例子是浮点数 f64:
|
所以 f64 只实现了 PartialEq 和 PartialOrd,没有 Eq 和 Ord.这引出一个实用结论:
结构体含 f64 时无法 derive(Eq) 和 derive(Ord)
如果结构体包含 f64,编译器只会允许 #[derive(PartialEq, PartialOrd)],拒绝 Eq 和 Ord.
要比较浮点字段,常用的折中方案是用 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等机械实现,优先用而非手写.
动手练习
- 定义与实现:定义
trait Describe { fn describe(&self) -> String; },为两个不同结构体分别实现. - 默认方法:给
Describe添加一个默认方法,验证未覆盖时使用默认实现. - 约束修复:给上一篇报错的
largest<T>加上T: PartialOrd + Copy约束,让它编译通过. - 孤儿规则绕过:尝试给
Vec<i32>实现std::fmt::Display,观察孤儿规则报错,再用 Newtype 模式绕过. - derive 验证:给一个结构体派生
Debug, Clone, PartialEq, Default,分别验证四种能力.