反向重构:将值对象改为引用对象(256)

class Product {
applyDiscount(arg) {this._price.amount -= arg;}
 
 
class Product {
applyDiscount(arg) {
  this._price = new Money(this._price.amount - arg, this._price.currency);
}

动机

在把一个对象(或数据结构)嵌入另一个对象时,位于内部的这个对象可以被视为引用对象,也可以被视为值对象。两者最明显的差异在于如何更新内部对象的属性:如果将内部对象视为引用对象,在更新其属性时,我会保留原对象不动,更新内部对象的属性;如果将其视为值对象,我就会替换整个内部对象,新换上的对象会有我想要的属性值。

如果把一个字段视为值对象,我可以把内部对象的类也变成值对象[mf-vo]。值对象通常更容易理解,主要因为它们是不可变的。一般说来,不可变的数据结构处理起来更容易。我可以放心地把不可变的数据值传给程序的其他部分,而不必担心对象中包装的数据被偷偷修改。我可以在程序各处复制值对象,而不必操心维护内存链接。值对象在分布式系统和并发系统中尤为有用。

值对象和引用对象的区别也告诉我,何时不应该使用本重构手法。如果我想在几个对象之间共享一个对象,以便几个对象都能看见对共享对象的修改,那么这个共享的对象就应该是引用。

做法

检查重构目标是否为不可变对象,或者是否可修改为不可变对象。

移除设值函数(331)逐一去掉所有设值函数。

提供一个基于值的相等性判断函数,在其中使用值对象的字段。

大多数编程语言都提供了可覆写的相等性判断函数。通常你还必须同时覆写生成散列码的函数。

范例

设想一个代表“人”的 Person 类,其中包含一个代表“电话号码”的 Telephone Number 对象。

class Person…

  constructor() {
  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;}

代码的当前状态是提炼类(182)留下的结果:从前拥有电话号码信息的 Person 类仍然有一些函数在修改新对象的属性。趁着还只有一个指向新类的引用,现在是时候使用将引用对象改为值对象将其变成值对象。

我需要做的第一件事是把 TelephoneNumber 类变成不可变的。对它的字段运用移除设值函数(331)。移除设值函数(331)的第一步是,用改变函数声明(124)把这两个字段的初始值加到构造函数中,并迫使构造函数调用设值函数。

class TelephoneNumber…

  constructor(areaCode, number) {
  this._areaCode = areaCode;
  this._number = number;
}

然后我会逐一查看设值函数的调用者,并将其改为重新赋值整个对象。先从“地区代码”(area code)开始。

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.number = arg;}

对于其他字段,重复上述步骤。

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 已经是不可变的类,可以将其变成真正的值对象了。是不是真正的值对象,要看是否基于值判断相等性。在这个领域中,JavaScript 做得不好:语言和核心库都不支持将“基于引用的相等性判断”换成“基于值的相等性判断”。我唯一能做的就是创建自己的 equals 函数。

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", "555-0142").equals(
      new TelephoneNumber("312", "555-0142")
    )
  );
});

这段测试代码用了不寻常的格式,是为了帮助读者一眼看出上下两次构造函数调用完全一样。

我在这个测试中创建了两个各自独立的对象,并验证它们相等。

在大多数面向对象语言中,内置的相等性判断方法可以被覆写为基于值的相等性判断。在 Ruby 中,我可以覆写==运算符;在 Java 中,我可以覆写 Object.equals()方法。在覆写相等性判断的同时,我通常还需要覆写生成散列码的方法(例如 Java 中的 Object.hashCode()方法),以确保用到散列码的集合在使用值对象时一切正常。

如果有多个客户端使用了 TelephoneNumber 对象,重构的过程还是一样,只是在运用移除设值函数(331)时要修改多处客户端代码。另外,有必要添加几个测试,检查电话号码不相等以及与非电话号码和 null 值比较相等性等情况。