• 中文
    • English
  • 注册
  • 查看作者
  • Java中的面向数据编程

    近年来,
    项目为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提供了新的工具,可以更容易地将数据建模为数据(记录),直接建模替代实体(封印类),以及灵活地分解多态数据模式(模式匹配)。

    Java中的面向数据编程

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

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

    示例:命令行选项

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

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

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

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

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

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

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

    代数数据类型

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

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

    应用:复杂的返回类型

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

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

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

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

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

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

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

    应用:临时的数据结构

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

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

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

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

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

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

    示例:JSON

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

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

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

    更加复杂的领域

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

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

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

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

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

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

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

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

    这不是一种或的关系

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

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

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

    跟随数据

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

    • 只对数据建模
      。使用记录类对数据进行建模,每个记录类只建模一项内容,明确每个记录类的内容,并为组件选择明确的名字。如果存在多个选项,比如“纳税申报单要么由纳税人提交,要么由法人代表提交”,将这些选项建模为封印类,并将每个选项建模为一个记录类。记录类的行为应该仅限于从数据本身实现派生量,比如格式化。

    • 数据是不可变的
      。如果我们想要对数据建模,就不应该担心数据会发生变化。记录类为我们提供了一些帮助,因为它们是不可变的,但仍然需要一些规则来避免让可变性注入到我们的数据模型中。

    • 在边界处验证数据
      。在将数据注入系统之前,我们需要确保它们是有效的。这可以在记录类的构造函数中完成(如果验证逻辑被应用在所有的实例上),或者通过从另一个数据源接收数据的边界代码来完成。

    • 让非法状态无法被表示
      。记录类和封印类让错误的状态无法被表示出来。这比一直要检查数据有效性要好得多!就像不变性消除了程序中许多常见的错误来源一样,避免对无效数据进行建模也起到了类似的作用。这种方法的一个潜在好处是可测试性。当代码的输入和输出是简单且定义良好的数据时,不仅测试代码很容易,而且为生成式测试(通常可以比手动编写测试用例更有效地发现Bug)打开了大门。

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

    原文链接

  • 0
  • 0
  • 0
  • 42
  • 请登录之后再进行评论

    登录
  • 任务
  • 实时动态
  • 发布
  • 单栏布局 侧栏位置: