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

面向数据编程 (DOP) 非常注重数据,此次讨论的原则涉及实现大多数域逻辑的方法,它建议将操作与数据分开。

1.示例场景

此次讨论面向数据编程 v1.1的将操作与数据分离原则的具体示例是以销售平台作为示例,该平台销售书籍、家具和电子设备,每个产品都由一条简单记录建模。它们都实现了密封接口Item,该接口未声明任何方法,因为这三个子类没有共享任何方法。

2.操作

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

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

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

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

3.模式匹配

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

  • 它可以用作表达式,例如使用 var foo = switch … 为变量赋值。
  • 如果 case 标签后面跟着箭头 ->(而不是冒号 :),则不会出现 fall-through。
  • 选择器表达式(关键字 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 了。在箭头的右侧,可以执行与操作(此处:运送物品)和特定数据(此处:Book、Furniture 或 ElectronicItem 的实例)匹配的逻辑。由于数据是透明建模的,因此所有信息都可供操作使用。

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

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

还有一个更为重要的原因:对中心领域概念进行建模的类型往往会吸引过多的功能,因此难以维护。DOP 通过将操作放在相应的子系统中来避免这种情况,即 Shipments.ship(Item) 而不是 Item.ship(Shipments)(其中 Shipments 是负责交付的系统)。

在 OOP 中,将操作与操作类型分开的要求也是众所周知的。前辈们甚至记录了一种设计模式(与模式匹配无关),称为访问者模式,它完全满足这一要求。在这方面,DOP 是优秀的,但由于现代语言特性,它可以使用模式匹配,这比访问者模式更简单、更直接。

4.更详细的模式

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

5.记录模式

记录模式由 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 和显式类型。

6.未命名模式

拆分记录非常方便,但每次列出所有组件时,如果您只需要其中的一部分,那就太烦人了。这就是未命名模式的用武之地,它已由 Java 22 中的 JEP 456 标准化。它们允许用单个下划线 _ 替换不必要的模式:

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...
}

7.嵌套模式

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

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

8.保护模式

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

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

保护模式也是 JEP 441 的一部分,它们允许将此类条件推到左边:

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

这有几个优点:

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

9.原始模式

原始模式是由 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);
}

10.可维护性

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

这个想法值得追求。为什么 instanceof 不受欢迎?答案由两部分组成:

  • 与接口配合使用的代码应该适用于其所有实现。
  • 添加新实现时,一系列 instanceof 检查长期难以更新,因为很难找到。

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

这正是访问者模式在面向对象中广泛使用的原因:它还实现了动态分派。(如果您数不清:继接口/实现、带类型模式的 switch 和 instanceof 之后,这是实现动态分派的第四种方法。)访问者模式以一种可靠的方式实现这一点,尽管由于其间接性而有些麻烦且有时难以理解。这是因为访问接口的每个新实现都会生成一系列编译错误,只有让每个现有访问者(即每个操作)考虑新类型才能修复这些错误。

关键点来了:同样适用于带模式的 switch!

11.详尽性

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

1.最后捕获所有剩余实例的默认分支:

 switch (item) {
     case Book book -> // ...
     case Furniture furniture -> // ...
     default -> // ...
 }

2.与选择器表达式具有相同类型并因此具有与默认值相同的效果的模式:

 switch (item) {
     case Book book -> // ...
     case Furniture furniture -> // ...
     case Item i -> // ...
 }

3.列出密封类型的所有实现:

 switch (item) {
     case Book book -> // ...
     case Furniture furniture -> // ...
     case ElectronicItem eItem -> // ...
 }

不幸的是,前两种变体无法帮助我们实现目标。这种切换在添加新实现时仍然是详尽的,因此不会产生编译错误。因此,如果将海报添加到网上商店,(1.它们将默默地以默认方式结束)(2.以案例项目结束)。然而,在第三种变体中,没有海报分支,因此我们会收到编译错误,这迫使我们更新操作。太好了。

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

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

最后一点也解释了为什么密封接口比密封类效果更好(还记得两篇文章中提到的要点吗?)。如果 Item 是非抽象类,则包含 Book、Furniture 和 ElectronicItem 分支的 switch 不会详尽无遗,因为 Item 本身可能有实例,但没有针对它们的分支。但是,如果使用 case Item 来处理它,则此分支还会处理每个新项目,例如海报,并且不会出现编译错误。

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

在此示例中,标题较短的书籍将被忽略,这可能是一个疏忽,在较长的代码中可能并不明显。使用受保护的模式不会发生这种情况:

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

这里,在案例 Book … when … 之后,必须有一个针对所有书籍的分支,然后该分支要么修复遗忘短标题书籍的错误,要么(如图所示)明确表明它们被故意忽略。

12.避免默认分支

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

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

然而,正如讨论的那样,这种情况应该不惜一切代价避免,而 Magazine 工具 Item 的添加(它们不是书籍,但仍需要目录)再次凸显了这个问题。相反,可以将几个具有未命名模式的案例标签组合成一个:

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

这比默认的代码 -> 多一点,但在添加杂志时会产生所需的编译错误,因此应该是首选。

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

13.总结

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

  • 9
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值