Rust 经常被说成”不是面向对象语言”。如果把面向对象等同于 class、继承层级、虚函数表、new 对象,那这个说法没错:Rust 没有 class,也不鼓励用继承树组织代码。
但如果把面向对象理解成三件事:
- 用数据结构表达领域对象。
- 把行为和数据关联起来。
- 通过接口和多态降低模块之间的耦合。
那么 Rust 其实有一套很完整的面向对象能力,只是它的核心不是”类”,而是 struct + impl + trait + enum + ownership。
这篇文章我会从 C++ 背景出发,把 Rust 的面向对象写法、继承和多态的替代方案,以及大型工程里比较常见的组织方式串起来。
1. Rust 的对象模型:没有 class,但有类型和方法
在 C++ 里,class 通常同时承担三件事:
- 定义对象内部有哪些字段。
- 定义对象上有哪些方法。
- 定义继承、虚函数、多态等类型关系。
Rust 把这些职责拆开了:
| 需求 | C++ 常见写法 | Rust 常见写法 |
|---|---|---|
| 定义数据 | class / struct 的字段 | struct / enum |
| 给类型加方法 | class 里的 member function | impl Type |
| 定义接口 | abstract class / pure virtual function | trait |
| 静态多态 | template | generics + trait bound |
| 动态多态 | base pointer / virtual function | dyn Trait |
| 复用实现 | inheritance | composition + default method |
| 表达有限种变体 | class hierarchy / tagged union | enum |
可以画成下面这样:
Rust 的思路很明确:数据结构、方法实现、抽象接口是三块独立拼装的东西。这让代码组织比传统 class 更分散一点,但也减少了”继承层级越来越深”带来的耦合。
2. struct:定义对象的数据
最接近 C++ class 的第一块,是 struct。
pub struct User { id: u64, name: String, email: String, active: bool,}这段代码只定义了数据。注意字段默认是私有的,即使 User 本身是 pub,外部模块也不能直接访问 id、name 这些字段。
Rust 的可见性不是只有”公开 / 私有”两档。常见写法有这几种:
| 写法 | 含义 |
|---|---|
不写 pub | 只在当前模块和子模块内可见,也就是默认私有 |
pub | 对外公开,只要能访问到这个模块路径就能使用 |
pub(crate) | 在当前 crate 内公开,对 crate 外隐藏 |
pub(super) | 对父模块公开 |
pub(in crate::some_mod) | 只在指定的祖先模块路径内公开 |
pub(self) | 等价于私有,较少显式这样写 |
例如 pub(crate) 很适合大型工程里的内部类型:同一个 crate 的多个模块可以共享,但不会暴露成库的外部 API。
pub struct User { pub(crate) id: u64, // crate 内可见 name: String, // 当前模块内可见 email: String, active: bool,}这点和 C++ 里的 class 默认 private 有点像,但 Rust 更常见的做法是:
- 类型本身公开:
pub struct User - 字段保持私有
- 通过构造函数和方法维护不变量
例如:
pub struct User { id: u64, name: String, email: String, active: bool,}
impl User { pub fn new(id: u64, name: String, email: String) -> Self { Self { id, name, email, active: true, } }
pub fn deactivate(&mut self) { self.active = false; }
pub fn is_active(&self) -> bool { self.active }
pub fn email(&self) -> &str { &self.email }}这里的 impl User 就是在给 User 添加方法。
几个细节值得注意:
Self表示当前实现块对应的类型,也就是User。new不是语言内置构造函数,只是一个普通的关联函数。Rust 社区习惯把最主要、最普通的构造函数命名为new。- 方法第一个参数如果是
self/&self/&mut self,就可以用点号调用。
调用方式:
let mut user = User::new( 1, String::from("Alice"), String::from("alice@example.com"),);
user.deactivate();println!("{}", user.is_active());更准确地说,impl User 里可以定义两类函数。
第一类是关联函数(associated function),没有 self 参数,调用时用 User::xxx(...)。new 就是关联函数:
impl User { pub fn new(id: u64, name: String, email: String) -> Self { Self { id, name, email, active: true, } }}
let user = User::new(1, "Alice".into(), "alice@example.com".into());第二类是方法(method),第一个参数是 self、&self 或 &mut self,调用时用点号:
impl User { pub fn deactivate(&mut self) { self.active = false; }}
let mut user = User::new(1, "Alice".into(), "alice@example.com".into());user.deactivate();所以,Rust 构造对象并不是只能用 new。常见构造方式大概有这些:
// 1. 字段可见时,可以直接用结构体字面量let user = User { id: 1, name: "Alice".into(), email: "alice@example.com".into(), active: true,};
// 2. 最普通的构造函数,通常叫 newlet user = User::new(1, "Alice".into(), "alice@example.com".into());
// 3. 可能失败的构造函数,通常叫 try_new / parse / from_xxxlet email = Email::parse("alice@example.com".to_string())?;
// 4. 从已有类型转换,可以实现 From / TryFromlet user_id = UserId::from(1_u64);
// 5. 有合理默认值时,可以实现 Defaultlet config = Config::default();命名上也有一些社区习惯:
| 名字 | 常见含义 |
|---|---|
new | 普通构造,通常不失败 |
try_new | 构造时要校验,可能返回 Result |
parse | 从字符串解析 |
from_xxx | 从某种输入创建对象 |
with_xxx | 创建时指定某个配置 |
default | 使用默认值,来自 Default trait |
如果字段是私有的,外部模块就不能直接写结构体字面量,只能通过你暴露的构造函数创建对象。这正是封装的意义:外部代码拿不到随便构造非法状态的机会。
这里和 C++ 有一个很大的不同:Rust 没有固定语法意义上的构造函数。
C++ 里构造函数和类同名,不能写返回值,语言会在对象初始化时自动调用它:
class User {public: User(int id, std::string name);};
User user(1, "Alice"); // 调用 User::User(...)Rust 没有这种”和类型同名的特殊函数”。User::new(...) 只是一个返回 User 的普通关联函数,名字叫 new 完全是社区约定。你也可以叫 create、from_parts、with_email,只要返回 Self 或 Result<Self, Error> 就可以承担构造职责。
impl User { pub fn from_parts(id: u64, name: String, email: String) -> Self { Self { id, name, email, active: true, } }
pub fn try_new(id: u64, name: String, email: String) -> Result<Self, UserError> { if !email.contains('@') { return Err(UserError::InvalidEmail); }
Ok(Self { id, name, email, active: true, }) }}析构也类似:Rust 没有 C++ 那种 ~User() 语法。需要自定义清理逻辑时,实现标准库里的 Drop trait:
impl Drop for User { fn drop(&mut self) { println!("drop user: {}", self.name); }}这里的 impl User 和 impl Drop for User 是两种不同的 impl。
impl User 叫固有实现(inherent impl)。它是在 User 这个类型自己身上定义函数和方法:
impl User { pub fn new(id: u64, name: String, email: String) -> Self { // ... }
pub fn email(&self) -> &str { &self.email }}这些函数属于 User 本身,所以可以写 User::new(...) 或 user.email()。
impl Drop for User 叫trait 实现(trait impl)。它的意思是:为 User 这个类型实现标准库定义好的 Drop 这个 trait。
impl Drop for User { fn drop(&mut self) { // ... }}这里的 for User 可以读成”给 User 实现 Drop”。类似地,后面会看到:
impl Draw for Button { fn draw(&self) { // ... }}也就是”给 Button 实现 Draw 这个能力”。
那么 Drop 到底是什么?它是标准库里的一个 trait,大致可以理解成:
pub trait Drop { fn drop(&mut self);}真实定义就在标准库里,用户只需要实现里面的 drop 方法。Rust 编译器会在值离开作用域时自动调用这个方法,用来释放文件句柄、网络连接、锁、堆内存等资源。普通字段的释放不需要你手写,Rust 会自动递归 drop 每个字段;只有当类型有额外清理逻辑时,才需要实现 Drop。
当 User 离开作用域时,Rust 会自动调用 drop。这点和 C++ RAII 的”离开作用域自动析构”很像,但写法和规则不同:
| 主题 | C++ | Rust |
|---|---|---|
| 构造函数 | 和类同名的特殊成员函数 | 没有特殊构造函数;用结构体字面量或普通关联函数 |
| 常见构造命名 | User(...) | new、try_new、from_xxx、parse 等约定 |
| 析构函数 | ~User() | 实现 Drop::drop |
| 手动调用析构 | 可以显式调用析构函数,但通常不推荐 | 不能直接调用 Drop::drop;可以用 std::mem::drop(value) 提前消费并释放 |
| 初始化失败 | 构造函数里抛异常,或用工厂函数返回错误 | 通常用 Result<Self, Error> |
从 C++ 视角看,impl 有点像把成员函数定义从 class 声明里拆了出来。但它不只是语法差异:Rust 允许你为同一个类型写多个 impl 块,把普通方法、构造方法、trait 实现分开组织。
impl User { pub fn new(...) -> Self { // ... }}
impl User { pub fn deactivate(&mut self) { // ... }}大型工程里经常会利用这一点,把不同关注点拆开,例如一个 impl 负责构造,一个 impl 负责状态变化,一个 impl 实现序列化相关 trait。
3. 方法接收者:self、&self、&mut self
Rust 方法最重要的语法点,是 receiver,也就是方法的第一个参数。
impl User { pub fn id(&self) -> u64 { self.id }
pub fn rename(&mut self, name: String) { self.name = name; }
pub fn into_email(self) -> String { self.email }}三种写法代表三种所有权关系:
| receiver | 含义 | C++ 类比 |
|---|---|---|
&self | 只读借用对象 | const T& this 的感觉 |
&mut self | 可变借用对象 | T& this 的感觉 |
self | 消费整个对象,拿走所有权 | 按值传入,并且原对象不能再用 |
into_email(self) 这种方法在 Rust 里很常见。方法名用 into_... 通常暗示它会消费对象,把内部资源转出去。
let user = User::new(1, "Alice".into(), "alice@example.com".into());let email = user.into_email();
// println!("{}", user.is_active());// ❌ 编译错误:user 已经被 into_email 消费这和 Rust 的所有权系统是一体的:对象方法不只是”能不能改字段”,还会明确表达”这个方法会不会拿走对象”。
4. trait:Rust 的接口
Rust 里没有 abstract class。对应的核心概念是 trait。
pub trait Draw { fn draw(&self);}trait 描述某种能力:只要一个类型实现了 Draw,它就可以被当成”能绘制的东西”。
pub struct Button { label: String,}
pub struct TextBox { value: String,}
impl Draw for Button { fn draw(&self) { println!("draw button: {}", self.label); }}
impl Draw for TextBox { fn draw(&self) { println!("draw textbox: {}", self.value); }}这很像 C++ 的纯虚接口:
class Draw {public: virtual void draw() const = 0; virtual ~Draw() = default;};但 Rust 的 trait 和 C++ abstract class 有几个关键差异:
- trait 不能直接保存字段,它只描述行为。
- 一个类型可以实现多个 trait,不需要多继承。
- trait 可以在类型定义之后、甚至不同模块里实现,但要遵守 orphan rule:通常要求 trait 或被实现的类型至少有一个定义在当前 crate。
- trait 可以用于静态多态,也可以用于动态多态。
这让 trait 更接近”能力接口”,而不是”父类”。
如果你接触过 Go,这里会感觉有点熟悉。Go 的 interface 也是在描述”一个类型具备什么方法”,而不是要求类型继承某个父类。
Go 里可以这样写:
type Draw interface { Draw()}
type Button struct { Label string}
func (b Button) Draw() { fmt.Println("draw button:", b.Label)}Button 没有显式写”implements Draw”。只要它有 Draw() 方法,Go 编译器就认为它满足 Draw interface。这叫隐式实现。
Rust 的 trait 和 Go interface 在思想上相似:都把抽象放在”行为集合”上,而不是放在继承树上。但它们也有几个明显区别:
| 主题 | Go interface | Rust trait |
|---|---|---|
| 实现方式 | 隐式实现:方法集合匹配即可 | 显式实现:必须写 impl Trait for Type |
| 方法来源 | 方法直接定义在类型上,interface 只是匹配 | trait 方法来自某个明确的 trait 实现 |
| 静态多态 | Go 1.18 后有泛型,但传统 interface 主要是动态分发 | T: Trait 是常用静态分发方式 |
| 动态多态 | interface 值天然是动态分发 | 需要显式写 dyn Trait |
| 接口约束 | 主要约束方法集合 | 可以有默认方法、关联类型、supertrait 等更强表达能力 |
| 类型关系可见性 | 看方法集合才能知道是否满足 interface | 看到 impl Draw for Button 就知道关系存在 |
所以,如果从 Go 背景看 Rust,trait 可以粗略理解成”显式版、更强类型系统版的 interface”。Rust 选择显式 impl,代价是多写一点代码,收益是类型和接口之间的关系更清楚,也更方便编译器做一致性检查。
5. trait bound:静态多态
先看静态多态。假设我们要渲染一个元素:
fn render<T: Draw>(item: &T) { item.draw();}或者写成 where:
fn render<T>(item: &T)where T: Draw,{ item.draw();}这表示:render 可以接收任何实现了 Draw 的类型。编译器会在编译期为具体类型生成对应代码,这叫 monomorphization。
let button = Button { label: "Save".into(),};
let textbox = TextBox { value: "hello".into(),};
render(&button);render(&textbox);从 C++ 角度看,这更像 template + concept:
template <typename T>void render(const T& item) { item.draw();}只不过 Rust 的 trait bound 是显式写出来的,编译器知道 T 至少有什么方法,因此错误信息和接口约束通常更清楚。
静态多态的特点:
- 优点:没有虚函数调用开销,编译器更容易内联。
- 优点:类型信息完整,适合性能敏感代码。
- 缺点:不同具体类型不能直接放进同一个
Vec<T>。 - 缺点:泛型展开可能增加编译时间和二进制体积。
6. dyn Trait:动态多态
如果我们想把不同类型放进同一个列表,静态泛型就不够了。比如一个 UI 界面里既有 Button,也有 TextBox:
pub struct Screen { components: Vec<Box<dyn Draw>>,}
impl Screen { pub fn run(&self) { for component in &self.components { component.draw(); } }}dyn Draw 表示一个 trait object。它的含义是:具体类型在编译期不固定,只保证运行时这个对象支持 Draw。
为什么要写成 Box<dyn Draw>,而不是直接写 Vec<dyn Draw>?因为不同具体类型大小不同:
Button可能有一个StringTextBox可能有多个字段- 未来的
ImageView可能更大
Vec<T> 要求每个元素大小相同。dyn Draw 本身大小不固定,所以通常要放在指针后面,例如:
Box<dyn Draw>:拥有对象,放在堆上。&dyn Draw:只借用对象。Arc<dyn Draw + Send + Sync>:多线程共享对象。
动态分发大致可以这样理解:
dyn Trait 的特点:
- 优点:可以把不同具体类型收进同一个容器。
- 优点:调用方不需要知道具体类型,适合插件式、运行期组合的场景。
- 缺点:通过 vtable 间接调用,通常不能像静态泛型那样内联。
- 缺点:trait 要满足 object safety 才能变成
dyn Trait。
实际工程里,一个简单判断是:
- 如果类型集合在编译期明确、追求性能,优先用泛型
T: Trait。 - 如果要在运行时组合不同实现,或者需要异构容器,用
Box<dyn Trait>/Arc<dyn Trait>。
7. 继承:Rust 不提供 class inheritance
Rust 没有 C++ 那种:
class Dog : public Animal {public: void speak() override;};原因不是 Rust 做不到类似效果,而是 Rust 刻意不把”代码复用”和”类型层级”绑在一起。
在 C++ 里,继承同时表达几种不同关系:
- is-a:
Dog是一种Animal。 - 代码复用:子类复用父类字段和方法。
- 动态分发:通过
Animal*调用虚函数。 - 访问控制:
protected字段给子类用。
这几种关系混在一起后,很容易形成深层继承树。越到后期,问题越明显:
- 父类改动会影响很多子类。
- 子类为了复用一点代码,被迫进入某个类型层级。
- 虚函数覆盖关系不直观。
- 构造、析构、所有权边界容易复杂化。
Rust 把这些需求拆成不同工具:
| C++ 继承里的需求 | Rust 更常见的替代 |
|---|---|
| 表达共有行为 | trait |
| 复用代码 | composition / helper function / default method |
| 运行期多态 | dyn Trait |
| 编译期多态 | generics + trait bound |
| 共享字段 | 把公共字段组合进结构体 |
| 有限变体建模 | enum |
也就是说,Rust 不是”没有 OOP”,而是拒绝把 OOP 建成一棵继承树。
8. 组合优于继承:把共享状态放进字段
假设我们要建模不同 UI 组件,它们都有位置和尺寸。
C++ 里可能会写:
class Widget {protected: int x; int y; int width; int height;};
class Button : public Widget { std::string label;};Rust 更常见的写法是组合:
pub struct Rect { x: i32, y: i32, width: u32, height: u32,}
pub struct Button { bounds: Rect, label: String,}
pub struct TextBox { bounds: Rect, value: String,}如果很多类型都需要暴露 bounds 行为,可以加一个 trait:
pub trait HasBounds { fn bounds(&self) -> &Rect;}
impl HasBounds for Button { fn bounds(&self) -> &Rect { &self.bounds }}
impl HasBounds for TextBox { fn bounds(&self) -> &Rect { &self.bounds }}共享逻辑可以写成普通函数:
pub fn hit_test<T: HasBounds>(item: &T, x: i32, y: i32) -> bool { let rect = item.bounds();
x >= rect.x && y >= rect.y && x < rect.x + rect.width as i32 && y < rect.y + rect.height as i32}这比继承稍微啰嗦一点,但边界更清楚:Button 不是”继承了 Widget”,而是”拥有一个 Rect,并实现了 HasBounds 能力”。
9. trait 的默认方法:复用行为,但不复用状态
trait 可以提供默认方法:
pub trait Summary { fn title(&self) -> &str; fn author(&self) -> &str;
fn summarize(&self) -> String { format!("{} by {}", self.title(), self.author()) }}实现者只要提供 title 和 author:
pub struct Article { title: String, author: String,}
impl Summary for Article { fn title(&self) -> &str { &self.title }
fn author(&self) -> &str { &self.author }}然后就能使用默认的 summarize:
let article = Article { title: "Rust OOP".into(), author: "Alice".into(),};
println!("{}", article.summarize());这有点像 C++ 抽象基类里提供非纯虚成员函数。但区别仍然很重要:trait 默认方法不能访问某个父类字段,因为 trait 没有字段。它只能依赖 trait 要求的方法。
也就是说,Rust 可以复用行为模板,但不会偷偷引入一份共享状态。
10. enum:很多时候比继承树更直接
假设我们要建模表达式 AST:
1 + x * 2C++ OOP 可能会设计成:
class Expr {public: virtual int eval(Context&) const = 0;};
class Literal : public Expr { ... };class Variable : public Expr { ... };class Add : public Expr { ... };class Mul : public Expr { ... };Rust 里更自然的方式通常是 enum:
pub enum Expr { Literal(i64), Variable(String), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>),}
impl Expr { pub fn eval(&self, ctx: &Context) -> i64 { match self { Expr::Literal(value) => *value, Expr::Variable(name) => ctx.get(name), Expr::Add(lhs, rhs) => lhs.eval(ctx) + rhs.eval(ctx), Expr::Mul(lhs, rhs) => lhs.eval(ctx) * rhs.eval(ctx), } }}enum 的优势是:所有变体在一个地方列出来,match 必须覆盖完整。以后新增一种表达式,例如 Sub,编译器会提醒你哪些地方没有处理。
什么时候用 enum,什么时候用 trait?
| 场景 | 更适合 |
|---|---|
| 变体集合封闭,例如 AST、状态机、协议消息 | enum |
| 未来可能由外部模块新增实现 | trait |
| 需要穷举处理每一种情况 | enum + match |
| 只关心某种能力,不关心具体类型 | trait |
这个区别在大型工程里很重要。enum 适合 closed world,trait 适合 open world。
11. supertrait:接口之间的约束
trait 可以要求实现者同时实现另一个 trait,这叫 supertrait。
pub trait Shape { fn area(&self) -> f64;}
pub trait Drawable: Shape { fn draw(&self);}这里 Drawable: Shape 的意思不是 Drawable 继承了 Shape 的字段,而是:任何实现 Drawable 的类型,也必须实现 Shape。
pub struct Circle { radius: f64,}
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }}
impl Drawable for Circle { fn draw(&self) { println!("draw circle, area = {}", self.area()); }}这能表达接口依赖关系,但仍然不是 class inheritance。它没有父类字段,也没有构造顺序问题。
12. 关联类型和泛型 trait:更精确地描述接口
Rust 的 trait 不只是”一组方法”。它还可以包含关联类型:
pub trait Repository { type Item; type Error;
fn find(&self, id: u64) -> Result<Option<Self::Item>, Self::Error>; fn save(&self, item: Self::Item) -> Result<(), Self::Error>;}实现时指定具体类型:
pub struct UserRepository { // db pool, config, logger...}
impl Repository for UserRepository { type Item = User; type Error = RepositoryError;
fn find(&self, id: u64) -> Result<Option<User>, RepositoryError> { // 这里省略具体的数据库查询代码 Ok(None) }
fn save(&self, item: User) -> Result<(), RepositoryError> { // 这里省略具体的数据库写入代码 Ok(()) }}关联类型让接口表达得更紧。调用方可以写:
pub fn load_user<R>(repo: &R, id: u64) -> Result<Option<User>, R::Error>where R: Repository<Item = User>,{ repo.find(id)}这类写法在库设计里很常见:trait 不只描述”有什么方法”,还描述”这些方法围绕什么类型工作”。
13. 大型工程里的代码组织:按边界拆模块
Rust 的模块系统由 mod、pub、use 和 crate 组成。做大型工程时,最重要的不是把每个 struct 都拆成一个文件,而是先想清楚边界:
- 哪些类型是外部 API?
- 哪些实现细节应该隐藏?
- 哪些 trait 是跨模块的抽象边界?
- 哪些依赖应该从具体实现反转成接口?
一个常见组织方式是:
src/ lib.rs domain/ mod.rs user.rs article.rs application/ mod.rs publish_article.rs infrastructure/ mod.rs postgres_user_repository.rs web/ mod.rs handlers.rs对应的依赖方向可以画成:
这里的关键不是目录名字,而是依赖方向:
domain放核心业务类型和规则,尽量不依赖外部框架。application编排用例,依赖 domain,并通过 trait 依赖抽象接口。infrastructure实现数据库、HTTP client、文件系统等外部细节。web/ CLI / worker 这类入口层负责把请求转成 application 调用。
用 trait 做依赖倒置时,可以这样写:
pub trait UserRepository { fn find(&self, id: UserId) -> Result<Option<User>, RepositoryError>; fn save(&self, user: &User) -> Result<(), RepositoryError>;}
pub struct RegisterUser<R> { repo: R,}
impl<R> RegisterUser<R>where R: UserRepository,{ pub fn new(repo: R) -> Self { Self { repo } }
pub fn execute(&self, command: RegisterUserCommand) -> Result<UserId, RegisterError> { let user = User::new(command.name, command.email)?; let id = user.id();
self.repo.save(&user)?; Ok(id) }}这属于静态分发:RegisterUser<PostgresUserRepository> 和 RegisterUser<InMemoryUserRepository> 是不同的具体类型。
如果你希望在运行期选择实现,或者把 service 放进统一容器,可以用动态分发:
pub struct RegisterUser { repo: Box<dyn UserRepository>,}
impl RegisterUser { pub fn new(repo: Box<dyn UserRepository>) -> Self { Self { repo } }
pub fn execute(&self, command: RegisterUserCommand) -> Result<UserId, RegisterError> { let user = User::new(command.name, command.email)?; let id = user.id();
self.repo.save(&user)?; Ok(id) }}两种写法都合理。工程上更常见的取舍是:
- 库代码、性能敏感路径、简单依赖注入:优先泛型。
- 应用层 service、插件系统、运行期配置切换:可以接受
dyn Trait。
14. Rust OOP 的几个实践建议
第一,不要急着为每个名词写 trait。
trait 是抽象边界,不是给每个类型都配一个接口的机械套路。如果当前只有一个实现,而且未来也看不到第二个实现,直接用具体类型通常更简单。过早抽象会让类型签名变长,也会增加维护成本。
第二,优先用私有字段维护不变量。
pub struct Email(String);
impl Email { pub fn parse(value: String) -> Result<Self, EmailError> { if value.contains('@') { Ok(Self(value)) } else { Err(EmailError::InvalidFormat) } }
pub fn as_str(&self) -> &str { &self.0 }}这种 newtype 写法很适合表达领域约束。相比到处传 String,Email 这个类型本身就携带了”已经验证过”的语义。
第三,closed world 用 enum,open world 用 trait。
如果一组类型天然封闭,例如订单状态、解析器 token、协议消息,用 enum 往往比 trait hierarchy 更清楚。如果未来希望别人新增实现,例如 storage backend、render backend、serializer,用 trait 更合适。
第四,组合共享数据,trait 共享行为约束。
不要为了复用几个字段模拟继承。把公共字段抽成一个结构体,再组合进去。需要对外暴露能力时,再加 trait。
第五,公开 API 要克制使用泛型。
泛型很强,但签名会变复杂。对外库 API 如果到处都是长长的 trait bound,使用者会有负担。可以把泛型留在内部,把公开函数设计得更直接。
第六,错误类型也是接口设计的一部分。
在大型工程里,trait 方法返回什么错误类型会影响模块耦合。可以用关联类型保留灵活性,也可以在应用边界统一成自己的错误枚举。
pub trait PaymentGateway { type Error;
fn charge(&self, request: ChargeRequest) -> Result<ChargeId, Self::Error>;}第七,需要共享所有权时再引入 Rc / Arc。
OOP 背景的人容易把对象引用到处传。Rust 会迫使你想清楚:谁拥有对象?谁只是借用?谁需要共享?大型工程里,这反而能减少生命周期混乱。
15. 和 C++ 的总对比
最后把关键差异收束成一张表:
| 主题 | C++ | Rust |
|---|---|---|
| 核心对象单位 | class / struct | struct / enum |
| 方法定义 | class 内或类外定义 | impl Type |
| 接口 | abstract class / pure virtual | trait |
| 继承 | 支持单继承、多继承 | 不支持 class inheritance |
| 代码复用 | 继承、模板、组合 | 组合、默认方法、泛型函数 |
| 动态多态 | virtual function | dyn Trait |
| 静态多态 | template | generics + trait bound |
| 运行期对象布局 | 对象 + 可选 vptr | 具体类型;dyn Trait 通过 fat pointer |
| 资源管理 | RAII,但规则靠程序员和类型设计 | ownership + borrow checker + Drop |
| 类型层级建模 | class hierarchy 很常见 | enum / trait / composition 分场景选择 |
如果用一句话总结:
C++ 的 OOP 以 class 和 inheritance 为中心;Rust 的 OOP 以 type、trait、ownership 和 composition 为中心。
16. 小结
Rust 不是传统的 class-based OOP 语言,但它完全可以表达面向对象里的核心思想。
它的风格更接近:
- 用
struct定义对象状态。 - 用
impl绑定对象行为。 - 用
trait描述跨类型能力。 - 用泛型做零成本静态多态。
- 用
dyn Trait做运行期动态多态。 - 用
enum表达封闭变体。 - 用组合取代继承,把所有权边界写清楚。
对 C++ 程序员来说,最需要转变的不是语法,而是设计习惯:少想”这个类应该继承谁”,多想”这个类型拥有什么数据、暴露什么行为、依赖什么抽象、所有权边界在哪里”。
一旦按这个方向组织代码,Rust 的面向对象反而会显得很直接:没有深层继承树,没有隐式共享字段,也没有随处飘的裸指针;取而代之的是清晰的类型、接口和所有权关系。