C.2 没有 class 的对象感
你带着什么问题来
Section titled “你带着什么问题来”在 Java 里,组织代码的默认单位是 class:字段、方法、可见性、继承关系,都写在同一个花括号里。进入 Rust 后,class 关键字不存在;但 s.push_str("hello")、v.iter().map(f) 这些写法的手感是熟悉的——数据后面跟一个点,方法跟着出来。
这份对象感不是巧合,它的来源可以说清:class 不是一件东西,是一个捆绑包。在 Java 里,一个 class 同时承担六种职责。Rust 没有照搬这个包,而是把六种职责拆开,每种配一个专用零件。
零件全有,捆绑没有。你丢掉的不是面向对象的能力,是打包方式。下文按两层核对:先看六件职责各自有没有零件,再看这种打包方式本身会带来什么后果。
class 一次捆了什么
Section titled “class 一次捆了什么”先数清包里有什么。在 Java 里写下一个 class,你同时得到了:
| 职责 | 在 Java 里怎么承担 |
|---|---|
| 数据与行为的捆绑 | 字段和方法写进同一个花括号,一次定死 |
| 封装边界 | private 的可见范围是本类 |
| 接口抽象 | interface,在类定义处 implements |
| 实现的复用 | extends,子类拿到父类的全部成员 |
| 晚绑定 | 实例方法默认就是虚方法,运行期决定调谁;final 关掉 |
| 运行期自省 | 反射:getClass、instanceof |
面向对象这套范式,指的是这六件事本身;class 是它们的默认打包:写好一个类,六件一起到账。说「默认」有原因:interface 和反射并不写在类定义体里,Java 也允许一个类实现多个接口。打包不等于独占,只是六件一起送来最省事。
判断 Rust「支不支持 OOP」,标准不是有没有 class 关键字,而是这六件事各自有没有着落。
拆开看:每件职责的着落
Section titled “拆开看:每件职责的着落”| 六件职责 | Rust 的零件 | 与 Java 的差别 |
|---|---|---|
| 数据与行为的捆绑 | struct + impl 块 |
松捆绑:行为可以不在数据定义处写 |
| 封装边界 | 模块可见性(pub(crate) 等) |
边界从类换成模块 |
| 接口抽象(编译期) | 泛型 + trait 约束 | 为每个具体类型生成一份代码 |
| 接口抽象(运行期) | dyn Trait |
走虚表,没有继承层级 |
| 晚绑定 | trait 默认方法 | 默认方法回调必选方法;Java 8 的接口默认方法同款 |
| 实现的复用 | 组合 + 委托 | 继承整块被拒,拆成子问题代偿 |
| 运行期自省 | Any + downcast |
只留一扇窄门 |
| 多出的一件:方法动不动状态 | self / &self / &mut self |
Java 没有对应零件,能力写进签名 |
前七行对应那六件职责(接口抽象占两行),最后一行是 Rust 多给的零件。下面把几个关键零件打开看。
边界先搬个家:封装的落点
Section titled “边界先搬个家:封装的落点”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]}Account.balance 没有 pub,模块外读不到;同一个模块里的自由函数 is_overdrawn 反而能直接读它。边界是模块而不是类型,所以「谁能摸私有字段」和「方法和谁写在一起」成了两件事。这条缝就是下一节要用的。
代价也说清:模块不是类型。同一个模块里放两个类型,它们共享同一条边界,粒度比 private 粗;模块切得越细,边界越准,跨模块要写的样板也越多。
行为可以搬走:impl 与定义分离
Section titled “行为可以搬走:impl 与定义分离”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());}这段代码演示两件事。第一,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());}&self:共享借用(borrowing),方法只读;&mut self:排他借用,方法会修改状态;self:取得所有权(Ownership),方法消费这个值——close之后account不能再用。
调用方不必读函数体,签名已经说明这个方法对状态做什么。这超出了 Java 的封装概念:封装不再只是藏住数据,还包括在签名层声明访问权限。
多态:一个 trait,两种分发
Section titled “多态:一个 trait,两种分发”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);}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());}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,或者改用组合转发。
继承:整块被拒,子职责代偿
Section titled “继承:整块被拒,子职责代偿”六件职责的零件都齐了,剩下唯一被拒的构造是 Java 里把类连成一片的 extends:子类连字段一起继承这件事,Rust 直接拒绝。这不是缺货,是拒绝,理由在内存布局上。
Java 的对象一律通过引用访问,继承发生在指针视角:子类加字段,指针指向的对象变成什么样,调用方看不见,运行期自然处理。Rust 的值直接内联在容器和结构体里,布局是类型的一部分:如果 Rect 继承自 ShapeBase 再加字段,Vec<Rect> 里每个元素的布局就被这层继承关系锁死。往 ShapeBase 加一个字段,所有派生类型的布局一起变,已经编译好的调用方和跨版本的二进制全部作废。C++ 已经用对象切片、加字段即破坏二进制兼容、虚析构函数演示过这笔学费:继承把两个类型的布局绑成了同一份合同。
Rust 的做法是把继承承担的子职责分头处理:
- 「is-a」关系 → trait 约束:
fn draw(s: &impl Shape)只要求这一片能力,不拖家当; - 实现代码复用 → 组合 + 委托:结构体里放一个字段,方法里显式转发;
- 晚绑定 → 上一节的默认方法。
有一条常见的弯路要点名:给类型实现 Deref,让方法顺着它「透传」出去,模拟继承。编译器接受这种写法,但类型的接口表面会跟着 Deref 的目标失控扩张,而你能做的检查都变少了。委托多写几行,换回来的是接口表面可控。
自省:只留一扇窄门
Section titled “自省:只留一扇窄门”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);}describe 里 downcast_ref::<Rect>() 命中拿 Some,不命中拿 None——类型判断退回普通返回值,语言不再提供内建自省。门还更窄:Any 只接受 'static 类型,带借用参数的类型进不来;as_any 这样的出口要 trait 自己声明。
判断留在这里:真需要频繁 downcast,通常说明差异应该写成 trait 方法,让调用点直接调用,而不是先猜类型再分支。
巨型 class 从哪来
Section titled “巨型 class 从哪来”捆绑销售的后果,会随代码长大而显形。
回看 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 的边界是模块,而新建一个模块的成本很低。一个是结构性引力,一个是组织惰性。后者靠纪律能对抗,前者不能。
对象感由哪些机制提供
Section titled “对象感由哪些机制提供”回头看开头的手感问题。s.push_str("hello") 这种写法之所以成立,是几个机制叠加的结果:
- 方法调用语法:编译器把
u.greet()处理成Greet::greet(&u),并在接收者处自动补上需要的借用或解引用; - trait 的按组授权:方法表面跟着
use走,引入什么才有什么; - 接收者签名:读、写、消费在调用点可见;
Deref转换:&String能传给要&str的参数,智能指针用起来像普通引用——这是视图的借用,不是继承。
每个机制各管一段,没有一个中心化的「class 实体」承载它们。对象感是零件组装出来的效果,不是内置范式的产物。
Rust 完成的不是「一部分面向对象」。回到开头那张表,六件职责都有零件着落:数据与行为的捆绑用 struct + impl,封装边界用模块,接口抽象用 trait(泛型或 dyn),晚绑定用默认方法,实现的复用用组合 + 委托,运行期自省用 Any + downcast。
被拒的只有一个具体构造:extends。字段继承没有零件,它承担的三件事分别代偿——is-a 关系落到 trait 约束,代码复用落到组合 + 委托,晚绑定落到默认方法。此外丢掉的只有打包方式本身:没有一个语法把这些零件焊成一个实体,也就没有任何一件能力跟着它一起被否决。
巨型 class 是捆绑的必然产物,不是编码习惯问题:五种单位坍缩在一个构造里,扩张只能整体膨胀,而签名隐藏的依赖让拆解成本随行数上涨。Rust 把单位拆还给模块、文件、impl 块和 trait,每个单位都能独立地小。
带走一个判断:下次想在 Rust 里知道「这个类型能干什么」,不要找它的类定义——看它引入了哪些 trait。能力不再挂在实体上,挂在零件上。