封装

分解模块时最重要的标准,也许就是识别出那些模块应该对外界隐藏的小秘密了。类与模块已然是实施封装的最大实体了,但小一点的函数对于封装实现细节也有益处。

封装记录

动机

记录型结构是多数编程语言提供的一种常见特性。他们能直观地组织起存在关联的数据,但简单的记录型结构也有缺陷,它强迫我清晰地区分”记录中存储的数据“和”通过计算得到的数据“。

这就是对于可变数据,我总是更偏爱使用类对象而非记录的原因。对象可以隐藏结构的细节,该对象的用户不必追究存储的细节和计算的过程。另外,这是对于可变数据,对于不可变数据,大可直接把数据直接保存在记录里,需要做数据变换时增加一个填充步骤即可。

记录型结构可以有两种类型,一是需要声明合法的字段名字,另一种可以随便用任何字段名字,后者常用语言库本身实现,并通过类的形式提供出来。使用这类结构也有缺陷,就是一条记录上持有什么字段往往不够直观。

做法

  1. 对持有记录的变量使用封装变量,将其封装到一个容易搜索到名字的函数中。
  2. 创建一个类,将记录包装起来,并将记录变量都值替换为该类的一个实例。然后再类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数。令其使用这个访问函数。
  3. 测试。
  4. 新建一个函数,让它返回该类的对象,而不是那条原始的记录。
  5. 对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例的函数调用。使用对象上的访问函数来获取数据的字段。
  6. 一处类对于原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
  7. 测试。
  8. 如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录

范例

首先,我从与一个常量开始,该常量在程序中被大量使用。

// 这是一个普通的 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

还有一种方法是,以某种形式限制集合的访问权,只允许对集合进行读操作,比如返回一个只读代理。

也许最常见的做法是,危机和提供一个取值函数,令其返回一个集合的副本,但如果这个集合很大,这个做法可能带来性能问题,,但多数列表都没这么大。

采用哪种方法并无定式,但最重要的是在同个代码库中做法要保持一致。

做法

  1. 如果集合的引用尚未封装起来,先用封装变量封装它。
  2. 在类上添加用于“添加集合元素”和“移除集合元素”的函数
  3. 执行静态检查。
  4. 查找集合的引用点,如果由调用者直接修改集合,令该处调用使用新的添加/移除元素的函数。每次修改后执行测试。
  5. 修改集合的取值函数,使其返回一份只读的数据,可以使用只读代理或数据副本。
  6. 测试。

范例 假设有个人要去上课,我们用一个简单的 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()
}

任何负责管理集合的类都应该总是返回数据副本,只要我做的事看起来可能改变集合,我也会返回一个副本。

以对象取代基本类型

动机

开发初期,你可能会用简单的数据项来表示简单的情况,比如使用数字或字符串来表示电话号码等。但随着开发的进行,你可能会发现,这些简单的数据项不再是那么简单了,比如电话号码可能还需要格式化,抽取区号之类的特殊行为,这类逻辑很快会制造出许多重复代码。

一旦我对某个数据不仅仅局限于打印时,我就会为它创建一个新类,创建新类无需太大的工作量,但它们对代码库有深远的影响,实际上,很多有经验的开发者认为,这是他们工具箱里最实用的重构手法之一。

做法

  1. 如果变量尚未被封装起来,先使用封装变量封装它。
  2. 为这个数据值创建一个简单的类,类的构造函数应该保存这个数据值,并为它提供一个取值函数。
  3. 执行静态检查。
  4. 修改第一步得到的设值函数,令其创建一个新类的对象并将其存入字段,如果有必要的话,同时修改字段的类型声明。
  5. 修改取值函数,令其调用新类的取值函数,并返回结果。
  6. 测试。
  7. 对第一步得到的访问函数使用函数改名
  8. 考虑应用将引用对象改为值对象将值对象改为引用对象

范例

