建模 = 在写代码之前,先动手把"系统里有什么、各自会什么、谁跟谁有关系"想清楚并画出来。 它的流程只有四步:找对象 → 定属性 → 定行为 → 定关系。找对象的窍门是从需求里的"名词"里挑候选、从"动词"里找方法。画完之后要自问三句:职责清不清?关系对不对?有没有多余的类? 建模不是画完就完——代码变了,图要跟着改。
9.3.2 教了"类图长什么样、怎么读",本节回答"这张图是怎么从需求变出来的"——它是 9.2(对象思路)+ 9.3.2(图的符号)的落地流程:9.2.2 说"第一步是把名词圈出来",本节把这个动作变成一套可重复的四步法。学完这一节,你就有了从"一句话需求"走到"一张类图"的完整能力——这正是动手写 class 之前该走完的路。
一、四步建模法
一张类图从来不是"想出来的",而是一步步推出来的。四个步骤,顺序不能乱:
| 步骤 | 做什么 | 关键问题 |
|---|---|---|
| ① 找对象 | 从需求里挑出候选的类 | 系统里有哪些角色? |
| ② 定属性 | 每个类自己要知道什么 | 它的状态由哪几项组成? |
| ③ 定行为 | 每个类自己要会做什么 | 哪件事主要改自己的数据? |
| ④ 定关系 | 类之间怎么连、连几条、多强 | 是"是一种"还是"有一个"?整体没了部分还在吗? |
为什么顺序不能乱:先想"谁"、再想"它有什么"、再想"它会什么"、最后想"它们怎么连"。反过来(先定关系)会陷入"这两个类该不该连"的死循环,因为你还不知道它们各自是什么。
二、第一步:找对象——从"名词"里挑候选
这是最关键也最容易做过头的一步。方法很朴素:
把需求描述里的名词(以及名词短语)圈出来,它们就是"类的候选名单"。
例(一句话需求):
"读者拿着借书证到图书馆借一本图书,管理员登记一条借阅记录。"
圈出的名词:读者、借书证、图书馆、图书、管理员、借阅记录。
候选名单有了,但还不能全要——要过一遍筛子:
| 筛子 | 问题 | 本例判断 |
|---|---|---|
| 是系统里的角色/事物吗? | 还是只是一句话的修饰? | 管理员——如果系统只是自助借还,就不是类(是人不是软件对象) |
| 它有数据或行为要管吗? | 纯粹的值不值得建类? | 借书证——只有编号的话,可能只是"读者"的一个属性 |
| 它是不是多余的? | 能不能并入别的类? | 借阅记录——要留!它有"借出日期、应还日期",是独立的数据 |
筛完的结果:读者、图书馆、图书、借阅记录(4 个类)。
永远问一句:"这个类真的需要吗?" 多一个类,就多一份要维护的东西;少一个类,就会出现"某个类什么都管"的怪物。这一步的判断力,就是设计经验的核心。
三、第二、三步:定属性、定行为
定属性——问"它自己要知道什么":
| 类 | 属性(问:它的"状态"由什么决定?) |
|---|---|
| 读者 | 姓名、编号、已借数量 |
| 图书 | 书名、作者、是否在架上 |
| 借阅记录 | 借出日期、应还日期 |
| 图书馆 | 全部图书(集合) |
定行为——判据只有一条(9.2.2 讲过):
"这件事主要是在改谁的数据?就写在谁身上。"
| 动作 | 改谁的数据 | 结论 |
|---|---|---|
| 借出图书 | 改图书的在架状态 | → Book::borrow() |
| 归还图书 | 改图书的在架状态 | → Book::giveBack() |
| 登记记录 | 往图书馆的记录集合里加一条 | → Library::record() |
| 查还能借几本 | 看读者自己的已借数量 | → Reader::remaining() |
注意"读者.借书"这件事:读者发起借书,但真正改状态的是图书。所以 Reader 那边是个"请求"(Reader::borrow(Book*)),它内部去调 book->borrow()。"发起"和"执行"经常不在同一个类上——这是建模时最要细心的一处。
四、第四步:定关系——三条快问
对着每两个类,问这三句:
| 快问 | 是 → | 否 → |
|---|---|---|
| ① "A 是一种 B"吗?(is-a) | 泛化/继承(空心三角) | 继续问 ② |
| ② "A 有一个 B"吗?(has-a) | 关联/聚合/组合(实线,看菱形) | 可能没关系,或只是依赖 |
| ③ 若 has-a:A 没了,B 能独立活吗? | 能 → 聚合(空心◇);不能 → 组合(实心◆) | —— |
本例走一遍:
图书馆 ◆—— * 图书 (图书馆没了,它的藏书也就没了 → 组合)
图书馆 ◆—— * 借阅记录 (同上 → 组合)
读者 —— * 借阅记录 (记录不属于读者,是"关联";读者没了记录要留档)
读者 —— * 图书 (借来的书不属于读者 → 关联,并标多重性)
画出来:
┌───────────┐1 *┌───────────┐
│ Reader │──────────>│ Book │
├───────────┤ ├───────────┤
│ - name │ │ - title │
│ - id │ │ - onShelf │
│ - count │ ├───────────┤
├───────────┤ │ + borrow()│
│ + borrow()│ │+ giveBack()│
└───────────┘ └───────────┘
│ 1 △ * (组合,菱形指向整体)
│ │
│ * ┌───────────────┐
┌───────────┐ │ Library │
│ LoanRecord│<────────◆├───────────────┤
├───────────┤ * 1 │ - books:Book[]│
│ - outDate │ │ - records[] │
│ - dueDate │ ├───────────────┤
└───────────┘ │ + addBook() │
│ + record() │
└───────────────┘
五、需求 → 用例 → 类图:一条落地链
真实项目里,建模通常不是从零开始的,而是沿着这条链往下走:
① 需求描述(自然语言) "读者能借书、还书,管理员能查在架图书"
↓ 用【用例图】梳理"谁能用系统做什么"
② 用例(谁 + 做什么) 读者:借书 / 还书;管理员:查询
↓ 对每个用例,走【四步建模法】
③ 类图(有哪些类、怎么连) Reader / Book / Library / LoanRecord
↓ 对复杂场景,用【时序图】理清调用顺序
④ 时序图(对象怎么来回配合)读者 → 图书.borrow() → 图书馆.record()
| 图 | 站在什么位置 |
|---|---|
| 用例图 | 需求阶段——把"要做什么"列清楚,不涉及内部结构 |
| 类图 | 设计阶段——把"用什么类来实现"定下来 |
| 时序图 | 细节阶段——把一次具体操作的调用顺序画清楚 |
一句话:用例图管"做什么",类图管"有什么",时序图管"怎么跑"。
六、画完之后:自问三句
图不是画完就算好,收笔前必须过一遍这三问:
| 自问 | 发现问题怎么办 |
|---|---|
| ① 职责清不清? | 如果一个类的方法一半在管自己的数据、一半在管别人的,说明职责越界——把不属于它的方法挪走 |
| ② 关系对不对? | 检查有没有把 has-a 画成了继承;检查菱形方向(在"整体"那端);检查多重性(是 * 还是 1..*) |
| ③ 有没有多余的类? | 只有一两个属性、没有任何方法的类,往往可以并入别的类(比如"借书证"并进"读者") |
再加一问(很值钱):"如果我明天要加一个新需求(比如'预约'),这张图会怎么变?" ——改动小的图,是好图(这就是 9.2.2 说的"改动范围收敛",在建模阶段就先验一遍)。
七、建模不是一次性的
最后一件必须记住的事:
代码变了,图要跟着改;否则图就从"资产"变成"负债"。
- 图过时的危害:新人照着旧图理解代码,会被误导——比没有图更糟。
- 现实的取舍:不要求图和代码逐行对应。只保持"关键结构"同步(有哪些主要类、谁继承谁、谁拥有谁)。
- 笨但有效的方法:把图放在代码旁边,改结构时顺手改一笔;或者在关键节点(版本发布、大改之前)统一对一次。
这跟测试的道理是一样的:不维护的东西,就不能指望它有用。
八、动手 Demo:从一句话需求走到类图
需求(一句话):
"老师布置作业,学生提交作业,系统记录成绩。"
第①步:圈名词 → 老师、作业、学生、成绩(系统本身不算类)
第②③步:列属性与行为
| 类 | 属性 | 方法 |
|---|---|---|
Teacher |
姓名 | + assign(hw: Homework) : void(布置) |
Student |
姓名、学号 | + submit(hw: Homework) : void(提交) |
Homework |
题目、截止日期 | + isLate(now) : bool(判断是否逾期) |
Score |
分数、评语 | —— |
第④步:定关系
| 一对 | 快问 | 结论 |
|---|---|---|
Homework ↔ Teacher |
"作业是一种老师"?不是;"作业是老师的部分"?是 | 组合 ◆(老师不再教这门课,作业也没意义了) |
Student ↔ Homework |
"提交"=临时持有 | 关联,标 1 对 * |
Homework ↔ Score |
一份作业对应一个分数,且分数因作业而生 | 组合 ◆,标 1 对 1 |
看到什么算成功:你能画出四格方框 + 两条菱形线 + 一条普通实线,并说清"为什么 Score 对 Homework 是组合而不是关联"(分数不能脱离那次作业独立存在)。
30 秒实验:加一个需求——"学生可以补交作业,但会被标记"。你需要在图里改什么?
→ ① Homework 加一个方法 + markLate() : void;② Score 可能要加 - late : bool。注意:Teacher 和 Library 式的"大管家"类一个字都不用改——因为改动被关在了"作业自己的事"里。这就是 9.2.2 那句"谁变了就改谁"在建模阶段的提前演练。
九、常见误区
- "建模就是画好看的图"——不。建模的核心产出是想清楚的过程:哪些类、各自职责、什么关系。图只是这个过程的记录。
- "每个名词都要建类"——过头了。很多名词只是属性(借书证 → 读者的一个字段)。类越多,维护成本越高。
- "建模一次做完就不用动了"——图是活的。代码演进,图要跟着走;否则它就从资产变成了"看起来很有用的误导"。
- "必须先画完图才能写代码"——不必这么重。小项目在纸上画十分钟就够了;关键是先想结构,再动手写,而不是"先写 500 行再补图"。建模的度,看项目大小。
- "先定关系更省事"——顺序错了。先想清"有哪些类、各自是什么",关系才会自然浮现;上来就纠结"这根线是聚合还是组合",通常是因为还没想清这两个类是什么。
- "图越细越好"——细节留给代码。类图里写清主要属性和行为即可;把每个局部变量、每个循环都画进图里,只会让它失去"一眼看懂"的价值。
自测三问(点开对答案)
1. 面向对象建模的四步是什么?顺序为什么不能乱?——**① 找对象 → ② 定属性 → ③ 定行为 → ④ 定关系**。因为**"关系"要建立在"知道双方是什么"的基础上**;先定关系会陷入"该不该连"的死循环。 2. 找对象的窍门是什么?怎么筛选候选?——从**需求描述里的名词**里圈候选。再用三把筛子:**是系统里的角色/事物吗?它有数据或行为要管吗?它能不能并入别的类(多余吗)?** 3. 画完类图后必须自问哪三句?——**① 职责清不清(有没有越界)?② 关系对不对(is-a/has-a 有没有搞混、菱形方向、多重性)?③ 有没有多余的类(只有一两个属性、没方法的类往往可以并掉)?** 另加一句很有用的:**"明天加个新需求,这张图要改多少?"**- ☐ 9.4 小结(下一节)
相关笔记
- 统一建模语言的结构 —— 上一节:类图的符号怎么读
- 面向对象编程 —— 本节第一步用的方法:"把名词圈出来"
- 面向对象的特点 —— is-a 用继承、has-a 用包含
- 小结 —— 下一节:本章收束
- 🔷 C++基础 · 索引 —— 全课路线图