6556 字
33 分钟
Rust学习笔记:面向对象

Rust 经常被说成”不是面向对象语言”。如果把面向对象等同于 class、继承层级、虚函数表、new 对象,那这个说法没错:Rust 没有 class,也不鼓励用继承树组织代码。

但如果把面向对象理解成三件事:

  1. 用数据结构表达领域对象。
  2. 把行为和数据关联起来。
  3. 通过接口和多态降低模块之间的耦合。

那么 Rust 其实有一套很完整的面向对象能力,只是它的核心不是”类”,而是 struct + impl + trait + enum + ownership

这篇文章我会从 C++ 背景出发,把 Rust 的面向对象写法、继承和多态的替代方案,以及大型工程里比较常见的组织方式串起来。

1. Rust 的对象模型:没有 class,但有类型和方法#

在 C++ 里,class 通常同时承担三件事:

  • 定义对象内部有哪些字段。
  • 定义对象上有哪些方法。
  • 定义继承、虚函数、多态等类型关系。

Rust 把这些职责拆开了:

需求C++ 常见写法Rust 常见写法
定义数据class / struct 的字段struct / enum
给类型加方法class 里的 member functionimpl Type
定义接口abstract class / pure virtual functiontrait
静态多态templategenerics + trait bound
动态多态base pointer / virtual functiondyn Trait
复用实现inheritancecomposition + default method
表达有限种变体class hierarchy / tagged unionenum

可以画成下面这样:

Rust object model

Rust 的思路很明确:数据结构、方法实现、抽象接口是三块独立拼装的东西。这让代码组织比传统 class 更分散一点,但也减少了”继承层级越来越深”带来的耦合。

2. struct:定义对象的数据#

最接近 C++ class 的第一块,是 struct

pub struct User {
id: u64,
name: String,
email: String,
active: bool,
}

这段代码只定义了数据。注意字段默认是私有的,即使 User 本身是 pub,外部模块也不能直接访问 idname 这些字段。

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. 最普通的构造函数,通常叫 new
let user = User::new(1, "Alice".into(), "alice@example.com".into());
// 3. 可能失败的构造函数,通常叫 try_new / parse / from_xxx
let email = Email::parse("alice@example.com".to_string())?;
// 4. 从已有类型转换,可以实现 From / TryFrom
let user_id = UserId::from(1_u64);
// 5. 有合理默认值时,可以实现 Default
let 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 完全是社区约定。你也可以叫 createfrom_partswith_email,只要返回 SelfResult<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 Userimpl 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 Usertrait 实现(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(...)newtry_newfrom_xxxparse 等约定
析构函数~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 interfaceRust 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 可能有一个 String
  • TextBox 可能有多个字段
  • 未来的 ImageView 可能更大

Vec<T> 要求每个元素大小相同。dyn Draw 本身大小不固定,所以通常要放在指针后面,例如:

  • Box<dyn Draw>:拥有对象,放在堆上。
  • &dyn Draw:只借用对象。
  • Arc<dyn Draw + Send + Sync>:多线程共享对象。

动态分发大致可以这样理解:

Dispatch comparison

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++ 里,继承同时表达几种不同关系:

  1. is-a:Dog 是一种 Animal
  2. 代码复用:子类复用父类字段和方法。
  3. 动态分发:通过 Animal* 调用虚函数。
  4. 访问控制: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())
}
}

实现者只要提供 titleauthor

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 * 2

C++ 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 的模块系统由 modpubuse 和 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

对应的依赖方向可以画成:

Layered architecture

这里的关键不是目录名字,而是依赖方向:

  • 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 写法很适合表达领域约束。相比到处传 StringEmail 这个类型本身就携带了”已经验证过”的语义。

第三,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 / structstruct / enum
方法定义class 内或类外定义impl Type
接口abstract class / pure virtualtrait
继承支持单继承、多继承不支持 class inheritance
代码复用继承、模板、组合组合、默认方法、泛型函数
动态多态virtual functiondyn Trait
静态多态templategenerics + 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 的面向对象反而会显得很直接:没有深层继承树,没有隐式共享字段,也没有随处飘的裸指针;取而代之的是清晰的类型、接口和所有权关系。

Rust学习笔记:面向对象
https://blog.gzher.com/posts/rust-object-oriented/
作者
中会
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0