(译) Dart中适用于不可修改对象模型的 built_value

原文链接

上周我写了关于built_collection文章。我最后指出要真正使用不可变集合,你需要不可变的值。所以这里我们是要介绍的是:built_value。这是我在Dart开发者峰会上的第二个主要内容。

值类型

built_value包是用来定义你自己的值类型的。这个术语有着确切的含义,但是我们是按照非正式的方式使用它,就意味着相等是基于值的相等。举个例子,数字上来说,我的3是与你的3相等的。

不仅如此:我的3将永远等于你的3;它不能改为4,或空,或完全不同的类型。值类型是生而不可变的。这使得和它们之间的交互和推断变得简单。

这听起来非常抽象。值类型的优点是什么?事实证明:很多。一大堆。虽然有一定争论,但我还是坚称任何用于模拟现实世界的类都应该是值类型。看下面的代码:

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

它应该打印什么?至关重要的是,这两个实例都应该指的是现实世界中的某个人。因为他们的值相同,所以他们肯定指的是同一个人。所以他们一定是相等。

那么关于不变性呢?思考下:

1
user1.nickname = 'Joe';

更新用户的昵称意味着什么?它可能意味着许多变化;假定我网页上的欢迎文本使用了昵称,这个文本就应该被更新。我可能在某处存储了用户信息,因此也需要对其进行更新。我现在有两个主要问题:

  • 我不知道谁有着“user1”的引用。值刚刚发生变化,根据使用情况的不同,可能会产生大量不可预测的影响。
  • 任何持有“user2”或类似内容的人现在都持有一个过时的值。

不变性对第二个问题无能为力,但它确实消除了第一个问题。这意味着没有不可预测的更新,只有明确的更新:

1
2
var updatedUser = new User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // Database will notify frontend.

至关重要的是,这意味着在明确发布之前,变更是本地的。这导致代码变得简单且易于推断 - 并且同时做到正确和快速。

值类型的问题

所以,显而易见的问题是:如果价值类型如此有用,为什么不是随处可见的呢?

不幸的是,他们实施起来非常费力。在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
=== user.dart ===
abstract class User {
String get name;
@nullable
String get nickname;
User._();
factory User() = UserImpl;
}
=== user.g.dart is generated by 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
=== 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 is generated by source_gen ===
part of user;
class _$User extends User {
String name;
String nickname;
_$User() : super._();
// Generated implementation goes here.
}

我们有了一定进展!我们有办法生成代码并将其插入我们手工编写的代码中。现在回到有趣的部分:你实际需要手工编写什么以及built_value应该生成什么。


我们缺少一种方式来真正指定这些域的值。我们可以考虑使用命名的可选参数:

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

但是这有一些缺点:它会强制你重复构造函数中的所有字段名称,它只提供了一次设置所有字段的方法;如果你想逐件建立一个价值怎么办?

幸运的是,builder模式让其得到了拯救。我们已经看到它在Dart中的集合中,得益于级联运算符的良好使用效果。假定我们有一个builder类型,我们可以将它用于构造函数 - 通过请求一个以builder作为参数的方法:

1
2
3
4
5
6
7
abstract class User {
String get name;
@nullable
String get nickname;
User._();
factory User([updates(UserBuilder b)]) = _$User;
}

这有点令人惊讶,但结果是一个非常简单的实例化语法:

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

如何根据旧值创建新值?传统的builder模式提供了一个“toBuilder”方法来转换为构建器;然后,你应用你的更新并调用“构建”。但对于大多数用例来说,更好的模式是使用“重建”方法。与构造函数一样,它需要一个带有builder的方法,并提供简单的内联更新:

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

尽管如此,针对你希望将builder保持一段时间的情况,我们确实仍旧需要“toBuilder”。所以我们想要我们所有的值类型都有着两种方式:

1
2
3
4
5
6
abstract class Built<V, B> {
// Creates a new instance: this one with [updates] applied.
V rebuild(updates(B builder));
// Converts to a builder.
B toBuilder();
}

您不需要为这些编写实现,built_value将为您生成它。所以你仅需要声明你要“实现Built”:

1
2
3
4
5
6
7
8
9
10
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 ==”,“hashCode”,“toString”以及对必填字段的空检查。


不过我已经跳过了一个重要细节:我说“假定我们有一个builder类型”。当然,我们在生成代码,所以答案很简单:我们将为您生成代码。 “User”引用的“UserBuilder”在“user.g.dart”中创建。

…除非你想在builder中编写一些代码,这是一件非常合理的事情。如果这就是你想要的,那么你对builder遵循相同的模式。它被声明为抽象的,具有私有构造函数和委托给生成的实现的工厂:

1
2
3
4
5
6
7
8
9
10
11
12
abstract class UserBuilder extends Builder<V, B> {
@virtual
String name;
@virtual
String nickname;
// Parses e.g. John "Joe" Smith into username+nickname.
void parseUser(String user) {
...
}
UserBuilder._();
factory UserBuilder() => _$UserBuilder;
}

“@virtual”注释来自“package:meta”,需要允许生成的实现覆盖字段。现在您已经将实用程序方法添加到构建器中,您可以将它们内联使用,就像您可以分配给字段一样:

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

定制的builder的用例相对较少,但它们可以非常强大。例如,您可能希望builder实现用于设置共享字段的公共接口,从而它们能互换使用。

嵌套Builders

你还没有看到build_value的一个主要特性:嵌套builder。当built_value字段包含built_collection或另一个built_value时,默认情况下它在构建器中可用作嵌套构建器。这意味着你可以比整个结构是可变的情况,更容易地更新深层嵌套的字段:

1
2
3
4
5
6
7
8
9
var structuredData = new Account((b) => b
..user.name = 'John Smith'
..user.nickname = 'Joe'
..credentials.email = 'john.smith@example.com'
..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');

为什么说比结构是可变的时候会简单些?

首先,所有构建器提供的“更新”方法意味着您可以随时进入新的作用域,重启级联运算符并进行简洁和内联的任何更新:

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'));

其次,嵌套builder是根据需要自动创建。例如,在built_value的基准代码中,我们定义了一个名为Node的类型:

1
2
3
4
5
6
7
8
9
10
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
var node = new 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,您可以使用完全类型化的不可变对象模型……它与高效的不可变树一样快速而强大。您可以混合和匹配类型化数据,自定义结构(如“节点”示例)和来自built_collection的集合:

1
2
3
4
5
6
7
8
9
10
11
var structuredData = new 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和端到端系统与服务器和客户端的聊天示例。

非典型前端coder wechat
想要随时Follow我的最新博客,可扫码关注我的公众号