0%

【文章翻譯】Dart’s built_value for Immutable Object Models

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

Dart 的 built_value,實現不可變物件模型

上週我寫了關於 built_collection 的文章。最後我提到,要真正使用不可變集合,您需要不可變值。所以我們現在來談談 built_value。這是我在 Dart 開發者峰會 (影片) 上演講的第二個主要部分。

值類型

built_value 套件用於定義您自己的值類型。這個術語有精確的含義,但我們非正式地使用它來表示僅基於值判斷相等性的類型。例如,數字:我的 3 等於你的 3。

不僅如此:我的 3 將永遠等於你的 3;它不能變成 4,也不能變成 null,更不能變成完全不同的類型。值類型天生就是不可變的。這使得它們易於互動和推理。

這聽起來非常抽象。值類型有什麼用?事實證明:有很多用處。非常多。可以說——而且我經常這樣說——任何用於模擬現實世界的類都應該是值類型。觀察:

1
2
3
4
var user1 = User(name: "John Smith");
var user2 = User(name: "John Smith");

print(user1 == user2);

它應該印出什麼?關鍵是,這兩個實例都應該指向現實世界中的某個人。因為它們的值相同,所以它們必須指向同一個人。所以它們必須被認為是相等的。

那麼不可變性呢?考慮:

1
user1.nickname = 'Joe';

更新「使用者」暱稱意味著什麼?它可能意味著任何數量的更改;也許我的網頁上的歡迎文字使用了暱稱,那也應該更新。我可能在某個地方有一些儲存空間,那也需要更新。我現在有兩個主要問題:

  • 我不知道誰持有對 user1 的引用。該值在它們下面剛剛更改;根據它們的使用方式,這可能會產生任何數量不可預測的影響。
  • 任何持有 user2 或類似值的人現在都持有一個過時的值。

不可變性無法解決第二個問題,但它確實消除了第一個問題。這意味著沒有不可預測的更新,只有明確的更新:

1
2
var updatedUser = User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // 資料庫將通知前端。

關鍵是,這意味著更改是局部的,直到明確發佈。這會產生易於推理的簡單程式碼——並且使程式碼既正確又快速。

值類型的問題

那麼,顯而易見的問題是:如果值類型如此有用,為什麼我們沒有到處看到它們?

不幸的是,它們的實作非常費力。在 Dart 和大多數其他物件導向語言中,需要大量的樣板程式碼。在我於 Dart 開發者峰會上的演講中,我展示了一個簡單的雙欄位類需要如此多的樣板程式碼,以至於佔滿了整張投影片 (影片)。

介紹 built_value

我們需要一個語言特性——討論起來令人興奮,但不太可能很快出現——或者某種形式的元程式設計。我們發現 Dart 已經有一種非常好的元程式設計方法:source_gen

目標很明確:使定義和使用值類型變得如此容易,以至於我們可以在任何值類型有意義的地方使用它們。

首先,我們需要快速繞道,看看如何使用 source_gen 處理這個問題。source_gen 工具在您手動維護的原始碼旁邊的新檔案中建立生成的原始碼,因此我們需要為生成的實作留出空間。這意味著一個抽象類別:

1
2
3
4
5
6
abstract class User {
String get name;

@nullable
String get nickname;
}

這有足夠的資訊來產生一個實作。按照慣例,生成的程式碼以 _$ 開頭,以標記它是私有的和生成的。因此生成的實作將被稱為 _$User。為了允許它擴展 User,將有一個名為 _ 的私有建構函數用於此目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=== user.dart ===

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = UserImpl;
}

=== user.g.dart 由 source_gen 產生 ===

class _$User extends User {
String name;
String nickname;

_$User() : super._();
}

我們需要使用 Dart 的 part 陳述式來引入生成的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
=== user.dart ===

library user;

part 'user.g.dart';

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = _$User;
}

=== user.g.dart 由 source_gen 產生 ===

part of user;

class _$User extends User {
String name;
String nickname;

_$User() : super._();

// 生成的實作放在這裡。
}

我們正在取得進展!我們有一種方法可以產生程式碼,並將其插入到我們手寫的程式碼中。現在回到有趣的部分:您實際上手寫的內容,以及 built_value 應該生成的內容。

我們缺少一種實際指定欄位值的方法。我們可以考慮使用具名可選參數:

1
factory User({String name, String nickname}) = _$User;

但這有幾個缺點:它強迫您在建構函數中重複所有欄位名稱,並且它只提供了一種一次設定所有欄位的方法;如果您想逐個構建值怎麼辦?

幸運的是,建構器模式 來拯救我們了。我們已經看到它在 Dart 中的集合中效果如何——感謝串聯運算符。假設我們有一個建構器類型,我們可以使用它作為建構函數——透過請求一個將建構器作為參數的函數:

1
2
3
4
5
6
7
8
9
abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

這有點令人驚訝,但它導致了一個非常簡單的實例化語法:

1
2
3
var user1 = User((b) => b
..name = 'John Smith'
..nickname = 'Joe');

如何根據舊值建立新值?傳統的建構器模式提供了一個 toBuilder 方法來轉換為建構器;然後您應用您的更新並調用 build。但對於大多數使用案例來說,一個更好的模式是有一個 rebuild 方法。與建構函數一樣,它接受一個以建構器為參數的函數,並提供簡單的內聯更新:

1
2
var user2 = user1.rebuild((b) => b
..nickname = 'Jojo');

不過,我們仍然需要 toBuilder,以防您想將建構器保留一段時間。因此,我們希望所有值類型都有兩種方法:

