當談到 Go中結構體值時,人們糾結:透過指標傳遞這些值還是隻是複製值?
- 由於指標會帶來一些開銷,因此人們自然的反應是不惜一切代價避免使用它們,並儘可能傳遞結構值複製副本。
- 而我通常選擇使用指標結構的兩個原因是標識性和一致性。
對於我的專案,我寧願使用 語義:
- 我會檢視結構型別應該表示什麼,並預先決定是否要為其使用指標或值複製語義。
- 一旦我做出決定,這個決定幾乎總是在專案期間持續存在。
為了在指標和值複製語義之間劃清界限,我選擇的標準部分受到領域驅動設計 (DDD) 的啟發,部分受到Ardan Labs 這篇文章的啟發。
這一切都與型別的標識身份有關。
標識身份Identity
DDD 將實體與值物件區分開來。解釋兩者之間差異的最簡單方法是問自己這個問題:
- 如果同一結構型別的兩個值的所有屬性都相等,那麼這兩個值是否相同?
同樣的問題,稍微修改一下:
- 如果我更改其中一個屬性的值,是否會破壞結構值的標識?
最後但並非最不重要的一點是:
- 該結構型別的零值有用嗎?
如果所有問題的答案都是 "是",那麼您就擁有了一個經典值物件,可以自由使用值複製語義。
為什麼呢?
因為值物件是不可變的。
如果你改變了其中一個屬性的值,那麼你實質上是建立了一個全新的值。
值物件
Go 標準庫中最好的例子就是 time.Time,time.Time 結構表示納秒精度的瞬間時間。
從本質上講,time.Time 值是對兩個 uint64 的封裝
- 如果改變這兩個值中的任何一個,我們就會得到一個完全不同的 time.Time 值。
- 換句話說,它的特性是其屬性相等的乘積。
此外,一個空的 time.Time 值就是它的邏輯零值:
var t, tt time.Time |
能否使用指標語義來使用 time.Time?
當然可以
指標只是對記憶體中已知值的引用。
你可以定址任何值,無論其型別如何。但在這種情況下,你不會得到任何實際的好處,這就是為什麼大多數時候,時間例項都是作為值副本傳遞的原因。
有人可能會反對使用 *time.Time,認為在值可能根本不存在的情況下(可選性)使用 *time.Time可能會很有用。
一個很好的例子是:
一個帶有 DeletedAt 屬性的結構體,只有在資料被刪除(如從資料庫中刪除)時,該屬性才會被設定:
type DBRow { |
我認為可以透過引入一個單獨的結構型別來解決這個問題,在這個結構型別中,DeletedAt 總是有效的:
type DBRow { |
實體
很自然,這就引出了第二種型別,即實體。以上述三個問題為標準,我們可以將任何結構型別定義為實體,只要它沒有合理的零值,或者其任何屬性的改變都不意味著一個新值。
對於 time.Time,無論我們有多少個相同的 time.Time 副本,時間例項(如 2024 年 1 月 1 日)始終是相同的。時間例項的身份是從其內部狀態推斷出來的。
對於更復雜的型別,我們可能不能這麼說,因為即使它們可能具有相同的內部狀態,但它們是不同的副本,會隨著時間的推移發生不同的演變,這將帶來不希望看到的後果。
想想檔案引用或資料庫連線。如果有多個副本引用相同的資源,就有可能從兩個不同的位置讀取或更改檔案,或者無法正確關閉資料庫事務(更有甚者,會耗盡可用資料庫連線的最大值)。
如果我們無法合理地確定一個物件的身份,那麼我們就需要一種指向它的引用形式,即 "無論我們有多少份該引用的副本,它們都指向同一個源"。指標完美地扮演了這一角色。
讓我們用一個不太明顯的自定義型別示例來說明這一點。假設我們正在構建一個專案管理系統,Project 和 Person 是其中的兩個核心資料模型。一個專案必須有一個標題和一個相關的 Person 型別的所有者。
在處理 Project 時,我們先使用值複製語義。馬上就會出現幾個有趣的問題:
- 首先,專案的零值到底是什麼--一個沒有所有者、標題為空的專案是有用的零值嗎?
- 一個有所有者但沒有標題的專案又如何呢?
- 或者,反過來呢?也許,通用的零值是更好的選擇?
即使我們在這一點上存在分歧,但我們可能最終會出現由同一個人擁有多個標題相同的專案,這種情況又有多常見呢?這肯定是合法的,也是可能的。如果我們遵循價值複製語義,那麼如果專案 A 和專案 B 擁有相同的所有者和相同的標題,我們如何確定它們是不同的呢?
me := Person{Name: <font>"John"} |
有人會說,這就是 ID 的作用。好吧,那我們就給每個人都加上唯一的 ID,這樣我們就可以根據這些人的不同來進行區分了:
me := Person{Name: <font>"John"} |
但話又說回來,如果這些 ID 都是 0,或者有人忘記設定了呢?那麼,這兩個值又會相等,這在邏輯上是錯誤的。
但還有其他問題。如果我們現在將專案 A 的一個副本傳遞給函式,並在函式內部對其進行修改,那麼我們現在就會有兩個指向相同 ID 但標題不同的副本。
根據上述假設,識別條目身份的唯一合理方法就是對該型別使用指標語義。
同樣的道理也適用於 Person 型別:
me := Person{Name: <font>"John"} |
總結
指標的用途遠比複製數值要廣泛得多。
事實上,在大多數情況下,我並不懼怕使用指標結構型別,在真正有意義的情況下,我才會使用值複製,而且我可以保證,我不必為日後改變語義而付出代價。
指標並不壞,它們不會咬人,也不會讓程式碼有異味,而且它們確實是程式設計的正常組成部分。
擁有一個一致的程式碼庫,在這個程式碼庫中,每種型別都可以透過合理的推理進行引用,這遠比針對可能(而且很可能)永遠不會發生的情況進行最佳化要重要得多。