第6章:可以工作的类
第6章:可以工作的类:用不变量、最小接口与组合把类变成能在生命周期内守住抽象合同的边界。
学习目标
- 能解释一个类的不变量、公开操作和错误边界,并指出构造完成后必须保持什么。
- 能修改一个过度暴露状态的类,用封装和组合收紧变化面,再用边界样例验证行为。
- 能回答:当子类型无法保持父类型的合同、或一个类只有“万能”职责时,应该拒绝继承还是拆分职责,证据是什么?
为什么需要这一机制
想象一个借阅柜台:工作人员不会把整本账册交给每位读者,而只开放“借出、归还、查询”几个动作。这样做的目的不是藏住数据,而是让每次动作都经过同一套检查。软件对象也需要这样的边界;否则任何调用者都能把状态改到下一次操作无法解释的位置。
没有这条边界时,问题往往不会在赋值的那一行爆炸,而会在很久以后以错误金额、缺失记录或无法恢复的异常出现。本章把“一个类应该公开什么”变成可观察的验收:先写对象必须保持的条件,再改变一个入口,最后检查状态、错误和恢复是否仍然一致。
核心合同
这个式子在说:对象刚被构造好时要有效,每一次公开调用返回后也要有效;只要其中一个时刻失守,类就不能把结果交给下一步。
把合同写成四列会更容易检查:对象状态、允许的输入、成功后的状态、拒绝后的状态。例如有容量上限的 Inventory 必须保证数量在零和容量之间,补货超限时拒绝且不改变原状态。下面的实验把这条合同画成五个节点,并允许注入一次“公开字段绕过检查”的故障。
专属机制实验
猜一猜:把“直接暴露字段”的故障打开后,最先失守的是构造、接口、状态、组合还是验收?先选择一个节点,再注入故障,观察首个拒绝点是否与你的预测一致。
第6章 · 生命周期合同
一个可工作的类,如何守住自己的边界?
五个节点把构造、接口、状态、组合和验收连起来。切换节点查看证据,再注入一次公开字段泄漏,定位首个失守位置。
当前节点:构造
依赖和范围在对象可见之前通过检查。
安全路径只允许通过命名操作改变状态;故障路径模拟调用者直接写入字段。两条路径最后都必须经过验收,不要因为一次读取成功就把对象判为可工作。点击“重置实验”后,节点、故障开关和状态文字应回到同一基线。
6.1 类的基础:抽象数据类型
↡先规定可观察操作和合法状态,再隐藏具体表示的数据与操作集合(ADT)关心的是“能做什么”和“什么状态算合法”,不要求调用者知道数据放在数组、链表还是数据库里。类是实现这种边界的一种常见载体,但类名本身不会自动产生边界;公开字段仍可能把表示泄漏出去。
需要用到ADT的例子
金额、日期范围、队列、配置快照和权限集合都适合先写成 ADT。以日期范围为例,调用者只需要创建范围、判断某天是否落在范围内、移动起点;它不应该依赖内部的时区转换顺序。把这些操作固定下来,后续更换表示时,使用方仍然只依赖稳定行为。
使用ADT的益处
ADT 把“表示变化”和“使用变化”分开。若队列从数组改成环形缓冲区,调用者只看到入队、出队和满空语义;测试集中验证这些语义,不必为每一种内部布局重写一遍。隔离变化不是减少测试,而是把测试放在最能表达合同的位置。
更多的ADT示例
日志写入器、重试策略和权限策略也可以用 ADT 表达:它们分别暴露写入、下一次等待时间或是否允许的判断。一个好例子应该有清楚的输入、输出和拒绝条件。若所有调用者都要先读取内部字段再自己拼判断,说明抽象没有收拢关键规则。
在非面向对象环境中用ADT处理多份数据实例
没有类语法也能使用 ADT。可以把多份记录放在私有数据结构中,让每个公开函数接收句柄或实例编号,再由函数统一验证状态。关键不在于是否写了 class,而在于表示是否集中管理、实例是否隔离,以及调用者能否绕过合法操作改写共享数据。
ADT和类
ADT 是行为合同,类是可以承载合同的实现结构,两者不能画等号。一个类可能只是数据袋,也可能包住完整生命周期;判断标准是调用者能否只凭公开接口完成任务,并在每次操作后推断对象状态。先写 ADT 再写类,能避免被语言默认可见性牵着走。
6.2 良好的类接口
接口是调用者与对象之间的承诺:名称表达意图,参数表达输入,返回值和错误表达结果。一个接口越宽,调用者越容易依赖偶然细节;一个接口越小,越需要把重要的动作命名清楚。好的接口不是方法数量少,而是每个公开操作都对应稳定的使用问题。
好的抽象
好的抽象隐藏的是不必要的决策,而不是把所有信息都藏起来。Inventory.add() 应让调用者知道加入多少和超限如何处理,却不必知道库存桶如何分配。若上限策略变化只影响类内部,抽象隔离得不错;若每个调用点都要跟着改,表示已经泄漏。
良好的封装
↡让表示和维持合法状态的规则只在对象边界内被修改 不是把字段改成私有后就结束,而是限制状态变化的路径,并提供表达意图的操作。构造函数建立初始条件,公开方法维护后置条件,拒绝操作保持原状态或给出明确错误。调用者不应复制一份“请先检查再写入”的规则。
6.3 有关设计和实现的问题
设计类时先画责任和依赖,再决定字段与方法。字段数量不能说明类是否合理;更有用的问题是:谁拥有数据,谁能改变它,哪种变化会让这个类重新编译或重新测试。关系选择、成员分工和构造顺序,都是在回答这些责任问题。
包含(“有一个……”的关系)
↡用一个对象持有另一个对象,把变化与责任组合在清晰边界内的关系表达“有一个”:订单有一个定价策略,窗口有一个布局策略。它把替换策略的成本限制在拥有者内部,也让每个部件维护自己的不变量。若对象只需协作而不共享表示,优先保留这种窄连接。
继承(“是一个……”关系)
↡让子类型承诺可以在父类型出现的所有位置保持同一可观察合同 表达“是一个”,但语法上的父子关系不是证据。若父类型承诺归还后数量不增加,子类型不能把归还解释成追加;若父类型允许某个输入,子类型也不能无理由收紧它。替换后若调用者必须判断真实类型,继承已破坏边界。
成员函数和数据成员
数据成员保存状态,成员函数定义状态如何变化;两者应该围绕同一个责任聚拢。把所有字段公开、再提供一组“帮忙修改”的函数,会产生两条冲突路径。更稳妥的做法是让函数接收业务动作,内部完成检查,并把不能独立成立的字段组合成一个状态。
构造函数
构造函数不是把参数抄进字段,而是建立对象可以被使用的起点。它应拒绝缺失依赖、非法范围和矛盾选项;若初始化必须分多步,就用构建器或工厂表示“尚未完成”,不要让半初始化对象流入普通接口。构造后立即检查不变量,能在最早位置暴露错误。
6.4 创建类的原因
创建类应当有可说出的收益:保护不变量、隔离变化点、让一组操作共享合同,或让多个实例彼此独立。如果只是为了把几行代码包进一个名字,函数或模块可能更合适。类的边界必须降低使用方要记住的规则数量,而不是增加一层名词。
应该避免的类
避免万能类、只存字段的类、只转发调用的类和混合不相关工具的类。它们的症状是方法来自多个变化原因,构造参数越来越长,或者调用者必须先设置一串开关才能让对象可用。识别后应拆责任、收紧状态路径,而不是继续添加条件分支。
总结:创建类的理由
一个类值得存在,当它能把规则放在最靠近状态的位置,并让调用者通过少量有名字的操作获得可预测结果。评审时问三句:它保护什么,谁拥有它,哪项变化会被隔离?三句都答不上来时,先退回函数、模块或数据结构。
与具体编程语言相关的问题
语言只提供默认可见性、构造语法、异常机制和继承规则,不能替你决定合同。C++ 要注意拷贝、移动、析构和资源所有权;TypeScript 的类型检查不会在运行时验证外部输入;Java 或 C# 的属性也可能暴露可变状态。跨语言迁移时,重新核对生命周期和失败语义。
class Inventory {
private count = 0;
constructor(private readonly capacity: number) {
if (capacity < 0) throw new Error("capacity must be non-negative");
}
add(amount: number): boolean {
if (amount < 0 || this.count + amount > this.capacity) return false;
this.count += amount;
return true;
}
}6.6 超越类:包
当多个类共同维护更大的边界时,包或模块可以继续隐藏组织细节。包的价值不是目录变深,而是让依赖方向、公开入口和内部实现成为可检查的合同。只从稳定入口导出对象,把互相依赖的类放在同一责任边界内,可以避免调用者跨包拼装内部状态。
更多资源
本章目录范围以《代码大全(第2版)》公开试读目录为准;语言层面的接口与资源管理可参照C++ Core Guidelines,软件工程中的抽象边界可参照SWEBOK。这些资料用于核对范围和通用原则,本文的实验、例子和表达均为独立重写。
关键点
类的质量取决于它是否在整个生命周期内守住合同:构造建立合法状态,接口限制变化路径,组合隔离责任,继承只有在替换合同成立时才成立,包再把多个类的边界组织起来。把每个公开操作放进“输入—状态—输出—拒绝—恢复”的轨迹中,评审才有机会找到首个偏离。
故障诊断与误区
本页小结
- ADT 先写行为合同,类再选择如何承载它。
- 不变量在构造完成后和每次公开调用后都必须成立。
- 组合表达拥有关系,继承必须通过可替换性验收。
- 封装把表示和状态规则收回对象边界,包把多个类收回模块边界。
- 练习应同时覆盖正常值、边界值、故障和复位后的重放。
练习
问题 1: 为 Inventory 设计“增加库存”操作,列出正常值、恰好达到容量、超出容量和负数四个输入的预期状态,并说明拒绝时是否改变原状态。
问题 2: 哪一种关系更适合“订单有一个定价策略”:让订单继承定价器,还是让订单持有定价器?请写出一个会迫使选择改变的需求。
问题 3: 修改本章实验组件或 Inventory 示例,增加“重置后重放”检查:注入绕过接口的故障,再点击重置,确认同一输入得到与初始轨迹相同的节点和状态。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 抽象数据类型
只规定可以做哪些操作、哪些状态合法,不规定内部数据必须怎样存。
- 不变量
对象在应该有效的时刻必须一直成立的条件,例如数量不能小于零。
- 封装
把状态和改变状态的规则关在边界里,避免调用者从旁边改坏它。
- 类接口
调用者可以依赖的一组命名操作、输入、输出和错误约定。
- 组合
一个对象持有另一个对象并与它协作,像订单拥有一个定价策略。
- 继承
子类型承诺能在父类型出现的地方保持同一套可观察行为。