曾用名:以数据类取代记录(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;}
}
```
### 动机
记录型结构是多数编程语言提供的一种常见特性。它们能直观地组织起存在关联的数据,让我可以将数据作为有意义的单元传递,而不仅是一堆数据的拼凑。但简单的记录型结构也有缺陷,最恼人的一点是,它强迫我清晰地区分“记录中存储的数据”和“通过计算得到的数据”。假使我要描述一个整数闭区间,我可以用`{start: 1, end: 5}`描述,或者用`{start: 1, length: 5}`(甚至还能用`{end: 5, length: 5}`,如果我想露两手华丽的编程技巧的话)。但不论如何存储,这3个值都是我想知道的,即区间的起点(`start`)和终点(`end`),以及区间的长度(`length`)。
这就是对于可变数据,我总是更偏爱使用类对象而非记录的原因。对象可以隐藏结构的细节,仅为这3个值提供对应的方法。该对象的用户不必追究存储的细节和计算的过程。同时,这种封装还有助于字段的改名:我可以重新命名字段,但同时提供新老字段名的访问方法,这样我就可以渐进地修改调用方,直到替换全部完成。
注意,我所说的偏爱对象,是对可变数据而言。如果数据不可变,我大可直接将这3个值保存在记录里,需要做数据变换时增加一个填充步骤即可。重命名记录也一样简单,你可以复制一个字段并逐步替换引用点。
记录型结构可以有两种类型:一种需要声明合法的字段名字,另一种可以随便用任何字段名字。后者常由语言库本身实现,并通过类的形式提供出来,这些类称为散列(hash)、映射(map)、散列映射(hashmap)、字典(dictionary)或关联数组(associative array)等。很多编程语言都提供了方便的语法来创建这类记录,这使得它们在各种编程场景下都能大展身手。但使用这类结构也有缺陷,那就是一条记录上持有什么字段往往不够直观。比如说,如果我想知道记录里维护的字段究竟是起点/终点还是起点/长度,就只有查看它的创建点和使用点,除此以外别无他法。若这种记录只在程序的一个小范围里使用,那问题还不大,但若其使用范围变宽,“数据结构不直观”这个问题就会造成更多困扰。我可以重构它,使其变得更直观——但如果真需要这样做,那还不如使用类来得直接。
程序中间常常需要互相传递嵌套的列表(list)或散列映射结构,这些数据结构后续经常需要被序列化成JSON或XML。这样的嵌套结构同样值得封装,这样,如果后续其结构需要变更或者需要修改记录内的值,封装能够帮我更好地应对变化。
### 做法
- 对持有记录的变量使用封装变量(132),将其封装到一个函数中。
> 记得为这个函数取一个容易搜索的名字。
- 创建一个类,将记录包装起来,并将记录变量的值替换为该类的一个实例。然后在类上定义一个访问函数,用于返回原始的记录。修改封装变量的函数,令其使用这个访问函数。
- 测试。
- 新建一个函数,让它返回该类的对象,而非那条原始的记录。
- 对于该记录的每处使用点,将原先返回记录的函数调用替换为那个返回实例对象的函数调用。使用对象上的访问函数来获取数据的字段,如果该字段的访问函数还不存在,那就创建一个。每次更改之后运行测试。
> 如果该记录比较复杂,例如是个嵌套解构,那么先重点关注客户端对数据的更新操作,对于读取操作可以考虑返回一个数据副本或只读的数据代理。
- 移除类对原始记录的访问函数,那个容易搜索的返回原始数据的函数也要一并删除。
- 测试。
- 如果记录中的字段本身也是复杂结构,考虑对其再次应用封装记录(162)或封装集合(170)手法。
### 范例
首先,我从一个常量开始,该常量在程序中被大量使用。
`const organization = {name: "Acme Gooseberries", country: "GB"};`这是一个普通的JavaScript对象,程序中很多地方都把它当作记录型结构在使用。以下是对其进行读取和更新的地方:
```
result += `<h1>${organization.name}</h1>`;
organization.name = newName;
```
重构的第一步很简单,先施展一下封装变量(132)。
`function getRawDataOfOrganization() {return organization;}`##### 读取的例子...
`result += `<h1>${getRawDataOfOrganization().name}</h1>`;`##### 更新的例子...
`getRawDataOfOrganization().name = newName;`这里施展的不全是标准的封装变量(132)手法,我刻意为设值函数取了一个又丑又长、容易搜索的名字,因为我有意不让它在这次重构中活得太久。
封装记录意味着,仅仅替换变量还不够,我还想控制它的使用方式。我可以用类来替换记录,从而达到这一目的。
##### class Organization...
```
class Organization {
constructor(data) {
this._data = data;
}
}
```
##### 顶层作用域
```
const organization = new Organization({name: "Acme Gooseberries", country: "GB"});
function getRawDataOfOrganization() {return organization._data;}
function getOrganization() {return organization;}
```
创建完对象后,我就能开始寻找该记录的使用点了。所有更新记录的地方,用一个设值函数来替换它。
##### class Organization...
`set name(aString) {this._data.name = aString;}`##### 客户端...
`getOrganization().name = newName;`同样地,我将所有读取记录的地方,用一个取值函数来替代。
##### class Organization...
`get name() {return this._data.name;}`##### 客户端...
`result += `<h1>${getOrganization().name}</h1>`;`完成引用点的替换后,就可以兑现我之前的死亡威胁,为那个名称丑陋的函数送终了。
```
function getRawDataOfOrganization() {return organization._data;}
function getOrganization() {return organization;}
```
我还倾向于把`_data`里的字段展开到对象中。
```
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;}
}
```
这样做有一个好处,能够使外界无须再引用原始的数据记录。直接持有原始的记录会破坏封装的完整性。但有时也可能不适合将对象展开到独立的字段里,此时我就会先将`_data`复制一份,再进行赋值。
### 范例:封装嵌套记录
上面的例子将记录的浅复制展开到了对象里,但当我处理深层嵌套的数据(比如来自JSON文件的数据)时,又该怎么办呢?此时该重构手法的核心步骤依然适用,记录的更新点需要同样小心处理,但对记录的读取点则有多种处理方案。
作为例子,这里有一个嵌套层级更深的数据:它是一组顾客信息的集合,保存在散列映射中,并通过顾客ID进行索引。
```
"1920": {
name: "martin",
id: "1920",
usages: {
"2016": {
"1": 50,
"2": 55,
// remaining months of the year
},
"2015": {
"1": 70,
"2": 63,
// remaining months of the year
}
}
},
"38673": {
name: "neal",
id: "38673",
// more customers in a similar form
```
对嵌套数据的更新和读取可以进到更深的层级。
##### 更新的例子...
`customerData[customerID].usages[year][month] = amount;`##### 读取的例子...
```
function compareUsage (customerID, laterYear, month) {
const later = customerData[customerID].usages[laterYear][month];
const earlier = customerData[customerID].usages[laterYear - 1][month];
return {laterAmount: later, change: later - earlier};
}
```
对这样的数据施行封装,第一步仍是封装变量(132)。
```
function getRawDataOfCustomers() {return customerData;}
function setRawDataOfCustomers(arg) {customerData = arg;}
```
##### 更新的例子...
`getRawDataOfCustomers()[customerID].usages[year][month] = amount;`##### 读取的例子...
```
function compareUsage (customerID, laterYear, month) {
const later = getRawDataOfCustomers()[customerID].usages[laterYear][month];
const earlier = getRawDataOfCustomers()[customerID].usages[laterYear - 1][month];
return {laterAmount: later, change: later - earlier};
}
```
接下来我要创建一个类来容纳整个数据结构。
```
class CustomerData {
constructor(data) {
this._data = data;
}
}
```
##### 顶层作用域...
```
function getCustomerData() {return customerData;}
function getRawDataOfCustomers() {return customerData._data;}
function setRawDataOfCustomers(arg) {customerData = new CustomerData(arg);}
```
最重要的是妥善处理好那些更新操作。因此,当我查看`getRawDataOfCustomers`的所有调用者时,总是特别关注那些对数据做修改的地方。再提醒你一下,下面是那步更新操作。
##### 更新的例子...
`getRawDataOfCustomers()[customerID].usages[year][month] = amount;`“做法”部分说,接下来要通过一个访问函数来返回原始的顾客数据,如果访问函数还不存在就创建一个。现在顾客类还没有设值函数,而且这个更新操作对结构进行了深入查找,因此是时候创建一个设值函数了。我会先用提炼函数(106),将层层深入数据结构的查找操作提炼到函数里。
##### 更新的例子...
`setUsage(customerID, year, month, amount);`##### 顶层作用域...
```
function setUsage(customerID, year, month, amount) {
getRawDataOfCustomers()[customerID].usages[year][month] = amount;
}
```
然后我再用搬移函数(198)将新函数搬移到新的顾客数据类中。
##### 更新的例子...
`getCustomerData().setUsage(customerID, year, month, amount);`##### class CustomerData...
```
setUsage(customerID, year, month, amount) {
this._data[customerID].usages[year][month] = amount;
}
```
封装大型的数据结构时,我会更多关注更新操作。凸显更新操作,并将它们集中到一处地方,是此次封装过程最重要的一部分。
一通替换过后,我可能认为修改已经告一段落,但如何确认替换是否真正完成了呢?检查的办法有很多,比如可以修改`getRawDataOfCustomers`函数,让其返回一份数据的深复制的副本。如果测试覆盖足够全面,那么当我真的遗漏了一些更新点时,测试就会报错。
##### 顶层作用域...
```
function getCustomerData() {return customerData;}
function getRawDataOfCustomers() {return customerData.rawData;}
function setRawDataOfCustomers(arg) {customerData = new CustomerData(arg);}
```
##### class CustomerData...
```
get rawData() {
return _.cloneDeep(this._data);
}
```
我使用了lodash库来辅助生成深复制的副本。
另一个方式是,返回一份只读的数据代理。如果客户端代码尝试修改对象的结构,那么该数据代理就会抛出异常。这在有些编程语言中能轻易实现,但用JavaScript实现可就麻烦了,我把它留给读者作为练习好了。或者,我可以复制一份数据,递归冻结副本的每个字段,以此阻止对它的任何修改企图。
妥善处理好数据的更新当然价值不凡,但读取操作又怎么处理呢?这有几种选择。
第一种选择是与设值函数采用同等待遇,把所有对数据的读取提炼成函数,并将它们搬移到`CustomerData`类中。
##### class CustomerData...
```
usage(customerID, year, month) {
return this._data[customerID].usages[year][month];
}
```
##### 顶层作用域...
```
function compareUsage (customerID, laterYear, month) {
const later = getCustomerData().usage(customerID, laterYear, month);
const earlier = getCustomerData().usage(customerID, laterYear - 1, month);
return {laterAmount: later, change: later - earlier};
}
```
这种处理方式的美妙之处在于,它为`customerData`提供了一份清晰的API列表,清楚描绘了该类的全部用途。我只需阅读类的代码,就能知道数据的所有用法。但这样会使代码量剧增,特别是当对象有许多用途时。现代编程语言大多提供直观的语法,以支持从深层的列表和散列\[mf-lh\]结构中获得数据,因此直接把这样的数据结构给到客户端,也不失为一种选择。
如果客户端想拿到一份数据结构,我大可以直接将实际的数据交出去。但这样做的问题在于,我将无从阻止用户直接对数据进行修改,进而使我们封装所有更新操作的良苦用心失去意义。最简单的应对办法是返回原始数据的一份副本,这可以用到我前面写的`rawData`方法。
##### class CustomerData...
```
get rawData() {
return _.cloneDeep(this._data);
}
```
##### 顶层作用域...
```
function compareUsage (customerID, laterYear, month) {
const later = getCustomerData().rawData[customerID].usages[laterYear][month];
const earlier = getCustomerData().rawData[customerID].usages[laterYear - 1][month];
return {laterAmount: later, change: later - earlier};
}
```
简单归简单,这种方案也有缺点。最明显的问题是复制巨大的数据结构时代价颇高,这可能引发性能问题。不过也正如我对性能问题的一贯态度,这样的性能损耗也许是可以接受的——只有测量到可见的影响,我才会真的关心它。这种方案还可能带来困惑,比如客户端可能期望对该数据的修改会同时反映到原数据上。如果采用了只读代理或冻结副本数据的方案,就可以在此时提供一个有意义的错误信息。
另一种方案需要更多工作,但能提供更可靠的控制粒度:对每个字段循环应用封装记录。我会把顾客(customer)记录变成一个类,对其用途(usage)字段应用封装集合(170),并为它创建一个类。然后我就能通过访问函数来控制其更新点,比如说对用途(usage)对象应用将引用对象改为值对象(252)。但处理一个大型的数据结构时,这种方案异常繁复,如果对该数据结构的更新点没那么多,其实大可不必这么做。有时,合理混用取值函数和新对象可能更明智,即使用取值函数来封装数据的深层查找操作,但更新数据时则用对象来包装其结构,而非直接操作未经封装的数据。我在“Refactoring Code to Load a Document”\[mf-ref-doc\]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。
- 第1章 重构,第一个示例
- 1.1 起点
- 1.2 对此起始程序的评价
- 1.3 重构的第一步
- 1.4 分解statement函数
- 1.5 进展:大量嵌套函数
- 1.6 拆分计算阶段与格式化阶段
- 1.7 进展:分离到两个文件(和两个阶段)
- 1.8 按类型重组计算过程
- 1.9 进展:使用多态计算器来提供数据
- 1.10 结语
- 第2章 重构的原则
- 2.1 何谓重构
- 2.2 两顶帽子
- 2.3 为何重构
- 2.4 何时重构
- 2.5 重构的挑战
- 2.6 重构、架构和YAGNI
- 2.7 重构与软件开发过程
- 2.8 重构与性能
- 2.9 重构起源何处
- 2.10 自动化重构
- 2.11 延展阅读
- 第3章 代码的坏味道
- 3.1 神秘命名(Mysterious Name)
- 3.2 重复代码(Duplicated Code)
- 3.3 过长函数(Long Function)
- 3.4 过长参数列表(Long Parameter List)
- 3.5 全局数据(Global Data)
- 3.6 可变数据(Mutable Data)
- 3.7 发散式变化(Divergent Change)
- 3.8 霰弹式修改(Shotgun Surgery)
- 3.9 依恋情结(Feature Envy)
- 3.10 数据泥团(Data Clumps)
- 3.11 基本类型偏执(Primitive Obsession)
- 3.12 重复的switch (Repeated Switches)
- 3.13 循环语句(Loops)
- 3.14 冗赘的元素(Lazy Element)
- 3.15 夸夸其谈通用性(Speculative Generality)
- 3.16 临时字段(Temporary Field)
- 3.17 过长的消息链(Message Chains)
- 3.18 中间人(Middle Man)
- 3.19 内幕交易(Insider Trading)
- 3.20 过大的类(Large Class)
- 3.21 异曲同工的类(Alternative Classes with Different Interfaces)
- 3.22 纯数据类(Data Class)
- 3.23 被拒绝的遗赠(Refused Bequest)
- 3.24 注释(Comments)
- 第4章 构筑测试体系
- 4.1 自测试代码的价值
- 4.2 待测试的示例代码
- 4.3 第一个测试
- 4.4 再添加一个测试
- 4.5 修改测试夹具
- 4.6 探测边界条件
- 4.7 测试远不止如此
- 第5章 介绍重构名录
- 5.1 重构的记录格式
- 5.2 挑选重构的依据
- 第6章 第一组重构
- 6.1 提炼函数(Extract Function)
- 6.2 内联函数(Inline Function)
- 6.3 提炼变量(Extract Variable)
- 6.4 内联变量(Inline Variable)
- 6.5 改变函数声明(Change Function Declaration)
- 6.6 封装变量(Encapsulate Variable)
- 6.7 变量改名(Rename Variable)
- 6.8 引入参数对象(Introduce Parameter Object)
- 6.9 函数组合成类(Combine Functions into Class)
- 6.10 函数组合成变换(Combine Functions into Transform)
- 6.11 拆分阶段(Split Phase)
- 第7章 封装
- 7.1 封装记录(Encapsulate Record)
- 7.2 封装集合(Encapsulate Collection)
- 7.3 以对象取代基本类型(Replace Primitive with Object)
- 7.4 以查询取代临时变量(Replace Temp with Query)
- 7.5 提炼类(Extract Class)
- 7.6 内联类(Inline Class)
- 7.7 隐藏委托关系(Hide Delegate)
- 7.8 移除中间人(Remove Middle Man)
- 7.9 替换算法(Substitute Algorithm)
- 第8章 搬移特性
- 8.1 搬移函数(Move Function)
- 8.2 搬移字段(Move Field)
- 8.3 搬移语句到函数(Move Statements into Function)
- 8.4 搬移语句到调用者(Move Statements to Callers)
- 8.5 以函数调用取代内联代码(Replace Inline Code with Function Call)
- 8.6 移动语句(Slide Statements)
- 8.7 拆分循环(Split Loop)
- 8.8 以管道取代循环(Replace Loop with Pipeline)
- 8.9 移除死代码(Remove Dead Code)
- 第9章 重新组织数据
- 9.1 拆分变量(Split Variable)
- 9.2 字段改名(Rename Field)
- 9.3 以查询取代派生变量(Replace Derived Variable with Query)
- 9.4 将引用对象改为值对象(Change Reference to Value)
- 9.5 将值对象改为引用对象(Change Value to Reference)
- 第10章 简化条件逻辑
- 10.1 分解条件表达式(Decompose Conditional)
- 10.2 合并条件表达式(Consolidate Conditional Expression)
- 10.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
- 10.4 以多态取代条件表达式(Replace Conditional with Polymorphism)
- 10.5 引入特例(Introduce Special Case)
- 10.6 引入断言(Introduce Assertion)
- 第11章 重构API
- 11.1 将查询函数和修改函数分离(Separate Query from Modifier)
- 11.2 函数参数化(Parameterize Function)
- 11.3 移除标记参数(Remove Flag Argument)
- 11.4 保持对象完整(Preserve Whole Object)
- 11.5 以查询取代参数(Replace Parameter with Query)
- 11.6 以参数取代查询(Replace Query with Parameter)
- 11.7 移除设值函数(Remove Setting Method)
- 11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function)
- 11.9 以命令取代函数(Replace Function with Command)
- 11.10 以函数取代命令(Replace Command with Function)
- 第12章 处理继承关系
- 12.1 函数上移(Pull Up Method)
- 12.2 字段上移(Pull Up Field)
- 12.3 构造函数本体上移(Pull Up Constructor Body)
- 12.4 函数下移(Push Down Method)
- 12.5 字段下移(Push Down Field)
- 12.6 以子类取代类型码(Replace Type Code with Subclasses)
- 12.7 移除子类(Remove Subclass)
- 12.8 提炼超类(Extract Superclass)
- 12.9 折叠继承体系(Collapse Hierarchy)
- 12.10 以委托取代子类(Replace Subclass with Delegate)
- 12.11 以委托取代超类(Replace Superclass with Delegate)
- 参考文献
- 重构列表
- 坏味道与重构手法速查表
