模型数据、完整数据以及仅此而已的数据 - 面向数据编程 v1.1

面向数据编程 (DOP) 以尽可能接近地对数据进行建模为中心,这一点应该不足为奇,因此 DOP 的核心原则之一是*对数据、完整数据以及仅此而已的数据进行建模*。实现这一目标的最佳方法是结合使用记录和密封类型,以及一些对于面向对象开发人员来说可能看起来很奇怪的编程实践。

我们将在本文中探讨所有这些内容,这是系列文章中的第三篇,该系列文章对 1.1 版的四项 DOP 原则进行了改进。在上一篇文章讨论了如何使用记录对数据进行不可变和透明的建模之后,我们现在可以将重点放在密封类型和前面提到的编程实践上。我们将继续使用一个简单的销售平台示例,该平台销售书籍、家具和电子设备,它们都由一个简单的记录进行建模。

密封类型

创建记录 BookFurnitureElectronicItem 后,就对核心领域数据进行了建模。但是,这并不完整,因为它们之间存在尚未捕获的关系:我们商店中的每个商品*要么*是 Book,*要么*是(一件)Furniture,*要么*是 ElectronicItem。为了表示这种关系,我们使用密封类型。

密封类型已在 Java 17 中最终确定。类或接口通过关键字 sealed 标记为*密封*,然后只有 permits 子句中列出的类型才能继承它 - 其他类型被禁止这样做,否则会出现编译错误。这种机制非常适合对备选方案进行建模。“商品要么是书籍,要么是家具,要么是电子设备”这句话变成了

sealed interface Item permits Book, Furniture, ElectronicItem {
	// ...
}

当系统无法在添加新实现时正常工作时,密封类型特别有用。另一个 List 实现?没问题,这将无缝衔接。另一个 Item 实现?现在必须检查增值税税率,必须调整专用视图(例如公寓规划器或目录显示),并且可能必须引入新的交付方式。

在许多其他情况下,仅仅添加接口实现是不够的。例如,身份验证提供程序或支付方式:仅仅编写 CreditCardPayment implements Payment 是不够的,因为至少还必须实现相关的支付系统,并且可能还需要一种机制来在代码中的正确位置收集支付信息并将其传输到合适的支付系统。我们将在关于操作的文章中看到如何使用密封类型优雅地完成此操作。

首先,密封类型的一些属性

  • 允许的子类型必须与密封类型位于同一模块中,或者(如果代码未编译为模块)位于同一包中。
  • 如果密封类型和允许的子类型包含在同一个源代码文件中,则可以省略 permits 子句。
  • 允许的子类型必须直接继承自密封类型。
  • 允许的子类型必须是 finalsealed 或显式 non-sealed(Java 的第一个带连字符的关键字!)。

虽然密封类是可能的,有时也很有用,但在一个非常具体的方面,处理密封接口要令人愉快得多,我们将在讨论操作时讨论这一点。这就是为什么我通常建议关注密封接口,因此本系列文章也是这样做的。

仅对数据进行建模

记录可以轻松地聚合数据,而密封类型可以轻松地表达备选方案。结合起来,这两种机制非常强大,即使是复杂的结构也可以很好地建模。

定制的聚合和备选方案

记录的简单定义促使我们创建定制的、可能数量众多的类型。与其为街道、邮政编码、城市和国家/地区获取 User 的组件,不如将这些组件存储在 Address 记录中,然后用户拥有该记录的实例。

如果地址是可选的,并且用户还可以选择存储电子邮件地址和电话号码,则与其为每个联系信息设置一个可能不存在的字段,不如为该类型提供一个 List<ContactInfo> contacts 字段,其中包含 sealed interface ContactInfo permits Address, Email, Phone。是否至少需要一个联系信息?设置一个 ContactInfo primaryContact 字段,并将列表重命名为 additionalContacts

目标是使用这些功能根据实际领域数据定制类型。这使得开发人员更容易理解代码,因为它与他们无论如何都需要了解的数据非常相似,而且也更容易维护,因为更容易拒绝非法数据 - 我们将在研究如何仅表示合法状态时详细讨论这一点。

相等性(和类型模式)

