跳转到内容

C.2 没有 class 的对象感

在 Java 里,组织代码的默认单位是 class:字段、方法、可见性、继承关系,都写在同一个花括号里。进入 Rust 后,class 关键字不存在;但 s.push_str("hello")、v.iter().map(f) 这些写法的手感是熟悉的——数据后面跟一个点,方法跟着出来。

这份对象感不是巧合,它的来源可以说清:class 不是一件东西,是一个捆绑包。在 Java 里,一个 class 同时承担六种职责。Rust 没有照搬这个包,而是把六种职责拆开,每种配一个专用零件。

零件全有,捆绑没有。你丢掉的不是面向对象的能力,是打包方式。下文按两层核对:先看六件职责各自有没有零件,再看这种打包方式本身会带来什么后果。

先数清包里有什么。在 Java 里写下一个 class,你同时得到了:

职责 在 Java 里怎么承担
数据与行为的捆绑 字段和方法写进同一个花括号,一次定死
封装边界 private 的可见范围是本类
接口抽象 interface,在类定义处 implements
实现的复用 extends,子类拿到父类的全部成员
晚绑定 实例方法默认就是虚方法,运行期决定调谁;final 关掉
运行期自省 反射:getClass、instanceof

面向对象这套范式,指的是这六件事本身;class 是它们的默认打包:写好一个类,六件一起到账。说「默认」有原因:interface 和反射并不写在类定义体里,Java 也允许一个类实现多个接口。打包不等于独占,只是六件一起送来最省事。

判断 Rust「支不支持 OOP」,标准不是有没有 class 关键字,而是这六件事各自有没有着落。

六件职责 Rust 的零件 与 Java 的差别
数据与行为的捆绑 struct + impl 块 松捆绑:行为可以不在数据定义处写
封装边界 模块可见性(pub(crate) 等) 边界从类换成模块
接口抽象(编译期) 泛型 + trait 约束 为每个具体类型生成一份代码
接口抽象(运行期) dyn Trait 走虚表,没有继承层级
晚绑定 trait 默认方法 默认方法回调必选方法;Java 8 的接口默认方法同款
实现的复用 组合 + 委托 继承整块被拒,拆成子问题代偿
运行期自省 Any + downcast 只留一扇窄门
多出的一件:方法动不动状态 self / &self / &mut self Java 没有对应零件,能力写进签名

前七行对应那六件职责(接口抽象占两行),最后一行是 Rust 多给的零件。下面把几个关键零件打开看。

Java 的 private 把可见范围定在类上:谁和字段住同一个花括号,谁就能摸。Rust 把这条边界画在模块上,pub、pub(crate)、pub(super) 是模块尺度上的刻度,private 一词在这门语言里不存在。

//! Uncomment the marked line and compile to get error[E0616].
mod accounts {
pub struct Account {
// Private to this module: no `pub`.
balance: i64,
}
impl Account {
pub fn new(balance: i64) -> Self {
Self { balance }
}
pub fn balance(&self) -> i64 {
self.balance
}
}
// A free function in the same module can read the private field,
// so this code does not have to live in an `impl` block.
pub fn is_overdrawn(account: &Account) -> bool {
account.balance < 0
}
}
fn main() {
let account = accounts::Account::new(-5);
println!("overdrawn: {}", accounts::is_overdrawn(&account));
println!("balance: {}", account.balance());
// Outside the module the field is unreachable:
// println!("{}", account.balance); // error[E0616]
}
在 Rust Playground 中运行

Account.balance 没有 pub,模块外读不到;同一个模块里的自由函数 is_overdrawn 反而能直接读它。边界是模块而不是类型,所以「谁能摸私有字段」和「方法和谁写在一起」成了两件事。这条缝就是下一节要用的。

代价也说清:模块不是类型。同一个模块里放两个类型,它们共享同一条边界,粒度比 private 粗;模块切得越细,边界越准,跨模块要写的样板也越多。

Java 的方法只能写在类里——想摸私有字段的代码,物理上必须住进那个类。Rust 把这两件事解开了:impl 块不必挨着 struct 定义,同一个类型可以有多个 impl 块,它们可以分布在同一个 crate 的不同文件。

