Java 21模式匹配标志Java正式支持函数式编程

来自WSCP 的博客:Java 21 将于 2023 年 9 月 19 日发布,支持 switch 块和表达式中的记录模式。这种语法是具有里程碑意义的,它标志着 Java 可以被认为以类似于 Kotlin、Rust 或 C# 的方式正确支持函数式编程模式。

作为一名 Kotlin 开发者,这标志着我第一次可以说我感到嫉妒。

类型理论有很多内容,但大部分与本文无关。
因此,我将不解释类型理论,而是谈谈对我们有用的几种特定类型。

Bottom或Empty 类型 (⊥)
这种类型描述了所有无法计算值的集合。对于任何普通编程语言(Ø)来说,这个集合通常都是空的。

由于它是一个空集,因此任何对象都不能被转换为 bottom 类型。

Kotlin 的 Nothing 就是这种类型的一个例子。Nothing实例的存在被认为是一个错误。Kotlin 防止 Nothing 实例化的方法是将其构造函数设置为私有。

这种类型的 Java 版本是 Void,它是 void 原始类型的封装类。同样,由于 Void 的构造函数是私有的,因此无法构造 Void 实例。然而,Void 不能被视为真正的底层类型,因为 Void 变量仍然可以保持 null,这意味着它在技术上是一个单元类型2(下文将详细介绍)。从这个意义上说,原始 void 更好。完全没有办法将它用作变量类型,所以你甚至不能拥有一个空的 void 变量。

TOP类型 (⊤)
在 Kotlin 中,这种类型被命名为 Any。人们可能会认为 Object(与 Any 相对应的 Java 类型)是TOP类型,但事实上,原始类型也是存在的。原始类型完全独立于 Java 的对象模型,并以非标准的方式与对象交互。因此,从技术上讲,Java 并不像其他语言那样拥有TOP类型。

与此同时,C 语言也是如此,它使用 void * 来表示一个指针,而这个指针可以指向任何类型的值,从而重载了 void。真有趣!

由于 U类型包含了所有存在的值,因此每个对象都可以被转换为 top 类型。

关于 top,我们没有太多可说的,只能说这种类型的变量可以容纳任何东西。包括 bottom 类型的值。不过,祝你好运能找到那个值。

Unit类型(())
这种类型只有一个值。该值只有一个实例,不可能创建更多的实例。

Java 的 void 原始类型在技术上就是这样工作的:当一个方法返回 void 时,您可以将其视为隐式返回 void 类型的唯一实例(JVM 并不是这样处理 void 的)。

Java 与理论规范的不同之处在于,void 永远不能作为参数传递给方法。

从技术上讲,您可以通过声明一个新类来模拟 Java 中的Unit类型,该类是最终类,除了静态实例值外没有其他字段。这样,您就可以将该实例视为唯一的Unit类型值。

事实上,Kotlin 正是这样定义自己的Unit的。如果你浏览一下 Unit 的定义,就会发现它的定义是多么简单:它只是一个对象!

与 Java 不同,Kotlin 允许你在任何地方使用 Unit,包括作为方法的参数。因此,下面的代码段是合法的:

fun identity(param1: Unit): Unit = param1

val result = identity(param1 = Unit) // just returns the Unit instance again.

布尔类型
现在我们进入了熟悉的领域。

布尔类型有两个有效值,即 true 和 false(或其他任何您想使用的名称)。事实上,您甚至不需要使用您语言的原生布尔类型来表示这种类型;您可以使用单元类型的可空实例来表示这种类型。如果变量为非空,则为真;如果为空,则为假。当然,这只是无用的浪费时间,只适合那些对混淆源代码感兴趣的人。

至此,我们已经了解了类型理论中用于定义类型的 "规则 "的一些基本示例。下面,让我们进入问题的核心,讨论和与乘积类型,以及 Java 21 如何允许我们通过记录和密封类来表示它们。

Product类型
Product类型由两个或多个组成类型构成。一般来说,Product类型是由两个或多个类型组合而成的列表。Product类型的 "弧度 "或 "度 "是其中组成类型的数量。

如果你想得到一个关于Product类型的具体示例,那就看看不起眼的 C 结构吧:

struct some_type {
    int val1; // Type 1
    char *val2;
// Type 2
    double val3;
// Type 3
    int val4;
// Type 4
};

