值物件
2016 年 11 月 14 日
在編寫程式時,我常發現將事物表示為複合體很有用。2D 座標包含 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 角度對值物件最深入的探討。他涵蓋了如何在值和實體之間做出決定、實作提示以及持久化值物件的技術。
此術語在 2000 年代初期開始獲得關注。當時有兩本書在討論這個主題,分別是 PoEAA 和 DDD。在 Ward's Wiki 上也有一些有趣的討論。
術語混淆的一個來源是,在世紀之交時,一些 J2EE 文獻將「值物件」用於 資料傳輸物件。這種用法現在大多已經消失了,但你可能會遇到它。
備註
1: 在領域驅動設計中,Evans 分類 將值物件與實體進行對比。我認為實體是參考物件的一種常見形式,但只在領域模型中使用「實體」一詞,而參考/值物件二分法對所有程式碼都很有用。
2: 嚴格來說,這是 awt.geom.Point2D 中完成的,它是 awt.Point 的超類別
3: Java 中大多數物件比較都是使用 equals
完成的,這本身有點尷尬,因為我必須記得使用它,而不是等號運算子 ==
。這很煩人,但 Java 程式設計師很快就會習慣,因為 String 的行為方式相同。其他 OO 語言可以避免這種情況 - Ruby 使用 ==
運算子,但允許覆寫它。
4: Java-8 之前的日期和時間系統最糟糕的功能有很多競爭者,但我會投給這個。值得慶幸的是,現在我們可以使用 Java 8 的 java.time
套件來避免大部分這種情況
5: 這並不是嚴格的不可變,因為客戶端可以操作 _data
屬性。但一個紀律嚴明的團隊可以在實務上讓它不可變。如果我擔心一個團隊不夠自律,我可能會使用 freeze
。事實上,我可以在一個簡單的 JavaScript 物件上使用 freeze,但我更喜歡宣告存取器的類別的明確性。
6: 在 Evans 的 DDD 書籍 中有更多關於此內容的討論。
7: 不變性對於參考物件也很有價值 - 如果銷售訂單在取得請求期間沒有變更,那麼讓它不變是有價值的;而且如果這樣做是有用的,那將使複製它變得安全。但如果我根據唯一的訂單號碼來確定相等性,這不會使銷售訂單成為值物件。