枚举类型

enum 关键字用于创建一个新类型,其中包含一组数量有限的命名变量,并视这些变量为常规程序组件。

枚举类型的基本特性

之前已经见过可以调用枚举类型中的 values() 方法遍历枚举常量列表。values() 方法生成一个由枚举常量组成的数组,其中常量的顺序和常量声明的顺序保持一致,这样就可以方便地(比如通过for-in循环)使用结果数组了。

当创建枚举类型时,编译器会生成一个辅助类,这个类自动继承自 java.lang.Enum,它提供了下面一些功能:

enum Shrubbery { GROUND, CRAWLING, HANGING }
public class EnumClass {
    public static void main(String[] args) {
        for(Shrubbery s : Shrubbery.values()) {
            System.out.println(s + " ordinal: " + s.ordinal());
            System.out.print(s.compareTo(Shrubbery.CRAWLING) + " ");
            System.out.print(s.equals(Shrubbery.CRAWLING) + " ");
            System.out.println(s == Shrubbery.CRAWLING);
            System.out.println(s.getDeclaringClass());
            System.out.println(s.name());
            System.out.println("-------------");
        }
        for(String s : "HANGING CRAWLING GROUND".split(" ")) {
            Shrubbery shrub = Enum.valueOf(Shrubbery.class, s);
            System.out.println(shrub);
        }
    }
}
/* output
GROUND ordinal: 0
-1 false false
class Shrubbery
GROUND
-------------
CRAWLING ordinal: 1
0 true true
class Shrubbery
CRAWLING
-------------
HANGING ordinal: 2
1 false false
class Shrubbery
HANGING
-------------
HANGING
CRAWLING
GROUND */

ordinal() 方法返回一个从 0 开始的 int 值,代表每个枚举实例的声明顺序。可以使用 == 来比较枚举实例(equals()hashCode() 方法会由编译器自动生成) Enum 类实现了 Comparable 接口(因此可比较),所以自动包含了 compareTo() 方法,另外它还实现了 Serializable 接口(因此可序列化)。

调用枚举实例的 getDeclaringClass() 方法会得到该枚举实例所属的外部包装类。

name() 方法返回枚举实例被声明的名称,使用 toString() 同样也可以返回该名称。 valueOf() 方法是 Enum 类中的静态方法,它根据传入的 String,返回名称与该 String 匹配的枚举实例。如果匹配的实例不存在,则抛出异常。

静态导入枚举类型

static import 将所有的枚举实例标识符都引人了本地命名空间,这样不需要显式地使用枚举类型来限定。相较于显式地用枚举类型来限定枚举实例,两种方式要根据具体情况评估使用。

注意,如果枚举定义在同一个文件中,或者定义在默认包中,则无法使用该方式。

在枚举类型中增加自定义方法

除了无法被继承外,枚举类型基本可以看作一个普通的类。即可以在里面增加自定义方法,甚至增加一个 main() 方法。

默认的 toString() 方法只会返回枚举实例的名称,而你很可能想为枚举实例生成不同于该默认方式的描述。为此,可以实现一个构造方法,以获取额外的信息,然后再用额外的方法来提供扩展描述,如下例所示:

如果你想增加自定义方法,则必须先用分号结束枚举实例的序列。同时,Java 会强制你在枚举中先定义实例。如果在定义实例之前定义了任何方法或字段,则会抛出编译时错误。

除了少量特殊限制外,枚举类型的构造器和方法的写法和普通类一样。

虽然本例中的构造方法是私有的,但使用哪种访问权限实际上区别并不大:构造方法只能用来创建你在枚举定义中声明的枚举实例,在枚举定义完成后,编译器不会允许你用它来创建任何新的类型。

重载方法

还可以重载 toString() 方法来为枚举生成不同的 String 值。重载 enum 的 toString() 方法和重载任何普通类的方法相同:

toString() 方法通过调用 name() 方法获取 SpaceShip 的名称,并且修改了结果,使得结果中的英文单词仅首字母为大写。

在 switch 语句中使用枚举

这是枚举的一个非常方便的功能。通常,switch 语句只能使用整型或字符串类型的值,但是由于 enum 内部已经构建了一个整型序列,并且可以通过 ordinal() 方法来得到枚举实例的顺序(编译器做了相应的工作),所以枚举类型可以用在 switch 语句中。

通常要使用枚举实例必须用枚举的类型名来限定,但在 case 语句中不需要。下面是一个状态机示例:

