反向重构:将值对象改为引用对象(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 值比较相等性等情况。