类型系统:泛型、trait 与多态
📚 Rust 课程系列
如果说所有权与生命周期是 Rust 内存安全的「纵向骨架」,那么类型系统就是「横向骨架」–它决定了哪些抽象可以表达、哪些错误能在编译期消灭。Rust 的类型系统把两套通常水火不容的能力缝合在一起:一套是 ML/Haskell 风格的参数化多态(泛型),另一套是 typeclass 风格的特设多态(trait)。前者保证「同一段代码对任意类型都成立」,后者保证「不同类型可以按各自的方式满足同一契约」。两者通过 trait bound 结合,再借由单态化在编译期展开成具体代码,从而实现了「高层抽象 + 零运行时开销」这一在多数语言里只能二选一的目标。
本章是全书的枢纽。前面几章(结构体、枚举、模式匹配)展示了类型如何「描述数据」,本章则回答类型如何「描述行为」并「安全复用」。主线有四条:泛型如何抽象「与类型无关」的代码、trait 如何抽象「类型相关的行为」、分发如何在静态与动态之间取舍、以及 Sized、型变、PhantomData 这些「管道层」如何让上述抽象在数学上自洽。标准库各 trait 的逐一签名与实现要点见《Rust 标准 Trait 详解》,Deref/Drop/From 等常用 trait 的工程用法见《Unsafe Rust 与常用 trait 详解》,本文聚焦语言机制本身。
类型推断
Rust 的类型推断不是单向的「从右向左」,而是双向约束收集:编译器同时从变量的使用处和声明处收集约束,汇聚出唯一解;约束不足时要求显式注解,约束冲突时直接报错。这种推断让大部分代码无需写类型,又能在大类型签名上保持可读性。
1 | |
整数字面量默认 i32、浮点字面量默认 f64,这是回退规则(fallback)–仅当没有任何其他约束时才启用。一旦出现上下文约束,回退就让位:let n = 42u8; 直接定为 u8,let m: u64 = 42; 由注解决定。
推断失效与涡轮鱼
推断在两类场景必然失效,必须由人补全类型信息:
1 | |
补全信息有两种写法:在变量上加注解,或用涡轮鱼(turbofish)::<T> 在表达式内部指定。涡轮鱼的好处是不必为临时值引入变量:
1 | |
💡 提示:
collect::<Vec<_>>()是最常用的涡轮鱼写法–_表示「元素类型交给编译器推断,我只指定容器」。它比collect::<Vec<i32>>()更省事,因为元素类型通常已由上游map/filter决定。
_ 占位与部分推断
下划线 _ 是「请推断这里」的占位符,可出现在类型注解、涡轮鱼、函数签名中(受限)。它与省略注解的区别在于:_ 显式声明「这里我故意不写」,能让编译器在「需要一点提示」时收敛:
1 | |
⚠️ 注意:函数签名的参数和返回值不会被推断–函数是供外部调用的边界,必须显式写清类型,否则签名会随调用点漂移。这是 Rust 刻意的工程取舍:公共契约要稳定、可文档化。
泛型
泛型是参数化多态:用类型参数 T 表示「任意类型」,写一份代码适配所有类型。Rust 泛型的关键承诺是零成本抽象–它通过单态化(monomorphization)在编译期为每个具体类型生成一份专用代码,运行时不存在任何「泛型分发」开销。
🔄 对比:Rust 泛型 ≈ C++ 模板(编译期展开),但 Rust 在定义处就用 trait bound 检查约束,而非等到实例化才报一堆深层错误;Java/C# 泛型走类型擦除,运行时只有一份
Object代码,装箱开销不可避免;Go 泛型也单态化,但表达力受接口约束模型限制。
单态化:零成本的代价是体积
1 | |
编译器看到 first::<i32> 和 first::<&&str> 两个调用点,会分别生成两份机器码。运行时调用 first 是直接跳转,无间接寻址、无虚表。代价是二进制体积增大:每个具体类型一份代码。对小型泛型函数这几乎无感,但对大型泛型算法被多种类型实例化时,体积可能显著膨胀。
💡 提示:缓解体积膨胀的常见手段:把泛型函数的核心逻辑抽成一个处理
&[u8]或具体 trait 对象的非泛型函数,泛型外壳只做薄转发。Vec、HashMap等标准库容器内部就大量采用这种「泛型外壳 + 非泛型内核」结构。
泛型函数与 trait bound
无约束的泛型只能做「所有类型都能做的事」–赋值、移动、借用,仅此而已。要调用方法、比较大小,必须用 trait bound 声明 T 具备的能力:
1 | |
T: PartialOrd 是 bound:承诺「调用者传入的 T 必须实现了 PartialOrd」,于是 item > max 才合法。bound 在定义处生效,意味着泛型函数的作者写代码时就拥有 T 的能力清单,编辑器补全、错误提示都基于这份清单。
泛型结构体、枚举与方法
泛型可用于结构体、枚举及其方法。类型参数写在名字后的 <T>,方法在 impl 块上也要声明:
1 | |
impl<T> 中的 <T> 是必须的–它声明「这是一个泛型实现,T 是泛型参数」。若写成 impl Point<T> 会报错,因为编译器会把 T 当成某个具体类型而非参数。
一个关键技巧:可以为特定泛型实例单独实现方法,称为特化实现(不是 C++ 那种正式的 specialization,而是按具体类型分别 impl):
1 | |
Point::<f64>.dist() 可用,Point::<i32> 则没有 dist。标准库的 impl Point<f64> 与 impl<T> Point<T> 可以共存,编译器按「更具体者优先」选择。
x 和 y 共用同一个 T,所以 Point { x: 1, y: 2.0 } 会报错(i32 与 f64 不匹配)。要允许不同类型,用多个参数:
1 | |
const 泛型
const 泛型把编译期常量值作为参数,最常见的是数组长度。它让函数能直接接受定长数组 [T; N] 而非退化成切片 &[T],保留长度信息:
1 | |
const N: usize 表示 N 是一个编译期 usize 值。Array<i32, 4> 和 Array<i32, 8> 是不同类型,不能互相赋值–这在类型层面防止了「把 4 长度数组传给期望 8 长度的函数」。
⚠️ 注意:当前 const 泛型参数仅支持整型、
bool、char等基本类型,且不能任意运算(如N * 2作参数受限制)。完整的 const 表达式泛型(如[T; N][..M])仍在稳步推进中。日常最实用的场景是封装定长数组、实现矩阵/向量类型。
生命周期与泛型参数
泛型参数还能接受生命周期约束 T: 'a,含义是「T 中所有引用的存活都不短于 'a」。它解决一个问题:当含引用的泛型类型被放进更长的生命周期上下文时,编译器需要确认 T 内部的引用不会先于外部失效。
1 | |
多数情况下 T: 'a 由编译器隐式推导:Ref<'a, T> 的字段是 &'a T,编译器据此自动加上 T: 'a,无需手写。需要显式写 T: 'a 的场景,通常是泛型函数要把 T 借出比参数自身更久、或把含 T 的结构体存入更长生命周期的容器。
💡 提示:
T: 'a不是「T是引用」,而是「T若含引用,那些引用至少活'a」。对不含引用的类型(如i32),T: 'a对任意'a都平凡成立,因为它本就没有引用会失效。生命周期约束与所有权机制的深层关系见《所有权、借用与生命周期》。
trait
trait 定义一组方法签名(可带默认实现),描述「实现此 trait 的类型能做什么」。除方法外,trait 还可声明关联类型、关联常量与关联函数(无 self 的函数);其中关联类型是 trait 表达「输出类型」的核心机制,后文单列一节。trait 对应其他语言的接口/typeclass,但有一个根本差异:必须显式 impl,不会仅因方法签名凑巧匹配就自动成立。
🔄 对比:Rust trait ≈ Haskell typeclass(显式实例)+ Go interface(方法集),但 Rust 走名义类型(nominal)路线–类型只有写了
impl Trait for T才算实现,而 Go interface 是结构化类型(structural),签名匹配即满足。Rust 的选择避免了「无意中满足某接口」的意外耦合。
定义与实现
1 | |
impl Summary for Article 把 Summary 的要求与 Article 的实现绑定。实现体必须覆盖 trait 中所有无默认实现的方法,否则编译失败。
默认实现
trait 方法可提供默认实现,实现者可选用、也可覆盖:
1 | |
默认实现的威力在于可组合:notify 的默认实现调用了 summarize,实现者只需覆盖 summarize,notify 自动获得正确行为。这是 trait 复用的核心机制。
超 trait
超 trait(supertrait)要求「实现 B 之前必须先实现 A」,语法是 trait B: A。它表达 trait 间的依赖:B 的方法需要 A 提供的能力作为前提:
1 | |
OutlinePrint: fmt::Display 意为「OutlinePrint 是 Display 的超集,实现 OutlinePrint 的类型必须也实现 Display」。outline_print 的默认实现因此可以安全调用 self.to_string()(来自 Display)。
⚠️ 注意:超 trait 是对实现者的约束,不是对调用者的。
impl OutlinePrint for T时编译器会检查T: Display;但若你只写fn f<T: OutlinePrint>,编译器不会自动认为T: Display成立,调用to_string仍需显式加T: OutlinePrint + Display。这是常见踩坑点。
trait bound 与 impl Trait
trait bound 是泛型与 trait 的粘合剂:它约束类型参数必须满足哪些 trait,从而让泛型代码能用上具体能力。bound 有三种写法,按复杂度递进。
内联 bound
最简单的形式,把 bound 直接写在类型参数后:
1 | |
+ 组合多个 bound,表示「同时满足」。bound 越多,函数能调用的方法越多,但能接受的类型越少–这是抽象与具体的固有张力。
where 子句
当 bound 多到挤占函数签名、或涉及关联类型约束时,用 where 子句把约束挪到签名下方,可读性显著提升:
1 | |
where 子句尤其擅长表达关联类型约束:第三行约束 T::Err 必须实现 Debug,这种约束在内联写法里几乎无法书写。<T as FromStr>::Err 是完全限定语法,显式指明 Err 来自哪个 trait 的实现。
💡 提示:经验法则–bound 少于两个且简单时用内联;一旦出现关联类型约束或超过两个 bound,一律改用
where。统一用where也无妨,可读性只增不减。
impl Trait:bound 的语法糖
impl Trait 在两个位置出现,语义略有不同:
1 | |
参数位置的 impl Trait 是 bound 的纯语法糖,与泛型版本完全等价(仍是静态分发、单态化)。
返回位置的 impl Trait 是存在类型(existential):「我返回某个实现了 Iterator 的类型,但具体是哪个不告诉你」。它让函数能返回无法写出名字的类型–最典型的是闭包:
1 | |
⚠️ 注意:返回
impl Trait的函数只能返回单一具体类型。下面写法非法–两个分支返回了不同的迭代器类型:
1 | |
需要在分支间返回不同类型时,必须用 trait 对象(Box<dyn Trait>,见下节)或把它们统一成一个枚举。这是静态分发「单类型」限制的直接体现。
dyn 与 impl 的选择预告
impl Trait 是静态分发,dyn Trait 是动态分发。两者的取舍是本章后半段的主线,先给一张速查表,后文逐一展开:
| 维度 | impl Trait / 泛型 | dyn Trait |
|---|---|---|
| 分发时机 | 编译期单态化 | 运行时 vtable |
| 运行时开销 | 零 | 一次间接调用 |
| 能否异构集合 | 否 | 是 |
| 能否返回多类型 | 否 | 是 |
| 二进制体积 | 随实例数增长 | 一份代码 |
关联类型
关联类型把「trait 实现所对应的输出类型」与实现绑定,由实现方决定,而非调用方指定。它是 trait 表达「这个类型有一个相关联的类型」的机制。
1 | |
Self::Item 是关联类型的引用方式。实现 Container 时必须指定 type Item = ...,一旦指定,该类型对这个 trait 的实现就固定了–VecContainer<i32> 的 Item 永远是 i32。
关联类型 vs 泛型参数
这是 trait 设计中最高频的决策。同一个 trait,用关联类型还是泛型参数,决定了「谁能决定输出类型」与「能否多次实现」:
1 | |
两者的核心区别:
| 维度 | 泛型参数 Trait<U> | 关联类型 type Item |
|---|---|---|
| 谁决定输出类型 | 调用方 | 实现方 |
| 同一类型可实现次数 | 多次(每个 U 一次) | 仅一次 |
| 使用时写法 | Trait<T, U> 要带上参数 | Trait 即可,Item 已确定 |
| 典型例子 | From<T>、Add<Rhs> | Iterator::Item、Deref::Target |
flowchart LR
A["设计 trait"] --> B{"输出类型由谁决定?"}
B -->|"调用方/可多次实现"| C["泛型参数"]
B -->|"实现方/唯一确定"| D["关联类型"]
C --> E["例: From<T>, Add<Rhs>"]
D --> F["例: Iterator::Item, Deref::Target"]💡 提示:判断口诀–「同一类型是否需要多种实现」。需要多种(如
String能从i32、从bool转换)→ 泛型参数;只需唯一一种(如Vec的迭代元素类型)→ 关联类型。标准库的From/Into/Add用泛型,Iterator/Deref/AsRef的「目标」用关联类型,正合此律。
关联常量
关联常量把「与类型绑定的编译期常量」写进 trait,由实现方提供值:
1 | |
关联常量可以带默认值(const NAME: &str = "Anon";),实现方可覆盖。它常用于给类型打「配置标签」,如几何类型里的维度数、协议类型里的版本号。
静态分发与动态分发
泛型与 impl Trait 走静态分发:编译期单态化,每个具体类型一份代码,调用是直接跳转。dyn Trait 走动态分发:运行时通过虚表(vtable)间接调用,一份代码服务所有类型。这是 Rust 多态的核心权衡。
静态分发:单态化的得失
1 | |
draw_all::<Circle> 和 draw_all::<Square> 生成两份机器码,各自直接调用 Circle::draw / Square::draw。运行时零开销,但二进制里有两份 draw_all。优势是性能(无间接调用、可内联),代价是体积与编译时间。
动态分发:vtable 机制
dyn Trait 是一个动态大小类型(DST),无法直接存放在栈上–它的体积运行时才知。因此 dyn Trait 总以胖指针(fat pointer)形式出现,同时携带数据地址与虚表地址:
1 | |
&dyn Draw 在内存中是两个机器字:一个指向数据,一个指向 vtable。vtable 是一张函数指针表,记录 Draw 的方法在该具体类型上的实现地址。调用 item.draw() 时,程序从 vtable 取出对应槽位的函数指针,再带着数据指针跳转。
flowchart TB
subgraph FP["胖指针 &dyn Draw"]
D["data ptr"]
V["vtable ptr"]
end
subgraph HT["堆上的 Circle 实例"]
D2["Circle 字段..."]
end
subgraph VT["Circle 的 Draw vtable"]
S1["drop_in_place 指针"]
S2["size / align 元数据"]
S3["draw 函数指针"]
end
D --> D2
V --> VTBox<dyn Draw> 拥有堆上数据,其内部同样是「数据指针 + vtable 指针」的胖指针;Rc<dyn Draw>、Arc<dyn Draw> 同理。vtable 中除了 trait 方法指针,还存放析构函数(drop_in_place)与类型大小/对齐–这些是「释放盒、布局内存」所需的信息,因为调用方已不知道具体类型。
异构集合:动态分发的核心价值
静态分发的集合只能存同构类型(Vec<Circle> 不能装 Square)。动态分发让一个集合容纳不同类型成为可能:
1 | |
Box<dyn Draw> 把「类型信息」从编译期推迟到运行时,于是 Vec 里可以混装 Circle 与 Square。这是 GUI 框架、插件系统、解释器中「一组可替换实现」的惯用形态。
💡 提示:默认用泛型/
impl Trait(静态分发)–它更快、更利于编译器优化。只有当确实需要异构集合或返回多种类型、或想抑制二进制膨胀(某泛型被大量类型实例化)时,才转向dyn Trait。两者的边界就是「是否需要在运行时持有多个不同具体类型」。
🔄 对比:
dyn Trait的 vtable ≈ C++ 虚函数表,但 Rust 是显式选择(写dyn才动态),C++ 则按成员函数是否virtual决定;Go interface 值天然是动态分发(interface 值本身是胖指针),无需关键字。Rust 的设计让「分发策略」在类型签名上一目了然。
对象安全
对象安全是「trait 能否被做成 trait 对象」的约束。它源于一个根本矛盾:dyn Trait 在运行时已丢失具体类型信息,因此凡是要用到具体类型的能力,都不能出现在 trait 对象的方法里。
三条规则
一个 trait 要对象安全,其所有方法必须满足:
- 不能返回
Self。Self是具体类型,trait 对象不知道具体类型,无法构造它。 - 不能有泛型参数。vtable 是固定签名的函数指针表,无法为每个泛型实例化预留槽位。
- 第一个参数必须是接收器(
self/&self/&mut self/Box<Self>等),不能是fn foo(x: &T)这种关联函数。
此外,trait 自身不能有 Sized 作为超 trait(因为 dyn Trait 本身不是 Sized)。
1 | |
为什么是这些规则
每条规则都对应「trait 对象运行时缺失的信息」:
- 返回
Self要构造一个具体类型的值,但 vtable 里没有「具体类型是什么」的运行时反射(Rust 刻意不做运行时类型反射),无从构造。 - 泛型方法要在调用点单态化,每个
T生成一个调用入口;vtable 是定长表,无法装下「无穷多个T的入口」。 - 关联函数(无
self)无法通过dyn调用,因为没有self就无从找到对应的 vtable–vtable 是「附在数据上的」。
规避手法
当 trait 有不对象安全的方法,但又想用 trait 对象时,常见三种处理:
1 | |
第三种是把该 trait 拆成「对象安全」与「不安全」两个 trait,需要动态分发时只对前者造 dyn。
⚠️ 注意:对象安全是在使用点(写
dyn Trait时)检查的,不是在 trait 定义处报错。trait 本身可以包含不对象安全的方法,编译器只在你想把它做成dyn时才拒绝。这也意味着设计公共 trait 时若预期会被动态分发,应主动避免Self返回与泛型方法。
💡 提示:标准库里
Clone(返回Self)、Sized、PartialOrd(部分方法)不对象安全;Iterator、Read、Write、Display对象安全。Box<dyn Iterator<Item = i32>>、Box<dyn std::io::Read>是常见用法。
孤儿规则与 trait 工程实践
trait 系统的稳健性来自一条规则:孤儿规则(orphan rule)。它规定 impl Trait for Type 时,Trait 或 Type 至少有一个在当前 crate 定义。换言之,不能给外部类型实现外部 trait。
孤儿规则的意义
没有孤儿规则,两个 crate 可能各自给 Vec<T> 实现 MyTrait,链接到一起时实现冲突,无从裁决。孤儿规则把「实现权」分配给「定义了 trait 或类型的那个 crate」,从根上消除冲突:
1 | |
想给 Vec<String> 实现 Display,标准库的 Display 和 Vec 都不属于你,禁止。这正是 newtype 模式的用武之地(见下节)。
blanket 实现
当 trait 由你定义时,可以给「所有满足某 bound 的类型」一次性实现,称为 blanket impl:
1 | |
blanket impl 强大但需谨慎:它会把实现「蔓延」到所有满足 bound 的类型,包括未来用户自定义的类型,可能挤占用户自行实现的余地。标准库的 impl<T: IntoIterator> ... 之类广泛使用 blanket impl,但公共库定义 trait 时若非确有必要,避免随意 blanket。
sealed trait 模式
有时你想让 trait「只能被本 crate 的类型实现」,外部类型无法实现,但外部可以「使用」它。这通过 sealed trait 模式实现:把一个私有 marker trait 作为超 trait,外部无法实现私有 trait,于是也就无法实现你的 trait:
1 | |
外部类型想 impl Stable 必须先 impl sealed::Sealed,但 Sealed 是私有的,外部无法实现,于是 Stable 对外「关闭了实现、开放了使用」。标准库的 Unpin、TrustedLen 等都用此模式封装 unsafe 或不稳定契约。
newtype 模式
newtype 是 Rust 类型工程的基石模式:用一个单字段元组结构体把已有类型包一层,得到编译期全新的类型,而运行时零开销–底层内存布局与原类型完全一致,区别只存在于类型检查阶段。
零开销的语义区分
最直接的收益是让编译器区分「底层相同、语义不同」的值。两个函数都接收 u32,一个表示用户 ID、一个表示会话 ID,传反位置时编译器毫无办法:
1 | |
UserId 和 SessionId 底层都是 u32,运行时无任何额外开销,但类型系统把它们视为两个互不兼容的类型。这种「用类型挡住参数误用」的思路,是把运行时 bug 提前到编译期的最廉价手段。
🔄 对比:newtype ≈ Haskell 的
newtype,零开销语义完全一致;C++ 没有等价物(强 typedef 提案多年未通过),只能用继承或宏笨拙模拟;TypeScript 的 branded type(type UserId = string & { __brand: ... })是运行时无、编译期有的近似物,但 TS 的结构化类型系统让隔离不如 Rust 彻底。
绕过孤儿规则
newtype 是孤儿规则最常用的「绕法」。想给 Vec<String> 实现 Display 直接报错,但 Wrapper(Vec<String>) 是本 crate 的新类型,给它实现 Display 合法:
1 | |
用 Deref 透传原类型能力
newtype 默认不继承原类型的方法(它是新类型)。若想保留原类型的多数方法又只覆盖少数,可手动实现 Deref:
1 | |
⚠️ 注意:
Deref应仅用于「智能指针」语义(Box、Rc、String→str),不要把它当「继承」用。滥用Deref会让方法解析变得隐晦、并在类型转换链上产生意外。newtype 若只是想复用几个方法,显式写转发函数比Deref更清晰。
newtype 的进阶应用(类型状态机、带校验的构造器)见《最佳实践、性能与调试》。
类型别名
type 关键字创建类型别名–给已存在类型起个新名字,但不创建新类型,与 newtype 有本质区别:
1 | |
Kilometers 和 i32 可互换,编译器不区分。需要类型安全时 newtype 才是正解,type 仅用于可读性:
1 | |
💡 提示:判断用
type还是 newtype 的标准–若你希望两个类型互相赋值报错,用 newtype;若只是嫌全名太长想缩短,用type。标准库io::Result<T>、Box<dyn Error>的频繁出现,都靠type别名简化。
Never 类型 !
! 是「永不返回」的发散类型(never type)。它的核心特性是可强制转换为任意类型,因此 panic!、return、break、loop {} 等永不正常求值的表达式能与任意其他值类型统一:
1 | |
若无 ! 的「转为任意类型」能力,上例 match 的两个分支类型无法统一(i32 与 return),编译器会拒绝。! 让「控制流跳转」在类型层面表现为「任何类型都行」,分支类型一致得以保持。
发散函数显式标注返回 !,表示它不会正常返回:
1 | |
💡 提示:
!未来将稳定为完整类型,可用于表示「不可能发生的状态」。历史上常用enum Void {}(无变体枚举)模拟,!稳定后可直接用!,且!能自动满足任意 trait bound(因为它不可能有值,任何方法都不会被实际调用)。
Sized 与 ?Sized
Sized 是最重要的标记 trait:表示类型在编译期已知大小。所有泛型参数默认隐式绑定 T: Sized,因为绝大多数代码需要知道类型大小才能在栈上分配、计算偏移。
动态大小类型(DST)
并非所有类型大小都编译期可知。[T](切片)、str(字符串切片)、dyn Trait 都是动态大小类型(DST)–大小要到运行时、看了具体值才知道。DST 不能直接存放在栈上,必须以胖指针形式出现:
&[T]= 数据指针 + 长度&str= 数据指针 + 长度&dyn Trait= 数据指针 + vtable 指针
胖指针本身大小固定(两个机器字),于是「指向 DST 的指针」是 Sized 的,被指向的 DST 不是。
?Sized:放宽约束
?Sized 表示「类型可能不是 Sized」,即「允许 DST」。它用来写能接受 DST 的通用函数:
1 | |
bar 能接受 &str、&[u8]、&dyn Draw 这些指向 DST 的引用,而 foo 不能。?Sized 是「在约束上做减法」–移除默认的 Sized 要求,是少数几个 ? 约束之一。
💡 提示:标准库的
Box<T>、Rc<T>、Arc<T>、&T、&mut T都对T用了?Sized,所以它们能持有str、[T]、dyn Trait。日常写泛型几乎不需手动加?Sized,除非你在写「接受任意 DST 引用」的底层通用函数,如fn to_debug<T: ?Sized: Debug>(x: &T)。
递归类型与 Box
枚举或结构体若直接包含自身,大小无法在编译期确定–会无限嵌套下去。用 Box 把递归字段放到堆上,给出固定大小的指针即可打破:
1 | |
Box<List> 大小恒为指针大小(一个机器字),于是 List 的大小 = 变体判别字 + i32 + 指针,可计算。链表、树、AST 等递归数据结构都靠这种「指针断开」实现。
💡 提示:
Box在此的作用是提供固定大小的间接层,而非「堆分配」本身(尽管它确实分配在堆上)。任何固定大小的间接类型(Rc、Arc、裸指针+PhantomData)都能打破递归,Box因独占所有权与零开销而最常用。智能指针的完整对比见《智能指针、迭代器与闭包》。
型变(Variance)
型变描述「子类型关系如何沿泛型/引用传播」。它是借用检查器安全保证的数学基础,平时由编译器自动处理,但在写 unsafe 代码或设计含生命周期的 API 时必须理解。
Rust 的子类型:只有生命周期
多数语言的子类型是「类型间」的(Dog 是 Animal 的子类型)。Rust 几乎没有类型间子类型(除了 !),但生命周期存在子类型关系:活得更长的生命周期是活得更短的生命周期的子类型。即 'static 是任意 'a 的子类型('static: 'a),因为 'static 引用能安全替代 'a 引用。
三种型变
把一个「容器类型」F<T> 沿 T 的子类型关系传播时,有三种结果:
| 型变 | 含义 | Rust 示例 |
|---|---|---|
| 协变 | 子类型关系与 T 同向 | &'a T 对 'a 与 T 协变 |
| 逆变 | 子类型关系与 T 反向 | fn(T) -> R 对参数 T 逆变 |
| 不变 | 子类型关系不传播 | &mut T、Cell<T> 对 T 不变 |
1 | |
&'a T 协变意味着「需要 &'a str 的地方,给 &'static str 也行」–更长的总是安全的。这是「活得更久可以替代活得更短」的直接体现。
为什么 &mut T 必须不变
不变性的经典论证:若 &mut T 协变,就能把短生命周期引用写进「原本承诺长生命周期」的可变引用,制造悬空:
1 | |
不变性堵死了这条路径:&mut &'static str 与 &mut &'a str 被视为不兼容类型,*long = short 编译失败。同理,Cell<T>、UnsafeCell<T>、RefCell<T> 内部可变性也要求 T 不变–否则通过 Cell 写入短命值会破坏长命引用。
🔬 进阶:函数指针
fn(T) -> R对T逆变、对R协变,源于函数调用的「替换方向」–一个「接受更宽输入、返回更窄输出」的函数可安全替代「接受更窄、返回更宽」的。&'a T协变、&'a mut T对'a协变但对T不变,区分点正是「只读引用可放宽、可写引用不可放宽」。型变规则由编译器依据类型的逻辑结构自动推导,PhantomData可手动覆盖。
PhantomData
PhantomData<T> 是零大小的标记类型,告诉编译器「假装我拥有/使用了 T」,从而影响型变、drop 检查与生命周期约束,却不实际存储 T。它解决一个常见困境:类型参数 T 未出现在任何字段中,但需要让结构体「逻辑上」与 T 相关。
未使用类型参数的问题
1 | |
T 未被任何字段使用时,编译器会报「未使用类型参数」错误,因为这种类型对 T 的型变、drop 检查无从推导。PhantomData 是标准的修补手段:
1 | |
_marker 零大小,不占内存,但让 TaggedVec<T> 在类型系统里「拥有 T 的语义」。
PhantomData 的多种语义
PhantomData<X> 的类型参数 X 决定它传达的语义,远不止「标记一个 T」:
| 写法 | 传达语义 | 型变 | drop 检查 |
|---|---|---|---|
PhantomData<T> | 拥有 T | 协变 | 参与(可能 drop T) |
PhantomData<*const T> | 非拥有裸指针 | 不变 | 不参与 |
PhantomData<&'a T> | 借用 'a | 对 'a 协变 | 不参与 |
PhantomData<fn(T) -> T> | 函数式使用 | 逆变+协变 | 不参与 |
标准库内部广泛使用 PhantomData 精确表达所有权语义:
Box<T>用PhantomData<T>表达「拥有T、参与 drop 检查、对T协变」。Rc<T>用PhantomData<*const T>表达「非拥有但参与型变,且对T不变」(裸指针没有所有权语义,编译器保守选择不变,防止通过Rc制造型变漏洞)。std::iter::Map等用PhantomData<fn() -> T>表达「产出T但不拥有它」。
🔬 进阶:drop 检查(drop check)决定「当一个类型被 drop 时,它持有的引用是否仍需有效」。
PhantomData<T>让结构体参与对T的 drop 检查,意味着结构体可能在析构时访问T的引用,于是T的生命周期必须覆盖结构体。选择PhantomData<*const T>则退出 drop 检查,放宽生命周期约束–这是Rc、Arc等非拥有智能指针能持有任意生命周期的关键。PhantomData的实战应用(类型状态机)见《最佳实践、性能与调试》。
类型转换
Rust 不支持隐式类型转换–任何转换都通过 trait 显式完成,避免 C/C++ 那种静默截断、符号丢失、精度损失。这是「显式优于隐式」在类型层面的贯彻。
转换 trait 总览
| trait | 用途 | 失败时 |
|---|---|---|
From/Into | 不会失败的转换 | 不失败 |
TryFrom/TryInto | 可能失败的转换 | 返回 Result |
AsRef/AsMut | 廉价引用转换 &T → &U | 不失败 |
FromStr/ToString | 字符串 ↔ 类型 | FromStr 返回 Result |
as | 基本数值强制转换 | 可能截断(静默) |
1 | |
From/Into 与 TryFrom/TryInto
From 表示「不会失败的转换」,Into 是其反向(实现了 From 自动获得 Into)。惯例是实现 From,让调用方用 .into():
1 | |
可能失败的转换用 TryFrom/TryInto,返回 Result,把「截断/越界」显式化:
1 | |
as 转换:便利与陷阱
as 用于基本数值类型间的强制转换,可能静默截断:
1 | |
⚠️ 注意:
as转换不检查越界,截断静默发生。涉及「可能丢数据」的转换(宽→窄、浮→整)应优先try_into(),让失败显式可见。as适合「确知范围安全」的快速转换,如usize as u32在已知不超过u32::MAX的索引场景。
Deref 强制转换
除了显式转换 trait,Rust 还有**Deref 强制转换**(deref coercion):当某类型 T: Deref<Target=U> 时,&T 可自动转为 &U。这让 &String 能传给期望 &str 的函数、&Vec<T> 传给 &[T]、&Box<T> 传给 &T:
1 | |
Deref 强制转换是有限的、由类型系统保证安全的隐式转换,与「隐式数值转换」不同–它只发生在「明确实现了 Deref」的类型上,且只沿 Deref 链进行。Deref 的实现要点与陷阱见《Unsafe Rust 与常用 trait 详解》。
💡 提示:转换 trait 的选择口诀–确定不失败用
From/Into;可能失败用TryFrom/TryInto;只是「换个视角看同一份数据」用AsRef;字符串解析用FromStr。From在错误传播中的关键作用(?运算符依赖From转换错误类型)见《错误处理与 Panic 恢复》。
类型系统是 Rust 的「中央基础设施」:泛型与 trait 提供表达力,单态化与 vtable 决定执行模型,Sized/型变/PhantomData 守住安全底线,newtype 与转换 trait 桥接具体工程。后续章节几乎都在这一框架内展开–集合是泛型与 trait 的直接应用,错误处理依赖 From 与 ?,智能指针是 Deref/Drop 与 Box<dyn Trait> 的舞台,并发则建立在 Send/Sync 这两个标记 trait 之上。掌握本章,便掌握了读 Rust 标准库与第三方库源码的钥匙。
延伸阅读:Rust 标准 Trait 详解(标记/派生/操作/迭代/IO/错误 trait 的逐一签名与实现要点)。



