从一个简单的订单(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

以查询取代临时变量

动机

临时变量的一个作用是保存某段代码的返回值,以哦便在函数的后面部分使用它。临时变量允许我引用之前的值,既能解释它的含义,还能避免对代码进行重复计算。但尽管使用变量很方便,很多时候还是值得将它们抽取成函数。

这项函数在类中施展效果最好,因为类为待提炼函数提供了一个共同的上下文。以查询取代临时变量手法只是用预处理某些类型的临时变量:那些只被计算一次且之后不再被修改的变量。

做法

  1. 检查变量在使用前是否已经完全计算完毕,检查计算它的那段代码是否每次都能得到同样的值。
  2. 如果变量目前不是只读的,但是可以改造成只读变量,那就先改造它。
  3. 测试。
  4. 将为变量赋值的代码段提炼成函数。
  5. 测试。
  6. 应用内联变量手法移除临时变量。

范例

这里有一个简单的订单类

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
}

提炼类

动机

你也许听过类似这样的建议:一个类应该是一个清晰的抽象,只处理一些明确的责任,等等。但在实际工作中,随着责任不断增加,这个类会变得过于复杂。

如果类太大,类中的某些数据和某些函数总是一起出现,某些数据经常同时变化甚至彼此相依,这就表示你应该将它们分离出去。另一个往往在开发后期出现的信号是类的子类化方式。

做法

  1. 决定如何分解类所负的责任。
  2. 创建一个新的类,用以表现从旧类中分离出来的责任。
  3. 构造旧类时创建一个新类的实例,建立“从旧类访问新类”的连接关系。
  4. 对于你想搬移的字段,运用搬移字段搬移之。每次更改后进行测试。
  5. 使用搬移函数将必要函数搬移到新类。先搬移较低层函数(也就是多次被其他函数调用)。每次更改后进行测试。
  6. 检查两个类的接口,去掉不再需要的函数。
  7. 决定是否公开新的类。如果确实需要,考虑对新类应用将引用对象改为值对象使其成为一个值对象。

范例 我们从一个简单的 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
}

内联类

动机

内联类正好与提炼类相反,如果一个类不再提供足够责任,不再有单独存在的理由(通常是因为重构动作移走了这个类的责任),我就会将这个类塞进另一个类中。

应用这个手法的拎一个场景是,我手头有两个类,想重新安排它们肩负的责任,并让它们产生关联。

做法

  1. 对于待内联类(源类)中所有的 public 函数,在目标类上创建一个对应的函数,新创建的所有函数应该直接委托至源类。
  2. 修改源类 public 方法的所有引用点,令它们调用目标类对应的委托方法。每次更改后运行测试。
  3. 将源类中的函数和数据全部搬移到目标类,每次修改后进行测试,知道源类变成空壳。
  4. 删除源类。

范例

下面这个类存储了一次物流运输的若干跟踪信息。

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
}

隐藏委托关系

动机

一个好的模块化设计,“封装”即使不是其最关键特征,也是最关键特征之一。如果某些客户端先通过服务对象的字段得到另一个对象(受托类),然后调用后者的函数,那么客户就必须知晓这一层委托关系。万一受托类修改了接口,变化会别波及通过服务对象使用它的所有客户端。我可以在服务对象上放置一个简单的委托函数,将委托关系隐藏起来,从而去除这种依赖。

做法

  1. 对于每个委托关系中的函数,在服务对象端建立一个简单的委托函数。
  2. 调整客户端,令它只调用服务对象提供的函数。每次调整后运行测试。
  3. 如果将来不再有任何客户端需要取用受托类,便可移除服务对象中的相关访问函数。
  4. 测试。

范例

本例从两个类开始,代表‘人’的 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 访问函数了。

移除中间人

动机

隐藏委托关系的重构手法中。如果每次客户端要使用受托类的新特性时,你就必须在服务端添加一个简单委托函数,随着受托类的特性越来越多,服务类完全变成了一个中间人,此时就应该让客户直接调用受托类。

很难说什么程度的隐藏才是合适的,还好,有了隐藏委托关系和删除中间人,我大可不必操心这个问题。

做法

  1. 为受托对象创建一个取值函数。
  2. 对于每个委托函数,让其客户端转为连续的访问函数调用,每次替换后运行测试。

范例

我又要从一个 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