外观
第 17 章 图形面积计算器
小螃蟹说
这一章,我们做一个小学数学老师都爱的工具:图形面积计算器。圆形、方形、三角形,各有各的面积公式——但我们要用一种特别的方式把它们装进同一个列表里,用同一套方法算出各自的面积。
这就是编程里大名鼎鼎的面向对象编程(OOP)思想的 Rust 版本。别被名字吓到——"面向对象"的意思很简单:把"数据"和"数据会做的事"打包在一起,让不同的东西用同一种方式操作。
这一章还会认识一个重要的新写法:trait 对象(Box<dyn Shape>)。它解决一个看起来很矛盾的问题:圆形、方形、三角形是三种不同的类型,怎么才能装进同一个 Vec?
17.1 预览:我们要做什么
- 定义一张"图形能力证书"(
Shapetrait):会算面积、会报名字 - 给圆形、方形、三角形都发证(实现 trait)
- 把它们混着装进一个列表,统一算面积
- 统计:总面积多少?最大的图形是谁?
运行结果
text
圆形 的面积是 78.54
方形 的面积是 16.00
三角形 的面积是 9.00
圆形 的面积是 12.57
总面积:116.11
最大的图形是 圆形!(半径 5 的圆 ≈ 78.54,边长 4 的方 = 16,底 6 高 3 的三角 = 9,半径 2 的圆 ≈ 12.57。)
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
| trait 对象 | 装进盒子里的"能力证书" | Box<dyn Shape> 混合列表 |
dyn | 动态分发的标记 | 运行时才决定"是哪种图形" |
| 封装 | 数据和方法打包 | struct + impl(第 4 章复习) |
| 多态 | 同一个接口,不同行为 | 圆形方形都"会算面积" |
| 动态 vs 静态分发 | 运行时找 vs 编译时定 | dyn vs 泛型对比 |
17.2 动手做
步骤一:什么是面向对象?
面向对象(object-oriented)的三个关键词,先认识:
- 封装(encapsulation):数据和"数据会做的事"打包在一起。这个我们第 4 章就做了——
struct Pet+impl Pet,宠物的数据和宠物会做的事(自我介绍)打包。✅ 早就学会了 - 继承(inheritance):子类继承父类的东西。Rust 没有继承——但这不是缺点,我们马上看到 Rust 的替代方案(trait)
- 多态(polymorphism):同一个接口,不同的类型都能用。比如"图形都会算面积"——圆形算、方形算、三角形也算,但算法各不相同
Rust 的多态,靠的是 trait(第 9 章的能力证书)。这一章我们把 trait 用出花样来。
步骤二:图形能力证书
建项目:
bash
cargo new shape_area
cd shape_area先定义"图形能力证书"——会算面积、会报名字:
rust
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &'static str;
}然后造三个图形,给它们发证:
rust
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
fn name(&self) -> &'static str {
"圆形"
}
}
struct Square {
side: f64,
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
fn name(&self) -> &'static str {
"方形"
}
}
struct Triangle {
base: f64,
height: f64,
}
impl Shape for Triangle {
fn area(&self) -> f64 {
self.base * self.height / 2.0
}
fn name(&self) -> &'static str {
"三角形"
}
}全是老朋友:trait 定义(第 9 章 Grade 同款)、struct + impl(第 4 章)、面积公式(小学数学)。三种图形,一张证书,各自实现。
小贴士
注意 Triangle 的两个字段:base(底)和 height(高)——三角形面积 = 底 × 高 ÷ 2。f64 是小数(第 9 章),半径 5.0、底 6.0,都带小数点。
步骤三:三种图形,一个列表?
现在难题来了。我们想让"圆形、方形、三角形"混在一个 Vec 里:
rust
let shapes = vec![
Circle { radius: 5.0 },
Square { side: 4.0 },
Triangle { base: 6.0, height: 3.0 },
];试试编译——报错!因为 Vec 要求所有元素是同一种类型(第 7 章):第一个是 Circle,第二个是 Square,类型都不一样,放不进一个柜子。
三种不同的类型,怎么装进同一个列表?答案:trait 对象(trait object)——把"会算面积的图形"当成一种类型!
步骤四:Box<dyn Shape>:装进盒子的证书
把列表改成:
rust
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Square { side: 4.0 }),
Box::new(Triangle { base: 6.0, height: 3.0 }),
Box::new(Circle { radius: 2.0 }),
];拆开看:
dyn Shape:读作"动态的 Shape"——"任何持有 Shape 证书的东西"。dyn是 dynamic(动态)的缩写,意思是"运行时才知道具体是哪种图形"Box<dyn Shape>:第 14 章的盒子!圆形、方形、三角形大小不一样,放进大小固定的盒子里,就能一起排队了Box::new(...):把每种图形装盒
Vec<Box<dyn Shape>> 翻译成人话:"一个列表,里面都是'装在盒子里的、持有图形证书的'东西。" 证书(trait)+ 盒子(Box)= trait 对象。
现在遍历列表,统一喊"算面积!":
rust
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Square { side: 4.0 }),
Box::new(Triangle { base: 6.0, height: 3.0 }),
Box::new(Circle { radius: 2.0 }),
];
for shape in &shapes {
println!("{} 的面积是 {:.2}", shape.name(), shape.area());
}
}运行:
运行结果
text
圆形 的面积是 78.54
方形 的面积是 16.00
三角形 的面积是 9.00
圆形 的面积是 12.57四行代码,四种图形,同一句话 shape.area() —— 这就是多态:调用方式完全一样,实际算法各不相同(圆用 πr²,方用 a²,三角用 bh/2)。shape 不用管自己是圆形还是方形,证书说了算:会算面积就行。
为什么能"混装"?
Box<dyn Shape> 里装的不是"圆形"或"方形",而是**"一张证书 + 一个地址"**:证书说明"这玩意儿会算面积",地址指向真正的东西(装在堆上,第 14 章)。列表只关心"都有证书",具体是谁,运行时再问。这就是 dyn(动态)分发的意思:运行时才知道真身。
步骤五:统计:总面积和最大的图形
加上统计,main 完整版:
rust
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Square { side: 4.0 }),
Box::new(Triangle { base: 6.0, height: 3.0 }),
Box::new(Circle { radius: 2.0 }),
];
let mut total = 0.0;
let mut biggest: Option<&Box<dyn Shape>> = None;
for shape in &shapes {
println!("{} 的面积是 {:.2}", shape.name(), shape.area());
total += shape.area();
match biggest {
None => biggest = Some(shape),
Some(current) => {
if shape.area() > current.area() {
biggest = Some(shape);
}
}
}
}
println!("总面积:{:.2}", total);
match biggest {
Some(shape) => println!("最大的图形是 {}!", shape.name()),
None => println!("没有图形!"),
}
}都是老朋友:total += 累加(第 1 章)、Option 擂主(第 7 章练习二)、match 拆信封(第 2 章)。biggest 的类型 Option<&Box<dyn Shape>>——"借来的、装在盒子里的图形的引用"。
运行:
运行结果
text
圆形 的面积是 78.54
方形 的面积是 16.00
三角形 的面积是 9.00
圆形 的面积是 12.57
总面积:116.11
最大的图形是 圆形!图形面积计算器完工! 圆形、方形、三角形和平共处,一个列表,统一计算。
步骤六:对比:泛型版(trait 对象的兄弟)
第 9 章学过泛型 <T: Shape>。它也能写"会算面积的函数":
rust
fn total_area<T: Shape>(shapes: &[T]) -> f64 {
let mut total = 0.0;
for shape in shapes {
total += shape.area();
}
total
}两个版本的区别,就是 Rust 多态的两种分发方式:
| 泛型(静态分发) | trait 对象(动态分发) | |
|---|---|---|
| 写法 | fn total_area<T: Shape> | Vec<Box<dyn Shape>> |
| 装什么 | 一种类型的一堆(全是圆形) | 好几种类型混装 |
| 什么时候定类型 | 编译时(编译器为每种类型造一份) | 运行时(动态查表) |
| 速度 | 快(零成本抽象,第 11 章) | 慢一丁点(运行时查一下) |
| 灵活 | 差一点(不能混装) | 强(随便混) |
选择题:同一种类型一大堆 → 泛型;不同类型要混装 → trait 对象。 泛型是"一条生产线造不同型号",trait 对象是"一个仓库装不同货物"。各有各的主场。
17.3 知识深挖
17.3.1 面向对象三兄弟,Rust 的答案
| OOP 概念 | 其他语言 | Rust 的答案 |
|---|---|---|
| 封装 | class | struct + impl(第 4 章)+ 私有性 pub(第 6 章) |
| 继承 | 子类 extends 父类 | 没有继承——用 trait 代替 |
| 多态 | 父类引用指向子类 | trait 对象 Box<dyn Trait> |
Rust 故意不提供继承。为什么?继承有个著名的问题:"父类的东西,子类不一定该要"——小猫会"叫",小狗也会"叫",但鲨鱼不会。继承逼着鲨鱼也继承"会叫",只能一层层覆盖,烦不胜烦。
trait 的思路更干脆:按"能力"分类,而不是按"出身"分类。 图形会算面积 → 发 Shape 证书;宠物会说话 → 发 Speak 证书。要什么能力,领什么证,互不干扰。
17.3.2 trait 对象:装进盒子的证书
trait 对象的完整写法:Box<dyn Trait>(还有 &dyn Trait 借用的形式)。
三个零件各司其职:
dyn:告诉编译器"这里的类型是动态的,运行时再定"Trait:能力证书,规定"必须会什么"Box:大小固定的盒子,装下大小不同的真身(第 14 章)
trait 对象的本质是胖指针(fat pointer):一个普通地址(指向真身)+ 一张"能力表"(真身实现了哪些方法)。调用 shape.area() 时,程序先查能力表,再跳转到对应的实现——动态分发。
17.3.3 静态分发 vs 动态分发
两种分发,性能差一点点,灵活差很多:
- 静态分发(泛型,第 9 章):编译时,编译器为每个具体类型单独造一份函数代码(单态化)。快,但一份类型一份代码
- 动态分发(trait 对象):运行时,通过能力表查一下再调用。慢一丁点(几乎感觉不到),但一份代码通吃所有类型
TRPL 的忠告:优先用泛型(零成本抽象);真到了"需要混装不同类型"的时候,才用 trait 对象。我们的图形列表必须混装 → trait 对象;统计函数只处理单一类型 → 泛型。
17.3.4 对象安全:不是所有 trait 都能装盒
trait 对象有个规矩:trait 必须"对象安全"(object safe)。简单记两条:
- 不能返回
Self(自己这个具体类型)——因为动态分发不知道"自己"是谁 - 方法不能是泛型方法——泛型参数无法动态查表
比如:
rust
trait NotObjectSafe {
fn make_copy(&self) -> Self; // 返回 Self:不能做 trait 对象!
}写错时编译器会直接告诉你"trait 不能做成对象"——照它说的改就行。现在不用背规则,遇到报错再回头看这两条。
17.3.5 什么时候用 trait 对象?
三个信号:
- 要混装:不同类型放进同一个 Vec(我们的图形列表)
- 要统一调用:同一句话,不同行为(全部
.area()) - 类型数目不确定:以后可能加"五边形、六边形……"——trait 对象不用改调用方代码
第 20 章的小网站里,我们会看到一个更"大型"的 trait 对象用法。trait 对象是"抽象"的瑞士军刀:握住把手(证书),刀身随便换。
17.4 动脑筋练习
练习一:长方形报到
图形家族加新成员:长方形(Rectangle,宽 width × 高 height)。要动两处:定义结构体并实现 Shape,列表里加一个实例。面积 5×3=15。
点开看答案
第一处,Triangle 的 impl 下面加:
rust
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> &'static str {
"长方形"
}
}第二处,列表加一个:
rust
Box::new(Rectangle {
width: 5.0,
height: 3.0,
}),运行:
text
圆形 的面积是 78.54
方形 的面积是 16.00
三角形 的面积是 9.00
圆形 的面积是 12.57
长方形 的面积是 15.00
总面积:131.11新增一种图形,main 里的遍历一行都不用改——这就是多态的好处:调用方只认证书,新图形自动融入。试试再加"菱形"、"梯形"……是不是越来越熟练?
练习二:泛型面积函数
写一个泛型函数 total_area<T: Shape>(shapes: &[T]) -> f64,然后:
- 造一个"全是圆形"的
Vec<Circle>,算总面积 - 造一个"全是方形"的
Vec<Square>,算总面积
体会一下:泛型版只能装一种类型,而 17.2 的 trait 对象版能混装——两种工具的差别,亲手摸一遍。
点开看答案
rust
fn total_area<T: Shape>(shapes: &[T]) -> f64 {
let mut total = 0.0;
for shape in shapes {
total += shape.area();
}
total
}
fn main() {
let circles = vec![
Circle { radius: 5.0 },
Circle { radius: 2.0 },
Circle { radius: 1.0 },
];
println!("三个圆形的总面积:{:.2}", total_area(&circles));
let squares = vec![Square { side: 4.0 }, Square { side: 3.0 }];
println!("两个方形的总面积:{:.2}", total_area(&squares));
}运行:
text
三个圆形的总面积:94.25
两个方形的总面积:25.00(78.54 + 12.57 + 3.14 = 94.25;16 + 9 = 25.00。)
现在试试:把 circles 和 squares 拼成一个 Vec 传给 total_area——编译器立刻报错:泛型 T 只能是一种类型。想混装?那就要 trait 对象(17.2 的做法)。两种工具,一个"专一",一个"博爱",各司其职。
练习三:迭代器版统计
main 里的 total 是手写循环累加的。改用第 11 章的迭代器重写:"shapes.iter() 把每个图形递出来,map 取出面积,sum 加起来"。注意要告诉 sum 结果是 f64(let total: f64 = ...)。
点开看答案
把循环里的 total += shape.area(); 删掉(循环只保留打印和找最大),在循环后面加:
rust
let total: f64 = shapes.iter().map(|shape| shape.area()).sum();运行,结果一样:
text
总面积:116.11看这行代码有多像英文:"把图形们一个个递出来,每个取出面积,加起来。" 迭代器 + trait 对象,两个"抽象"联手,一行顶五行。 这就是为什么 Rust 程序员爱用它们:又短又清楚,还不会写错。
17.5 完整代码清单
项目结构:
text
shape_area/
├── Cargo.toml
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "shape_area"
version = "0.1.0"
edition = "2024"文件:src/main.rs
rust
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &'static str;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
fn name(&self) -> &'static str {
"圆形"
}
}
struct Square {
side: f64,
}
impl Shape for Square {
fn area(&self) -> f64 {
self.side * self.side
}
fn name(&self) -> &'static str {
"方形"
}
}
struct Triangle {
base: f64,
height: f64,
}
impl Shape for Triangle {
fn area(&self) -> f64 {
self.base * self.height / 2.0
}
fn name(&self) -> &'static str {
"三角形"
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Square { side: 4.0 }),
Box::new(Triangle { base: 6.0, height: 3.0 }),
Box::new(Circle { radius: 2.0 }),
];
let mut total = 0.0;
let mut biggest: Option<&Box<dyn Shape>> = None;
for shape in &shapes {
println!("{} 的面积是 {:.2}", shape.name(), shape.area());
total += shape.area();
match biggest {
None => biggest = Some(shape),
Some(current) => {
if shape.area() > current.area() {
biggest = Some(shape);
}
}
}
}
println!("总面积:{:.2}", total);
match biggest {
Some(shape) => println!("最大的图形是 {}!", shape.name()),
None => println!("没有图形!"),
}
}怎么运行:
bash
cd shape_area
cargo run运行检查单:
四种图形(两圆一方一三角)都能打印面积
总面积 = 116.11
最大的图形是圆形(78.54)
试着把列表改成 Vec<Circle>(不用 Box),看编译器怎么拒绝混装
给 Triangle 的 impl 删掉一个方法,看编译器怎么提醒你"证书没发全"
把 Box<dyn Shape> 的 dyn 删掉,看报错长什么样(看完改回来!)