软件建模04:OO 分析与 CRC
“设备、租赁、请求、回执”能列成一串名词,却不能说明取消时该删除什么。设备仍然存在,原请求也确实发生过;需要移除的是当前占用。如果仅给这些名词各建一个类,再把全部判断交给调用方,类的数量增加了,取消后重试是否重新占位仍没有明确的负责人。
ER 与数据建模 已经区分回执与有效预留的关系。OO 分析继续追问:谁掌握判断所需的信息,谁完成检查,谁可以改变占用,其他对象通过什么消息获得结果。CRC 卡可以把这些决定写得足够短,再用场景检查遗漏。从要完成的动作寻找职责
CRC 通常按类名、职责和协作者组织一张卡。Beck 与 Cunningham 的 1989 年论文 §2 要求职责用带主动动词的短语表达,并把发送或接收相关消息的对象列为协作者。这样的记录比属性清单更接近一次实际协作:写“比较重复请求的载荷”,就必须说明需要哪两个请求以及比较结果怎样影响后续动作。原论文 §2
“保存设备编号”只能说明对象含有什么;“拒绝同设备重叠预留”同时暴露了行为和信息需求。作出后一个决定需要设备标识、候选租期以及已有有效预留。一个只保存设备名称的 Equipment 对象没有这些信息,不能因为业务句子出现了“设备”就自动承担冲突判断。
本系列基线用 Period 保存两个 Instant,并在构造时拒绝空区间与倒置区间。它提供 overlaps,掌握半开区间比较;设备相同与否由预留处理逻辑决定。这样,区间对象只回答两个时间范围是否相交,不需要读取请求回执,也不需要知道设备目录是否存在。
RequestId 则区分两种意图:同 ID 同载荷代表旧请求重试,新 ID 才表示新的预留尝试。名称相似的“请求”和“有效预留”不能合并成一个随取消一起消失的对象,否则最初结果也随之丢失。当前实现把原请求与结果组成回执,另存一份有效预留关系;这项职责分配来自重试和取消场景。
同一个租赁柜台的两种分配
候选 A 就是累计代码里的 RentalDesk。它维护 receipts 和 bookings 两个内存映射,负责先重放旧回执,再检查新请求是否冲突,最后保存结果和有效预留。cancel 只删除 bookings 中的对应项。调用者提交完整请求,不需要自己扫描列表或决定回执是否该保留。
候选 B 保留同样的外部入口,在实验中把内部职责分给 Ledger 和 Schedule。前者保存最初请求与结果,比较旧载荷;后者管理当前占用,检查重叠并释放预留。SplitDesk 按顺序协调这两个具体对象。两个方案都保留“租赁柜台”这个业务概念,只是它独自完成的职责不同。
| 候选对象 | 职责短语 | 实际协作者和消息 |
|---|---|---|
| A:RentalDesk | 重放原结果、拒绝冲突、登记和释放占用 | ReserveRequest 取值、Period.overlaps、两个 Map 读写 |
| B:SplitDesk | 协调重放、预留、记录结果 | Ledger.lookup/remember、Schedule.reserve/release |
| B:Ledger | 比较旧载荷、保留原结果 | ReserveRequest.equals |
| B:Schedule | 拒绝重叠、登记并释放占用 | ReserveRequest 取值、Period.overlaps |
这份表没有把每个字段转换成一张卡,也没有给每个类配一个只含单一实现的接口。B 的两个辅助类只存在于 labs/04/CrcCheck.java,累计实现仍采用 A。当前四十余行的核心行为尚未显示出必须拆分的复杂度;B 的作用是让职责边界接受同一组场景检验。
拆分也产生一个需要保护的新边界。Schedule.reserve 本身不理解请求重放,调用者若绕过 SplitDesk 直接使用它,就可能跳过回执比较。实验把辅助对象限制在候选实现内部,使外部入口仍负责协调顺序。拆成三个对象,并不会自动获得三个可以独立公开的服务。
职责也不必和方法一一对应。“拒绝重叠”在候选 B 中包含读取已有预留、比较设备、比较区间和选择结果,最终可以由一个 reserve 方法完成。若卡片直接列出每一次 get、put 和循环条件,讨论会过早固定在集合实现;若只写“管理预留”,又无法判断取消是否包含删除回执。可用的粒度应当足以决定哪个对象承担失败后果,同时允许内部实现替换。
协作者清单还需要说明方向。SplitDesk 依赖 Ledger 提供原结果,账本并不需要反过来调用柜台。若为了图形对称添加双向引用,就会引入并未被场景要求的路径。走查时可以从一次外部输入出发,逐个记录接收者拿到什么信息、完成哪项判断、向谁返回什么;无法说清返回内容时,通常意味着职责短语仍隐藏着两种不同操作。
03 的 receipt 和 booking 两张关系也没有强制对应两个业务类。A 使用一个私有回执记录和请求映射即可保存它们,B 的账本负责一组回执而非单条数据库行。表名提供存储视角,CRC 分配的是完成场景所需的行为。把两者机械配对,会把集合级的冲突判断分散到每一条记录之外,却没有指定谁负责汇总判断。
书面走查:取消后旧请求仍返回确认
这里采用合成输入做书面角色走查,没有真实访谈或现场工作坊。时间统一为 2026-10-03 UTC,租期使用 [start,end)。A 请求预留 E1 的 [10:00,12:00),B 请求同设备 [11:00,13:00),C 请求 [12:00,13:00),D 请求另一设备 E2 的 [10:00,12:00)。
在候选 A 中,柜台先查回执,再扫描有效预留。A 确认,B 拒绝,C 相邻所以确认,D 因设备不同也确认。在候选 B 中,Ledger.lookup 对新 ID 返回无记录,Schedule.reserve 用相同条件检查占用,再由柜台把结果交给 Ledger.remember。拒绝结果同样必须记录,否则 B 重试就会变成重新作决定。
若 A 的 ID 改为携带 E2,回执比较应抛出异常,且不能先改动占用。这里将职责写成“处理重复请求”仍过于含糊;它必须分清比较载荷、返回原结果和拒绝 ID 复用三项后果。正确的协调顺序是先辨认旧请求,再决定是否进入占用检查。
取消 A 时,A 的占用被移除,原回执仍返回 CONFIRMED。这个结果表示最初请求曾获确认,不代表当前仍占用设备。两种实现都用“取消 A、重放 A、用新 ID F 申请同一时段”的组合检验:F 应确认。只检查重放 A 的返回值,无法判断占用是否错误地重建。
实验还取消 C,使最初阻挡 B 的占用全部解除,然后重放 B。B 仍应返回原拒绝结果;资源已经空闲也不能把旧请求变成新尝试。这一步把“保留拒绝回执”的职责独立检验出来,避免因为残留冲突恰好仍会拒绝而得到假阳性。
CRC 原论文 §3 建议从少量明显对象出发,用执行场景发现缺失职责,再补充或拆分对象。这支持按需要修改卡片的工作方式。论文 §4 也明确没有后续研究,因此不能据此宣称 CRC 已被证明能提高项目生产率。原论文 §§3–4
让错误分配留下可观察结果
实验先把上述场景分别施加于 A、B,再对一个独立 B 实例注入错误:取消同时删除原回执。后续重放 A 会经过“新请求”分支,再次登记占用。旧 A 的返回值仍是 CONFIRMED,与正确实现相同;新 F 的结果却变为 OVERLAP_REJECTED。
这一负例把“回执归谁保管”变成外部可见差异。故障注入使用实验内部字段访问,正常使用路径仍只调用预留和取消方法。负例并非演示一种可接受实现;检查程序要求它确实出现预留复活,否则负例检测失败,整个实验返回非零。
1 | |
入口从自身位置定位源码,使用 Java 21 标准库,执行 javac --release 21 -Xlint:all -Werror 后运行断言。无需 Maven 或测试框架。可下载 模型、完整源码与验证记录,解压后在根目录运行同一命令。JAVA_HOME 应换成本机 JDK 21 路径,脚本会拒绝不兼容版本。
本次使用 Corretto 21.0.11,退出码为 0。原始输出保存在 examples/software-modeling/evidence/04/output.txt,其中关键行为为:
1 | |
两种分配在已执行场景上满足相同外部契约;这不是对全部可能输入的等价证明。实验没有线程、事务、进程重启或磁盘写入。尤其 B 先登记占用再保存回执,两步之间若加入可失败的持久化操作,就必须重新设计失败后的状态保证,不能直接把内存走查结果推广到数据库服务。
变化会使哪张卡需要修改
若设备归还后增加半小时清洁,合同租期与资源占用期就可能不同。A 的柜台需要知道如何计算占用期;B 的 Schedule 也需要接收计算后的区间,或新增明确的计算职责。仅把 Period.overlaps 的严格比较改成包含端点,会把所有相邻租期都拒绝,却没有表达半小时这个数量。
这张变化卡尚未实现。需要先确定清洁是否适用于所有设备、取消是否释放清洁占用以及历史请求按哪版规则重放。它也说明不能提前把“策略接口、仓储、事件总线”填满 CRC 卡:没有被场景要求的协作者,只会增加尚未验证的假设。
当前最简单的交付仍是 A。模型目录 models/04/crc.md 保留两种分配、消息走查与反例,labs/04/ 保留候选实现;未来职责确实需要迁移时,可以复用同一组外部行为检查,核对取消和重试的含义有没有随拆分一起改变。
参考资料
- Kent Beck、Ward Cunningham,A Laboratory for Teaching Object-Oriented Thinking,OOPSLA 1989,原论文,§2 职责与协作者,§3 场景驱动卡片修订,§4 经验边界。
- 系列契约:
examples/software-modeling/README.md、models/01/scenarios-and-rules.md。 - 模型与实验:
examples/software-modeling/models/04/crc.md、examples/software-modeling/labs/04/CrcCheck.java;原始输出与退出码:examples/software-modeling/evidence/04/。