编译器没有因为该 switch 中没有 default 语句而报错,但这并不是因为它注意到了你为每个 Signal 实例都编写了 case 分支。即使你注释掉某个 case 分支,也仍然不会报错。这意味着你必须要小心,确保你手动投盖了所有的分支。但是,如果此时你在 case 语句中调用了 return(而且没有编写 dafault),则编译器会报错,即使你已经覆盖到了枚举中的所有值。

values() 方法的神秘之处

所有的枚举类型都是由编译器通过继承 Enum 类来创建的。然而,如果仔细查看 Enum 类的代码,你会发现里面并没有 values() 方法,却已经能直接使用它了。可以使用反射来看一下:

values() 方法是由编译器添加的一个静态方法。注意,在创建 Explore 的过程中,编译器还为其添加了 valueOf() 方法。可以看到,Enum 类中本来就有 valueOf() 方法,但是该方法有两个参数,新加入的则只有一个。因为这里 Set 方法只关心方法名,不考虑方法签名,因此调用 Explore.removeAll(Enum) 后,只剩下了 [values]

打印结果显示 Explore 枚举被编译器限定为 final 类,所以你无法继承一个枚举类。此外还有一个 static 的初始化子句,稍后会看到它可以被重定义。

由于类型擦除,反编译器得不到 Enum 类的完整信息,因此只能将 Explore 类的基类作为一个原始 Enum 类显示,而不是实际的 Enum<Explore>

由于 values() 方法是在枚举类定义时插入的静态方法,因此如果将枚举类型向上转型为 Enum,该方法将不可用。注意,Class 类有一个 getEnumConstants() 方法,所以即使 Enum 的接口中没有 values() 方法,仍然可以通过 Class 对象得到 enum 的实例:

由于 getEnumConstants() 是 Class 类中的方法,对非枚举类的调用:

此时会返回 null,如果尝试引用结果,就会抛出异常。

实现,非继承

由于所有的 enum 对象都继承自 java.lang.Enum,因此无法通过继承方式创建一个枚举对象。

但是,可以实现一个或多个接口:

这里必须先得有一个枚举实例,才能在其上调用某个方法。由于实现了 Supplier 接口,因此此处的 CartoonCharacter 对象可以传入任何将 Supplier 对象作为参数的方法。

随机选择

由于许多示例需要从 enum 中随机选择实例,因此可以使用泛型将这项任务的实现抽象成公共能力,并放到公共库中:

这里的 <T extends Enum<T>> 声明了 T 是一个枚举类型实例,通过传入 Class<T>,使得这个 Class 对象变得可用,从而可以生成枚举实例的数组。重载后的 random() 方法只需使用 T[] 作为参数,因为它并不会调用 Enum 上的任何操作,它只需从数组中随机选择一个元素即可。这样,最终的返回类型正是 enum 的类型。

下面是对该方法的测试:

使用接口组织枚举

枚举类型无法被继承,这一点可能有时会让人沮丧。想要继承枚举的动机,一部分源自希望扩充原始枚举中的元素,另一部分源自想要使用子类型来创建不同的子分组。

可以在一个接口内对元素进行分组,然后基于这个接口生成枚举,通过这样的方式来实现元素的分类。假如有一些类型互不相同的 Food,想创建若干 enum 组织它们,但又希望它们仍然是 Food 类型:

实现接口是唯一可子类化枚举的方式,因此所有嵌套在 Food 中的枚举类型都实现了 Food 接口。

对于每个实现了 Food 接口的枚举类型,都可以向上转型为 Food,因此它们全都是 Food 类型。

如果要创建“由枚举组成的枚举”,可以为 Food 中每个枚举类型都创建一个外部枚举类型:

这里,每个枚举类型都接收相应的 Class 对象以作为构造参数,从而可以使用 getEnumConstants() 来提取出所有的枚举实例并存储起来,这些实例稍后会在 randomSelection() 中被用到。现在可以从每个 Course(菜项)中选择一种 Food,生成一份随机菜单了:

此外,创建一个由枚举类型组成的枚举类型的意义在于,可以方便地遍历每个 Course。之后的例子 VendingMachine.java 中,是另一种由不同约束条件指定的分类方法。

另一种更简洁的分类方法是在枚举内嵌套枚举:

Security 接口用于将内部的枚举类型作为公共类型聚合到一起,然后再将它们归类到 SecurityCategory 中的枚举中。

可以将该方法应用到 Food 示例中:

这样只是重新组织了下代码,但在一些情况下,可以使结构更清晰。

用 EnumSet 来代替标识

