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