mod shapes {
pub trait Area {
fn area(&self) -> f64;
}
pub struct Rect {
pub w: f64,
pub h: f64,
}
}
// The impl lives away from the type's home module.
impl shapes::Area for shapes::Rect {
fn area(&self) -> f64 {
self.w * self.h
}
}
fn main() {
let rect = shapes::Rect { w: 3.0, h: 4.0 };
// Remove this `use` line and the call below fails with error[E0599].
use shapes::Area;
println!("{}", rect.area());
}
在 Rust Playground 中运行

这段代码演示两件事。第一,impl Area for shapes::Rect 写在 shapes 模块外面,行为与类型定义分离。第二,rect.area() 能不能编译,取决于 Area 这个 trait 是否在作用域内——删掉 use shapes::Area; 那一行,编译器报 error[E0599]:找不到 area 方法。

第二件事是 Java 没有的机制:一个类型的方法表面不是「这个类型的全部 public 方法」,而是「你引入了哪些 trait」。API 表面按 trait 分组,一组一个开关。

签名不说谎:接收者的三种写法

Section titled “签名不说谎:接收者的三种写法”

Java 的每个方法都隐式持有 this:任何方法都能读写字段、调用其他方法,而这些依赖不出现在签名里。想知道一个方法碰了哪些状态,只能读实现。

Rust 把这份信息提到签名层。接收者有三种写法,各自对应一种权限:

pub struct Account {
balance: i64,
}
impl Account {
fn new(balance: i64) -> Self {
Self { balance }
}
// Read-only: the receiver is a shared borrow.
fn balance(&self) -> i64 {
self.balance
}
// Mutation: an exclusive borrow, visible in the signature.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Consuming: the receiver is moved in; the account is gone after this.
fn close(self) -> i64 {
self.balance
}
}
fn main() {
let mut account = Account::new(100);
account.deposit(50);
println!("{}", account.balance());
println!("closed with {}", account.close());
}
在 Rust Playground 中运行
  • &self:共享借用(borrowing),方法只读;
  • &mut self:排他借用,方法会修改状态;
  • self:取得所有权(Ownership),方法消费这个值——close 之后 account 不能再用。

调用方不必读函数体,签名已经说明这个方法对状态做什么。这超出了 Java 的封装概念:封装不再只是藏住数据,还包括在签名层声明访问权限。

trait 承担接口抽象,但分发方式有两种。写法不同,代价也不同:

trait Shape {
fn area(&self) -> f64;
}
struct Rect {
w: f64,
h: f64,
}
struct Circle {
r: f64,
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.r * self.r
}
}
// Static dispatch: one compiled copy per concrete type.
fn print_area<T: Shape>(shape: &T) {
println!("static: {}", shape.area());
}
// Dynamic dispatch: one copy; each call goes through a vtable.
fn print_area_dyn(shape: &dyn Shape) {
println!("dynamic: {}", shape.area());
}
fn main() {
let rect = Rect { w: 3.0, h: 4.0 };
let circle = Circle { r: 1.0 };
print_area(&rect);
print_area_dyn(&rect);
print_area_dyn(&circle);
}
在 Rust Playground 中运行

print_area<T: Shape> 是静态分发:编译期为每个用到的类型各生成一份函数,调用是直接跳转,没有运行期开销。这个过程叫单态化(Monomorphization)。

print_area_dyn(shape: &dyn Shape) 是动态分发(dynamic dispatch):函数只有一份,&dyn Shape 是一个胖指针——一端指向数据,一端指向该类型的方法表(vtable)。每次调用先查表再跳转,与 Java 的虚方法调用同一个套路,但表上没有继承链。

两种写法按场景选择:热点路径用泛型换性能;要装异构集合(比如 Vec<&dyn Shape> 同时放 Rect 和 Circle)时用 dyn 换灵活。

晚绑定:默认方法就是模板方法

Section titled “晚绑定:默认方法就是模板方法”

设计模式里的模板方法——父类写流程、子类填空——在 Rust 里由 trait 默认方法承担:

