值对象
2016年11月14日
在编程时,我经常发现将事物表示为复合体很有用。一个二维坐标由 x 值和 y 值组成。一笔金额由数字和货币组成。一个日期范围由开始日期和结束日期组成,它们本身可以是年、月和日的复合体。
当我这样做的时候,我遇到了两个复合对象是否相同的问题。如果我有两个点对象都表示笛卡尔坐标系中的 (2,3),那么将它们视为相等是有意义的。由于其属性值(在本例中为其 x 和 y 坐标)而相等的对象称为值对象。
但是,除非我在编程时小心谨慎,否则我可能无法在我的程序中获得这种行为。
假设我想用 JavaScript 表示一个点。
const p1 = {x: 2, y: 3}; const p2 = {x: 2, y: 3}; assert(p1 !== p2); // NOT what I want
遗憾的是,该测试通过了。这是因为 JavaScript 通过查看它们的引用来测试 js 对象的相等性,而忽略它们包含的值。
在许多情况下,使用引用而不是值是有意义的。如果我正在加载和操作一堆销售订单,那么将每个订单加载到一个位置是有意义的。如果我需要查看 Alice 的最新订单是否在下次交付中,我可以获取 Alice 订单的内存引用或标识,并查看该引用是否在交付订单列表中。对于此测试,我不必担心订单中的内容。类似地,我可以依赖唯一的订单号,测试 Alice 的订单号是否在交付列表中。
因此,我发现将对象分为两类很有用:值对象和引用对象,具体取决于我如何区分它们 [1]。我需要确保我知道我希望每个对象如何处理相等性,并对它们进行编程,以便它们的行为符合我的预期。我如何做到这一点取决于我正在使用的编程语言。
有些语言将所有复合数据视为值。如果我在 Clojure 中创建一个简单的复合体,它看起来像这样。
> (= {:x 2, :y 3} {:x 2, :y 3}) true
这就是函数式风格——将所有内容都视为不可变的值。
但是,如果我不是在使用函数式语言,我仍然可以经常创建值对象。例如,在 Java 中,默认的点类按我想要的方式运行。
assertEquals(new Point(2, 3), new Point(2, 3)); // Java
这种工作方式是点类使用值的测试覆盖默认的 equals
方法。 [2] [3]
我可以在 JavaScript 中做类似的事情。
class Point { constructor(x, y) { this.x = x; this.y = y; } equals (other) { return this.x === other.x && this.y === other.y; } }
const p1 = new Point(2,3); const p2 = new Point(2,3); assert(p1.equals(p2));
这里 JavaScript 的问题是,我定义的这个 equals 方法对任何其他 JavaScript 库来说都是一个谜。
const somePoints = [new Point(2,3)]; const p = new Point(2,3); assert.isFalse(somePoints.includes(p)); // not what I want //so I have to do this assert(somePoints.some(i => i.equals(p)));
这在 Java 中不是问题,因为 Object.equals
在核心库中定义,并且所有其他库都使用它进行比较(==
通常仅用于基元)。
值对象的一个好处是,我不需要关心我在内存中是否有对同一个对象的引用,或者是否有对具有相等值的另一个对象的引用。但是,如果我不小心,这种快乐的无知会导致问题,我将用一点 Java 代码来说明。
Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016")); // this means we need a retirement party Date partyDate = retirementDate; // but that date is a Tuesday, let's party on the weekend partyDate.setDate(5); assertEquals(new Date(Date.parse("Sat 5 Nov 2016")), retirementDate); // oops, now I have to work three more days :-(
这是一个 别名错误 的例子,我在一个地方更改了一个日期,它产生的后果超出了我的预期 [4]。为了避免别名错误,我遵循一个简单但重要的规则:值对象应该是不可变的。如果我想更改我的派对日期,我会创建一个新对象。
Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016")); Date partyDate = retirementDate; // treat date as immutable partyDate = new Date(Date.parse("Sat 5 Nov 2016")); // and I still retire on Tuesday assertEquals(new Date(Date.parse("Tue 1 Nov 2016")), retirementDate);
当然,如果值对象真的是不可变的,那么将它们视为不可变的要容易得多。对于对象,我通常可以通过简单地不提供任何设置方法来做到这一点。所以,我之前的 JavaScript 类看起来像这样: [5]
class Point { constructor(x, y) { this._data = {x: x, y: y}; } get x() {return this._data.x;} get y() {return this._data.y;} equals (other) { return this.x === other.x && this.y === other.y; } }
虽然不可变性是我避免别名错误的首选技术,但也可以通过确保赋值始终进行复制来避免它们。某些语言提供了这种能力,例如 C# 中的结构体。
是将概念视为引用对象还是值对象取决于您的上下文。在许多情况下,将邮政地址视为具有值相等性的简单文本结构是值得的。但是,更复杂的映射系统可能会将邮政地址链接到一个复杂的分层模型中,在该模型中,引用更有意义。与大多数建模问题一样,不同的上下文会导致不同的解决方案。 [6]
用适当的值对象替换常见的基元(例如字符串)通常是一个好主意。虽然我可以用字符串表示电话号码,但将其转换为电话号码对象会使变量和参数更加明确(在语言支持的情况下进行类型检查),自然地关注验证,并避免不适用的行为(例如对整数 ID 号码进行算术运算)。
小对象(例如点、货币或范围)是值对象的很好的例子。但是,如果较大的结构没有任何概念标识或不需要在程序周围共享引用,则通常可以将它们编程为值对象。这更自然地适合默认使用不可变性的函数式语言。 [7]
我发现值对象(尤其是小的值对象)经常被忽视——被视为太琐碎而不值得考虑。但是,一旦我发现了一组好的值对象,我就会发现我可以围绕它们创建丰富的行为。要体验这一点,请尝试使用 范围类,并查看它如何通过使用更丰富的行为来防止对开始和结束属性进行各种重复操作。我经常遇到这样的代码库,其中像这样的特定于域的值对象可以作为重构的重点,从而导致系统的大幅简化。这种简化通常会让人们感到惊讶,直到他们见过几次——到那时它就是一个好朋友了。
致谢
James Shore、Beth Andres-Beck 和 Pete Hodgson 分享了他们在 JavaScript 中使用值对象的经验。
Graham Brooks、James Birnie、Jeroen Soeters、Mariano Giuffrida、Matteo Vaccari、Ricardo Cavalcanti 和 Steven Lowe 在我们的内部邮件列表中提供了宝贵的意见。
延伸阅读
Vaughn Vernon 的描述可能是 从 DDD 的角度对值对象进行的最深入的讨论。他涵盖了如何决定值和实体、实现技巧以及持久化值对象的技术。
这个词在 21 世纪初开始流行起来。从那时起,有两本书从那时起就谈到了它们,分别是 PoEAA 和 DDD。在 Ward 的 Wiki 上也有一些有趣的讨论。
术语混淆的一个来源是,在本世纪之交,一些 J2EE 文献使用“值对象”来表示 数据传输对象。这种用法现在已经基本消失了,但你可能会遇到它。
注释
1: 在领域驱动设计中,Evans 分类法 将值对象与实体进行了对比。我认为实体是引用对象的一种常见形式,但仅在域模型中使用术语“实体”,而引用/值对象二分法适用于所有代码。
2: 严格来说,这是在 awt.geom.Point2D 中完成的,它是 awt.Point 的超类。
3: Java 中的大多数对象比较都是使用 equals
完成的——这本身有点尴尬,因为我必须记住使用它而不是等于运算符 ==
。这很烦人,但 Java 程序员很快就会习惯它,因为 String 的行为方式相同。其他面向对象的语言可以避免这种情况——Ruby 使用 ==
运算符,但允许覆盖它。
4: Java 8 之前的日期和时间系统中最糟糕的功能是什么?我的答案是这个。值得庆幸的是,我们现在可以使用 Java 8 的 java.time
包来避免这种情况。
5: 这不是严格意义上的不可变,因为客户端可以操作 _data
属性。但是,一个训练有素的团队可以在实践中使其不可变。如果我担心团队的纪律不够严明,我可能会使用 freeze
。事实上,我可以简单地对一个简单的 JavaScript 对象使用 freeze,但我更喜欢使用已声明访问器的类的显式性。
6: Evans 的 DDD 书 中对此有更多讨论。
7: 不可变性对于引用对象也很有价值——如果销售订单在获取请求期间没有更改,那么使其不可变是有价值的;如果这很有用,那么复制它是安全的。但是,如果我根据唯一的订单号确定相等性,这不会使销售订单成为值对象。