[譯] 值物件(ValueObject)

黃禪宗發表於2017-03-25

本翻譯已徵得Martin Fowler同意,並連結在部落格原文下方。本文轉自值物件(ValueObject)- Martin Fowler部落格

程式設計時,我發現把東西表示成一個組合物是很有用的。一個二維座標由x和y組合。一定數量金額由一個數值和一種貨幣組成。一個日期範圍由開始和結束日期組成,而日期又可以由年、月、日組成。

當我這樣做的話,我就會遇到了兩個組合物是否是一樣的問題。假設我有兩個都是表示笛卡爾座標(2,3)的point物件,把他們當作是相等是有意義的。根據其屬性來判斷相等性的物件被稱為值物件,在這裡的屬性就是他們的x和y座標。

但除非我在程式設計時很小心,不然在我的程式裡可能得不到那樣的效果。

比如想在JavaScript表示一個座標。

const p1 = {x: 2, y: 3};
const p2 = {x: 2, y: 3};
assert(p1 !== p2);  // 不是我想要的

遺憾的是,測試通過了。它之所以這樣是因為JavaScript通過尋找他們的引用來測試js物件的相等性,而忽視他們所包含的值。

在很多場景裡,使用引用而不是值是有意義的。如果我正在載入和操縱一堆銷售訂單,把每個訂單載入到一個單獨的地方是有意義的。如果我想看下Alice最新的訂單有沒有在下一批派送裡,我可以使用Alice訂單的記憶體引用,或者標記符,然後看一下該記憶體引用是否在此派送的訂單列表裡。對於這個檢測,我不用擔心有什麼在訂單裡。類似地我可能依賴於一個唯一的訂單號,檢測Alice的訂單號有沒有在派送列表裡。

所以,我發現思考怎麼把這兩類物件:值物件和引用物件,區分開來是很有用的[1]。我需要確保我已理解我怎麼期望各個物件處理相等性和程式設計他們,才能讓他們可以根據我的設想行動。我怎麼做,取決於我正在使用的程式語言。

有些語言把全部組合的資料當作值。如果我在Clojure構建了一個簡單的組合,它會看起來像這樣。

> (= {:x 2, :y 3} {:x 2, :y 3})
true

這是函式式風格 -- 把所有東西視為不可變值。

但我用的不是函式式語言,我仍然能經常建立值物件。以Java為例,我喜歡預設Point類的行為是這樣。

assertEquals(new Point(2, 3), new Point(2, 3)); // Java

這種方式之所以有效,是因為Point類通過針對值進行檢測重寫了預設的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

// 所以我不得不這樣做
assert(somePoints.some(i => i.equals(p)));

在Java這不是一個問題,因為Object.equals在核心庫中定義,並且其他類庫使用它來進行比較(==通常只用於原始型別)。

值物件其中一個好的成果是,我不需要擔心我在記憶體中是否有指向同一物件的引用,或者相同的值有不同的引用。然而如果我不夠小心的話,天真愚蠢就會導致問題的發生,我會通過少量的Java程式碼來演示這點。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));

// 這意味著我們需要一個退休party
Date partyDate = retirementDate;

// 但那天是週三,讓我們週末再來舉行party
partyDate.setDate(5);

assertEquals(new Date(Date.parse("Sat 5 Nov 2016")), retirementDate);
// 天哪,現在我要工作多三天 :-(

這是一個Aliasing Bug例子,我在某個地方改變了一個日期,結果超出了我的期望[4]。為了避免Aliasing Bug,我遵循了一個簡單但重要的規則:值物件應該是不可變的。如果想改變party的日期,我可以建立一個新的物件來替代。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
Date partyDate = retirementDate;

// 把日期作為不可變
partyDate = new Date(Date.parse("Sat 5 Nov 2016"));

// 這樣我還是在周天退休
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;
  }
}

不可變性是我用於避免aliasing bug最愛的技術,還可以通過確保賦值總是產生一個副本來避免。有些語言提供了這個功能,例如C#裡的結構。

把一個概念當作引用物件還是值物件取決於你的上下文。在大部分場景裡,把一個投遞的地址當作是一個簡單的帶值相等的文字的結構是值得的。但一個更復雜的對映系統可能把投遞地址連結到一個複雜的、引用可產生更大意義的分層模型。與大多數建模的問題一樣,具體問題具體分析。[6]

用合適的值物件替換公共的原語,例如字串,通常是一個好主意。當我可以用一個字串表示一個電話號碼時,轉換為一個電話號碼物件使得變數和引數更明確(當開發語言支援時會有型別檢測),驗證自然的關注,和避免不適用的行為(例如在整型ID數字上進行算術操作)。

例如座標、貨幣或者範圍這些小物件是值物件的好例子。但如果更大的結構沒有任何概念標記或者不需要在程式裡共享引用,是可以經常把他們程式設計為值物件。這在本質上更適合於函式式語言,預設為不可變性。[7]

我發現值物件,尤其是小的那些,經常會被忽然 -- 被看作是太不重要以致不值得去考慮。但一旦我構建了一組好的值物件,我發現我可以在他們之上建立豐富的行為。如果想要體驗一下,可試用一下Range類,看下它是怎樣通過更豐富的行為來防止各種重複比較開始和結束屬性。我經常遇到像這樣的領域特定值物件程式碼庫可以作一個重構的關注點,從而大大簡化系統。如此一個簡化經常會讓人感到驚訝,直到人們一次又一次地看到了它 -- 那時它將會是一位好朋友。

致謝

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視角關於值物件最具深度的討論。他涵蓋了如何在值和實體之間權衡,實現技巧以及持久化值物件的技術。

這個詞在早期就開始獲得關注。那時討論他們的兩本書是PoEAADDD。在Ward的Wiki上也有一些有趣的討論。

術語困惑的一個來源是在世紀之交某些J2EE文獻為資料傳送物件(Data Transfer Object)使用了“值物件”。這種用法現在幾乎都消失了,但有可能你會遇到它。

註解

1、在領域驅動設計裡,Evan分類對比了值物件和實體。我把實體看作是引用物件的一種通用形式,但只在領域模型裡使用術語“實體”,而引用物件/值物件二分法則對所有程式碼都有用。

2、嚴格上,這是在awt.geom.Point2D裡完成的,這是awt.Point的超類。

3、在Java裡大部分物件的比較是通過equals來完成的 -- 這有一點尷尬,因為我不得不記住使用equals而不是相等操作符==。這一點很煩人,但Java程式設計師很快就習慣了因為String的行為和這一樣。其他OO語言可以避免這點 -- Ruby使用了==操作符,但允許它被過載。

4、對於pre-Java-8日期和時間系統裡最糟糕的特徵來說,這是一個強大的競爭對手 -- 但我會投它一票。謝天謝地,通過Java 8 的java.time包,我們可以避免這些的大部分。

5、嚴格來說這不是不可變的,因為客戶端可以操作_data屬性。但在實踐中,一個紀律嚴明的團隊可以讓它成為不可變的。如果我考慮到這個團隊還不夠紀律嚴明,那麼我會使用freeze。確實在一個簡單的JavaScript物件上我可以使用凍結,但我更傾向使用宣告訪問器的類的顯式。

6、關於這點,在Evan的《領域特定語言》一書裡有更多討論。

7、不可變性對於引用物件也是有價值的 -- 如果在一個Get請求裡,銷售訂單不會發生改變,那麼讓它成為不可變是有價值的;並且會使得複製它更安全,如果那樣有用的話。但如果我決定基於一個唯一訂單號來判斷相等性的話,那樣就不能把銷售訂單作為一個值物件。

相關文章