跳到主要内容

封装:给每个可变数据装一道门

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

4. 责任的分与合:提炼类、内联类

类会不断长大:这儿加一点功能,那儿加一点数据,每次都「不值得单独拆一个类」,直到「你的类就会变成一团乱麻」10。提炼类的信号很具体:某些数据和函数总是一起出现、经常同时变化;还有一个自检问题——「如果你搬移了某些字段和函数,会发生什么事?其他字段和函数是否因此变得无意义?」无意义,就说明它们本来就该一起走11。原书范例是 Person 里的 officeAreaCode/officeNumber 搬进 TelephoneNumber 类,搬完顺手清理:office 前缀在电话号码类里没了道理,函数改名。

反向的内联类处理「萎缩类」:一个类不再承担足够责任、失去单独存在的理由(常因为此前的重构把责任搬走了),就把它塞进最频繁的使用者里12。另一个妙用是重新安排职责:两个类关系要调整时,「先用本手法将它们内联成一个类再用提炼类去分离其职责会更加简单」——先合后分,常常比直接搬家容易13

5. 门开多大:隐藏委托与移除中间人的拉锯

封装在模块关系上的应用是隐藏委托关系:客户端写 aPerson.department.manager,就等于知道「部门负责记录经理」——受托类一改接口,所有客户端遭殃。解法是在 Person 上放一个委托函数 get manager(),把 Department 藏起来;封装的定义随之落地:「每个模块都应该尽可能少了解系统的其他部分」14

但门开太小有代价:受托类每多一个特性,服务类就要多写一个转发函数,写到后来「服务类完全变成了一个中间人」——这时就该用移除中间人把门重新打开。作者对盲目信条补了一刀:这味道「通常在人们狂热地遵循迪米特法则时悄然出现。我总觉得,如果这条法则当初叫作“偶尔有用的迪米特建议”,如今能少很多烦恼」15

这两个手法互为反向,而且没有正确答案——合适程度随代码演化漂移。作者给的解法是把问题交给过程:「6个月前恰如其分的封装,现今可能就显得笨拙。重构的意义就在于:你永远不必说对不起——只要把出问题的地方修补好就行了」16。这句话是整章的方法论:封装程度不是设计出来的,是随重构不断调整出来的。

6. 最后一招:替换算法

前面所有手法都是渐进改造,替换算法是唯一的例外:有时就该「壮士断腕,删掉整个算法,代之以较简单的算法」17。触发它的情形:对问题理解加深后发现更简单的解法、引入的库和自己的代码重复、想让函数做一件略有差异的事。它的安全绳和别处不同——先备好测试:把待替换函数先抽成独立函数、用测试固定行为,再换,新旧结果对不上立刻知道。原书例子把一个逐个比对名字的循环换成一行 people.find(p => candidates.includes(p)):行为不变,读的成本骤降。

7. 作者的判断与证据

判断证据
封装的对象远不止字段:记录、集合、委托关系都值得藏全章结构本身;Parnas 的引用1
集合封装必须返回副本/只读,不能靠团队自觉给出失效场景(测试顺序相关的诡异 bug)5
以对象取代基本类型是「最实用但被低估」的手法引「许多经验丰富的开发者」的共识,作者附议(经验立场)8
封装程度无定式,靠隐藏/移除中间人往返调整方法论立场,「你永远不必说对不起」16

判断(我们的,不是书里的): 本章七种手法共享同一个成本模型——每道门都有维护费(转发函数、副本开销、多一层间接),收益是「修改点可数」。所以「门装几道」的正确答案是数修改频率:被改得越频繁的数据,越值得多装门;一次写入永不修改的数据,一道门都是浪费。这个算法化表述是我们提炼的,书里的对应说法只有「数据被使用得越广,就越是值得花精力给它一个体面的封装」18如果错,会错在: 如果某份数据的使用模式从「写多」变成「只读」(比如变成配置),门就该拆掉,否则封装自己变成坏味道「中间人」。

8. 边界与局限

  • 返回副本对大集合有性能开销;作者沿用全书口径:多数列表没那么大,真到瓶颈再优化19
  • 副本与只读代理有个行为差异:对源数据的修改会反映到代理、不会反映到副本;同库必须统一选一种20
  • 迪米特法则的吐槽不等于法则失效——作者的立场是「偶尔有用」,不是「没用」。
  • 替换算法是名录里唯一「先有测试再动手」为硬前提的手法;没有测试时它最危险。

9. 可带走的

  1. 封装=控制修改的入口;装门的层级跟数据被碰的广度走。
  2. 记录里「存的」和「算的」不要让门外的人区分——包成类。
  3. 集合封装三件套:add/remove 方法、取值返回副本、全库做法统一。
  4. 字符串开始需要格式化、校验、比较之外的行为时,给它建类;别用裸值硬扛。
  5. 类太胖:找「总一起出现、一起变化」的数据块搬出去;自检「搬走后剩下的还有意义吗」。
  6. 类太瘦:内联掉;重新安排职责时先合后分。
  7. a.b().c() 链和满屏转发函数是同一味药的两面,封装程度随修改频率来回调。
  8. 换算法前先把旧算法锁进测试。

