重构,改善代码的既有设计——重新组织数据
重新组织数据
数据结构在程序中扮演着重要的角色,我有一组重构手法专门用于数据结构的组织。将一个值用于多个不同的用途,这就是催生混乱和 bug 的温床。
拆分变量
动机
变量有各种不同的用途,其中某些用途会很自然地导致临时变量被多次赋值。“循环变量”和“结果收集变量“就是两个典型例子:循环变量即为 for 循环中的 i;结果收集变量则将”通过整个函数的运算“而构成的某个值收集起来。
做法
- 在待分解变量的声明及第一次被赋值处,修改其名称。
- 如果可能,将新的变量声明为不可修改。
- 以该变量的第二次赋值动作为界,修改此前对该变量的所有引用,让它们引用新变量。
- 测试
- 重复上述过程,每次都在声明处对变量改名,并修改下次赋值之前的引用,直至到达最后一处赋值。
范例
下面我要计算一个苏格兰布丁运动的距离
function distanceTravelled(scenario, time) {
let result;
let acc = scenario.primaryForce / scenario.mass;
let primaryTime = Math.min(time, scenario.delay);
result = 0.5 * acc * primaryTimr * primaryTime;
let secondaryTime = time - scenario.delay;
if (secondaryTime > 0) {
let primaryVelocity = acc * scenario.delay;
acc = (scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
result +=
primaryVelocity * secondaryTimr +
0.5 * acc * secondaryTime * secondaryTime;
}
return result;
}
上面代码真是丑陋。 acc
变量有两个责任:第一是保存第一个力造成的初始加速度,第二是保存两个力共同造成的加速度。将 acd
以两个变量替换。
function distanceTravelled(scenario, time) {
let result;
let primaryAcceleration = scenario.primaryForce / scenario.mass;
let primaryTime = Math.min(time, scenario.delay);
result = 0.5 * acc * primaryTimr * primaryTime;
let secondaryTime = time - scenario.delay;
if (secondaryTime > 0) {
let primaryVelocity = acc * scenario.delay;
const secondaryAcceleration =
(scenario.primaryForce + scenario.secondaryForce) / scenario.mass;
result +=
primaryVelocity * secondaryTimr +
0.5 * secondaryAcceleration * secondaryTime * secondaryTime;
}
return result;
}
字段改名
动机
命名很重要,对于程序中广泛使用的记录结构,其中字段的命名格外重要。
做法
- 如果记录对作用域较小,可以直接修改所有该字段的代码,然后测试。
- 如果记录还未封装,请先封装。
- 在对象内部对私有字段改名,对应调整内部访问该字段的函数。
- 测试。
- 如果构造函数的参数使用了旧的字段名,运用改变函数声明将其改名。
- 运用函数改名给访问函数改名
范例:给字段改名
先从一个常量开始
const 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(aString) {
this._name = aString;
}
get country() {
return this._country;
}
set country(aCountryCode) {
this._country = aCountryCode;
}
}
const organization = new Organization({
name: "Acme Gooseberries",
country: "GB",
});
现在,记录结构已经被封装成类。在对字段改名时,有 4 个地方需要留意:取值函数、设值函数、构造函数以及内部数据结构。我现在可以分别修改这 4 处,小步修改意味着每一步出错的可能性大大减少。
class Organization {
constructor(data) {
// 先修改构造函数中的值。现在既可以使用 name 也可以使用 title
this._title = data.title !== undefined ? data.title : data.name;
this._country = data.country;
}
// 设值取值都返回修改后的值
get name() {
return this._title;
}
set name(aString) {
this._title = aString;
}
get country() {
return this._country;
}
set country(aCountryCode) {
this._country = aCountryCode;
}
}
接下来就可以在构造函数中使用 title 字段
// 修改为新字段
const organization = new Organization({
title: "Acme Gooseberries",
country: "GB",
});
// 之后查看所有调用构造函数的地方,改为新名
class Organization {
constructor(data) {
this._title = data.title;
this._country = data.country;
}
// 接下来改名访问函数和设值函数
get title() {
return this._title;
}
set title(aString) {
this._title = aString;
}
get country() {
return this._country;
}
set country(aCountryCode) {
this._country = aCountryCode;
}
}
以查询取代派生变量
动机
可变数据是软件中最大的错误源头之一。对数据的修改常常导致代码段各个部分以丑陋的方式互相耦合:在一处修改数据,却在另一处造成难以发现的破坏,完全去掉可变数据并不现实,但我还是强烈建议:精良吧可变数据的作用域限制在最小范围。
做法
- 识别出所有对变量做更新的地方。如有必要可使用拆分变量。
- 新建一个函数,用于计算该变量的值。
- 用引入断言断言该变量和计算函数始终给出同样的值。
- 测试。
- 修改读取该变量的代码,令其调用新建的函数。
- 测试。
- 用移除死代码去掉变量的声明和赋值。
范例
下面这个例子虽小,却完美展示了代码的丑陋。
class ProductionOPlan...
get production() {
return this._production
}
applyAdjustment(anAdjustment) {
this._adjustments.push(anAdjustment)
this._production += anAdjustment.amount
}
我可以对 production
进行即时计算
class ProductionOPlan...
get production() {
return this.calculatedProduction
}
get calculatedProduction() {
return this._adjustments
.reduce((sum, a) => sum + a.amount, 0)
}
之后用内联函数将计算逻辑内联到 production
函数内
class ProductionOPlan...
get production() {
return this._adjustments
.reduce((sum, a) => sum + a.amount, 0)
}
applyAdjustment(anAdjustment) {
this._adjustments.push(anAdjustment)
}
将引用对象改为值对象
动机
在把一个对象嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不变,更行内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。
一般来说,不可变的数据结构处理起来更容易,我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。值对象在分布式系统和并发系统中尤为有用。
如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。
做法
- 检查重构目标是否为不可变对象,或是否可修改为不可变对象。
- 用移除设值函数注意去掉所有设值函数。
- 提供一个基于值的相等性判断函数,在其中使用值对象字段。
范例
设想一个代表“人”的 Person
类,其中包含一个代表“电话号码”的 Telephone Number
对象
class Person...
constructor() {
this._telephoneNumber = new TelephoneNumber()
}
// 可以看到类中仍然有一些函数在修改新对象的属性
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...
get areaCode() {
return this._areaCode
}
set areaCode(arg) {
this._areaCode = arg
}
get number() {
return this._number
}
set number(arg) {
this._number = arg
}
先把 TelephoneNumber
类变为不可变的。
class TelephoneNumber...
constructor(areaCode, number) {
this._areaCode = areaCode
this._number = number
}
class Person...
get officeAreaCode() {
return this._telephoneNumber.areaCode
}
set officeAreaCode(arg) {
this._telephoneNumber = new TelephoneNumber(arg, this.officeNumber)
}
get officeNumber() {
return this._telephoneNumber.number
}
set officeNumber(arg) {
this._telephoneNumber = new TelephoneNumber(this.officeAreaCode, arg)
}
现在, TelephoneNumber
类已经是不可变的类,可以将其变成真正的值对象了。可以创建自己的 equal
函数用于值的相等性判断。
class TelephoneNumber...
equals(other) {
if (!(other instanceof TelephoneNumber)) return false
return this.areaCode === other.areaCode && this.number === other.number
}
// 进行测试
it('telephone equals', function() {
assert(new TelephoneNumber('312', '55-0142').equals(new TelephoneNumber('312', '55-0142')))
})
将值对象改为引用对象
动机
一个数据结构可能包含多个记录,而这些记录都关联到同一个逻辑数据结果。把值对象改为引用对象会带来以恶搞结果:对于一个客观实体,只有一个代表它的对象,只为某个实体创建一次对象,以后始终从仓库中获取该对象。
做法
- 为相关对象创建一个仓库
- 确保构造函数有办法找到关联对象的正确实例
- 修改宿主对象的构造函数,令其从长苦衷获取关联对象,每次修改后执行测试。
范例
我将从一个代表“订单”的 Order
类开始,其实例对象从一个 JSON
文件创建,用来创建订单数据中有一个顾客 customer
ID,我们用它来进一步创建 Customer
对象。
class Order...
constructor(data) {
this._number = data.number
this._customer = new Customer(data.customer)
}
get customer() {
return this._customer
}
class Customer...
constructor(id) {
this._id = id
}
get id() {
return this._id
}
这种方式创建的 Customer
是值对象。如果有 5 个订单都属于 ID 为 123 的顾客,就会有 5 个独自的 Customer
对象。对其中一个的修改,不会反映在其他几个对象上。如果我想增强 Customer
对象,比如接受了更多关于客户的信息,我就必须用同样的数据更新所有 5 个对象。
如果我每次都使用同一个 Customer
对象,那么就需要有一个地方存储这个对象。最简单的情况下,我会使用一个仓库对象。
let _repositoryData;
export function initialize() {
_repositoryData = {};
_repositoryData.customers = new Map();
}
export function registerCustomer(id) {
if (!_repositoryData.customers.has(id))
_repositoryData.customers.set(id, new Customer(id));
return findCustomer(id);
}
export function findCustomer(id) {
return _repositoryData.customers.get(id);
}
仓库对象根据 ID 注册顾客,并且对于一个 ID 只会创建一个 Customer
对象。有了仓库对象,我就可以修改 Order
对象的构造函数来使用它。
class Order...
constructor(data) {
this._number = data.number
this._customer = _repositoryData(data.customer)
}
get customer() {
return this._customer
}
上面的代码还有一个问题:构造函数与一个全局的仓库对象耦合。如果想解决这个问题,可以将仓库对象作为参数传递给构造函数。