🎯 什麼情境該想到我
當你「要一個依內容判斷相等、而非依身分的小物件」的時候(如日期、座標、金額)。
⚙️ 怎麼用(步驟 / 公式)
意圖:用「值」而非「識別」判斷相等的小物件,通常設計為不可變。
做法要點:
- 相等性依欄位內容決定,兩個值相同即視為相等(覆寫相等與雜湊)。
- 通常設計為不可變(immutable),要變就產生新物件,避免共享時被意外改動。
- 對比參考物件(Reference Object):後者靠身分(identity)辨別,如客戶、帳戶。
★★ 定義只有一條:相等性由「值」決定,不由「身分」決定
// ★ 值物件:兩個 Money{1999, "TWD"} 完全可互換
type Money struct{ cents int64; currency string }
m1 == m2 // ★★ 只要欄位相同就相等
// ★ 實體:兩個 Order 即使所有欄位相同,也是不同的訂單
type Order struct{ id ID; … }
o1.ID() == o2.ID() // ★ 相等性看 ID★★ 判準:如果把它換成另一個「所有欄位都相同」的實例,有任何差別嗎?
沒有 → 值物件。有(那是「另一張訂單」)→ 實體。
★ 這一條就決定了它要不要 ID、要不要自己的表、要不要樂觀鎖。
★★ Go 是少數能把值物件做得很乾淨的語言
// ★ 三個 Go 特有的優勢
// ① ★★ struct 是值 —— 賦值與傳參都是複製,天然的值語義
a := Money{1999, "TWD"}
b := a; b.cents = 0 // ★ a 不變(Java/Python 是別名,做不到)
// ② ★★ == 直接可用(★ 只要所有欄位可比較)
m1 == m2 // ✅
// ⚠️ 含 slice/map/func 的 struct ★★ 不可比較(編譯錯誤),也不能當 map key
// ③ ★ 未匯出欄位 + 建構函式 = 真正的不可變
type Money struct{ cents int64; currency string } // ★ 外部無法構造非零值也無法修改
func New(cents int64, cur string) (Money, error) { … }
func (m Money) Add(o Money) (Money, error) { … } // ★★ 值接收者,回傳新值★★ == 可用是被低估的好處:它讓值物件能當 map key、能用 slices.Contains、
能在測試裡直接比較——★ 而這些在需要自訂 equals() 的語言裡都要多寫程式碼。
★★ 五個必做的事
// ① ★★ 不可變:小寫欄位 + 只有值接收者的方法
func (m *Money) SetCents(c int64) { m.cents = c } // ★★ 有這個就不是值物件了
// ② ★ 建構函式驗證(唯一的入口)
func New(cents int64, cur string) (Money, error) {
if !validCurrency(cur) { return Money{}, fmt.Errorf("bad currency %q", cur) }
return Money{cents, cur}, nil
}
// ③ ★★ 零值必須是「明顯無效」或「有意義的預設」
var m Money // ★ {0, ""} —— 0 元的什麼幣別?
// → ★ 對策:使用點檢查 m.currency == "",或讓零值代表一個明確的語義
// ④ ★★ 序列化邊界要自己補(否則驗證被繞過)
func (m *Money) UnmarshalJSON(b []byte) error { … } // ★ 走 New()
func (m Money) Value() (driver.Value, error) { … }
func (m *Money) Scan(src any) error { … }
// ⚠️ ★★ 多欄位的值物件無法用 sql.Scanner(它是單欄位)→ 見 [[內嵌值]]
// ⑤ ★ String() 讓日誌與除錯可讀
func (m Money) String() string { … }★★ ③④ 是最常漏的兩個:
零值繞過建構函式(Go 擋不住)、UnmarshalJSON 沒實作讓 API 進來的資料完全不驗證。
★★ 什麼時候不要做成值物件
① ★★ 它其實有身分
★ 「地址」在物流系統裡可能是實體(要能被修改、被追蹤變更歷史)
→ ★ 判準:有人會說「把那個地址改一下」嗎?會 → 它有身分
② ★ 純粹是「不想搞混」→ ★★ 具名型別就夠(type UserID string),不必 struct + 建構函式
③ ★ 值會頻繁變動且很大 → ★ 不可變意味著每次修改都複製
→ ⚠️ 但小 struct 的複製通常無感,★ 先 profile 再說
④ ★★ 它需要有自己的生命週期(建立、修改、刪除)→ 那是實體
★ 值物件帶來的三個具體收益(不只是「型別安全」)
① ★★ 不變式只需要在一個地方守
★ Money 存在的意思就是「這是一個合法的金額」
② ★★ 相關的邏輯有家了
m.Add()、m.Multiply()、m.Allocate()(★ 分帳的餘數處理)
→ ★ 原本散落在五處的計算收斂到一個型別上
③ ★ 它是 map key 與集合成員
map[Currency]Money、slices.Contains(allowed, cur)
★ 兄弟辨析
| 差別 | |
|---|---|
| 金錢 | ★ 最經典的值物件(★ 而且它的細節多到值得自己一頁) |
| 內嵌值 | ★ 值物件在資料庫的持久化方式 |
| 識別欄位 | ★ 實體有它,值物件沒有 —— 這是定義差別 |
| 以物件取代基本型別 | ★ 重構手法:怎麼把 string 變成值物件 |
| 可變資料 | 不可變性的一般討論 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
需要跨處共享並追蹤同一實體、以身分辨別時,用參考物件而非值物件。
-
記得同時正確覆寫相等與雜湊,否則放進集合會出錯。
-
不可變帶來安全,但頻繁產生新物件在極端情況下要注意成本。
-
★★ 判準只有一條:換成另一個「所有欄位相同」的實例有差別嗎? 沒有 → 值物件。
-
★★ 含 slice/map/func 的 struct 不可比較(
==編譯錯誤),也不能當 map key。需要比較就自己寫Equal。 -
★★ 零值永遠繞過建構函式,Go 擋不住。要讓零值明顯無效並在使用點檢查。
-
★★ 序列化邊界必須自己補(
UnmarshalJSON/Scanner/Valuer),否則 API 進來的資料完全不驗證。 -
★★ 多欄位值物件無法用
sql.Scanner(單欄位介面),必須由對應器組裝(見 內嵌值)。 -
★ 有 setter 就不是值物件。 用值接收者 + 回傳新值。
-
★ 「有人會說『把那個地址改一下』嗎?」會 → 它有身分,是實體。
-
★ 純粹不想搞混 → 具名型別就夠(
type UserID string),不必 struct + 建構函式。 -
不可變的代價是複製,但小 struct 通常無感,先 profile 再說。
🔗 相關工具
- 金錢 Money(表示金額與幣別的值物件)
- 特例 Special Case(以子類別代表特殊值,取代 null)
- 企業應用架構模式
- 金錢 —— ★ 最經典的值物件
- 內嵌值 —— 持久化方式
- 識別欄位 —— 實體有、值物件沒有
- 以物件取代基本型別 —— 重構手法
- 可變資料 —— 不可變性的一般討論
- 工具-實體值物件與聚合 —— DDD 的定義