trait Greet {
// Implementors must provide `name`.
fn name(&self) -> String;
// Shared flow: the default method calls back into `name`.
fn greet(&self) -> String {
format!("Hi, {}", self.name())
}
}
struct User {
name: String,
}
struct Bot {
id: u32,
}
impl Greet for User {
fn name(&self) -> String {
self.name.clone()
}
}
impl Greet for Bot {
fn name(&self) -> String {
format!("bot-{}", self.id)
}
}
fn main() {
let user = User { name: String::from("Alice") };
let bot = Bot { id: 7 };
// One `greet`, two bindings of `name`.
println!("{}", user.greet());
println!("{}", bot.greet());
}
在 Rust Playground 中运行

greet 的函数体只写了一份,写在 trait 里;name 是必选方法,每个 impl 自己填。运行时 user.greet() 调到 User 的 name,bot.greet() 调到 Bot 的 name——默认方法里的 self.name() 是晚绑定的。模板方法模式原样成立,不需要继承树。

Java 8 之后,接口默认方法给的是同一件东西,所以这一节不是 Rust 独有。差别在后面两步:Rust 的 blanket impl 能给一整类类型统一写实现(impl<T: Display> Greet for T,它和上面 User、Bot 的单独 impl 不能并存),Java 写不出「给所有满足条件的类型自动补上这个接口」;反过来,Rust 能把自己的 trait 实现到别人定义的类型上,Java 无法让一个已有的类补上你的接口。

它的边界也在这里:默认方法只能调用本 trait,以及它 supertrait 上声明的方法。写成 trait Greet: Display,默认方法里就能直接把 self 交给 format!;想拼一条没声明成 supertrait 的能力,编译器不认,得把那个 trait 写进 supertrait,或者改用组合转发。

六件职责的零件都齐了,剩下唯一被拒的构造是 Java 里把类连成一片的 extends:子类连字段一起继承这件事,Rust 直接拒绝。这不是缺货,是拒绝,理由在内存布局上。

Java 的对象一律通过引用访问,继承发生在指针视角:子类加字段,指针指向的对象变成什么样,调用方看不见,运行期自然处理。Rust 的值直接内联在容器和结构体里,布局是类型的一部分:如果 Rect 继承自 ShapeBase 再加字段,Vec<Rect> 里每个元素的布局就被这层继承关系锁死。往 ShapeBase 加一个字段,所有派生类型的布局一起变,已经编译好的调用方和跨版本的二进制全部作废。C++ 已经用对象切片、加字段即破坏二进制兼容、虚析构函数演示过这笔学费:继承把两个类型的布局绑成了同一份合同。

Rust 的做法是把继承承担的子职责分头处理:

  • 「is-a」关系 → trait 约束:fn draw(s: &impl Shape) 只要求这一片能力,不拖家当;
  • 实现代码复用 → 组合 + 委托:结构体里放一个字段,方法里显式转发;
  • 晚绑定 → 上一节的默认方法。

有一条常见的弯路要点名:给类型实现 Deref,让方法顺着它「透传」出去,模拟继承。编译器接受这种写法,但类型的接口表面会跟着 Deref 的目标失控扩张,而你能做的检查都变少了。委托多写几行,换回来的是接口表面可控。

Java 的 instanceof 和反射随时能问「你到底是什么类型」。Rust 里这件事得由类型自己开口:Any 提供 downcast_ref,但先要拿到一个 &dyn Any。

