【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】
Dart 的 built_value,實現不可變物件模型
上週我寫了關於 built_collection
的文章。最後我提到,要真正使用不可變集合,您需要不可變值。所以我們現在來談談 built_value
。這是我在 Dart 開發者峰會 (影片) 上演講的第二個主要部分。
值類型
built_value
套件用於定義您自己的值類型。這個術語有精確的含義,但我們非正式地使用它來表示僅基於值判斷相等性的類型。例如,數字:我的 3 等於你的 3。
不僅如此:我的 3 將永遠等於你的 3;它不能變成 4,也不能變成 null,更不能變成完全不同的類型。值類型天生就是不可變的。這使得它們易於互動和推理。
這聽起來非常抽象。值類型有什麼用?事實證明:有很多用處。非常多。可以說——而且我經常這樣說——任何用於模擬現實世界的類都應該是值類型。觀察:
1 | var user1 = User(name: "John Smith"); |
它應該印出什麼?關鍵是,這兩個實例都應該指向現實世界中的某個人。因為它們的值相同,所以它們必須指向同一個人。所以它們必須被認為是相等的。
那麼不可變性呢?考慮:
1 | user1.nickname = 'Joe'; |
更新「使用者」暱稱意味著什麼?它可能意味著任何數量的更改;也許我的網頁上的歡迎文字使用了暱稱,那也應該更新。我可能在某個地方有一些儲存空間,那也需要更新。我現在有兩個主要問題:
- 我不知道誰持有對
user1
的引用。該值在它們下面剛剛更改;根據它們的使用方式,這可能會產生任何數量不可預測的影響。 - 任何持有
user2
或類似值的人現在都持有一個過時的值。
不可變性無法解決第二個問題,但它確實消除了第一個問題。這意味著沒有不可預測的更新,只有明確的更新:
1 | var updatedUser = User(name: "John Smith", nickname: "Joe"); |
關鍵是,這意味著更改是局部的,直到明確發佈。這會產生易於推理的簡單程式碼——並且使程式碼既正確又快速。
值類型的問題
那麼,顯而易見的問題是:如果值類型如此有用,為什麼我們沒有到處看到它們?
不幸的是,它們的實作非常費力。在 Dart 和大多數其他物件導向語言中,需要大量的樣板程式碼。在我於 Dart 開發者峰會上的演講中,我展示了一個簡單的雙欄位類需要如此多的樣板程式碼,以至於佔滿了整張投影片 (影片)。
介紹 built_value
我們需要一個語言特性——討論起來令人興奮,但不太可能很快出現——或者某種形式的元程式設計。我們發現 Dart 已經有一種非常好的元程式設計方法:source_gen
。
目標很明確:使定義和使用值類型變得如此容易,以至於我們可以在任何值類型有意義的地方使用它們。
首先,我們需要快速繞道,看看如何使用 source_gen
處理這個問題。source_gen
工具在您手動維護的原始碼旁邊的新檔案中建立生成的原始碼,因此我們需要為生成的實作留出空間。這意味著一個抽象類別:
1 | abstract class User { |
這有足夠的資訊來產生一個實作。按照慣例,生成的程式碼以 _$
開頭,以標記它是私有的和生成的。因此生成的實作將被稱為 _$User
。為了允許它擴展 User
,將有一個名為 _
的私有建構函數用於此目的:
1 | === user.dart === |
我們需要使用 Dart 的 part
陳述式來引入生成的程式碼:
1 | === user.dart === |
我們正在取得進展!我們有一種方法可以產生程式碼,並將其插入到我們手寫的程式碼中。現在回到有趣的部分:您實際上手寫的內容,以及 built_value
應該生成的內容。
我們缺少一種實際指定欄位值的方法。我們可以考慮使用具名可選參數:
1 | factory User({String name, String nickname}) = _$User; |
但這有幾個缺點:它強迫您在建構函數中重複所有欄位名稱,並且它只提供了一種一次設定所有欄位的方法;如果您想逐個構建值怎麼辦?
幸運的是,建構器模式 來拯救我們了。我們已經看到它在 Dart 中的集合中效果如何——感謝串聯運算符。假設我們有一個建構器類型,我們可以使用它作為建構函數——透過請求一個將建構器作為參數的函數:
1 | abstract class User { |
這有點令人驚訝,但它導致了一個非常簡單的實例化語法:
1 | var user1 = User((b) => b |
如何根據舊值建立新值?傳統的建構器模式提供了一個 toBuilder
方法來轉換為建構器;然後您應用您的更新並調用 build
。但對於大多數使用案例來說,一個更好的模式是有一個 rebuild
方法。與建構函數一樣,它接受一個以建構器為參數的函數,並提供簡單的內聯更新:
1 | var user2 = user1.rebuild((b) => b |
不過,我們仍然需要 toBuilder
,以防您想將建構器保留一段時間。因此,我們希望所有值類型都有兩種方法:
1 | abstract class Built<V, B> { |
您不需要為這些方法編寫實作,built_value
將為您產生它。因此,您只需聲明您「實作 Built」:
1 | library user; |
就這樣!定義了一個值類型,產生了一個實作,並且易於使用。當然,生成的實作不僅僅是欄位:它還提供了 operator==
、hashCode
、toString
以及對必要欄位的 null 檢查。
不過,我跳過了一個主要細節:我說「假設我們有一個建構器類型」。當然,我們正在產生程式碼,所以答案很簡單:我們會為您產生它。User
中引用的 UserBuilder
是在 user.g.dart
中建立的。
…除非您想在建構器中編寫一些程式碼,這是一個非常合理的做法。如果您想這樣做,您可以對建構器遵循相同的模式。它被聲明為抽象的,有一個私有建構函數和一個委託給生成的實作的工廠:
1 | abstract class UserBuilder implements Builder<User, UserBuilder> { |
@virtual
註釋來自 package:meta
,並且是允許生成的實作覆寫欄位所必需的。現在您已經將工具方法加入到您的建構器中,您可以像將其賦值給欄位一樣內聯使用它們:
1 | var user = User((b) => b..parseUser('John "Joe" Smith')); |
自訂建構器的使用案例相對少見,但它們可以非常強大。例如,您可能希望您的建構器實作一個用於設定共用欄位的通用介面,以便它們可以互換使用。
嵌巢建構器
built_value
還有一個您還沒有看到的主要功能:嵌巢建構器。當一個 built_value
欄位持有 built_collection
或另一個 built_value
時,預設情況下,它在建構器中作為嵌巢建構器提供。這意味著您可以更容易地更新深層嵌套的欄位,而不是整個結構都是可變的:
1 | var structuredData = Account((b) => b |
為什麼比整個結構都是可變的「更容易」?
首先,所有建構器提供的 update
方法意味著您可以隨時進入新的作用域,「重新啟動」串聯運算符,並簡潔地內聯進行您想要的任何更新:
1 | var updatedStructuredData = structuredData.rebuild((b) => b |
其次,嵌套建構器會根據需要自動建立。例如,在 built_value
的基準測試程式碼中,我們定義了一個名為 Node
的類型:
1 | abstract class Node implements Built<Node, NodeBuilder> { |
建構器的自動建立讓我們可以內聯建立我們想要的任何樹結構:
1 | var node = Node((b) => b |
我提到基準測試了嗎?更新時,built_value
只複製需要更新的結構部分,重用其餘部分。所以它很快——而且記憶體效率很高*。
但您不必只構建樹。使用 built_value
,您可以使用完全類型的不可變物件模型…它們與高效的不可變樹一樣快速和強大。您可以混合和匹配類型資料、自訂結構(如 Node
範例)以及來自 built_collection
的集合:
1 | var structuredData = Account((b) => b |
當我說大多數資料都應該是值類型時,我說的就是這些值類型!
更多關於 built_value
我已經討論了為什麼需要 built_value
以及它的使用方法。還有更多:built_value
還提供了 EnumClass
,用於像列舉一樣的類別,以及 JSON 序列化,用於伺服器/客戶端通訊和資料儲存。我將在以後的文章中討論這些內容。
之後,我將深入研究聊天範例,該範例在具有伺服器和客戶端的端到端系統中使用 built_value
。
編輯:下一篇文章。
Dart 的 built_value
,實現不可變物件模型 最初發佈於 dartlang 的 Medium 上,人們在那裡透過醒目顯示和回應這個故事來繼續對話。