将操作与数据分离 - 面向数据编程 v1.1

毫不奇怪,面向数据编程 (DOP) 非常关注数据。事实上,我们正在探索的 DOP v1.1 的四个指导原则中的三个,在本系列文章中,建议如何最好地对数据进行建模。在本文中,我们将考察第四个原则,它涉及实现大多数领域逻辑的方法。它建议将操作与数据分离

我们将继续使用一个简单的销售平台的例子,该平台销售书籍、家具和电子设备,每个设备都由一个简单的记录建模。它们都实现了密封接口 Item,该接口不声明任何方法,因为三个子类没有共享任何方法。

操作

在探索如何对数据进行建模时,我列出了哪些方法适合在记录上使用,哪些方法不太适合。我基本上排除了所有包含非平凡领域逻辑或与不代表数据的类型交互的方法 - 我们称之为操作。操作将一个广泛但最终无生命的的数据表示转换为一个具有活动部件的活生生的系统。

在面向数据编程中,操作不应在记录上定义,而应在其他类上定义。将商品添加到购物车既不应该是 Item.addToCart(Cart) 也不应该是 Cart.add(Item),因为 ItemCart 是数据,因此是不可变的。相反,订购系统 Orders 应该接管此任务,例如使用 Orders.add(Cart, Item),它返回一个新的 Cart 实例,该实例反映了操作的结果。

如果其他子系统需要当前的购物车,它们应该对 Orders 而不是对可变购物车的引用,并且如果需要,可以通过 Orders.getCartFor(User) 查询用户的当前购物车。子系统之间的通信不是通过共享可变状态来隐式实现的,而是通过对当前状态的请求来显式实现的。状态更改仍然是可能的,但对更改发生的位置有限制 - 理想情况下,只发生在负责相应子域的子系统中。

但是这些操作是如何实现的呢?乍一看,如果 Item 接口没有定义任何方法,似乎很难用它做任何有用的事情。

模式匹配

这就是 switch 的模式匹配发挥作用的地方。switch 语句最近在很多方面得到了改进

  • 它可以用作表达式,例如使用 var foo = switch ... 将值分配给变量。
  • 如果 case 标签后面跟着一个箭头 ->(而不是冒号 :),则不会发生贯穿。
  • 选择器表达式(关键字 switch 后面的括号中的变量或表达式;口语上,被“切换”的内容)可以是任何类型。

这是这里至关重要的一点:如果选择器表达式没有任何最初允许的类型(数字、字符串、枚举),它不会与具体的值进行匹配,而是与模式进行匹配 - 因此称为模式匹配。选择器表达式的值会与一个模式一个模式地进行比较,从上到下,直到有一个匹配。然后,执行标签右侧的分支。(实际实现经过优化,以非线性方式工作。)

在最简单的形式中,模式是类型模式,就像我们在实现 equals时使用的那样。例如,处理商品如下所示

public ShipmentInfo ship(Item item) {
	return switch (item) {
		case Book book -> // use `book`
		case Furniture furniture -> // use `furniture`
		case ElectronicItem eItem -> // use `eItem`
	}
}

这里,变量 item 与左侧的类型进行比较,如果它是家具,则类型模式 case Furniture furniture 匹配。这声明了一个类型为 Furniture 的变量 furniture,并在执行关联的分支之前将 item 转换为它,然后可以使用 furniture。在箭头的右侧,可以执行与操作(这里:运送商品)和特定数据(这里:BookFurnitureElectronicItem 的实例)匹配的逻辑。并且因为数据以透明方式建模,所以所有信息都可供操作使用。

这最终实现了动态调度:选择应该为给定类型执行哪段代码。如果我们在接口 Item 上定义了方法 ship,然后调用 item.ship(...),则运行时将决定最终执行 Book.ship(...)Furniture.ship(. ..)ElectronicItem.ship(...) 中的哪个实现。使用 switch,我们手动执行此操作,这使我们不必在接口上定义方法。我们已经强调了这样做的一些原因

  • 记录不应实现非平凡的领域逻辑,而应保持简单的数据。
  • 记录不应执行操作,而应由操作处理。
  • 许多操作难以在不可变记录上实现。

在关于面向对象编程 (OOP) 的简短讨论中,出现了另一个重要原因在本系列的第一篇文章中:对核心领域概念进行建模的类型往往会吸引过多的功能,因此难以维护。DOP 通过将操作放置在相应的子系统中来避免这种情况,即 Shipments.ship(Item) 而不是 Item.ship(Shipments)(其中 Shipments 是负责交付的系统)。

