Java 中的面向数据编程

Java
277
0
0
2023-06-18

近年来, Amber项目为 Java 带来了许多新特性—— 局部变量类型推断、 文本块、 记录类、 封印类、 模式匹配 等等。虽然这些特性都是独立的,但也可以组合在一起使用。具体地说,记录类、封印类和模式匹配组合在一起让 Java 的面向数据编程变得更容易。在本文中,我们将讨论什么是面向数据编程,以及它如何影响 Java 的编程方式。

面向对象编程

任何一种编程范式的目标都是管理复杂性。但复杂性会以多种形式出现,并不是所有的范式都能同等处理好所有形式的复杂性。大多数编程范式都有一个口号:“一切皆……”面向对象编程的口号是“一切皆对象”,函数式编程的口号是“一切皆函数”,基于 Actor 的系统的口号是“一切皆 Actor”等等。(当然,这些都是为了达到某种效果而夸大的说法。)

OOP 鼓励我们使用定义了状态和行为的对象对复杂的实体和过程进行建模。OOP 鼓励封装性(协调对对象状态的访问)和多态性(使用公共接口与多种实体交互),尽管实现这些目标的机制因面向对象编程语言而异。当我们用对象对世界进行建模时,我们被鼓励按照 is-a(储蓄帐户是一个银行帐户)和 has-a(储蓄帐户有一个所有者和账号)的关系来思考问题。

虽然一些开发者大声宣告面向对象编程是一次失败的试验,但事实却很微妙。与其他所有的工具一样,它非常适合用来做一些事情,但也不太适合用来做另一些事情。OOP 做不好的事情可能可以用糟糕来形容,许多人都曾见过被运用得荒谬至极的 OOP 原则。但是,如果我们了解 OOP 的优点和缺点,就可以在它提供更多价值的地方使用它,在它提供较少价值的地方使用其他语言。

OOP 在定义和保持边界方面做得很出色——维护的边界、版本控制的边界、封装的边界、编译的边界、兼容性的边界、安全性的边界,等等。独立维护的库独立于依赖它们的应用程序(以及库彼此之间)而构建、维护和演化,如果我们希望能够自由地从库的一个版本迁移到下一个版本,就需要确保库和它们的客户端之间有清晰的、定义良好的和深思熟虑的边界。平台库可能有访问底层操作系统和硬件的特权,这些权限必须加以小心的控制。我们需要在平台库和应用程序之间建立一个牢固的边界来保持系统的完整性。OO 语言为我们提供了精确定义和保持这些边界的工具。

将一个大程序划分为具有明确边界的部分,有助于我们管理复杂性,因为它支持模块化推理——一次只分析程序的一部分,但仍然具备对整个程序进行推理的能力。对于单体程序来说,设置合理的内部边界有助于我们构建跨多个团队的更大的应用程序。Java 在单体时代兴盛并非偶然。

从那时起,程序变得越来越小,我们不再构建大单体,而是将许多较小的服务组合成较大的应用程序。在小型服务中,对内部边界的需求较少,足够小的服务可以由单个团队(甚至一个开发人员)维护。类似地,在这些较小的服务中,我们对长时间运行的有状态流程进行建模的需求也较少。

面向数据编程

Java 的强静态类型和基于类的模型对于较小的程序仍然非常有用,只是发挥作用的方式不同。OOP 鼓励我们使用类对业务实体和流程进行建模,而具有更少内部边界的小代码库通常会从使用类来建模数据的方式中获得更多好处。我们的服务负责处理来自外部的请求,例如带有非类型化 JSON/XML/YAML 有效载荷的 HTTP 请求。但是,只有最简单的服务才会直接使用这种数据。我们希望数字能够被表示成 int 或 long,而不是数字字符串,日期被表示成像 LocalDateTime 这样的类,列表被表示成集合,而不是用逗号分隔的长字符串。(并且,在处理数据之前,我们希望在边界上对数据进行验证。)

面向数据编程鼓励我们将数据建模为不可变数据,并单独保留包含操作数据的业务逻辑代码。随着小型程序趋势的发展,Java 提供了新的工具,可以更容易地将数据建模为数据(记录),直接建模替代实体(封印类),以及灵活地分解多态数据模式(模式匹配)。

面向数据编程鼓励我们将数据建模为不可变数据,而记录类、封印类和模式匹配使这方面的工作变得更容易。但这并不意味着要放弃静态类型。我们可以只使用非类型化的 Map 和 List 进行面向数据编程(通常在 JavaScript 等编程语言中这样做的),但静态类型在安全性、可读性和可维护性方面仍然提供了很多东西,即使我们只对普通数据建模。(无原则的面向数据编程通常被称为“字符串类型”,因为它使用字符串来建模不应该被建模为字符串的东西,如数字、日期和列表。)