在上面的结构体中,some_type 是由 int、char *、double 和 int 四种不同类型组成的Product类型。请注意,我们在这里重复使用了 int。当我们对 some_type 执行操作时,如何确定哪个是 int?这看似显而易见,但在数学中却是个问题,因为你必须从头开始构建你所使用的所有构件和概念!

在这种情况下,我们已经有了实现这一目标的工具。我们将每种类型与结构体中的名称联系起来(咄)。从数学角度讲,Product类型不仅仅是一个类型列表,而是一个有序对列表,其中每个有序对由一个类型和一个与该类型相关的名称组成。

例如,我们可以用有序对(int, "val1")来表示 some_type 的第一个值。这样,就不可能混淆两个 int 组件,因为它们有不同的名称!

那么像 Python 或 Rust 中的元组呢?
你可以把它们看作Product类型,其中的 "name名称 "就是元组中组件类型的索引。

some_tuple = (1, '2', True, 5)

int_1 = some_tuple[0] # (int, 0)
str_2 = some_tuple[1] # (str, 1)

...

为什么我们称它们为product类型呢?
在集合论中,"积Product "通常指两个集合的笛卡尔积。

注释
两个集合的笛卡儿积是由两个集合中每个元素的每种可能组合而成的有序对集合。

用简单的数学术语来思考,两个集合 A 和 B 的笛卡尔积 C 中的元素个数就是 A 中元素个数与 B 中元素个数的乘积。

你可以使用集合论符号将 A 和 B 两种类型的乘积表示为 C = A × B。这种乘积运算不是交换运算;A × B 与 B × A 并不相同!我刚才说的例子只使用了两种组件类型:A 和 B:例如,我们该如何表示 some_type 呢?答案是将多个Product操作连锁在一起,就像这样:

some_type = int × char* × double × int

产品类型中的一组值可以这样表示(请在评论中对我数学符号的(滥用)提出不满):

C = A × B = {(a, b) | a∈A, b∈B}

你可以这样表示 some_type 中所有值的集合:

some_type = { (val1, val2, val3, val4) | val1 ∈ int, val2 ∈ char*, val3 ∈ double, val4 ∈ int }


好了,我们已经知道了什么是Product类型。这和 Java 有什么关系呢?
Java 16 发布后,记录类功能得到了稳定。记录类就是Product类型的一个很好的例子。所有字段都是最终的;你也不能从这些类继承。记录的所有状态都是在创建时设置的,一旦创建了记录,在其剩余的生命周期中,所有的 200 毫秒都将是这样。

这与普通 Java 类形成了鲜明对比,后者的状态千变万化。你可以有公共状态和私有状态,也可以有通过继承隐藏的状态,这些状态你都不会想到,直到它像一些吃了咖啡因的闹鬼动画人一样跳出来,用奇怪的 bug 来吓唬你,你还可以有可变字段、静态字段,以及其他各种让人分心的东西,等等等等......(你懂的)。

普通 Java 类型的问题在于无法概括类型的组成部分。当你只想高效地处理数据时,这就很重要了;你必须在一个可能是非标准的获取器的迷宫中穿梭,才能从一开始就获取数据,更不用说对数据进行处理了。

现在,Java 从未像 JavaScript 或 Rust 那样支持重构。但即使 Java 支持重构,规范也可能会将重构功能限制在记录上。让我们问自己几个问题,以便更好地理解原因。

你到底要如何重构一个普通的 Java 类?
Java 类的内部状态包括所有字段,包括公共字段和私有字段。然而,允许通过重构提取私有字段似乎不是个好主意;我们都知道老鲍勃叔叔对破坏封装有多生气。所以好吧,我们必须排除私有状态。

那么公共状态呢?
让我们先想想这个问题:Java 对象如何公开公共状态?当然,你可以将一个字段定义为公共字段,如果你想防止不适当的修改,就将该字段设为最终字段。但还有另一种极为常见的方法。大多数 Java 对象都会将每个字段设置为私有,并且只能通过读写访问器方法访问所有字段。

不幸的是,对于访问器的定义,语言中并没有强制执行的约定;您可以给 foo 的获取器取名为 getBar,除了会让试图访问 bar 而不是 "foo "的人感到困惑之外,它仍然可以正常工作。

当然,您可以使用像 Lombok 这样的框架,通过在 POJO 类上添加一些注解来消除复杂性和不确定性,但这并不能改变一个基本事实,