Set 是一种不允许有重复元素存在的集合。enum 要求每个内部成员都是唯一的,看起来很像 Set,但是由于无法添加或移除元素,它井不如 Set 那么好用。于是 Enumset 被引入,用来配合 enum 的使用,以替代传统的基于 int 的“位标识”用法。这种标识可以用来表示某种“开/关”信息,不过,使用这种标志,最终操作的是各种位状态,而不是想要表达的概念,因此很容易写出令人难以理解的代码。

性能是 EnumSet 的设计目标之一,因为需要与高效的位标识竞争(位操作性能远高于 HashSet)。它的内部实现是用一个 long 型的位向量表示集合中的元素,所以非常高效。这样做的优点是,说明一个二进制位是否存在时,具有更好的表达能力,并且无需担心性能。

EnumSet 中的元素必须来自某个枚举类型。下面的例子,使用枚举来表示大楼中警报感应器的安装位置:

然后使用 EnumSet 跟踪报警的状态:

EnumSet 的方法名都相当直观,可以查阅 JDK 文档找到其完整详细的描述。查看文档,会发现 of() 方法被重载了多次,不但为可变数量参数进行了重载,而且为接收 2 至 5 个显式的参数的情况都进行了重载,体现出了 EnumSet 对性能的关注。其实只使用单独的 of() 方法解决可变参数已经可以解决整个问题了,但是对比显式的参数,会有一点性能损失。采用现在这种设计,当只使用 2 到 5 个参数调用 of() 方法时,可以调用对应的重载过的方法(速度稍快一点),而当使用一个参数或多过 5 个参数时,你调用的将是使用可变参数的 of() 方法。注意,如果你只使用一个参数,编译器并不会构造可变参数的数组,所以与调用只有一个参数的方法相比,也就不会有额外的性能损耗。

EnumSet 是基于 64 位的 long 构建的,每个枚举实例需要占用 1 位来表达是否存在的状态,这意味着在单个 long 的支撑范围内,1个 EnumSet 最多可支持包含 64 个元素的枚举类型。如果枚举中元素超过 64 个:

显然 EnumSet 支持超过 64 个元素的枚举类型,所以可以推测,它在必要时会引入新的 long 型变量。

使用 EnumMap

EnumMap 是一种特殊的 Map,它要求其中的键(key)必须来自一个 enum,由于 enum 本身的限制,所以 EnumMap 在内部可由数组实现。因此 EnumMap 的性能很好,可以放心地使用 enum 实例在 EnumMap 中进行查找操作。

只能用 enum 的实例作为键来调用 put() 方法,其他操作与使用一般的 Map 差不多。

下面的例子演示了设计模式中的命令模式。这种模式由一个(通常)只包含一个方法的接口开始,然后为该方法创建多个具有不同行为的实现。只需要配置好这些命令对象,程序就会根据需要来调用它们:

和 EnumSet 一样,EnumMap 中的元素顺序由它们在枚举中定义的顺序决定。

最后部分说明,enum 的每个实例作为一个键,总是存在的。但是,如果你没有为这个键调用 put() 方法来存入相应的值的话,其对应的值就是 null。

相较于常量特定方法(constant-specific method),EnumMap 的优势是可以改变值对象,而常量特定方法在编译时是不可变的。

后面会看到,EnumMap 支持多路分发,以应对多个类型的枚举共存且相互影响的各种场景。

常量特定方法

Java 的枚举机制可以通过为每个枚举实例编写不同的方法,来赋予它们不同的行为。要实现这一点,可以在枚举类型中定义一个或多个抽象方法,然后为每个枚举实例编写不同的实现:

可以通过关联的枚举实例来查找和调用方法。这通常叫做表驱动模式(注意,和前面的命令模式很像)。

在面向对象编程中,不同的行为和不同的类相关联。通过常量特定方法,枚举类型的各种实例可以拥有各自的行为,这表明每个实例都是不同的类型。在上面的例子中,每个枚举实例都被视同于“基类” ConstantSpecificMethod 的实例,但调用 getInfo() 方法时的行为是多态的。

然而,enum 实例与类的相似之处也仅限于此了。我们并不能真的将 enum 实例作为一个类来使用:

编译器不允许将枚举实例作为类类型来使用,通过分析编译器生成代码,就明白了:每个枚举元素都是 LikeClasses 的一个 static final 的实例。

同时,由于它们是 static 实例,无法访问外部类的非 static 元素或方法,所以对于内部的 enum 的实例而言,其行为与一般的内部类并不相同。