use std::any::Any;
trait Shape: Any {
fn area(&self) -> f64;
// The trait hands out a `&dyn Any`; without this exit there is no downcast.
fn as_any(&self) -> &dyn Any;
}
struct Rect {
w: f64,
h: f64,
}
struct Circle {
r: f64,
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.r * self.r
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn describe(shape: &dyn Shape) {
// Runtime type check: `None` when the concrete type is not `Rect`.
match shape.as_any().downcast_ref::<Rect>() {
Some(rect) => println!("rect {} x {} = {}", rect.w, rect.h, rect.area()),
None => println!("not a rect, area = {}", shape.area()),
}
}
fn main() {
let rect = Rect { w: 3.0, h: 4.0 };
let circle = Circle { r: 1.0 };
describe(&rect);
describe(&circle);
}
在 Rust Playground 中运行

describe 里 downcast_ref::<Rect>() 命中拿 Some,不命中拿 None——类型判断退回普通返回值,语言不再提供内建自省。门还更窄:Any 只接受 'static 类型,带借用参数的类型进不来;as_any 这样的出口要 trait 自己声明。

判断留在这里:真需要频繁 downcast,通常说明差异应该写成 trait 方法,让调用点直接调用,而不是先猜类型再分支。

捆绑销售的后果,会随代码长大而显形。

回看 class 这个构造。前面数的是它承担哪些职责,这里换一把尺子,数它同时充当哪些「单位」——两把尺子量的是同一个构造,刻度不一一对应,别互相换算。class 同时是五个单位:访问边界(谁能摸私有字段)、文件单位(一个 public 类一个文件)、行为单位(方法只能写在这里)、复用单位(extends 拿到全部)、API 表面单位(含祖先的全部 public 方法)。五个单位坍缩成一个语法,这个语法就有了单向的吸力:任何一个单位想扩张,都得把其余四个一起拖大。

一个几千行的 UserService 通常是这样长出来的:加一个字段 → 摸这个字段的方法进类 → 私有辅助方法跟进 → 类实现的接口越积越多 → 子类把祖先的全部方法继承下去。每一步都在语言规则之内。

拆不动的原因也在捆绑里。Java 方法的签名只写参数,但它隐式依赖整个 this——任何方法都能碰全部字段。想知道哪些方法关心哪块状态,只能通读实现。签名没写出来的依赖,才是拆类的真实成本。

Rust 那边,五个单位各有归属:

单位 Rust 里由谁承担
访问边界 模块
文件单位 无强制,impl 可分文件
行为单位 impl 块与自由函数
复用单位 trait 约束,只拿需要的一片
API 表面 按 trait 分组,引入什么才有什么

摸私有数据的代码可以写成同模块的自由函数(前面 is_overdrawn 就是),不必挤进 impl;不摸状态的代码,没有进 impl 的理由。自由函数的依赖全部写在签名里:fn height(p: &Point) -> f64 只依赖 p,签名怎么说,函数体就怎么碰。

拆开不等于免疫。固有 impl 必须与类型同 crate,你照样可以写出一个几千行、几十个 impl 块的巨型模块。差别在压力的方向:Java 的引力是语言内建的——封装边界焊在类上,绕不开;Rust 的边界是模块,而新建一个模块的成本很低。一个是结构性引力,一个是组织惰性。后者靠纪律能对抗,前者不能。

回头看开头的手感问题。s.push_str("hello") 这种写法之所以成立,是几个机制叠加的结果:

  1. 方法调用语法:编译器把 u.greet() 处理成 Greet::greet(&u),并在接收者处自动补上需要的借用或解引用;
  2. trait 的按组授权:方法表面跟着 use 走,引入什么才有什么;
  3. 接收者签名:读、写、消费在调用点可见;
  4. Deref 转换:&String 能传给要 &str 的参数,智能指针用起来像普通引用——这是视图的借用,不是继承。

每个机制各管一段,没有一个中心化的「class 实体」承载它们。对象感是零件组装出来的效果,不是内置范式的产物。

Rust 完成的不是「一部分面向对象」。回到开头那张表,六件职责都有零件着落:数据与行为的捆绑用 struct + impl,封装边界用模块,接口抽象用 trait(泛型或 dyn),晚绑定用默认方法,实现的复用用组合 + 委托,运行期自省用 Any + downcast。

被拒的只有一个具体构造:extends。字段继承没有零件,它承担的三件事分别代偿——is-a 关系落到 trait 约束,代码复用落到组合 + 委托,晚绑定落到默认方法。此外丢掉的只有打包方式本身:没有一个语法把这些零件焊成一个实体,也就没有任何一件能力跟着它一起被否决。

巨型 class 是捆绑的必然产物,不是编码习惯问题:五种单位坍缩在一个构造里,扩张只能整体膨胀,而签名隐藏的依赖让拆解成本随行数上涨。Rust 把单位拆还给模块、文件、impl 块和 trait,每个单位都能独立地小。

带走一个判断:下次想在 Rust 里知道「这个类型能干什么」,不要找它的类定义——看它引入了哪些 trait。能力不再挂在实体上,挂在零件上。