软件分析与设计笔记 20:面向对象概念:对象、关系与多态
面向对象不是「用某个语言写代码」:对象与类的区别、封装真正在保护什么、五种关系(依赖、关联、聚合、组合、继承)各自何时用,以及多态为什么能减少条件判断。
本文是 《软件分析与设计笔记》 系列的第 20 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
0 面向对象不是「用某个语言写代码」
一个常见的误解是:用 Java 或 C# 写程序,就是在做面向对象。但如果代码里只有一个「主流程类」加几十个静态方法,那只是用面向对象的语法写过程式代码。
面向对象(object orientation) 是一种组织系统描述的方式:把系统看成一组相对独立的、各自负责一件事的对象,它们通过互相发消息来协作。
它带来的直接好处是:改变了「改动会波及多远」这件事。后面几篇(22–25)讲的分析与设计,都是在这个基础上展开的。
1 对象与类
1.1 对象、类与实例
- 对象(object) —— 一个具体的、可区分的东西:「张三」「订单 A1091」;
- 类(class) —— 对一组对象的共同描述:「客户」「订单」;
- 实例(instance) —— 类是模板,对象是它的一个实例。
1.2 状态与行为
一个对象有两部分:
- 状态(state) —— 它当前是什么样,由属性(attribute) 记录;
- 行为(behaviour) —— 它能做什么,由操作(operation) 定义(在代码里就是方法)。
1.3 行为跟着数据走
关键在两者的绑定:行为应该由持有状态的那个对象提供。「计算订单总价」这个操作应该属于订单,而不是属于某个外部工具类 —— 因为总价依赖的数据(明细、单价)都在订单里。这条原则看起来朴素,但它正是笔记 25 里那个「依恋情结」坏味道的判断依据。
2 抽象与封装
2.1 抽象
抽象(abstraction) 是只看需要关心的部分,忽略其余。建模时不可能把所有细节都画进去 —— 一张订单在库存模块里关心的是数量,在财务模块里关心的是金额,两处看到的「订单」本来就不该完全一样。
2.2 封装真正保护的是什么
封装(encapsulation) 是把状态和行为打包在一起,并决定哪些对外可见。这个概念常被简化成「用 private 修饰字段」,但它真正保护的是信息隐藏(information hiding):外部只能通过你提供的方式改变状态,你因此可以保证状态始终合法。
举个具体的:如果金额字段可以被外部直接赋值,那任何一处代码都能写出「金额为负」的订单。加了设置方法之后,你可以在那里拦住非法值。
3 五种关系
3.1 五种关系一览
| 关系 | 中文 | 一句话判断 |
|---|---|---|
| dependency | 依赖 | 临时用一下就完了 |
| association | 关联 | 长期持有对方的引用 |
| aggregation | 聚合 | has-a,部分可以独立存在 |
| composition | 组合 | 强聚合,部分随整体消亡 |
| inheritance | 继承 | is-a,子类可以替换父类 |
3.2 依赖与关联
依赖(dependency) 是最弱的耦合:某个方法临时用到另一个对象。它出现在参数、局部变量里。
关联(association) 是长期引用,需要写清两件事:
- 重数(multiplicity) —— 一端对应几个,例如
1对*; - 角色名(role name) —— 这个引用在业务上叫什么,例如「客户」持有的是他下的「订单」。
3.3 聚合与组合的分界线
聚合(aggregation) 和组合(composition) 容易混。两者的分界线不是「是否包含」,而是生命周期:
- 旅行团包含若干旅客 —— 旅行团取消了,旅客还在,这是聚合;
- 订单包含订单明细 —— 订单删了,明细没有单独存在的意义,这是组合。
3.4 继承
继承(inheritance) 表达 is-a:子类(subclass)复用父类(superclass)的定义,并可以覆盖(override)其中一部分行为。它的前提是一条硬约束:子类必须能替换父类而不破坏使用者的预期。如果做不到,就不该用继承 —— 常见替代方案是组合。
4 多态:把条件判断换成对象
4.1 不用多态的写法
多态(polymorphism) 是同一段调用代码,在不同对象上产生不同行为。
先看不用的写法。假设要按客户类型算折扣:
double discount(Customer c) {
if (c.getType().equals("VIP")) {
return 0.2;
} else if (c.getType().equals("CORPORATE")) {
return 0.15;
}
return 0.0;
}
问题不在写法,而在于每加一种客户类型,都要回来改这个方法。而且同样的 if 很可能散落在好几处。
4.2 用多态改写之后
用多态之后:
abstract class Customer {
abstract double discount();
}
class VipCustomer extends Customer {
double discount() { return 0.2; }
}
class CorporateCustomer extends Customer {
double discount() { return 0.15; }
}
调用方只写 c.discount()。新增一种客户类型,只需要加一个类,已有的代码一行都不用动。
这就是「多态能减少条件判断」的含义:把「判断类型然后分支」换成了「让对象自己回答」。它也是后面笔记 24 讲设计原则时的核心工具。
5 单继承与多继承
单继承(single inheritance) 指一个子类只能有一个父类,多继承(multiple inheritance) 可以有多个。
多继承看起来更灵活,但会带来一个真实的歧义:两个父类有同名方法时,该用谁的?所以很多语言(包括 Java、C#)在类这一层只允许单继承,把「多个能力的组合」交给另一种机制去表达 —— 接口。这一点会在笔记 24 展开。
6 常见误解
- 「把所有东西都建成类就是面向对象」。 类多不等于设计好。一个类如果既是数据结构又是工具集合,那它只是把过程式代码装进了类里;
- 「继承用来复用代码」。 复用的手段很多,继承的语义是 is-a。为了省几行代码而继承,往往会在后面几周付出代价;
- 「封装就是加 private」。 如果每个字段都配了无脑的读写方法,等于没封装;
- 「组合优于继承是绝对规则」。 更准确的说法是:当关系不是 is-a 时,别用继承。
7 本节自测
- 类、对象、实例三者是什么关系?
- 「计算订单总价」这个方法为什么应该属于订单,而不是放在一个工具类里?
- 聚合和组合的分界线是什么?各举一个例子。
- 用多态改写一段
if (type.equals(...))的代码,改完最大的收益是什么? - 为什么很多语言在类的层面只允许单继承?
- 封装真正保护的是什么?如果金额字段可以被外部直接赋值会怎样?
参考答案
- 类是模板,对象是它的一个实例;对象是「一个具体可区分的东西」。类是「对一组对象的共同描述」(§1.1)。
- 因为行为应该由持有状态的对象提供 —— 总价依赖的明细、单价都在订单里。放工具类里就是笔记 25 讲的「依恋情结」(§1.3)。
- 分界线是整体消亡时部分还活不活得下去。聚合:部分可独立存在(如旅客与订单,订单取消旅客还在);组合:部分随整体消亡(如订单与订单明细)(§3)。
- 新增一种类型只需新增一个类,已有的判断代码一行都不用改,而且判断不再散落在多处(§4)。
- 因为多继承会带来菱形继承的歧义(同一个祖先被两条路径继承时,成员该用哪一份)。所以多数语言用「单继承 + 多接口」解决(§5)。
- 封装真正保护的是信息隐藏 —— 外部只能通过你提供的方式改变状态。如果金额可被直接赋值,任何代码都能写出「金额为负」的订单,而你没有任何地方可以拦住它(§2.2)。
8 术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| object orientation | 面向对象 | 把系统组织成一组互相协作的对象的方法 |
| object | 对象 | 具体的、可区分的实体 |
| class | 类 | 对一组对象的共同描述 |
| instance | 实例 | 类的一个具体对象 |
| state | 状态 | 对象当前的属性取值 |
| behaviour | 行为 | 对象能执行的操作 |
| attribute | 属性 | 记录对象状态的数据项 |
| operation | 操作 | 对象提供的行为,通常对应一个方法 |
| abstraction | 抽象 | 只保留当前关心部分的做法 |
| encapsulation | 封装 | 把状态与行为打包并控制外部访问 |
| information hiding | 信息隐藏 | 对外隐藏内部细节,只暴露必要接口 |
| dependency | 依赖 | 临时使用另一个对象的关系 |
| association | 关联 | 长期持有对方引用的关系 |
| multiplicity | 重数 | 一端可以对应多少个实例 |
| role name | 角色名 | 关联中该引用在业务上的称呼 |
| aggregation | 聚合 | 部分可独立存在的整体—部分关系 |
| composition | 组合 | 部分随整体消亡的强聚合 |
| inheritance | 继承 | 子类复用并覆盖父类定义的关系 |
| superclass | 父类 | 被继承的类 |
| subclass | 子类 | 继承父类的类 |
| single inheritance | 单继承 | 一个类只能有一个父类 |
| multiple inheritance | 多继承 | 一个类可以有多个父类 |
| polymorphism | 多态 | 同一调用在不同对象上产生不同行为 |
| override | 覆盖 | 子类重新实现父类的方法 |