🎯 什麼情境該想到我

當你「要一個依內容判斷相等、而非依身分的小物件」的時候(如日期、座標、金額)。

⚙️ 怎麼用(步驟 / 公式)

意圖:用「值」而非「識別」判斷相等的小物件,通常設計為不可變。

做法要點:

  • 相等性依欄位內容決定,兩個值相同即視為相等(覆寫相等與雜湊)。
  • 通常設計為不可變(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 再說。

🔗 相關工具