封装
分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了。类与模块已然是实施封装的最大实体了,但小一点的函数对于封装实现细节也有益处。
封装记录
动机
记录型结构是多数编程语言提供的一种常见特性。他们能直观地组织起存在关联的数据,但简单的记录型结构也有缺陷,它强迫我清晰地区分”记录中存储的数据“和”通过计算得到的数据“。
这就是对于可变数据,我总是更偏爱使用类对象而非记录的原因。对象可以隐藏结构的细节,该对象的用户不必追究存储的细节和计算的过程。另外,这是对于可变数据,对于不可变数据,大可直接把数据直接保存在记录里,需要做数据变换时增加一个填充步骤即可。
记录型结构可以有两种类型,一是需要声明合法的字段名字,另一种可以随便用任何字段名字,后者常用语言库本身实现,并通过类的形式提供出来。使用这类结构也有缺陷,就是一条记录上持有什么字段往往不够直观。
做法
- 对持有记录的变量使用封装变量,将其封装到一个容易搜索到名字的函数中。
- 创建一个类,将记录包装起来,并将记录变量都值替换为该类的一个实例。然后再类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数。令其使用这个访问函数。
- 测试。
- 新建一个函数,让它返回该类的对象,而不是那条原始的记录。
- 对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例的函数调用。使用对象上的访问函数来获取数据的字段。
- 一处类对于原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
- 测试。
- 如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录。
范例
首先,我从与一个常量开始,该常量在程序中被大量使用。
// 这是一个普通的 JS 对象,程序中很多地方都把它当作记录型结构在使用
const organization = {
name: "Acme Gooseberries",
country: "GB",
};
// 下面是对其进行读取和更新的地方
result += `<h1>${organization.name}</h1>`;
organization.name = newName;
使用一个类进行封装
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
get name() {
return this._name;
}
set name(aString) {
this._name = aString;
}
get country() {
return this._country;
}
set country(aCountryCode) {
this._country = aCountryCode;
}
}
const organization = new Organization({
name: "Acme Gooseberries",
country: "GB",
});
function getOrganization() {
return organization;
}
result += `<h1>${getOrganization().name}</h1>`;
这样做可以使外界无法引用原始的数据记录,直接持有原始的记录会破坏封装的完整性。
封装嵌套记录
上面的例子我们使用了浅复制展开到了对象里,但当我处理深层嵌套的数据时,重构手法的核心步骤依然适用,但对记录的读取点则有多种处理方案。
下面是一组顾客信息的集合,保存在散列映射中,通过顾客 ID 进行索引
"1920": {
name: 'martin',
id: '1920',
usages: {
'2016': {
'1': 50,
'2': 55
},
'2015': {
'1': 70,
'2': 63
}
}
},
'38765': {
name: 'neal',
id: '38673'
}
// 对数据进行更新
customerData[customerID].usages[year][month] = amount
// 读取数据
function compareUsage(customerID, laterYear, month) {
const later = customerData[customerID].usages[lateYear][month]
const earlier = customerData[customerID].usages[lateYear - 1][month]
return {
laterAmount: later,
change: later - earlier
}
}
接下来创建一个类来容纳整个数据结构
class CustomerData {
constructor(data) {
this._data = data
}
get rawData() {
return _.clone(this._data)
}
setUsage(customerID, year, month, amount) {
this._data[customerID].usages[year][month] = amount
}
}
const getCustomerData() {
return new CustomerData(customerData)
}
getCustomerData().setUsage(customerID, year, month, amount)
封装大型数据时,我会更多关注更新操作,将它们集中到一个地方,是此次封装过程最重要的一部分。
另一个方式是,返回一份只读的数据代理,如果客户端代码尝试修改对象的结构,那么该数据代理就会抛出异常。读取数据有下面几种方式。
第一种是与设值函数采用同等待遇,把所有对数据的读取提炼成函数,并将它们搬移到 CustomerData 类中
class CustomerData...
usage(customerID, year, month) {
return this._data[customerID].usage[year][month]
}
这种方式能够为 customerData 提供一份清晰的 API 列表,清楚描绘了该类的全部用途,但这样会使代码量剧增,现代编程语言大多提供直观的语法,,以支持从深层的列表和散列结构中获得数据,因此直接把这样的数据结构给到客户端,也不失为一种选择。
如果客户端想要拿到一份数据结构,我大可直接将实际的数据交出,但这样做我无法阻止用户直接对数据进行修改。最简单的方法是返回原始数据的一份副本,但这种方案会导致复制举到的数据结构时代价颇高。另一种方案需要更多工作,但能提供更可靠的控制粒度:对每个字段循环应用封装记录。
封装集合
动机
我喜欢封装程序中的所有可变数据,这使我很容易看清楚数据被修改的地点和修改方式。我们通常鼓励封装——使用面向对象技术的开发者对封装尤其重视——但封装集合时人们常常犯一个错误:只对集合变量的访问进行了封装,但依然让取值函数返回集合本身。这使得集合的成员变量可以直接被修改,而封装它的类则全然不知,无法介入。
最好的做法是,不要让集合的取值函数返回原始集合,这就避免了客户端的意外修改。
一种避免直接修改集合的方式是,永远不要直接返回集合的值,这种方式提倡,不要直接使用集合的字段,而是通过定义类上的方法来代替,比如将 aCustomer.orders.size 替换为 aCustomer.numberOfOrders
还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作,比如返回一个只读代理。
也许最常见的做法是,危机和提供一个取值函数,令其返回一个集合的副本,但如果这个集合很大,这个做法可能带来性能问题,,但多数列表都没这么大。
采用哪种方法并无定式,但最重要的是在同个代码库中做法要保持一致。
做法
- 如果集合的引用尚未封装起来,先用封装变量封装它。
- 在类上添加用于“添加集合元素”和“移除集合元素”的函数
- 执行静态检查。
- 查找集合的引用点,如果由调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试。
- 修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。
- 测试。
范例 假设有个人要去上课,我们用一个简单的 Course 来表示 “课程”
class Person...
constructor(name) {
this._name = name
this._courses = []
}
get name() {
return this._name
}
get courses() {
return this._courses
}
set courses(aList) {
this._courses = aList
}
class Course...
constructor(name) {
this._name = name
this._isAdvanced = isAdvanced
}
get name() {
return this._name
}
get isAdvanced() {
return this._isAdvanced
}
// 客户端会使用课程集合来获取课程的相关信息
numAdvancedCourses = aPerson.coursers
.filter(c => c.isAdvanced)
.length
// 客户端代码
const basicCourseNames = readBasicCourseNames(filename)
aPerson.courses = basicCourseNames.map(name => new Course(name, false))
// 但客户端也可能发现,直接更新课程列表显然更容易
for (const name of readBasicCourseNames(filename)) {
aPerson.courses.push(new Course(name, false))
}
上面就破坏了封装性,因为以此种方式更新列表 Person 类更笨无从得知。这里仅仅封装了字段引用,而未真正封装字段的内容。
现在我来对类实施真正的封装
class Person...
addCourse(aCourse) {
this._courses.push(aCourse)
}
removeCourse(aCourse, fnIfAbsent = () => {
throw new RangError()
}) {
const index = this._courses.indexOf(aCourse)
if (index === -1) fnIfAbsent()
else this._courses.splice(index, 1)
}
// 如果必须提供一个设置方法作为API,我至少要确保用一份副本为字段赋值
set courses(aList) {
this._courses = aList.slice()
}
// 同时我还希望确保所有的修改都通过这些方法进行,为达此目的,我会让取值函数返回一份副本
get courses() {
return this._courses.slice()
}
任何负责管理集合的类都应该总是返回数据副本,只要我做的事看起来可能改变集合,我也会返回一个副本。
以对象取代基本类型
动机
开发初期,你可能会用简单的数据项来表示简单的情况,比如使用数字或字符串来表示电话号码等。但随着开发的进行,你可能会发现,这些简单的数据项不再是那么简单了,比如电话号码可能还需要格式化,抽取区号之类的特殊行为,这类逻辑很快会制造出许多重复代码。
一旦我对某个数据不仅仅局限于打印时,我就会为它创建一个新类,创建新类无需太大的工作量,但它们对代码库有深远的影响,实际上,很多有经验的开发者认为,这是他们工具箱里最实用的重构手法之一。
做法
- 如果变量尚未被封装起来,先使用封装变量封装它。
- 为这个数据值创建一个简单的类,类的构造函数应该保存这个数据值,并为它提供一个取值函数。
- 执行静态检查。
- 修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明。
- 修改取值函数,令其调用新类的取值函数,并返回结果。
- 测试。
- 对第一步得到的访问函数使用函数改名。
- 考虑应用将引用对象改为值对象或将值对象改为引用对象。
范例
从一个简单的订单(order)类开始。该类有冲一个简单的记录结构里读取所需要的数据,其中有一个 priority 字段。
class Order...
constructor(data) {
this.priority = data.priority
// more initialization
}
// 客户端代码
highPriorityCount = orders.filter(o => 'high' === o.priority || 'rush' === o.priority).length
接下来对这个字段进行处理
class Order...
get priority() {
return this._priority
}
get priorityString() {
return this._priority.toString()
}
set priority(aString) {
this._priority = new Priority(aString)
}
class Priority {
constructor(value) {
this._value = value
}
// 一个返回字符串描述的 API 更能传达“发生了数据转换”的信息
toString() {
return this._value
}
}
// 客户端
highPriorityCount = orders.filter(o => 'high' === o.priority.toString() || 'rush' === o.priority.toString()).length
此时我可以开始支持让 Order 类的客户端拿着 Priority 实例来调用设值函数,这可以通过调整 Priority 类的构造函数来实现。
class Priority...
constructor(value) {
if (value instanceof Priority) return value
this._value = value
}
这样做的意义在于,新的 Priority 类可以容纳更多的业务行为——无论是新的业务代码,还是从别处搬移过来的,下面是一些比较逻辑。
class Priority...
constructor(value) {
if (value instanceof Priority) return value
if (Priority.legalValues().includes(value))
this._value = value
else
throw new Error(`<${value}> is invalid for Priority`)
this._value = value
}
toString() {
return this._value
}
// 确保 index 值不可修改
get _index() {
return Priority.legalValues().findIndex(s => s === this._value)
}
static legalValues() {
return ['low', 'normal', 'high', 'rush']
}
equals(other) {
return this._index === other._index
}
higherThan(other) {
return this._index > other._index
}
lowerThan(other) {
return this._index < other._index
}
// 客户端
highPriorityCount = orders.filter(o => o.priority.higherThan(new Priority('normal'))).length
以查询取代临时变量
动机
临时变量的一个作用是保存某段代码的返回值,以哦便在函数的后面部分使用它。临时变量允许我引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得将它们抽取成函数。
这项函数在类中施展效果最好,因为类为待提炼函数提供了一个共同的上下文。以查询取代临时变量手法只是用预处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。
做法
- 检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到同样的值。
- 如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它。
- 测试。
- 将为变量赋值的代码段提炼成函数。
- 测试。
- 应用内联变量手法移除临时变量。
范例
这里有一个简单的订单类
class Order...
constructor(quantity, item) {
this._quantity = quantity
this._item = item
}
get price() {
var basePrice = this._quantity * this._item.price
var discountFactor = 0.98
if (basePrice > 1000) discountFactor -= 0.03
return basePrice * discountFactor
}
我希望吧 basePrice 和 discount Factor 两个临时变量变成函数。
class Order...
constructor(quantity, item) {
this._quantity = quantity
this._item = item
}
get price() {
return this.basePrice * this.discountFactor
}
get basePrice() {
return this._quantity * this._item.price
}
get discountFactor() {
var discountFactor = 0.98
if (this.basePrice > 1000) discountFactor -= 0.03
return discountFactor
}
提炼类
动机
你也许听过类似这样的建议:一个类应该是一个清晰的抽象,只处理一些明确的责任,等等。但在实际工作中,随着责任不断增加,这个类会变得过于复杂。
如果类太大,类中的某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。另一个往往在开发后期出现的信号是类的子类化方式。
做法
- 决定如何分解类所负的责任。
- 创建一个新的类,用以表现从旧类中分离出来的责任。
- 构造旧类时创建一个新类的实例,建立“从旧类访问新类”的连接关系。
- 对于你想搬移的字段,运用搬移字段搬移之。每次更改后进行测试。
- 使用搬移函数将必要函数搬移到新类。先搬移较低层函数(也就是多次被其他函数调用)。每次更改后进行测试。
- 检查两个类的接口,去掉不再需要的函数。
- 决定是否公开新的类。如果确实需要,考虑对新类应用将引用对象改为值对象使其成为一个值对象。
范例 我们从一个简单的 Person 类开始
class Person...
get name() {
return this._name
}
set name(arg) {
this._name = arg
}
get telephoneNumber() {
return `(${this.officeAreaCode}) ${this.officeNumber}`
}
get officeAreaCode() {
return this._officeAreaCode
}
set officeAreaCode(arg) {
this._officeAreaCode = arg
}
get officeNumber() {
return this._officeNumber
}
set officeNumber(arg) {
this._officeNumber = arg
}
这里,我可以将与电话号码相关的行为分离到一个独立的类中。
class Person...
constructor() {
this._telephoneNumber = new TelephoneNumber()
}
get telephoneNumber() {
return this._telephoneNumber.toString()
}
get officeAreaCode() {
return this._telephoneNumber._areaCode
}
set officeAreaCode(arg) {
this._telephoneNumber._areaCode = arg
}
get officeNumber() {
return this._telephoneNumber.number
}
set officeNumber(arg) {
this._telephoneNumber.number = arg
}
class TelephoneNumber...
toString() {
return `(${this.areaCode}) ${this.number}`
}
get areaCode() {
return this._areaCode
}
set areaCode(ary) {
this._areaCode = arg
}
get number() {
return this._number
}
set number(arg) {
this._number = arg
}
内联类
动机
内联类正好与提炼类相反,如果一个类不再提供足够责任,不再有单独存在的理由(通常是因为重构动作移走了这个类的责任),我就会将这个类塞进另一个类中。
应用这个手法的拎一个场景是,我手头有两个类,想重新安排它们肩负的责任,并让它们产生关联。
做法
- 对于待内联类(源类)中所有的 public 函数,在目标类上创建一个对应的函数,新创建的所有函数应该直接委托至源类。
- 修改源类 public 方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试。
- 将源类中的函数和数据全部搬移到目标类,每次修改后进行测试,知道源类变成空壳。
- 删除源类。
范例
下面这个类存储了一次物流运输的若干跟踪信息。
class TrackingInformation {
get shippingCompany() {
return this._shippingCompany
}
set shippingCompany(arg) {
this._shippingCompany = arg
}
get trackingNumber() {
return this._trackingNumber
}
set trackingNumber(arg) {
this._trackingNumber = arg
}
get display() {
return `${this.shippingCompany}: ${this.trackingNumber}`
}
}
// 它作为 Shipment 类的一部分被使用。
class Shipment...
get trackingInfo() {
return this._trackingInformation.display
}
get trackingInformation() {
return this._trackingInformation
}
set trackingInformation(aTrackingInformation) {
this._trackingInformation = aTrackingInformation
}
// 调用方
aShipment.trackingInformation.shippingCompany = request.vendor
变为
class Shipment...
get trackingInfo() {
return `${this.shippingCompany}: ${this.trackingNumber}`
}
get shippingCompany() {
return this._shippingCompany
}
set shippingCompany(arg) {
this._shippingCompany = arg
}
get trackingNumber() {
return this._trackingNumber
}
set trackingNumber(arg) {
this._trackingNumber = arg
}
隐藏委托关系
动机
一个好的模块化设计,“封装”即使不是其最关键特征,也是最关键特征之一。如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会别波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。
做法
- 对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
- 调整客户端,令它只调用服务对象提供的函数。每次调整后运行测试。
- 如果将来不再有任何客户端需要取用受托类,便可移除服务对象中的相关访问函数。
- 测试。
范例
本例从两个类开始,代表‘人’的 Person 和代表‘部门’的 Department。
class Person...
constructor(name) {
this._name = name
}
get name() {
return this._name
}
get department() {
return this._department
}
set department(arg) {
this._department = arg
}
class Department...
get chargeCode() {
return this._chargeCode
}
set chargeCode(arg) {
this._chargeCode = arg
}
get manager() {
return this._manager
}
set manager(arg) {
this._manager = arg
}
// 有些客户端希望知道某人的经理是谁,为此,它必须先取得 Department 对象
// 客户端代码
manager = aPerson.department.manager
此时如果对客户隐藏 Department 可以减少耦合
class Person...
get manager() {
return this._department.manager
}
// 客户端代码
manager = aPerson.manager
只要完成了对 Department 所有函数的修改,并相应修改了 Person 的所有客户端,我就可以给移除 Person 中的 department 访问函数了。
移除中间人
动机
在隐藏委托关系的重构手法中。如果每次客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数,随着受托类的特性越来越多,服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。
很难说什么程度的隐藏才是合适的,还好,有了隐藏委托关系和删除中间人,我大可不必操心这个问题。
做法
- 为受托对象创建一个取值函数。
- 对于每个委托函数,让其客户端转为连续的访问函数调用,每次替换后运行测试。
范例
我又要从一个 Person 类开始了,这个类通过维护一个部门对象来决定某人的经理是谁。
// 客户端代码
manager = aPerson.manager
class Person...
get manager() {
return this._department.manager
}
class Department...
get manager() {
return this._manager
}
此时如果有大量函数用于在 Person 对象中调用 Department 的数据,就需要移除中间人
// 首先在 Person 中建立一个函数用于获取受托对象。
class Person...
get department() {
return this._department
}
// 之后注意处理每个客户端,使它们直接通过受托对象完成工作
manager = aPerson.department.manager