以不可变且透明的方式建模数据 - 数据导向编程 v1.1
Nicolai Parlog 于 2024 年 5 月 27 日以不可变且透明的方式建模数据是数据导向编程的四大原则之一。在本文中,我们探讨了在数据建模时为什么不可变性和透明性很重要,以及如何使用 Java 的特性(特别是记录)来实现这一点,这是 系列文章中的第二篇,该系列文章对这些原则进行了 1.1 版本的细化。
不可变性和透明性
软件错误的一个常见来源是大量由不同子系统修改的对象。代码库一端代码更改实例而另一端代码没有注意到这一点,即使它需要对此做出反应,这种情况也屡见不鲜。
一个特别简单而极端的例子是将一个对象存储在 HashSet
中,然后更改用于哈希码计算的值。 HashSet
不会注意到此更改,无法在新的哈希码下重新输入对象,因此它突然无法找到。
在这个例子中,两个子系统(HashSet
和修改对象的代码)访问同一个对象,但对修改它的要求不同,而且没有办法进行沟通 - 开发人员必须知道这些要求。这里,这种情况经常发生,大多数 Java 开发人员都知道从可变字段计算哈希码是有问题的,但这仅仅是因为两个受影响系统之一是具有简单契约的众所周知的系统(“不要这样做”) - 在更复杂和自建的系统中,这很难跟踪。
保证正确性的最简单方法是不可变性:如果没有任何东西可以改变,就不会出现此类错误。如果子系统只与不可变数据通信,那么这种常见的错误来源就会完全消失。
但是,如果数据不能改变,那么必要的状态更改必须在处理数据的系统中进行。就像可变对象可以在更改之前考虑其整个状态一样,这些系统现在必须考虑所处理对象的整个状态(有关详细信息,请参阅 关于操作的文章),为此,对象必须是透明的。如果一个对象的内部状态可以通过 API 访问和构建,则该对象是透明的,即
- 必须有一个访问方法,用于每个字段,该方法返回相同的值 (
==
) 或至少是相等的值 (equals
)。 - 必须有一个构造函数,该构造函数接受所有字段的值,如果这些值在有效范围内,则直接保存它们或至少保存它们的副本。
总而言之,这意味着给定一个现有实例,您可以通过查询所有字段并调用相应的构造函数来创建一个新的实例,该实例除了其标识 (==
) 之外,与第一个实例不可区分。
记录
因此,我们希望使用不可变数据的透明载体。幸运的是,记录正是为此而设计的!记录在 Java 16 中最终确定,通过声明所谓的组件来描述数据作为其类型定义的一部分,每个组件都指定一个类型和一个名称。例如,如果我们想对一本书的数据进行建模,包括标题、ISBN 和作者,那么自然的方式如下
record Book(String title, ISBN isbn, List<Author> authors) { }
为了充当透明数据载体,必须满足一些要求
- 必须有一个字段用于每个组件,用于存储其值。
- 这些字段必须是最终的(“不可变数据”)。
- 必须有一个规范构造函数,该构造函数接受并分配这些值,以及返回这些值的访问器方法(构造和访问的透明性)。
- 类型必须是最终的(否则记录的组件将无法完全描述数据)。
equals
和hashCode
方法基于此数据,而不是基于记录实例的标识(“数据的载体”)。
Java 不会让我们来满足这些要求,而是会自动生成所有这些内容。(因此,当使用记录时,我们享受到了样板代码减少的便利,但重要的是要理解,这不是它们的目的,而是其实际目的的受欢迎的副作用:成为不可变数据的透明载体。)
这就是为什么您可以在一行代码中定义简单的记录,尽管我们很快就会看到,在实践中,调整非常常见。这些调整完全是可能的
- 规范构造函数、访问器方法、
equals
和hashCode
可以被重写,从而进行自定义。 - 可以添加更多构造函数和任意方法(但不能添加字段或“私有组件”,因为这与透明性相矛盾)。
- 记录可以实现接口。
在我们继续之前,我想指出,记录简化了数据导向编程,但它既不是必需的,也不是强制性的。例如,如果记录的某个限制阻止了它们在特定类型上的使用,您可以将其设计为普通类,只要您仍然遵守 DOP 原则即可。在本原则的背景下,这意味着设计该类使其不可变且透明。
深入了解不可变性
记录字段是最终的,但这不会神奇地应用于它们引用的内容
record Book(String title, ISBN isbn, List<Author> authors) { }
// elsewhere
var threeBP = new Book(
"The Three-Body Problem",
new ISBN("978-0765382030"),
new ArrayList<>());
threeBP
.authors()
.add(new Author("Liu Cixin"));
在这个例子中,作者列表可以在构造之后更改!为了防止这种情况,记录应该尽可能在它们的构造函数中创建可变数据结构的不可变副本。 List
、Set
和 Map
的 copyOf
方法适用于 Java 集合
record Book(String title, ISBN isbn, List<Author> authors) {
Book {
authors = List.copyOf(authors);
}
}
这里我使用了一个紧凑构造函数,它不需要显式参数列表或对字段的赋值。紧凑构造函数的参数正是记录的组件,在代码块执行完毕后,这些值会自动分配给字段。因此,构造函数只需要包含绝对必要的内容 - 这里通过调用 List.copyOf
来复制作者列表。由于生成的列表是不可变的,因此如上所述调用 authors().add(...)
会导致异常。
对于其他数据结构,尤其是您自己的数据结构,这可能更复杂。如果没有办法创建不可变副本,您可以通过在构造函数中创建一个副本,然后在重写的访问器方法中再创建一个副本,来确保没有人拥有对记录内部状态的引用
// assume `ISBN` is a mutable class that has a copy constructor
record Book(String title, ISBN isbn, List<Author> authors) {
Book {
authors = List.copyOf(authors);
// create a copy, so references to
// the `isbn` argument can't change
// the record's internal state
isbn = new ISBN(isbn);
}
@Override
public ISBN isbn() {
// don't expose mutable inner state
return new ISBN(isbn);
}
}
虽然这可能出乎意料,也可能导致错误,但它通常比更改记录状态本身问题更小。
如果没有技术解决方案,也许沟通解决方案会有所帮助:团队可以达成一致,将从记录中获得的所有内容视为不可变的,并且不调用更改数据结构的方法。
总结
减少代码库中错误的一种可靠方法是限制潜在麻烦操作的影响范围,其中最重要的是对跨多个子系统共享的状态进行修改。数据导向编程建议子系统通过以不可变且透明的方式建模的数据进行通信。Java 通过记录使这变得特别容易,尽管在记录引用可变数据结构时需要小心。
在本系列文章中,了解有关数据导向编程 1.1 版本的更多信息
- Java 中的数据导向编程 - 1.1 版本
- 以不可变且透明的方式建模数据 - DOP v1.1(本文)
- 对数据进行建模,对所有数据进行建模,并且只对数据进行建模 - DOP v1.1
- 使非法状态无法表示 - DOP v1.1
- 将操作与数据分离 - DOP v1.1
- 总结 DOP v1.1
- 额外内容:为什么将 DOP 更新到 1.1 版本?