曾用名:以数据类取代记录(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]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。