封装:给每个可变数据装一道门
1. 这一章讲什么
第 05 章说过「数据要搬,先封装」。这一章展开封装的完整体系。它回答三个递进的问题:门装在哪一层(单个变量?整条记录?一个集合?一个裸的字符串值?)、责任怎么分合(一个类太胖了拆出电话号码类;一个类饿瘦了并回去)、门开多大(把委托藏起来的同时,别把客户端逼疯)。原书的总纲是一句引用:分解模块最重要的标准,是「识别出那些模块应该对外界隐藏的小秘密」1。
2. 顶层全景
裸数据 ──封装变量──→ 一对读写函数(05章已讲)
│
├─ 数据是结构化记录 ──→ 封装记录(存储 vs 计算的字段,门外不再区分)
├─ 数据是集合 ──→ 封装集合(add/remove 方法,取值返回副本)
├─ 数据是裸的字符串/数字 ──→ 以对象取代基本类型(行为有了着落点)
│
├─ 类太胖 ──→ 提炼类 责任分合 ↕
└─ 类太瘦 ──→ 内联类
│
└─ 模块间的关系 ──→ 隐藏委托关系 ↔ 移除中间人(开合旋钮)
图说:从上往下,门越装越深;中间两行管类自身的胖瘦,最后一行管类与类之间的距离。
3. 门装在哪一层:记录、集合、裸值
封装记录解决的是记录型结构最恼人的缺陷:「它强迫我清晰地区分“记录中存储的数据”和“通过计算得到的数据”」2。作者的例子是区间:同一个区间,可以存 {start:1, end:5},也可以存 {start:1, length:5};但使用者想知道的永远是三个 值——起点、终点、长度。用类包一层,门外的人要什么给什么,不必知道里面怎么存;改名也能渐进——新老两个名字的访问函数并存,客户端一个个迁。分寸感也有:这个偏爱只对可变数据成立,「如果数据不可变,我大可直接将这3个值保存在记录里」3。另一种常见的记录是语言自带的散列/map:字段有什么全靠猜,只能去翻创建点和使用点——散布越广越该换成类。
封装集合防的是最常见的封装漏洞:「只对集合变量的访问进行了封装,但依然让取值函数返回集合本身」——门外照样能往集合里塞东西,类毫不知情4。正解是给类配上「添加」「移除」方法,让所有修改经过类;为什么不能指望自觉?「依赖于别人的好习惯是不明智的,一个细小的疏忽就可能带来难以调试的bug」5。取值函数返回什么,作者比较了三种方案后取「返回副本」:有人改副本,不影响库里那份;但也不赞成用 numberOfOrders 这类特殊方法把集合操作全部包掉——那会牺牲集合管道的组合能力。最后一条纪律比方案选择更重要:「最重要的是在同个代码库中做法要保持一致」,让每个人调用集合访问函数时都能预期同样的行为6。
主走查(以对 象取代基本类型,Priority 类的生长史,数据均为原书示例): 订单类 Order 里有一个优先级字段,存字符串,客户端这样筛高优先订单:orders.filter(o => "high" === o.priority || "rush" === o.priority)。第一步,给字段封装一对读写函数;第二步,建一个最小值类 Priority,构造函数收字符串、带一个 toString 返回原值——作者特意用 toString 而不叫 getValue,因为「返回字符串描述的API应该更能传达“发生了数据转换”的信息」7;第三步,设值函数改存对象:set priority(aString) {this._priority = new Priority(aString)};第四步,发现取值函数返回字符串会误导,改名为 priorityString;第五步,客户端开始直接拿 Priority 对象比较——类上长出 higherThan(new Priority("normal")) 这样的行为方法,还能在构造函数里校验非法值。走查的终点是作者的关键论断:这个类「只要悉心照料,便能成长为有用的工具」——「许多经验丰富的开发者认为,这是他们工具箱里最实用的重构手法之一」,而新手最常低估它8。判断时机一句话:对某个数据的操作「不仅仅局限于打印」时,就为它建类9。