那就是 Java 中的普通类很难进行静态推理,因为定义类状态的 "变量 "太多了。(banq注:函数形式与可变状态分离,如同数学与语文分离,符号与内容分离一样,这是默认的抽象原则,普通类中可变状态,又包含改变状态的函数形式,又包含状态值这个内容,使得Java普通类难以确定性以符号推理,)

我怀疑这就是阻碍《Java 语言规范》作者一开始就为所有类添加模式匹配的问题之一。

为了解决这个问题,他们创建了记录,一个完全不同的类层次结构。已有先例:Java 5 引入了枚举,它继承自 java.lang.Enum。同样,所有记录都继承自 java.lang.Record。

那么,记录如何做到普通类做不到的事情呢?
记录通过限制定义记录的方式和严格定义记录的属性集来解决这个问题。


具体来说:

  • 记录是隐式最终类,不能继承。
    • 不再有与完全不同的姘妇在图书馆偷情而生下的私生子。
  • 记录不能扩展任何类,但java.lang.Record.
    • 这避免了继承状态污染记录代码的陷阱。
  • 记录的组件不能有任何可见性修饰符。
  • 记录的组件始终是最终的且不可变的。
    • 然而,这并没有将不变性扩展到每个记录组件的内容。
    • 只有记录组件的引用才被视为不可变。
  • 当您声明记录并且不定义 getter 方法时,将使用非常具体的语法来定义 getter。
    • 这个语法非常有规律;Java 只是使用字段的名称作为 getter 的名称。
    • 对于 字段a,getter 就是a()。
    • 如果您手动定义符合命名约定的 getter,Java 将使用您的定义。否则,Java 将自动创建一个正确遵循约定的 getter 方法。非getter 方法不会产生太大影响。
  • 支持记录组件的字段始终是隐式私有的,并且只能通过 getter 进行访问。

(还有更多内容,但这似乎是一个很好的切入点)。

记录的这些特性保证了 Java 推出的任何使用记录的新语言功能(如模式匹配)都能正常工作,因为语言规范本身就保证了记录的行为和结构。

模式匹配
如果根据数据类型设置了大量条件,那么编写嵌套代码就会非常麻烦。当我在文章中进一步介绍总和类型时,这个问题就会迎刃而解。模式匹配是一种静态(即在编译时,在编写代码时)验证正在处理的数据中是否存在某些模式的方法。

请看下面的示例。请注意,A 中的数据是一个记录实例,可以包含任何记录类型。我们首先尝试使用普通的 Java if 语句打印 r 的内容,然后再使用Switch模式匹配进行打印。

record A(Record inner) {}
record B(char b) {}
record SomeOtherRecord() {}

Record eitherAorB() {
  boolean cond1 = ((int)(Math.random() * 100) % 2 == 0);
  boolean cond2 = ((int)(Math.random() * 100) % 2 == 0);
    return cond1 ? new A(cond2 ? new A(null) : new B('e')) : new B('f'); // returns either A or B.
}

void main() {
  var r = eitherAorB();

  String oldJavaResult =
"";

  if (r instanceof A) {
    var inner = ((A)r).inner();
// We have to cast it...
    if (inner instanceof B) {
      oldJavaResult = String.valueOf(((B)inner).b());
    } else if (inner instanceof SomeOtherRecord) {
      oldJavaResult = null;
    }
  } else if (r instanceof B) {
    oldJavaResult = String.valueOf((B)r.b());
  } else {
    oldJavaResult =
"r does not match any pattern";
  }

  System.out.println(
"With the old method: \"" + oldJavaResult + "\"");

 
// The type is Record.
  var result = switch (r) {
    case A(B(char a)) -> String.valueOf(a);
// Destructuring!
    case A(SomeOtherRecord(
/* ... */)) -> {
     
// handle it.
      yield null;
    }
    case B(char b) -> String.valueOf(b);
    default ->
"r does not match any pattern";
  };
  
  System.out.println(result.toString());
}

与上面的 if-else 梯形图相比,Switch块的结构显然更好。当您想快速、轻松地提取深嵌套数据时,Switch模式是非常强大的,而无需使用 instanceof 检查和繁琐的类型转换。如果您有机会使用 Java 21(但愿您的管理层不会对新 Java 版本过敏),我相信您一定会喜欢这个功能。

如果您想亲自尝试一下,方法如下。安装 Java 21(AUR 中有一个 jdk21-jetbrains-bin 软件包,如果有人想立即尝试的话,可以用它。我用的是 Arch)。将此代码复制到 main.java 中,然后用以下命令运行:

java --enable-preview --source 21 main.java

更多点击标题