数据建模的一个核心部分是定义相等性。如关于记录的文章中所述,它们附带了一个使用所有组件的 equals(和 hashCode)实现。这在许多情况下都没问题,但在处理用户和商品的系统中,ID 无处不在,大多数具有 ID 的对象可能应该使用 ID 来确定相等性。这是覆盖 equals(和 hashCode)的众多原因之一。

在我们的示例中,根据 ISBN 定义 Book 的相等性是有意义的。我们可以借助一个功能非常优雅地做到这一点,该功能在稍后会变得更加重要:类型模式,在 Java 16 中标准化,在本例中为 instanceof

record Book(String title, ISBN isbn, List<Author> authors) {

	@Override
	public boolean equals (Object other) {
		return this == other
			|| other instanceof Book book
			&& Objects.equals(isbn, book.isbn);
	}

	@Override
	public int hashCode() {
		return Objects.hash(isbn);
	}

}

类型模式位于 other instanceof Book book 中。它完成三项任务

  • 检查 other 是否是类型 Book 的实例
  • 定义一个新的变量 Book book,该变量在测试返回 true 的任何地方都可见(“在范围内”)
  • 分配 book = (Book) other

由于 book 变量在类型检查为正的地方可见,因此您可以在 && 之后直接使用它来比较所需的字段。

(注意:使用 instanceof 实现 equals 并不总是正确的,但这里没有问题,因为 Book 是最终的。)

方法

您可以在记录上实现任意方法,但作为透明的数据载体,它们更喜欢某些方法

  • 没有参数的方法是最好的,因为它们除了返回记录的数据之外什么也做不了(除非它们引用全局变量,这在极少数情况下是个好主意)。例如,email.tld() 可以识别并返回电子邮件地址的顶级域名,或者 book.byline() 可以将书名和作者组合成一个字符串。
  • 接受该类型本身作为唯一参数的方法也是受欢迎的。例如,如果您实现 Comparable,这可能是 compareTo,或者 Book 可以有一个方法 commonAuthors(Book),该方法返回参与了这两本书的作者列表。
  • 接受其他记录(最好是已经用作组件类型的记录)的方法通常也可以:因为它们也应该是不可变的数据载体,所以可以假设没有状态被更改,所有结果都通过返回值进行传递。但是,在这种情况下,重要的是要避免实现非平凡的域逻辑。根据*将操作与数据分离*的原则,此类操作应保留给外部系统。
  • 具有任意参数(尤其是可变参数)的方法很有可能将记录从作为操作一部分处理的数据转变为这些操作的执行者,这通常应该避免。

请注意,这些不是硬性规定,而是在情况需要时可以暂停的准则,但您应该有充分的理由这样做。

接口契约

如果在面向数据的编程中,记录主要只是提供对数据的访问,而几乎没有或根本没有额外的操作,你可能会问自己如何在这样的设计中使用接口——毕竟,我们主要使用它们来为行为建模契约。确实,这个角色在这里的重要性要小得多。由记录实现的(密封)接口主要定义的不是类型的*行为*,而是它的*本质*。

  • 书籍、电子设备和家具*是*物品。
  • 地址、电子邮件地址和电话号码*是*联系信息。

从这些例子中可以看出,在接口下统一的类型通常很少有重叠。虽然物品可能至少都有一个物品编号,但不同的联系信息是完全不同的。因此,像 ContactInformation 这样的接口可能最终没有任何方法。这很不寻常,而且“看起来不对”,但这只是熟悉程度的问题。这里定义的契约不是描述*行为*(这对数据来说不是一个有意义的类别),而是描述*分组*(哪些数据在接口的上下文中互为替代),而这不需要任何方法。

总结

使用记录将数据聚合成有意义的、定制的类型,并使用密封接口来表达这些类型之间的替代方案。因为数据不附带行为,所以这些记录通常声明很少或根本不声明不只是以不同形式返回数据的方法。因此,它们实现的密封接口可能声明很少或根本不声明方法,这可能是新颖的,但也在预料之中,因为它们描述的契约是关于数据的*本质*(而不是它的行为)。

在本系列文章中了解更多关于面向数据编程 1.1 版的信息