1
2
3
4
5
6
7
abstract class Built<V, B> {
// 建立一個新的實例:應用 [updates] 的實例。
V rebuild(updates(B builder));

// 轉換為建構器。
B toBuilder();
}

您不需要為這些方法編寫實作,built_value 將為您產生它。因此,您只需聲明您「實作 Built」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
library user;

import 'package:built_value/built_value.dart';

part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

就這樣!定義了一個值類型,產生了一個實作,並且易於使用。當然,生成的實作不僅僅是欄位:它還提供了 operator==hashCodetoString 以及對必要欄位的 null 檢查。

不過,我跳過了一個主要細節:我說「假設我們有一個建構器類型」。當然,我們正在產生程式碼,所以答案很簡單:我們會為您產生它。User 中引用的 UserBuilder 是在 user.g.dart 中建立的。

除非您想在建構器中編寫一些程式碼,這是一個非常合理的做法。如果您想這樣做,您可以對建構器遵循相同的模式。它被聲明為抽象的,有一個私有建構函數和一個委託給生成的實作的工廠:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class UserBuilder implements Builder<User, UserBuilder> {
@virtual
String name;

@virtual
String nickname;

// 將例如 John "Joe" Smith 解析為使用者名稱 + 暱稱。
void parseUser(String user) {
...
}

UserBuilder._();
factory UserBuilder() = _$UserBuilder;
}

@virtual 註釋來自 package:meta,並且是允許生成的實作覆寫欄位所必需的。現在您已經將工具方法加入到您的建構器中,您可以像將其賦值給欄位一樣內聯使用它們:

1
var user = User((b) => b..parseUser('John "Joe" Smith'));

自訂建構器的使用案例相對少見,但它們可以非常強大。例如,您可能希望您的建構器實作一個用於設定共用欄位的通用介面,以便它們可以互換使用。

嵌巢建構器

built_value 還有一個您還沒有看到的主要功能:嵌巢建構器。當一個 built_value 欄位持有 built_collection 或另一個 built_value 時,預設情況下,它在建構器中作為嵌巢建構器提供。這意味著您可以更容易地更新深層嵌套的欄位,而不是整個結構都是可變的:

1
2
3
4
5
6
7
8
9
10
var structuredData = Account((b) => b
..user.name = 'John Smith'
..user.nickname = 'Joe'
..credentials.email = '[email protected]'
..credentials.phone.country = Country.us
..credentials.phone.number = '555 01234 567');

var updatedStructuredData = structuredData.rebuild((b) => b
..credentials.phone.country = Country.switzerland
..credentials.phone.number = '555 01234 555');

為什麼比整個結構都是可變的「更容易」?

首先,所有建構器提供的 update 方法意味著您可以隨時進入新的作用域,「重新啟動」串聯運算符,並簡潔地內聯進行您想要的任何更新:

1
2
3
4
5
6
var updatedStructuredData = structuredData.rebuild((b) => b
..user.update((b) => b
..name = 'Johnathan Smith')
..credentials.phone.update((b) => b
..country = Country.switzerland
..number = '555 01234 555'));

其次,嵌套建構器會根據需要自動建立。例如,在 built_value 的基準測試程式碼中,我們定義了一個名為 Node 的類型:

1
2
3
4
5
6
7
8
9
10
11
abstract class Node implements Built<Node, NodeBuilder> {
@nullable
String get label;
@nullable
Node get left;
@nullable
Node get right;

Node._();
factory Node([updates(NodeBuilder b)]) = _$Node;
}

建構器的自動建立讓我們可以內聯建立我們想要的任何樹結構:

1
2
3
4
5
6
7
var node = Node((b) => b
..left.left.left.right.left.right.label = 'I’m a leaf!'
..left.left.right.right.label = 'I’m also a leaf!');

var updatedNode = node.rebuild((b) => b
..left.left.right.right.label = 'I’m not a leaf any more!'
..left.left.right.right.right.label = 'I’m the leaf now!');

我提到基準測試了嗎?更新時,built_value 只複製需要更新的結構部分,重用其餘部分。所以它很快——而且記憶體效率很高*。

但您不必只構建樹。使用 built_value,您可以使用完全類型的不可變物件模型…它們與高效的不可變樹一樣快速和強大。您可以混合和匹配類型資料、自訂結構(如 Node 範例)以及來自 built_collection 的集合:

1
2
3
4
5
6
7
8
9
10
11
var structuredData = Account((b) => b
..user.update((b) => b
..name = 'John Smith')
..credentials.phone.update((b) => b
..country = Country.us
..number = '555 01234 567')
..node.left.left.left.account.update((b) => b
..user.name = 'John Smith II'
..user.nickname = 'Is lost in a tree')
..node.left.right.right.account.update((b) => b
..user.name = 'John Smith III'));

當我說大多數資料都應該是值類型時,我說的就是這些值類型!

更多關於 built_value

我已經討論了為什麼需要 built_value 以及它的使用方法。還有更多:built_value 還提供了 EnumClass,用於像列舉一樣的類別,以及 JSON 序列化,用於伺服器/客戶端通訊和資料儲存。我將在以後的文章中討論這些內容。

之後,我將深入研究聊天範例,該範例在具有伺服器和客戶端的端到端系統中使用 built_value

編輯:下一篇文章


Dart 的 built_value,實現不可變物件模型 最初發佈於 dartlang 的 Medium 上,人們在那裡透過醒目顯示和回應這個故事來繼續對話。