将操作与它们操作的类型分离的要求在 OOP 中也很有名。 四人帮甚至记录了一个设计模式(与模式匹配无关),称为访问者模式,它完全满足了这一要求。在这方面,DOP 与之相得益彰,但由于现代语言功能,它可以使用模式匹配,这比访问者模式简单得多,也更直接。

更详细的模式

switch 中的类型模式对于面向数据编程至关重要。这可能不适用于 Java 支持的(或即将支持的)五种其他类型的模式,但它们肯定很有用,因此我们将在此简要讨论它们。每个部分都包含对详细介绍该功能的 JDK 增强提案 (JEP) 的引用。

记录模式

记录模式JEP 440 在 Java 21 中最终确定,允许在匹配期间直接解构记录

switch(item) {
	case Book(String title, ISBN isbn, List<Author> authors) -> // use `title`, `isbn`, and `authors`
	// more cases...
}

您可以选择使用 var,在这种情况下,括号中的代码将是 var title, var isbn, var authors,或者如果您想让同事非常生气,可以使用 var 和显式类型的任何组合。

未命名模式

分解记录非常方便,但是每次都必须列出所有组件很烦人,尤其是在您只需要其中一些组件时。这就是未命名模式的用武之地,它由 JEP 456 在 Java 22 中标准化。它们允许用单个下划线 _ 替换不必要的模式

switch(item) {
	case Book(_, ISBN isbn, _) -> // use `isbn`
	// more cases...
}

未命名模式也可以在顶层使用

switch(item) {
	case Book book -> // use `book`
	case Furniture _ -> // no additional variable in scope
	// more cases...
}

我们将在稍后讨论可维护性时看到,为什么这是一个至关重要的功能。

嵌套模式

自从 Java 21 中通过 JEP 441 完成了 switch 中模式的最终确定,您就可以使用嵌套模式将模式嵌套在彼此内部。这允许我们更深入地挖掘记录,例如使用两个嵌套的记录模式。假设 ISBN 也是一个记录,它可以看起来像这样

switch(item) {
	case Book(_, ISBN(String isbn), _) -> // use `isbn`
	// more cases...
}

保护模式

如果域逻辑不仅需要按类型区分,还需要按值区分,那么在右侧简单地使用 if 似乎很自然

switch(item) {
	case Book(String title, _, _) -> {
		if (title.length() > 30)
			// handle long title
		else
			// handle regular title
	}
	// more cases...
}

保护模式也是 JEP 441 的一部分,它们允许将这些条件推到左侧

switch(item) {
	case Book(String title, _, _) when title.length() > 30 -> // handle long title
	case Book(String title, _, _) -> // handle regular title
	// more cases...
}

这有几个优点

  • 所有条件,即选择哪种类型哪个值,都在左侧找到,从而改善了代码的结构和可读性。
  • 如果不同分支需要不同的组件,则可以方便地忽略不需要的组件。
  • 保护模式已集成到我们将在下一节中讨论的完整性检查中。

原始模式

最后,简单介绍一下原始模式,它们是由 JEP 455 在 Java 23 中作为预览功能引入的。它们允许对原始类型(即“经典”开关)的 switch 语句进行扩展,以使用模式,这使得捕获选择器表达式的值变得更容易,并允许它在保护模式中使用

switch (Rankings.of(book).currentRank()) {
	case 1 -> firstPlace(book);
	case 2 -> secondPlace(book);
	case 3 -> thirdPlace(book);
	case int n when n <= 10 -> topTenPlace(book, n);
	case int n when n <= 100 -> nthPlace(book, n);
	case int n -> unranked(book, n);
}

可维护性

通过类型比较的 switch 肯定会让一些 OOP 老手起鸡皮疙瘩。一个美化的 instanceof 检查真的应该成为整个编程范式的基础吗?

这个想法值得探讨。为什么 instanceof 会受到批评?(鉴于媒介,这个问题显然是修辞性的,但我仍然建议您花一分钟时间想出一个答案,然后再继续阅读。)答案包括两部分

  • 使用接口的代码应该适用于所有实现。
  • 添加新实现时,一系列 instanceof 检查很难更新,因为很难找到它们。

换句话说:通过 instanceof 检查进行的动态调度不可靠。

这正是访问者模式在面向对象中得到广泛应用的原因:它也实现了动态调度。(如果您忘记了:在接口/实现、带有类型模式的 switchinstanceof 之后,这已经是实现动态调度的第四种方式了。)访问者模式以一种可靠的方式执行此操作,尽管由于其间接性,它有点繁琐,有时难以理解。这是因为访问接口的每个新实现都会生成一系列编译错误,这些错误只能通过让每个现有访问者(即每个操作)都考虑新类型来修复。