Java 中的面向数据编程

记录类、封印类和模式匹配可以组合在一起支持面向数据编程。我们可以用记录类来进行简单的数据建模,用封印类来进行选择建模,模式匹配为我们提供了一种简单且类型安全的方式来处理多态数据。对模式匹配的支持是逐步实现的,第一个迭代只添加了类型测试模式,并且只能在 instanceof 中使用,第二次迭代支持 switch 中的类型测试模式,最近,Java 19 中添加了 记录类的解构模式。本文中的示例将使用所有这些特性。

虽然记录类的语法比较简洁,但它们的主要优点是我们可以干净而简单地对聚合进行建模。与其他的数据建模一样,我们都需要做出创造性的决策,总有一些建模方式比其他的更好。组合使用记录类和封印类还更容易让非法状态变得无法表示,从而进一步提高安全性和可维护性。

示例:命令行选项

作为第一个例子,我们来看一下如何对命令行程序的选项进行建模。有些选项有参数,有些没有。有些参数可以是任意字符串,有些是更结构化的格式,如数字或日期。在处理命令行选项时,我们必须在程序执行的早期拒绝错误的选项和格式错误的参数。一种快速而粗暴的方法是循环遍历命令行参数,对于遇到的每一个选项,将它们存在或不存在的状态以及选项的参数保存在变量中。这很简单,但问题在于我们的程序依赖了一组字符串类型的全局变量。如果我们的程序很小,这可能没有问题,但它的伸缩性不是很好。随着程序的增长,这不仅会降低可维护性,也会降低程序的可测试性——我们只能通过命令行来测试整个程序。

一种不那么快速和复杂的方法是创建一个表示命令行选项的类,然后将命令行解析为选项对象列表。假设我们有一个类似 cat 的程序,它可以将一个或多个文件中的行复制到另一个文件中,将文件裁剪到特定的行数,并选择性地包含行号,我们可以使用 enum 和 Option 类对这些选项进行建模:

 enum MyOptions { INPUT_FILE, OUTPUT_FILE, MAX_LINES, PRINT_LINE_NUMBERS }

这是对前一种方法的改进,至少现在在命令行选项的解析和使用之间有了清晰的分离,这意味着我们可以通过向命令行 shell 提供选项列表来测试业务逻辑。但这仍然不够好,有些选项是没有参数的,但我们从选项的 enum 中看不出这一点,我们仍然需要用一个带有 optionValue 字段的 OptionValue 对象。而有参数的选项总是字符串类型。