10. 原文地图

主题原书章原文位置
隐藏小秘密第7章 开篇text/15-ch07.txt:3(搜「对外界隐藏的小秘密」)
记录的缺陷 / 区间第7章 §7.1text/15-ch07.txt:39(搜「记录中存储的数据」) · text/15-ch07.txt:41(搜「华丽的编程技巧」)
可变才偏爱类第7章 §7.1text/15-ch07.txt:44(搜「更偏爱使用类对象」)
集合封装漏洞第7章 §7.2text/15-ch07.txt:381(搜「常常犯一个错误」)
好习惯不明智第7章 §7.2text/15-ch07.txt:388(搜「依赖于别人的好习惯」)
副本 / 一致性第7章 §7.2text/15-ch07.txt:402(搜「返回一个集合的副本」) · text/15-ch07.txt:411(搜「做法要保持一致」)
Priority 生长史第7章 §7.3text/15-ch07.txt:573(搜「highPriorityCount」) · text/15-ch07.txt:596(搜「toString」) · text/15-ch07.txt:644(搜「instanceof Priority」)
建类时机 / 被低估第7章 §7.3text/15-ch07.txt:539(搜「不仅仅局限于打印」) · text/15-ch07.txt:543(搜「最实用的重构手法之一」)
一团乱麻第7章 §7.5text/15-ch07.txt:842(搜「一团乱麻」)
提炼类信号第7章 §7.5text/15-ch07.txt:845(搜「总是一起出现」) · text/15-ch07.txt:847(搜「变得无意义」)
萎缩类第7章 §7.6text/15-ch07.txt:982(搜「萎缩类」) · text/15-ch07.txt:986(搜「内联成一个类再用提炼类」)
封装定义第7章 §7.7text/15-ch07.txt:1091(搜「尽可能少了解」)
迪米特吐槽第7章 §7.8text/15-ch07.txt:1166(搜「偶尔有用的迪米特建议」)
永不必说对不起第7章 §7.8text/15-ch07.txt:1171(搜「永远不必说对不起」)
替换算法第7章 §7.9text/15-ch07.txt:1256(搜「壮士断腕」) · text/15-ch07.txt:1270(搜「固定它的行为」)

Footnotes

  1. 出处:「封装」第 3 段(text/15-ch07.txt:3,搜「对外界隐藏的小秘密」)。 2

  2. 出处:「封装」第 39 段(text/15-ch07.txt:39,搜「记录中存储的数据」)。

  3. 出处:「封装」第 44 段(text/15-ch07.txt:44,搜「更偏爱使用类对象」)。

  4. 出处:「封装」第 381 段(text/15-ch07.txt:381,搜「常常犯一个错误」)。

  5. 出处:「封装」第 388 段(text/15-ch07.txt:388,搜「依赖于别人的好习惯」)。 2

  6. 出处:「封装」第 411 段(text/15-ch07.txt:411,搜「做法要保持一致」)。

  7. 出处:「封装」第 596 段(text/15-ch07.txt:596,搜「toString」)。

  8. 出处:「封装」第 543 段(text/15-ch07.txt:543,搜「最实用的重构手法之一」);生长过程在第 539-541 段(text/15-ch07.txt:539,搜「不仅仅局限于打印」)。 2

  9. 出处:「封装」第 539 段(text/15-ch07.txt:539,搜「不仅仅局限于打印」)。

  10. 出处:「封装」第 842 段(text/15-ch07.txt:842,搜「一团乱麻」)。

  11. 出处:「封装」第 847 段(text/15-ch07.txt:847,搜「变得无意义」)。

  12. 出处:「封装」第 982 段(text/15-ch07.txt:982,搜「萎缩类」)。

  13. 出处:「封装」第 986 段(text/15-ch07.txt:986,搜「内联成一个类再用提炼类」)。

  14. 出处:「封装」第 1091 段(text/15-ch07.txt:1091,搜「尽可能少了解」);manager 例子在第 1132 段(text/15-ch07.txt:1132,搜「aPerson.department.manager」)。

  15. 出处:「封装」第 1166 段(text/15-ch07.txt:1166,搜「偶尔有用的迪米特建议」)。

  16. 出处:「封装」第 1171 段(text/15-ch07.txt:1171,搜「永远不必说对不起」)。 2

  17. 出处:「封装」第 1256 段(text/15-ch07.txt:1256,搜「壮士断腕」)。

  18. 出处:「第一组重构」第 1177 段(text/14-ch06.txt:1177,搜「数据被使用得越广」)。

  19. 出处:「封装」第 405 段(text/15-ch07.txt:405,搜「性能问题」)。

  20. 出处:「封装」第 408 段(text/15-ch07.txt:408,搜「数据代理和数据复制的另一个区别」)。