关键点在于:带有模式的 switch 也一样!

穷举性

这样的 switch 必须是穷举的,这意味着对于每个可能具有选择器表达式类型的实例,都必须有一个模式与之匹配,否则编译器会报告错误。有三种不同的方法可以实现这一点

  1. 一个默认分支,在最后捕获所有剩余的实例
     switch (item) {
         case Book book -> // ...
         case Furniture furniture -> // ...
         default -> // ...
     }
    
  2. 一个与选择器表达式具有相同类型的模式,因此与 default 具有相同的效果
     switch (item) {
         case Book book -> // ...
         case Furniture furniture -> // ...
         case Item i -> // ...
     }
    
  3. 列出密封类型的全部实现
     switch (item) {
         case Book book -> // ...
         case Furniture furniture -> // ...
         case ElectronicItem eItem -> // ...
     }
    

不幸的是,前两种变体并不能帮助我们实现目标。这样的 switch 在添加新实现时仍然是穷举的,因此不会产生编译错误。因此,如果海报被添加到网店,它们会默默地进入 default (1.) 或 case item (2.)。然而,在第三种变体中,海报没有分支,因此我们会得到一个编译错误,这迫使我们更新操作。很好.

为了使操作可维护(意味着如果它们没有明确涵盖所有情况,它们会导致编译错误),必须没有默认分支或捕获所有分支,这只有在以下情况下才有可能

  1. 切换到密封接口(或密封抽象类,但我们忽略了它们)和
  2. 列出所有实现

最后一点也解释了为什么密封接口比密封类工作得更好(还记得 两篇文章前 的那段话吗?)。如果 Item 是一个非抽象类,带有 BookFurnitureElectronicItem 分支的 switch 不会是穷举的,因为可能存在 Item 本身的实例,并且没有针对它们的分支。但是,如果您使用 case Item 处理它,这个分支也会处理每个新项目,例如海报,并且不会出现编译错误。

上一节关于保护模式的完整性检查的评论现在也应该说得通了。

switch(item) {
	case Book(String title, _, _) -> {
		if (title.length() > 30)
			// handle long title
	}
	// more cases for other types...
}

在这个例子中,标题很短的书会被忽略,这可能是一个疏忽,在更长的代码中可能并不明显。使用保护模式就不会发生这种情况

switch(item) {
	case Book(String title, _, _) when title.length() > 30 -> // handle long title
	case Book _ -> { /* ignore short titles */ }
	// more cases...
}

在这里,在 case Book ... when ... 之后,必须有一个分支用于所有书籍,然后要么修复标题很短的书籍被遗忘的错误,要么(如所示)明确说明它们是故意被忽略的。

避免默认分支

最后,关于默认分支以及如何避免它们的一点说明。有时,switch 实际上只想要处理一些情况,而忽略其他情况或以其他方式集体处理它们——默认分支似乎是显而易见的解决方案

switch(item) {
	case Book book -> createTableOfContents(book);
	default -> { }
}

但是,正如我们所讨论的,应该不惜一切代价避免这种情况,添加 Magazine implements Item(它们不是书籍,但仍然需要目录)再次突出了这个问题。相反,几个带有未命名模式的 case 标签可以合并成一个

switch(item) {
	case Book book -> createTableOfContents(book);
	case Furniture _, ElectronicItem _ -> { }
}

这比 default -> 多了一点代码,但在添加杂志时会产生所需的编译错误,因此应该优先考虑。

如果您暂时坚持使用 Java 21,您只能将未命名模式用作预览功能。由于它在 Java 22 中没有更改就被最终确定,因此这是可以想象的。但请注意,当使用 --enable-preview 激活预览功能时,所有预览功能都将可用,您必须小心不要使用其他更不稳定的预览功能(例如 字符串模板,例如 😬)。

总结

为了使数据建模记录免受非平凡的域逻辑的影响,并防止 API 膨胀,操作不应在它们上实现,而应该在专用子系统中实现。然后,操作通常会处理通常提供很少方法来进行交互的密封接口。相反,它们将切换到这些接口并枚举所有实现,从而实现自己的动态调度。只要避免默认分支和捕获所有分支,这将是面向未来的,因为新的接口实现将使这些 switch 变得不穷举。这会导致编译错误,这些错误会将开发人员直接引导到需要为新类型更新的操作。

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