Comprehensive Rust 课程精讲:组合优于继承(Composition over Inheritance)——用字段组合构建 Rust 类型体系
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
本篇文章取材于 Google Android 团队维护的开源 Rust 教学课程 Comprehensive Rust(本仓库即其课程源码)中 from-oop-to-rust/composition.md 一节,系统讲解"组合优于继承"这一 Rust 类型设计核心思想:不通过继承让子类获得父类的字段与方法,而是把其他类型的实例作为字段嵌入新类型。读完本文,你将理解为什么 Rust 舍弃继承、如何用结构体字段组合替代继承表达"is-a/has-a"关系、组合在字段访问可用性上的代价,以及派生 trait 时对组成字段类型的隐式要求,并能将其与 trait 多态、密封 trait、枚举等方案结合,写出清晰可控的 Rust API。
背景:为什么 Rust 没有继承
在深入组合模式之前,有必要先厘清 Rust 为何不提供继承。课程在 inheritance.md 中以 C++ 为例回顾了 OOP 中的继承:子类(如Car)通过public Vehicle继承获得父类的字段与方法(accelerate、brake),并可按需覆写方法、通过super调用父类实现。继承是 OOP 范式成功的关键,也是大量商业软件的核心组织方式(见章节引言 from-oop-to-rust.md)。
但 why-no-inheritance.md 明确列出了继承的三大缺点:
- 默认异构(Heterogeneous by default):类继承隐式允许不同类的实例互换使用,却无法指定具体类型或判断两个类型是否相同。在相等性与比较操作上,这可能导致抛错甚至 panic 的隐式比较。
- 数据结构的"多重事实来源":一个类型的字段被继承层级遮蔽,方法可能覆写父类或被子类覆写,在多团队维护的复杂代码库中难以判断类型的真实行为。
- 默认动态分派带来开销:动态分派需要在运行时保存"该调用哪个方法"的信息,即 vtable。方法调用比编译期已知类型的直接调用多出额外的解引用层级。
switch-perspective.md 则从 Rust 视角给出结论:类型是"具体数据 + 与之关联的行为",trait 是"必须由类型实现的抽象行为",而可继承的类看起来像是"既是类型又是 trait"。这种混合破坏了我们对具体类型的推理能力,也会模糊泛型行为与具体实现之间的界限。"字段扁平访问与类型定义 DRY 的便利,不值得用行为与数据的清晰划分来交换"——这正是组合思想出场的前提。
组合的核心模式:用字段拥有其他类型
课程 composition.md 给出的核心观点是:与其使用 mixin 或继承,不如通过"创建不同类型字段"来组合类型。原文示例:
pub struct Uuid([u8; 16]); pub struct Address { street: String, city_or_province: String, code: String, country: String, } pub struct User { id: Uuid, address: Address, }这个例子的要点:
Uuid是一个内嵌[u8; 16]数组的新类型(newtype),用于表示用户 ID;Address聚合了街道、城市/省份、邮编、国家四个String字段;User不再通过class User extends ...去继承,而是把Uuid与Address的实例作为字段直接拥有。
在 OOP 中,若要表达"用户有一个地址",通常要么把地址字段直接平铺在User里,要么通过继承体系强行建立不存在的层级关系;而 Rust 的组合把"用户拥有一个地址"这一关系显式化为address: Address字段。每个组成类型保持独立、可单独复用(例如订单、公司等类型同样可以持有Address),并且一个类型能做什么、能访问什么,完全由它的字段清单决定,没有任何隐藏的父类成员。这与 why-no-inheritance.md 批评的"字段被继承层级遮蔽"形成鲜明对比:组合类型的字段清单就是它的全部事实来源。
在此基础上可以继续为User添加自己的方法,形成"数据 + 关联行为"的完整类型:
impl User { fn new(id: Uuid, address: Address) -> Self { Self { id, address } } fn country(&self) -> &str { &self.address.country } }注意impl User { ... }只承载User自身的方法;对 trait 的实现应放在独立的impl Trait for User块中。这一约定与 why-no-inheritance.md 中的正例代码一致,它保证了"类型自身行为"与"trait 抽象行为"在代码组织上泾渭分明,便于阅读与推理。
组合的代价:字段访问的可用性折损
课程 composition.md 明确承认组合存在缺点——主要在字段访问的可用性(ergonomics)上。继承体系中,子类可以直接访问父类字段(扁平访问,flat field access);而在组合中,访问必须逐层穿透:
// 继承思路(示意,Rust 中并不存在): // user.street 即可直接访问 // 组合实际写法: let street = &user.address.street;这带来了略微冗长的访问路径,并且每当想为组合类型暴露底层字段时,往往需要额外编写访问方法(getter/setter)或实现Deref(需谨慎,Deref会带来隐式方法解析,可能掩盖类型边界)。这也是 switch-perspective.md 提到的"扁平字段访问便利"的具体所指。
作为补偿,组合带来的是更强的控制力与清晰度:
- 字段的可见性完全由你控制(
pub或私有),外部代码只能访问你允许暴露的接口; - 类型行为可预测:没有方法覆写、没有
super调用链,定位一个方法的实现只需要看当前类型及其字段类型; - 类型的字段列表即文档,读者一眼就能看出该类型"由什么构成、能访问什么"。
课程给出的判断是:这一代价是值得的,因为它换来了对"类型能做什么、能访问什么"的明确控制,以及跨团队维护复杂代码库时可推理的行为。
派生 trait 的组合约束:所有字段类型都必须实现该 trait
composition.md 还强调了一个极易踩坑的实操要点:
当派生(derive)trait 时,确保结构体的所有字段类型或枚举的所有变体类型都实现了该 trait。派生宏通常假设组成新类型的所有类型已经实现了该 trait。
Rust 的标准派生宏(如#[derive(Clone, Debug, PartialEq, Eq, Hash)])是对"字段级实现"的机械展开:为User派生Clone,等价于要求Uuid与Address都必须实现Clone;为Address派生Debug,等价于要求其四个String字段实现Debug(String本身已实现)。示例:
#[derive(Debug, Clone, PartialEq)] pub struct Uuid([u8; 16]); #[derive(Debug, Clone, PartialEq)] pub struct Address { street: String, city_or_province: String, code: String, country: String, } // 只有当 Uuid 与 Address 都实现了 Clone 时,下面这行才能编译通过: #[derive(Debug, Clone, PartialEq)] pub struct User { id: Uuid, address: Address, }若某个组成字段类型(例如第三方 crate 中的类型)未实现Clone,则#[derive(Clone)]会在编译期报错——派生宏不会、也无法替你实现底层字段的 trait。这既是约束也是保证:组合类型的能力完全由字段类型的能力决定,任何缺失都会在编译期暴露,而非运行时崩溃。设计组合类型时,应先把组成类型的 trait 实现规划清楚(必要时为新类型手写impl,参见课程新类型模式 newtype-pattern 中对封装与语义隔离的讨论)。
组合与 trait 的协同:多态行为另起炉灶
组合解决的是"数据如何组织"的问题;而"行为如何多态"由 Rust 的 trait 体系承担。课程 composition.md 所在章节(from-oop-to-rust 及其子章节)给出了完整的配套方案:
- 面向接口编程(trait):定义最小可用行为的 trait,如 problem-solving.md 中的
DrawApi/Draw例子,用泛型约束fn draw<T: DrawApi>(&self, surface: &mut T)表达"任何能画的东西"。组合类型Rect与 trait 实现互不干扰。 - Supertrait(近似"继承"的机制):如 supertraits.md 所示,
pub trait Trait: SuperTrait {}表面上看像继承,但它只约束行为不继承字段——trait 不暴露字段,只暴露方法与关联类型/常量,从而保持了"数据与行为分离、行为易于推理"的特性,也让"多重继承"变成简单的多重 trait 约束。 - 可扩展 vs 密封的多态:需要允许下游用户扩展行为时,公开 trait 让用户为自己的类型实现(见 sticking-with-traits.md);需要限制实现者集合(如密码学等高危领域)时,用"密封 trait"(supertrait 放在私有模块,见 sealed-traits.md)或枚举(见 sealing-with-enums.md)。
于是完整的范式转换是:用组合(字段)替代继承组织数据,用 trait 替代继承表达多态。数据拥有关系与行为抽象被彻底分开,这正是 switch-perspective.md 强调的"Rust 视角下的清晰分工"。
选型指南:何时用组合,何时用枚举或 trait
problem-solving.md 给出了从 OOP 思维迁移到 Rust 的实用决策顺序,可作为组合思想的落地指南:
- 先用枚举或"泛型 + trait"思考:问题是否只涉及一组确定的类型?是,则枚举(代数数据类型)往往最干净——每个变体可以携带不同结构(如 sealing-with-enums.md 中
GetSource::WebUrl(String)与GetSource::BytesMap(BTreeMap<...>)携带不同数据);问题是否只关心行为而不关心具体类型?是,则聚焦 trait。 - 寻找最小可用知识量:为解决问题,类型需要知道的最少信息是什么?是否已有现成 trait 可用?
- 确实需要异构集合时才用 trait 对象(动态分派):警惕 XY 问题——某个方案看似最直接,却可能没有触及根因,反而埋下新问题。在使用动态分派或 trait 之前,务必确认这正是你所需要的。
在此框架中,组合(字段嵌入)是构建具体数据类型的基础手段,它回答"这个类型由什么构成";枚举回答"合法取值有哪些";trait 回答"能做什么";trait 对象/泛型回答"如何多态"。组合与后三者正交互补——User既可以由组合字段构成,也可以实现Draw、Named等 trait 参与多态。
小结
Comprehensive Rust 课程 composition.md 传递的核心结论可归纳为:
- 用组合替代继承:把其他类型的实例作为字段嵌入新类型,类型的能力与可见范围完全由字段清单决定;
- 接受字段访问的可用性折损:扁平访问的便利,换取的是对类型行为的完全控制与清晰推理;
- 派生 trait 前检查字段类型:派生宏假设组成类型的每个字段类型都已实现该 trait,缺失会在编译期报错;
- 数据与行为分离:组合负责数据组织,trait 体系(含 supertrait、密封 trait、枚举)负责行为抽象与多态,二者结合可覆盖绝大多数从 OOP 迁移过来的设计问题。
如果你正在把 Java/C++ 的继承式设计迁移到 Rust,建议按本课程顺序通读 from-oop-to-rust 全章节(含动态分派 dynamic-dispatch 等子页),并结合课程其他模块如 泛型与 trait、trait 对象 深化理解;整套课程源码位于本仓库src/目录,book.toml配置了 mdBook 构建方式,可自行编译成网页版讲义进行学习。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考