下面是洗车的例子,每个用户拿到一个洗车选项的菜单,每个选项代表不同的操作,每个选项都可以分配一个常量特定方法,然后用一个 EnumSet 来持有用户的选择:

定义常量特定方法的语法与定义匿名内部类相似,但更简洁。

由于 EnumSet 是一种 Set,因此对同一个洗车选项,只能出现一次。而且输出的顺序与添加顺序无关,而是定义时的顺序。

除了实现 abstract 方法以外,是否可以重写常量特定方法:

虽然 enum 有某些限制,但是一般而言,还是可以将其看作是类来实验。

用枚举实现职责链模式

职责链(Chain of Responsibility)设计模式先创建一批用于解决目标问题的不同方法,然后将它们连成一条“链”。当一个请求到来时,它遍历这个链,直到链中的某个解决方案能够处理该请求。

用常量特定方法可以实现一条简单的职责链,考虑一个邮局模型,它对每 一封邮件都会尝试用最常见的方式来处理,(如果行不通)并不断尝试别的方式,直到该邮件最终被视为“死信”(无法投递)。每种尝试都可以看作一个策略(另一种设计模式),而整个策略列表放在一起就是一条职贵链。

先来描述一下邮件。邮件的每个关键特征都可以用 enum 来表示。程序将随机地生成 Mail 对象,如果要减小一封邮件的 GeneralDelivery 为 YES 的概率,那最简单的方法就是多创建几个非 YES 的 enum 实例,所以 enum 的定义看起来有点古怪。

Mail 中有一个 randomMail() 方法,它负责随机地创建用于测试的邮件。而 generator() 方法生成一个 Iterable 对象,该对象在你调用 next() 方法时,在其内部使用 randomMail() 来创建 Mail 对象。该结构允许通过调用 Mail.generator() 方法实现 for-in 循环:

职责链模式的作用体现在了 MailHandler 枚举中,枚举的定义顺序则决定了各个策略在每封邮件上被应用的顺序。该模式会按顺序尝试应用每个策略,直到某个策略执行成功,或者全部策略都执行失败(邮件无法投递)。

用枚举实现状态机

枚举类型很适合实现状态机,一个状态机可以具有有限个特定的状态,它通常根据输入,从一个状态转移到下一个状态,不过也可能存在瞬态(transient states),而一旦任务执行结束,状态机就会立刻离开瞬态。

每个状态都具有某些可接受的输入,不同的输入会使状态机从当前状态转移到不同的新状态。由于 enum 对其实例有严格限制,非常适合用来表现不同的状态和输入。一般而言,每个状态都具有一些相关的输出。

下面介绍一个自动售货机的例子,首先,在一个枚举中定义一系列输入:

注意,除了两个特殊的 Input 实例之外,其他的 Input 都有相应的价格,因此在接口中定义了 amount() 方法。然而,对那两个特殊 Input 实例而言,调用 amount() 方法并不合适,所以如果程序员调用它们的 amount() 方法就会有异常抛出(在接口内定义了一个方法,然后在你调用该方法的某个实现时就会抛出异常),尽管有些奇怪,但这是枚举的限制所导致的。

VendingMachine 接收到输入后,首先通过 Category 枚举对其分类,这样就可以在类别间切换了。下例演示了 enum 是如何使代码变得更加清晰且易于管理的:

因为通过 switch 语句在枚举实例中进行选择操作是最常见的方式(注意,为了使 enum 在 switch 语句中的使用变得简单,是需要付出其他代价的),所以在组织多个枚举类型时,常见的问题是“需要在什么 enum 中(即以什么粒度)进行 switch”。通过该例子,会发现在每种 state 下,需要针对输人操作的基本类别进行 switch 操作:投人钱币、选择商品、退出交易、关闭机器。然而,在这些基本分类之下,我们又可以塞人不同类型的钱币,可以选择不同的货物。Category 枚举会对不同的 Input 类型进行分类,因此 categorize() 方法可以在 switch 中生成恰当的 Category。且该方法用一个 EnumMap 实现了高效且安全的查询。

仔细研究 VendingMachine 类,会发现每个状态的区别,以及对于输入的不同响应,其中还有两个瞬时状态。在 run() 方法中,状态机等待着下一个 Input,并一直在各个状态中移动,直到它不再处于瞬时状态。

这里通过两种不同的 Supplier 对象以两种方法测试。RandomInputSupplier 会不停地生成除了 SHUT-DOWN 之外的各种输入。通过长时间地运行 RandomInputSupplier,可以起到健全测试(sanity test)的作用,能够确保该状态机不会进入一个错误状态。另一个是 FileInputSupplier,使用文件以文本的方式来描述输入,然后将它们转换成 enum 实例,并创建对应的 Input 对象。下面是用于生成以上输出的文本文件:

FilelnputSupplier 的构造器将这个文件转换为行级的 Stream 流,并忽略注释行。然后它通过 String.split() 方法将每一行都根据分号拆开,生成一个字符串数组,可以通过先将该数组转化为 Steam,然后执行 flatMap(),来将其注人(前面 FileInputSupplier 中生成的)Stream中。结果将删除所有的空格,并转换为 List<String>,并从中得到 Iterator<String>

上述设计有个限制:VendingMachine 中会被 State 枚举实例访问到的字段都必须是静态的,这意味着只能存在一个 VendingMachine 实例。不过实际的(嵌入式 Java)应用,也许并不是一个大问题,因为在一台机器上,我们可能只有一个应用程序。

多路并发

当处理多个交互类型时,程序可能会变得相当混乱。举例来说,考虑一个解析并执行数学表达式的系统。里面可能包括 Number.plus(Number), Number.multiply(Number) 等,此处的 Number 是数值对象家族的基类。但是当你要执行 a.plus(b),并且不知道 a 或 b 的具体类型时,如何保证它们间的相互作用是正确的?

Java 只能进行单路分发,如果你想对多个类型未知的对象进行操作,Java 只会对其中一个类型调用动态绑定机制。这并不能解决当前的问题,所以你最终只能手动检测类型,然后再实现你自己的动态绑定行为。

该问题的解决方法是多路分发(此处为双路分发),多态只能发生在方法调用时,所以,如果想使用两路分发,那么就必须有两个方法调用:第一个方法调用决定第一个未知类型,第二个方法调用决定第二个未知的类型。要使用多路分发,就必须为每一个类型提供一个实际的方法调用,如果要处理两个不同的类型体系,就需要为每个类型体系执行一个方法调用。通常需要设定好某种配置,以便一个方法调用能够引出更多的方法调用,从而能够在这个过程中处理多种类型。要达到这个目的,需要不止一个方法互相配合:每个分发都需要一个方法调用。下面示例(“猜拳”游戏)中对应方法是 compete()eval(),它们都是同一个类型的成员,可以产生三种 Outcome 实例中的一个作为结果:

Item 是多路分发类型的接口:

每个 Item 类型都提供了方法的对应实现:

下面的 RoShamBo1.match() 接收两个 Item 对象作为参数,通过调用 Item.compete() 开始双路分发:

要判定 a 的类型,分发机制会在 a 的实际类型的 compete() 内部起到分发的作用。compete() 方法通过调用 eval() 来为另一个类型实现第二次分发。将自身(this)作为参数传入 eval(),会产生一个对重载 eval() 的调用,由此保留了第一次分发的类型信息,当第二次分发结束后,就知道了两个 Item 对象的准确类型。

要配置好多路分发需要很多的工序,不过要记住,它的好处在于方法调用时的优雅的话法,这避免了在一个方法中判定多个对象的类型的丑陋代码。不过,在使用多路分发前,请先明确,这种优雅的代码对你确实有重要的意义。

使用枚举类型分发

直接将 RoShamBo1.java 转换为基于枚举的实现版本是有问题的,因为枚举实例不是类型,无法重载 eval() 方法,无法将枚举实例作为参数类型。不过,还有很多方式可以利用枚举实现多路分发。

一种方法是通过构造方法初始化每个枚举实例,并以“一组”结果作为参数,最终组成类似查询表的结构:

compete() 方法中,一旦两个类型都被确定了,那么唯一的动作就是返回得到的 Outcome。然而,你也可以调用其他方法,甚至是(比如)构造方法中分配的命令对象中的方法。

该例子比之前更短而且易于理解。注意,我们仍然是使用两路分发来判定两个对象的类型。在 RoShamBol.java 中,两次分发都是通过实际的方法调用实现,而在这个例子中,只有第一次分发是实际的方法调用。第二个分发使用的是 switch,不过这样做是安全的,因为 enum 限制了 switch 语句的选择分支。

用来操作枚举的代码是独立的,可以在其他例子中使用。首先,Competiotr 接口定义了一个和另一个 Competitor 竞争的类型:

然后我们定义了两个静态方法(使用静态是为了避免显式地指定参数类型)。首先, match() 方法中调用了 compete() 方法,让两个 Competitor 竞争,可以看到此处类型参数只需要是 Competitor<T>。但是在 play() 方法中,类型参数必须同时是 Enum<T>(因为要放在 Enums.random() 中使用)和 Competitor<T>(因为要传给 match() )。