更好的方法是直接对每个选项进行建模。在以前,这么做可能会很冗长,但幸运的是,现在已经不是这样了。我们可以使用一个封印类来表示一个选项,并且每个选项都有一个记录类:

 sealed interface Option { 

Option 的子类是纯数据。选项的值有漂亮干净的名称和类型,有参数的选项使用了适当的类型来表示参数,没有参数的选项不需要使用可能会被错误解释的无用参数变量。此外,使用模式匹配 switch 处理选项变得很容易(通常每个选项需要一行代码)。因为 Option 是封印类,所以编译器会进行类型检查,一个 switch 可以处理所有的选项类型。(如果以后添加更多的选项类型,编译器会提醒我们哪些 switch 需要扩展。)

我们可能都写过与上述两个版本类似的代码,尽管我们可能知道的更多。如果无法清晰、简洁地对数据进行建模,要“正确”实现这个目标通常需要做很多的工作(或写很多的代码)。

我们所做的是将来自调用边界(命令行参数)混乱、无类型的数据转换为强类型的、经过验证的、容易操作的(通过模式匹配)数据,并让非法状态(例如指定–input-file,而不是提供有效路径)变得不可表示。程序的其余部分可以安全地使用它。

代数数据类型

这种组合使用记录类和封印类型就是代数数据类型(ADT)的一个例子。记录类是“乘积类型”的一种形式,之所以这么说,是因为它们的状态空间是它们组件状态空间的笛卡尔乘积。封印类是“和类型”的一种形式,之所以这么说,是因为值的集合是备选值集合的和(并集)。这种简单的机制组合(聚合和选择)比看上去的更加强大,在许多编程语言中都有出现。(我们的例子仅限于一个层级,但这并不一定是一般的情况。一个封印接口的子类型也可以是另一个封印接口,可以对更复杂的结构进行建模。)

在 Java 中,代数数据类型可以被精确地建模成封印的层次结构,其叶子是记录类。在解释代数数据类型方面,Java 具有许多理想的属性。它们具有名词性质,类型和组件具有人类可读的名称。它们是不可变的,因此它们更简单、更安全,并且可以自由共享,而不用担心受到干扰。它们很容易测试,因为它们只包含数据(可能附带一些从数据派生出来的行为)。我们可以很容易地将它们序列化到磁盘或通过网络传输。它们具有很强的表现力,可在广泛的数据领域中建模。

应用:复杂的返回类型

复杂返回类型是代数数据类型最简单但最常用的应用之一。由于一个方法只能返回一个值,我们总是喜欢通过复杂的方式来表示返回值,例如使用 null 表示“Not Found”,将多个值编码成一个字符串,或用一个抽象的类型(数组、List 或 Map)将方法返回的所有不同类型的信息填充到单个载体对象中。代数数据类型可以很容易地实现这些目的,让之前的这些方法变得不那么诱人。

我们给出了一个例子,说明如何使用封印类在不使用异常的情况下抽象成功和失败条件:

 sealed interface AsyncReturn<V> {

这种方法的优点是,客户端可以通过对结果进行模式匹配来统一处理成功和失败,而不是当有返回值时表示调用成功,当 catch 块捕捉到异常时表示调用失败:

 AsyncResult<V> r = future.get();

封印类的另一个好处是,如果 switch 中没有 default,编译器会提醒你是否忘记了一个 case。(在有检查异常时,编译器也会提醒你,但以一种更具有侵入性的方式。)

另一个例子,假设我们有一个服务,它根据名称查找实体(用户、文档、组等),结果分别是“未找到匹配项”、“找到精确匹配项”和“没有找到精确匹配项,但有接近匹配项”。我们可以考虑将它们塞进一个列表或数组,虽然这样可能更容易编写搜索 API,但却难以理解、使用或测试。代数数据类型很容易就可以解决这个问题。我们可以编写一个简洁的 API 来准确表达我们的意图:

 sealed interface MatchResult<T> { 

如果我们在浏览代码或 JavaDoc 时遇到这个层次结构,我们会很容易知道这个方法可能返回的是什么,以及如何处理它的结果:

 Page userSearch(String user) { 

这种清晰的返回值提升了 API 的可读性和易用性,也更容易编写,因为代码实际上是根据需求编写的。与之相反的是,试图想出(和记录)“聪明”的编码,将复杂的结果塞进像数组或 Map 这样的抽象载体中需要做更多的工作。

应用:临时的数据结构

代数数据类型对于建模临时通用的数据结构来说也很有用。常用的 Optional 类可以被建模为代数数据类型:

 sealed interface Opt<T> { 

(这实际上是大多数函数式语言对 Optional 的定义方式。)针对 Opt 的常见操作可以通过模式匹配来实现:

 static<T, U> Opt<U> map(Opt<T> opt, Function<T, U> mapper) { 

类似地,二叉树可以这样表示:

 sealed interface Tree<T> { 

我们可以通过模式匹配实现通常的操作:

 static<T> boolean contains(Tree<T> tree, T target) { 

像遍历这样的常见行为“显然”应该作为接口的抽象方法,将它写成静态方法似乎有些奇怪。的确,将一些方法放到接口中是没有问题的。但记录类、封印类和模式匹配的组合为我们提供了新的选择。我们可以用老方法实现它们(将抽象方法放在基类中,在子类中实现具体的方法),也可以作为抽象类的默认方法在一个地方实现模式匹配,也可以实现为静态方法,或者(如果不需要递归)在使用的地方进行临时内联遍历。

因为数据结构是专门为这种情况而设计的,我们可以选择是否将行为与数据放在一起。这种方法与 OO 并不矛盾,它是我们的工具箱的一个有用的补充,让我们可以根据实际需要与 OO 一起使用。

示例:JSON

如果你仔细看过 JSON规范,你会发现 JSON 也是一个 ADT:

 sealed interface JsonValue { 

如果使用了这种表示,从 JSON 中提取相关信息的代码就会非常简单。如果我们想匹配 JSON {“name”:”John”, “age”:30, “city”:”New York”}就是:

 if (j instanceof JsonObject(var pairs)

当我们将数据建模为不可变数据,创建聚合和提取内容(或将其重新打包为另一种形式)就变得很简单,而且由于当某些内容不匹配时模式匹配会优雅地失败,分解这个 JSON 的代码相对来说就没有复杂的控制流程。(虽然我们可能倾向于使用比这个示例更具有工业强度的 JSON 库,但实际上我们只需要额外的几十行解析代码就可以实现一个解析工具,不仅遵循 JSON 规范中列出的词法规则,而且可以将它们转换为 JsonValue。)

更加复杂的领域

到目前为止,我们看到的例子要么是跨整个调用边界使用返回值,要么是建模通用领域(如列表和树结构)。但其实同样的方法对于更复杂的应用程序特定领域也很有用。如果我们想要对一个算术表达式进行建模,可以这样做:

 sealed interface Node { }

有了抽象了加法和乘法的封印接口 BinaryNode,我们在匹配 Node 时就有了更多选择。我们可以通过匹配 BinaryNode 来同时处理加法和乘法,或者根据情况分别处理它们。语言本身仍然会确保我们涵盖了所有的情况。

为这些表达式编写求值器很简单。由于表达式中有变量,我们需要存储它们,并将其传给求值器:

 double eval(Node n, Function<String, Double> vars) { 

定义终端节点的记录类实现了 toString,但输出可能比我们想要的更详细。我们可以编写一个格式化器来生成看起来更像数学表达式的输出:

 String format(Node n) { 

和以前一样,我们可以将它们作为静态方法,或作为基类的实例方法,但只提供一个实现,或作为普通的实例方法——我们可以自由选择对领域来说最具可读性的方法。

在抽象地定义了领域之后,我们还可以轻松地添加其他操作。我们可以很容易地对单个变量进行符号微分:

 Node diff(Node n, String v) { 

在记录类和模式匹配出现之前,编写这种代码的标准方法是访问者模式。模式匹配显然比访问者模式更简洁,也更灵活和强大。访问者模式需要为访问构建领域,并添加严格的约束。模式匹配支持更多的临时多态。最关键的是,模式匹配具有更好的可组合性,我们可以使用嵌套模式来表达复杂的条件,而这在使用访问者模式时要复杂得多。例如,当一个乘法节点的一个子节点是常数时,上面的代码将生成混乱的树结构。我们可以使用嵌套模式更容易地处理这些特殊情况:

 Node diff(Node n, String v) { 

如果使用访问者模式——尤其是在多个层次的嵌套中——很快就会变得相当混乱和容易出错。

这不是一种或的关系

这里阐述的许多想法可能看起来有点“不像 Java”,因为我们大多数人都被教导将实体和过程建模成对象。但在现实中,我们的程序通常使用相对简单的数据,这些数据通常来自“外部世界”,我们不能指望它完全与 Java 的类型系统相匹配。(在我们的 JSON 示例中,我们将数字建模为双精度值,但实际上 JSON 规范对数值范围并没有特别说明,系统边界的代码将不得不做出决定是否截断或拒绝难以表示的数值。)

当我们在建模复杂的实体或编写像 java.util.stream 这样的库时,OO 技术为我们提供了很多东西。但是,当我们在构建处理普通数据的简单服务时,面向数据编程技术可能会为我们提供一条更直接的路径。类似地,在跨 API 边界交换复杂的结果时(例如我们的匹配结果示例),使用 ADT 定义一个特别的数据模式通常比将结果和行为交织成一个有状态的对象更加简单和清晰(Java Matcher API 就是这样做的)。

OOP 和面向数据编程技术并不矛盾,它们分别针对不同的粒度和场景。我们可以根据具体情况随意混合搭配。

跟随数据

无论是建模一个简单的返回值,还是一个更复杂的领域(如 JSON 或我们的表达式树),都有一些简单的原则可以让我们得到简单、可靠的面向数据的代码。

  • 只对数据建模。使用记录类对数据进行建模,每个记录类只建模一项内容,明确每个记录类的内容,并为组件选择明确的名字。如果存在多个选项,比如“纳税申报单要么由纳税人提交,要么由法人代表提交”,将这些选项建模为封印类,并将每个选项建模为一个记录类。记录类的行为应该仅限于从数据本身实现派生量,比如格式化。
  • 数据是不可变的。如果我们想要对数据建模,就不应该担心数据会发生变化。记录类为我们提供了一些帮助,因为它们是不可变的,但仍然需要一些规则来避免让可变性注入到我们的数据模型中。
  • 在边界处验证数据。在将数据注入系统之前,我们需要确保它们是有效的。这可以在记录类的构造函数中完成(如果验证逻辑被应用在所有的实例上),或者通过从另一个数据源接收数据的边界代码来完成。
  • 让非法状态无法被表示。记录类和封印类让错误的状态无法被表示出来。这比一直要检查数据有效性要好得多!就像不变性消除了程序中许多常见的错误来源一样,避免对无效数据进行建模也起到了类似的作用。这种方法的一个潜在好处是可测试性。当代码的输入和输出是简单且定义良好的数据时,不仅测试代码很容易,而且为生成式测试(通常可以比手动编写测试用例更有效地发现 Bug)打开了大门。

记录类、封印类和模式匹配的组合使得遵循这些原则变得更加容易,从而获得更简洁、可读和可靠的程序。考虑到 Java 面向对象编程根深蒂固的基础,面向数据编程可能有点陌生,但这些技术非常值得被添加到我们的工具箱中。