ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、视频、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
曾用名:以数据类取代记录(Replace Record with Data Class) ![](https://box.kancloud.cn/9a23e462e8ab902357732cb4ee617305_505x283.jpeg) `organization = {name: "Acme Gooseberries", country: "GB"};`![](https://box.kancloud.cn/a3bed334e2e1f6d1a46c5039deb25af9_91x152.jpeg) ``` 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\]这篇文章中讨论了更多的细节,有兴趣的读者可移步阅读。