play() 方法没有将类型参数 T 作为返回值,因此,似乎你应该在 Class<T> 中用通配符(<?>)来取代主要的参数声明。然而通配符无法继承多个基类,因此我们必须使用上面的表达式。

使用常量特定方法

由于常量特定方法允许为不同的枚举实例提供不同的方法实现,所以看起来似乎是实现多路分发的完美解决方案。但是即使可以通过这种方式赋予枚举实例不同的行为,枚举实例也仍然不是类型,因此无法将它们作为方法签名中的参数类型。对于这个例子,最好的办法是构建一个 switch 语句:

虽然该例子没问题,但是相比下,RoShamBo2.java 在新增类型时需要的代码更少且简洁。

RoShamBo3 也可以简化一下:

此处的第二个分发是由两个参数版本的 compete() 执行的,它执行了一系列比较操作,因此看起来和 switch 的行为很相似。它更简短,却更难理解。如果将其用于大型系统的开发,这一混淆可能会削弱系统的可维护性。

使用 EnumMap 分发

使用 EnumMap 可以实现“真正的”双路分发,它是专门为 enum 设计的高效 Map。由于目标是在两个未知类型中切换,因此由 EnumMap 组成的 EnumMap(嵌套 EnumMap)可以实现双路分发。

这里 EnumMap 是用一个 static{} 子句进行初始化的,可以看到对 initRow() 的调用是类似表格的结构。注意 compete() 方法,其在内部的一条语句内发生了两次分发。

使用二维数组

可以注意到,每个 enum 实例都有一个固定的值(基于其声明的次序),并且可以通过 ordinal() 方法取得该值。因此可以进一步简化,使用一个二维数组将竞争者映射到结果,便可以实现最简单的方案(而且有可能是最快的,尽管 EnumMap 用了内部数组):

该例子看起来更易于理解和修改,但是它并没有之前的示例那么“安全",因为它使用了数组。如果处理一个更大的数组,则可能会得到错误的数组大小并且如果测试未覆盖到所有的可能情况,则也许会出现一些不易发现的问题。

所有这些解决方案其实都是各种不同类型的表,但仍然值得去探索各种表的表现方式,从而找到最合适的那一种。注意,该例在给定若干常量输入的情况下,它只能生成一个常量输出。不过也可以通过 table 来生成函数对象(function object)。对于某些特定类型的问题,“表驱动模式”(table-driven code)的概念是非常强大的。

支持模式匹配的新特性

可以认为模式匹配(pattern matching)是在 switch 关键字上进行了显著的功能扩充。它是分成了多个模块,历经 Java 的多个版本持续实现的。这保证了每个模块在其他模块加人前都可以安全地运行。最后,所有的模块集中到一起,就产生了这个新特性。

这种拆分实现的方式使得人们在面对跨多个版本增加的多个“子特性”时会有点团惑。试图通过逐个分析各个单独模块来理解模式匹配并不是明智的选择。在下面部分,本文将所有支持模式匹配的模块都汇总到了一起以减少理解上的困惑。

新特性:switch 中的箭头语法

JDK 14 增加了在 switch 中使用不同语法的 case 的能力:

colons() 中,可以看到为了防止继续向下执行,每个 case 语句(最后一个 default 以外)后面都有必要加上 break。而在 arrows() 中替换为箭头后便不再需要 break 语句了。然而这只是箭头的一部分功能而已。不能在同一 switch 中同时使用冒号和箭头。

新特性:switch 中的 case null

JDK 17 新增了(预览)功能,可以在 switch 中引人原本非法的 case null。以前只能在 switch 的外部检查是否为 null,如下所示:

可以看到,null 现在在 switch 中可以作为合法的 case 了。

default 是否会覆盖 null 的情况呢?从 defaultOnly() 可以看出 default 并不会捕获 null,而如果又没有使用 case null,就会抛出 NullPointException 异常。Java 声称 switch 会覆盖所有可能的值,即使它实际并未覆盖 null。这是向后兼容性导致的问题一一如果 Java 突然开始强制进行 null 检查,那么大部分已有代码会无法通过编译。

为了方便,可以像 combineNullAndDefault() 中那样将 case null 和 default 合并。

新特性:将 switch 作为表达式

switch 之前只能是一个语句,不会返回结果。JDK 14 使得 switch 可以作为一个表达式使用,即可以得到一个值:

colon() 中,使用旧的冒号语法时,可以使用新的关键字 yield 从 switch 中返回结果。注意,使用 yield 时不要再用 break,如果再加上 break,会在编译时产生错误消息“试图跳出 switch 表达式"(attempt to break out of a switch expression)。

如果试图在 switch 语句中使用 yield,编译器会产生错误消息“在 switch 表达式外部 yield”。

arrow() 实现同样效果,但是更清晰和紧凑。可以为 TrafficLight.java 改进:

编译器会确保在修改代码时不会缺少 case,即如果在不加上 case BLUE 的情况下将 BLUE 加到 enum Signal 中,则会出现“switch 表达式未覆盖所有可能输入值”。

如果一个 case 需要多个语句或表达式,可以放到花括号中:

从多行组成的 case 表达式中生成结果时,即使用箭头语法,也要用 yield。

新特性:智能转型

JDK 16 完成了“针对 instanceof 的模式匹配”的定稿(JEP 394),该名字称其为“模式匹配支持”(support for pattern matching)可能会更恰当。在 Kollin 语言中,该特性被简单地称为“智能转型”(smart casting),因为一旦确定类型后,就永远不需要对其转型了。

dumb() 中,一旦 instanceof 确定了 x 是 String 后,就必须显式地将它转型为 String s。否则就得在该函数中其余的地方到处插入转型操作。但在 smart() 中,注意 instanceof String s 自动用 String 类型创建了一个新的变量 s。s 在整个作用域中都可用,即使是在剩余的 if 条件中,如 && s.length() > 0

wrong() 可以看出,在 if 智能转型表达式中只能使用 &&。使用 || 则意味着可能 x 是个 instanceof String,也可能 s.length() > 0。但这也就意味着 x 也可能不是 String, 在这种情况下,Java 就不会将 x 智能转型以生成 s,因此 s 在 || 右侧是不可用的。

JEP 384 称 s 为模式变量(pattern variable)。加入该特性是作为模式匹配的构建块而引入的。

该特性可以产生某些奇怪的作用域行为:

只有在不引入会引发异常的语句时,s 才在作用域中。即如果注释掉 throw new RuntimeException(),编译器便会告诉你它无法在那里找到 s。

新特性:模式匹配

模式匹配在 JDK 17 中还是预览特性,还需要用到示例注释中给出的编译器和运行时标志。Java 的模式匹配仍在开发中,和成熟的模式匹配系统(如 Kotlin, Scala, 以及 Python 中的那些)相比,功能还相当有限。

违反里氏替换原则

基于继承的多态性实现了基于类型的行为,但是要求这些类型都在同一个继承层次结构中。而模式匹配也实现了基于类型的行为,却并不要求类型全都具有相同的接口,或都处于相同的继承层次结构中。

这是一种不同的反射使用方式,仍然需要在运行时确定类型,但该方式比反射更加正式、更加结构化。

如果感兴趣的类型全都具有共同的基类,并且只会使用公共基类中定义的方法,那么就遵循了里氏替换原则(Liskov Substitution Principle, LSP)。在这种情况下,模式匹配就不是必需的了一一只需使用普通的继承多态性即可。

所有方法都完全定义在 LifeForm 接口中,实现类中也并没有增加任何方法。

但如果需要增加难以放在基类中的方法呢?比如某些蠕虫身体被切断后可以再生,而长颈鹿肯定做不到。长颈鹿可以踢人,但是很难想象怎样可以在基类中表现出该行为,同时又不会使 Worm 错误地实现(该行为)。Java 集合库遇到了这种问题,并试图以一种笨拙的方式解决:在基类中增加“可选”方法,某些子类可以实现该方法,而另一些子类则不实现。这种方法遵循了里氏替换原则,但造成了混乱的设计。

Java 的基本灵感来自一种叫作 SmallTalk 的动态语言,它会利用已有的类并增加方法,以此实现代码复用。以 Smalltalk 的设计方式来实现一套 "pet"(宠物)继承层次结构,可能最终看起来是这样的:

我们利用了基本 Pet 的功能性,并通过增加我们需要的方法扩展了该类。这不同于本书中一贯的主张(正如 NormalLiskov.java 所示):应该仔细地设计基类,以包含所有继承层次结构中可能需要的方法,因此遵循了里氏替换原则。虽然很有前景,但是不切实际,试图将基于动态类型的 SmallTalk 模型强制引入基于静态类型的 Java 系统,这必然会造成妥协。在某些情况下,这些妥协可能是行不通的。模式匹配允许使用 SmallTalk 的方式向子类添加新方法,同时仍然保持里氏替换原则的大部分形式。基本上模式匹配允许违反里氏替换原则,而不会产生不可控的代码。

