基於DDD知識揭示Go中結構指標兩個優點

banq發表於2024-04-27


當談到 Go中結構體值時,人們糾結:透過指標傳遞這些值還是隻是複製值?

  • 由於指標會帶來一些開銷,因此人們自然的反應是不惜一切代價避免使用它們,並儘可能傳遞結構值複製副本。
  • 而我通常選擇使用指標結構的兩個原因是標識性和一致性。

對於我的專案,我寧願使用 語義:

  • 我會檢視結構型別應該表示什麼,並預先決定是否要為其使用指標或值複製語義。
  • 一旦我做出決定,這個決定幾乎總是在專案期間持續存在。 

為了在指標和值複製語義之間劃清界限,我選擇的標準部分受到領域驅動設計 (DDD) 的啟發,部分受到Ardan Labs 這篇文章的啟發。

這一切都與型別的標識身份有關。

標識身份Identity
DDD 將實體與值物件區分開來。解釋兩者之間差異的最簡單方法是問自己這個問題:

  • 如果同一結構型別的兩個值的所有屬性都相等,那麼這兩個值是否相同?

同樣的問題,稍微修改一下:

  • 如果我更改其中一個屬性的值,是否會破壞結構值的標識?

最後但並非最不重要的一點是:

  • 該結構型別的零值有用嗎?

如果所有問題的答案都是 "是",那麼您就擁有了一個經典值物件,可以自由使用值複製語義。
為什麼呢?
因為值物件是不可變的。

如果你改變了其中一個屬性的值,那麼你實質上是建立了一個全新的值。

值物件
Go 標準庫中最好的例子就是 time.Time,time.Time 結構表示納秒精度的瞬間時間。

從本質上講,time.Time 值是對兩個 uint64 的封裝

  • 如果改變這兩個值中的任何一個,我們就會得到一個完全不同的 time.Time 值。
  • 換句話說,它的特性是其屬性相等的乘積。

此外,一個空的 time.Time 值就是它的邏輯零值:
var t, tt time.Time
fmt.Println(t == tt) <font>// true<i>
fmt.Println(t.IsZero())
// true<i>

能否使用指標語義來使用 time.Time?
當然可以
指標只是對記憶體中已知值的引用。

你可以定址任何值,無論其型別如何。但在這種情況下,你不會得到任何實際的好處,這就是為什麼大多數時候,時間例項都是作為值副本傳遞的原因。

有人可能會反對使用 *time.Time,認為在值可能根本不存在的情況下(可選性)使用 *time.Time可能會很有用。

一個很好的例子是:
一個帶有 DeletedAt 屬性的結構體,只有在資料被刪除(如從資料庫中刪除)時,該屬性才會被設定:

type DBRow {
    <font>// Other attrs ..<i>
    DeletedAt *time.Time
}

我認為可以透過引入一個單獨的結構型別來解決這個問題,在這個結構型別中,DeletedAt 總是有效的:

type DBRow {
    <font>// Other attrs ..<i>
}

type DeletedDBRow {
    DBRow
// 很好地引入了該行的所有屬性<i>
    DeletedAt time.Time
// 我們假設該值始終有效<i>
}

實體
很自然,這就引出了第二種型別,即實體。以上述三個問題為標準,我們可以將任何結構型別定義為實體,只要它沒有合理的零值,或者其任何屬性的改變都不意味著一個新值。

對於 time.Time,無論我們有多少個相同的 time.Time 副本,時間例項(如 2024 年 1 月 1 日)始終是相同的。時間例項的身份是從其內部狀態推斷出來的。

對於更復雜的型別,我們可能不能這麼說,因為即使它們可能具有相同的內部狀態,但它們是不同的副本,會隨著時間的推移發生不同的演變,這將帶來不希望看到的後果。

想想檔案引用或資料庫連線。如果有多個副本引用相同的資源,就有可能從兩個不同的位置讀取或更改檔案,或者無法正確關閉資料庫事務(更有甚者,會耗盡可用資料庫連線的最大值)。

如果我們無法合理地確定一個物件的身份,那麼我們就需要一種指向它的引用形式,即 "無論我們有多少份該引用的副本,它們都指向同一個源"。指標完美地扮演了這一角色。

讓我們用一個不太明顯的自定義型別示例來說明這一點。假設我們正在構建一個專案管理系統,Project 和 Person 是其中的兩個核心資料模型。一個專案必須有一個標題和一個相關的 Person 型別的所有者。

在處理 Project 時,我們先使用值複製語義。馬上就會出現幾個有趣的問題:

  • 首先,專案的零值到底是什麼--一個沒有所有者、標題為空的專案是有用的零值嗎?
  • 一個有所有者但沒有標題的專案又如何呢?
  • 或者,反過來呢?也許,通用的零值是更好的選擇?

即使我們在這一點上存在分歧,但我們可能最終會出現由同一個人擁有多個標題相同的專案,這種情況又有多常見呢?這肯定是合法的,也是可能的。如果我們遵循價值複製語義,那麼如果專案 A 和專案 B 擁有相同的所有者和相同的標題,我們如何確定它們是不同的呢?

me := Person{Name: <font>"John"}

a := Project{Title:
"ToDos", Owner: me}
b := Project{Title:
"ToDos", Owner: me}

fmt.Println(a == b)
// 真,這在邏輯上是錯誤的<i>


有人會說,這就是 ID 的作用。好吧,那我們就給每個人都加上唯一的 ID,這樣我們就可以根據這些人的不同來進行區分了:

me := Person{Name: <font>"John"}

a := Project{ID: 1, Title:
"ToDos", Owner: me}
b := Project{ID: 2, Title:
"ToDos", Owner: me}

fmt.Println(a == b)
// false<i>


但話又說回來,如果這些 ID 都是 0,或者有人忘記設定了呢?那麼,這兩個值又會相等,這在邏輯上是錯誤的。

但還有其他問題。如果我們現在將專案 A 的一個副本傳遞給函式,並在函式內部對其進行修改,那麼我們現在就會有兩個指向相同 ID 但標題不同的副本。

根據上述假設,識別條目身份的唯一合理方法就是對該型別使用指標語義

同樣的道理也適用於 Person 型別:

me := Person{Name: <font>"John"}

a := Project{Title:
"ToDos", Owner: &me}
b := Project{Title:
"ToDos", Owner: &me}

fmt.Println(&a == &b)
// false, as it should be<i>


總結
指標的用途遠比複製數值要廣泛得多

事實上,在大多數情況下,我並不懼怕使用指標結構型別,在真正有意義的情況下,我才會使用值複製,而且我可以保證,我不必為日後改變語義而付出代價。

指標並不壞,它們不會咬人,也不會讓程式碼有異味,而且它們確實是程式設計的正常組成部分。

擁有一個一致的程式碼庫,在這個程式碼庫中,每種型別都可以透過合理的推理進行引用,這遠比針對可能(而且很可能)永遠不會發生的情況進行最佳化要重要得多。

 

相關文章