封装记录(Encapsulate Record | 162)
曾用名:以数据类代替记录(Replace Record with Data Class)
organization = {name: "Acme Gooseberries", country: "GB"};
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {return this._name;}
set name(arg) {this._name = arg;}
get country() {return this._country;}
set country(arg) {this._country = arg;}
}
动机
记录型结构是多数编程语言提供的一种常见特性。它们能直观的组织起存在关联的数据,可以将数据作为有意义的端元传递,而不仅是一堆数据的拼凑。但简单的记录型结构也有缺陷,需要清晰的区分“记录中存储的数据”和“通过计算得到的数据”。
对于可变数据,建议使用类对象,对象可以隐藏结构的细节,该对象的用户不必追究存储的细节和计算的过程,同时,这种封装还有助于字段的改名(外界通过访问函数来获取字段的值)。
记录型结构可以有两种类型:一种需要声明合法的字段名字,另一种可以随便用任何字段名字。后者常由语言库本身实现,并通过类的形式提供出来,这些类称为散列(hash)、映射(map)、散列映射(hashmap)、字典(dictionary)或关联数组(associative array)等。但使用这类结构也有缺陷,那就是一条记录上持有什么字段往往不够直观。
程序中间常常需要互相传递嵌套的列表(list)或散列映射结构,这些数据结构后续经常需要被序列化成JSON或XML。这样的嵌套结构同样值得封装,这样,如果后续其结构需要变更或者需要修改记录内的值,封装能更好地应对变化。
做法
-
对持有记录的变量使用封装变量(132),将其封装到一个函数中。
-
创建一个类,将记录包装起来,并将记录变量的值替换为该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数。
-
测试
-
新建一个函数,让它返回该类的对象,而非那条元素的记录。
-
对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数来获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试。
如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
-
移除类对元素记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
-
测试
-
如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录(162)或封装集合(170)手法。
封装集合(Encapsulate Collection | 170)
class Person {
get courses(){return this._courses;}
set courses(aList){this._courses-aList;}
// ...
}
class Person {
get courses(){return this.courses.slice();}
addCourse(aCourse){...}
removeCourse(aCourse){...}
// ...
}
动机
封装程序中的所有可变数据,可以很容易看清楚数据被修改的地点和修改方式,需要更改数据结构时就非常方便。但封装集合时通常会犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。为避免此种情况,可以在类上提供一些修改集合的方法——通常是“添加”和“移除”方法。
一种避免直接修改集合的方法是,永远不直接返回集合的值。这种方法提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替。
还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作(比如,在Java中可以很容易地返回集合的一个只读代理)。
最常见的做法是,为集合提供一个取值函数,但令其返回一个集合的副本。这样即使有人修改了副本,被封装的集合也不会受到影响。
采用哪种方法并无定式,最重要的是在同个代码库中做法要保持一致。
做法
- 如果集合的引用尚未被封装起来,先用封装变量(132)封装它
- 在类上添加用于“添加集合元素”和“移除集合元素”的函数(如果存在对集合的设值函数,尽可能先用移除设值函数(331)移除它。如果不能移除该设值函数,至少让它返回集合的一个副本)
- 执行静态检查
- 查找集合的引用点。如果有调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试
- 修改集合的取值函数,使其返回一份只读数据,可以使用只读代理或者数据副本。
- 测试
以对象取代基本类型(Replace Primitive with Object | 174)
曾用名:以对象取代数据值(Replace Data Value with Object)
曾用名:以类取代类型码(Replace Type Code with Class)
orders.filter(o => "high" === o.priority || "rush" === o.priority);
orders.filter(o => o.priority.higherThan(new Priority("normal")))
动机
开发初期,往往决定以简单的数据项表示简单的情况,比如使用数字或字符串等。但随着开发的进行,可能会发现这些简单数据不再那么简单。
一旦发现随某个数据的操作不仅仅局限于打印时,可以为它创建一个新类。一开始这个类也许只是简单包装一下简单类型的数据,不过只要有了类,日后添加业务累哦及就简单多了。
做法
- 如果变量尚未被封装起来,先使用封装变量(132)封装它。
- 为这个数据值创建一个简单的类。类的构造函数应该保存这个数据值,并为它提供一个取值函数。
- 执行静态检查。
- 修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明
- 修改取值函数,令其调用新类的取值函数,并返回结果。
- 测试
- 考虑对第一步得到的访问函数使用函数改名(124),以便更好反应其用途。
- 考虑应用将引用对象改为值对象(252)或将值对象改为引用对象(256),明确指出先对象的角色是值对象还是引用对象。
以查询取代临时变量(Replace Temp with Query | 178)
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000)
return basePrice * 0.95;
else
return basePrice * 0.98;
get basePrice() {this._quantity * this._itemPrice;}
...
if (this.basePrice() > 1000)
return this.basePrice() * 0.95;
else
return this.basePrice() * 0.98;
动机
临时变量的一个作用是保存某段代码的返回值,以便在函数的后面部分使用它。临时变量允许编程中引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得更进一步,将它们抽取成函数。
抽取成函数能避免在多个函数中重复编写计算逻辑。
这项重构手法在类中施展效果最好,因为类为待提炼函数提供了一个共同的上下文。
以查询取代临时变量(178)手法只适用于处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。
做法
- 检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到一样的值
- 如果变量目前不是只读的,但是可以改造成为只读变量,那就先改造它。
- 测试
- 将为变量赋值的代码提炼成函数(确保待提炼函数没有副作用。若有,先应用将查询函数和修改函数分离(306)手法隔离副作用)
- 测试
- 应用内联变量(123)手法移除临时变量
提炼类(Extract Class |182 )
反向重构:内联类(186)
class Person {
get officeAreaCode() {return this._officeAreaCode;}
get officeNumber() {return this._officeNumber;}
}
class Person {
get officeAreaCode() {return this._telephoneNumber.areaCode;}
get officeNumber() {return this._telephoneNumber.number;}
}
class TelephoneNumber {
get areaCode() {return this._areaCode;}
get number() {return this._number;}
}
动机
你也许听过类似这样的建议:一个类应该是一个清晰的抽象,只处理一些明确的责任,等等。但是在实际工作中,类会不断成长扩展。你会在这儿加入一些功能,在那儿加入一些数据。给某个类添加一项新责任时,你会觉得不值得为这项责任分离出一个独立的类。于是,随着责任不断增加,这个类会变得过分复杂。很快,你的类就会变成一团乱麻。
维护一个有大量函数和数据的类,这样的类往往因为太大而不易理解。此时需要考虑哪些部分可以分离出去,并将它们分离到一个独立的类中。如果某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示应该将它们分离出去。
往往在开发后期出现的信号是类的子类化方式。如果发现子类化只影响类的部分特性,或如果发现某些特性需要以一种方式来子类化,某些特性则需要以另一种方式子类化,这就意味着需要分解原来的类。
做法
- 决定如何分解类所负的责任。
- 创建一个新的类,用以表现从旧类中分离出来的责任(如果旧类剩下来的责任与旧类的名称不符合,为旧类改名)
- 构造旧类时创建一个新类的实列,建立“从旧类访问新类”的连接关系
- 对于想搬移的每一个字段,运用搬移字段(207)搬移之。每次更改后测试。
- 使用搬移函数(198)将必要函数搬移到新类。先搬移较低层的函数(也就是“被其他函数调用”多于“调用其他函数”者)。每次更改后运行测试
- 检查两个类的接口,去掉不再需要的函数,必要时为函数重新取一个合适新环境的名字
- 决定是否公开新的类。如果确定需要,考虑新类应用将引用对象改为值对象(252)使其成为一个值对象
内联类(Inline Class | 186)
反向重构:提炼类(182)
class Person {
get officeAreaCode() {return this._telephoneNumber.areaCode;}
get officeNumber() {return this._telephoneNumber.number;}
}
class TelephoneNumber {
get areaCode() {return this._areaCode;}
get number() {return this._number;}
}
class Person {
get officeAreaCode() {return this._officeAreaCode;}
get officeNumber() {return this._officeNumber;}
}
动机
内联类正好与提炼类(182)相反。如果一个类不再承担足够责任,不再有单独存在的理由(这通常是因为此前的重构动作移走了这个类的责任),将这个类塞进另外一个类中。
应用这个手法的另一个场景是,手头有两个类,想重新安排它们肩负的职责,并让它们产生关联。这时发现先用本手法将它们内联成一个类再用提炼类(182)去分离其职责会更加简单。
做法
- 对于待内联类(源类)中的所有public函数,在目标类上创建一个对应的函,新创建的所有函数应该直接委托至源类。
- 修改袁磊public方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试。
- 将源类中的函数于数据全部搬移到目标类,每次修改之后进行测试,直到源类编程空壳为止。
- 删除源类
隐藏委托关系(Hide Delegate | 189)
反向重构:移除中间人(192)
manager = aPerson.department.manager;
manager = aPerson.manager;
class Person {
get manager() {return this.department.manager;}
}
动机
一个好的模块化的设计,“封装”是最关键特征之一。“封装”意味着每个模块都应该尽可能少了解系统的其他部分。一旦发生变化,需要了解这一变化的模块就会比较少,这会使变化比较容易进行。
如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会波及通过服务对象使用它的所有客户端。
做法
- 对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
- 调整客户端,令它只调用服务对象提供的函数。每次调整后运行测试。
- 如果将来不再有任何客户端需要取用Delegate(受托类),便可移除服务对象中的相关函数。
- 测试。
移除中间人(Remove Middle Man | 192)
反向重构:隐藏委托关系(189)
manager = aPerson.manager;
class Person {
get manager() {return this.department.manager;}
}
manager = aPerson.department.manager;
动机
“封装受托对象”这层封装也是有代价的。每当客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数。随着受托类的特性(功能)越来越多,更多的转发函数就会使人烦躁。服务类完全变成了一个中间人(81),此时就应该让客户直接调用受托类。
很难说什么程度的隐藏才是合适的,可以在系统运行过程中不断进行调整。
做法
-
为受托对象创建一个取值函数。
-
对于每个委托函数,让其客户端转为连续的访问函数调用。每次替换后运行测试
替换完委托方法的所有调用点后,就可以删掉这个委托方法了。
这能通过可自动化的重构手法来完成,可以先对受托字段使用封装变量(132),再应用内联函数(115)内联所有使用它的函数。
替换算法(Substitute Algorithm | 195)
function foundPerson(people) {
for(let i = 0; i < people.length; i++) {
if (people[i] === "Don") {
return "Don";
}
if (people[i] === "John") {
return "John";
}
if (people[i] === "Kent") {
return "Kent";
}
}
return "";
}
function foundPerson(people) {
const candidates = ["Don", "John", "Kent"];
return people.find(p => candidates.includes(p)) || '';
}
动机
如果发现做一件事可以有更清晰的方式,就用比较清晰的方式取代复杂的方式。“重构”可以把一些复杂的东西分解为较简单的小块。
替换一个巨大且复杂的算法是非常困难的,只有先将它分解为较简单的小型函数,才能很有把握地进行算法替换工作。
做法
- 整理一下待替换的算法,保证它已经被抽取到一个独立的函数中。
- 先只为这个函数准备测试,以便固定它的行为。
- 准备好另一个(替换用)算法。
- 执行静态检查。
- 运行测试,比对新旧算法的运行结果。如果测试通过,那就大功告成;否则,在后续测试和调试过程中,以旧算法为比较参照标准。