有了模式匹配,就可以通过为每种可能的类型逬行检查并编写不同的代码,来处理 Pet 继承层次结构的非里氏替换原则的性质:

switch(p) 中的 p 称为选择器表达式(selector expression)。在模式匹配诞生之前,选择器表达式只能是完整的基本类型(char, byte, short 或 int),对应的包装类形式(Character, Byte, Short 或 Integer),String 或 enum 类型。有了模式匹配,选择器表达式扩展到可以支持任何引用类型。此处的选择器表达式可以是 Dog, Fish 或 Pet。

注意,这和继承层次结构中的动态绑定类似,但是并没有将不同类型的代码放在重写的方法中,而是将其放在了不同的 case 表达式中。

编译器强制增加了 case Pet,若不增加,switch 就无法覆盖所有可能的输入值。为基类使用接口可以消除该约束,但会增加另一约束。下面这个示例被放置在自己的 package 中,以防止命名冲突:

如果 Pet 没有用 sealed(密封,Java 15 引人的关键字,用于限制类或方法的随意继承扩充)修饰,编译器会再次发出警告“switch 语句没有覆盖所有可能的输入值”。这里的具体原因则是 interface Pet 可以被其他任何文件中的任何类实现,所以破坏了 switch 语句程盖的完整性。通过将 Pet 修饰为 sealed 的,编译器就能确保 switch 覆盖到所有可能的Pet类型。

模式匹配不会像继承多态性那样将你约束在单一继承层次结构中一一你可以匹配任意类型。想要这样做,就需要将 Object 传人 switch:

将 Object 参数传入 switch 的时候,编译器会要求有 default 存在,也是为了程盖所有可能的输人值(不过 null 除外,编译器并不要求存在为 null 的 case,尽管这种情况也可能发生)。

注意,在 JDK 21 中,null 只能和 default 合并在一起,而在 JDK 17 预览中还可以将 null 和普通模式合并。

守卫(JDK 21 未实现)

守卫(guard)使你可以进一步细化匹配条件,而不只是简单地匹配类型。它是出现在类型判断和 && 后的一项测试。守卫可以是任何布尔表达式,如果选择器表达式和 case 的类型相同,并且守卫判断为 true,那么模式就匹配上了:

支配性

switch 中 case 语句的顺序很重要,如果基类先出现,就会支配任何出现在后面的 case:

如果将基类那行上移,就意味着 switch 永远不会测试到 Derived,因为任何子类都会被 case Base 捕获。这时编译器就会报错,“该 case 标签被前面的 case 标签支配了”(this case label is dominated by a preceding case label)。

覆盖范围

模式匹配会引导你逐渐使用 sealed 关键字,这有助于确保你已覆盖了所有可能传人选择器表达式的类型。不过接下来再看一个示例:

sealed interface Transport 是通过 record 实现的,record 自动就是 final 的。如果增加了新的类型,编译器就会检测出来并告知,但是从上例中可以看出,编译器并未强制覆盖 null,如果加上 case null,就可以防止出现异常。但是编译器在此处帮不了你,显然是因为这会影响到太多已有的 switch 代码。

虽然模式匹配对于 Java 是一项改进,但也遭遇其他新增特性的问题:由于向后兼容性的问题,它只能是相对更强大的 Scala 及 Kotlin 版本的某种不完全实现。

理想情况下,Java 的模式匹配足以解决问题了。不过,如果你发现自己对模式匹配的依赖非常强烈,那么你可能需要一个比 Java 所提供的功能的更完整的功能集。Kotin 语言有着更全面的模式匹配能力,而且能生成可以无缝放入现有 Java 程序的类文件。

总结

虽然 Java 枚举明显比 C 或 C++ 中的 enum 更复杂和先进,但它仍然是个“小”功能。但是它的价值却很大,有时正是它给了最合适的杠杆来优雅而清晰地解决问题。优雅非常重要,而清晰则可以决定一个解决方案是成功还是失败的。

对于清晰性这一点,一个困惑之源是 Java 1.0 的失败选择:使用术语“enumeration", 而不是常见且已被广泛接受的术语“iterator”,来表示一个从序列中依次选取元素的对象(如之前所述)。有些语言基至将枚举的数据类型称为“enumerator”,这个错误后来被 Java 修正了,但是 Enumeration 接口显然无法简单地直接移除,因此它仍然会存在于在一些老旧代码、库以及文档中。