本文是根据《Effective Java 3rd》英文版翻译的,仅供自己学习用!
31. 使用限定通配符来增加API的灵活性
如条目 28所述,参数化类型是不变的。换句话说,对于任何两个不同类型的Type1
和Type
,List <Type1>
既不是List <Type2>
子类型也不是其父类型。尽管List <String>
不是List <Object>
的子类型是违反直觉的,但它确实是有道理的。 可以将任何对象放入List <Object>
中,但是只能将字符串放入List <String>
中。 由于List <String>
不能做List <Object>
所能做的所有事情,所以它不是一个子类型(条目 10 中的里氏替代原则)。
相对于提供的不可变的类型,有时你需要比此更多的灵活性。 考虑条目 29中的Stack
类。下面是它的公共API:
1 | public class Stack<E> { |
假设我们想要添加一个方法来获取一系列元素,并将它们全部推送到栈上。 以下是第一种尝试:
1 | // pushAll method without wildcard type - deficient! |
这种方法可以干净地编译,但不完全令人满意。 如果可遍历的src
元素类型与栈的元素类型完全匹配,那么它工作正常。 但是,假设有一个Stack <Number>
,并调用push(intVal)
,其中intVal
的类型是Integer
。 这是因为Integer
是Number
的子类型。 从逻辑上看,这似乎也应该起作用:
1 | Stack<Number> numberStack = new Stack<>(); |
但是,如果你尝试了,会得到这个错误消息,因为参数化类型是不变的:
1 | StackTest.java:7: error: incompatible types: Iterable<Integer> |
幸运的是,有对应的解决方法。 该语言提供了一种特殊的参数化类型来调用一个限定通配符类型来处理这种情况。 pushAll
的输入参数的类型不应该是“E的Iterable接口”,而应该是“E的某个子类型的Iterable接口”,并且有一个通配符类型,这意味着:Iterable <? extends E>
。 (关键字extends
的使用有点误导:回忆条目 29中,子类型被定义为每个类型都是它自己的子类型,即使它本身没有继承。)让我们修改pushAll
来使用这个类型:
1 | // Wildcard type for a parameter that serves as an E producer |
有了这个改变,Stack
类不仅可以干净地编译,而且客户端代码也不会用原始的pushAll
声明编译。 因为Stack
和它的客户端干净地编译,你知道一切都是类型安全的。
现在假设你想写一个popAll
方法,与pushAll
方法相对应。 popAll
方法从栈中弹出每个元素并将元素添加到给定的集合中。 以下是第一次尝试编写popAll
方法的过程:
1 | // popAll method without wildcard type - deficient! |
同样,如果目标集合的元素类型与栈的元素类型完全匹配,则干净编译并且工作正常。 但是,这又不完全令人满意。 假设你有一个Stack <Number>
和Object
类型的变量。 如果从栈中弹出一个元素并将其存储在该变量中,它将编译并运行而不会出错。 所以你也不能这样做吗?
1 | Stack<Number> numberStack = new Stack<Number>(); |
如果尝试将此客户端代码与之前显示的popAll
版本进行编译,则会得到与我们的第一版pushAll
非常类似的错误:Collection <Object>
不是Collection <Number>
的子类型。 通配符类型再一次提供了一条出路。 popAll
的输入参数的类型不应该是“E的集合”,而应该是“E的某个父类型的集合”(其中父类型被定义为E是它自己的父类型[JLS,4.10])。 再次,有一个通配符类型,正是这个意思:Collection <? super E>
。 让我们修改popAll
来使用它:
1 | // Wildcard type for parameter that serves as an E consumer |
通过这个改动,Stack类和客户端代码都可以干净地编译。
这个结论很清楚。 为了获得最大的灵活性,对代表生产者或消费者的输入参数使用通配符类型。 如果一个输入参数既是一个生产者又是一个消费者,那么通配符类型对你没有好处:你需要一个精确的类型匹配,这就是没有任何通配符的情况。
这里有一个助记符来帮助你记住使用哪种通配符类型: PECS代表: producer-extends,consumer-super。
换句话说,如果一个参数化类型代表一个T
生产者,使用<? extends T>
;如果它代表T
消费者,则使用<? super T>
。 在我们的Stack
示例中,pushAll
方法的src
参数生成栈使用的E
实例,因此src
的合适类型为Iterable<? extends E>
;popAll
方法的dst
参数消费Stack
中的E
实例,因此ds
t的合适类型是Collection <? super E>
。 PECS助记符抓住了使用通配符类型的基本原则。 Naftalin和Wadler称之为获取和放置原则( Get and Put Principle )[Naftalin07,2.4]。
记住这个助记符之后,让我们来看看本章中以前项目的一些方法和构造方法声明。 条目 28中的Chooser
类构造方法有这样的声明:
1 | public Chooser(Collection<T> choices) |
这个构造方法只使用集合选择来生产类型T
的值(并将它们存储起来以备后用),所以它的声明应该使用一个extends T
的通配符类型。下面是得到的构造方法声明:
1 | // Wildcard type for parameter that serves as an T producer |
这种改变在实践中会有什么不同吗? 是的,会有不同。 假你有一个List <Integer>
,并且想把它传递给Chooser<Number>
的构造方法。 这不会与原始声明一起编译,但是它只会将限定通配符类型添加到声明中。
现在看看条目 30中的union
方法。下是声明:
1 | public static <E> Set<E> union(Set<E> s1, Set<E> s2) |
两个参数s1
和s2
都是E
的生产者,所以PECS助记符告诉我们该声明应该如下:
1 | public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) |
请注意,返回类型仍然是Set <E>
。 不要使用限定通配符类型作为返回类型。除了会为用户提供额外的灵活性,还强制他们在客户端代码中使用通配符类型。 通过修改后的声明,此代码将清晰地编译:
1 | Set<Integer> integers = Set.of(1, 3, 5); |
如果使用得当,类的用户几乎不会看到通配符类型。 他们使方法接受他们应该接受的参数,拒绝他们应该拒绝的参数。 如果一个类的用户必须考虑通配符类型,那么它的API可能有问题。
在Java 8之前,类型推断规则不够聪明,无法处理先前的代码片段,这要求编译器使用上下文指定的返回类型(或目标类型)来推断E
的类型。union
方法调用的目标类型如前所示是Set <Number>
。 如果尝试在早期版本的Java中编译片段(以及适合的Set.of
工厂替代版本),将会看到如此长的错综复杂的错误消息:
1 | Union.java:14: error: incompatible types |
幸运的是有办法来处理这种错误。 如果编译器不能推断出正确的类型,你可以随时告诉它使用什么类型的显式类型参数[JLS,15.12]。 甚至在Java 8中引入目标类型之前,这不是你必须经常做的事情,这很好,因为显式类型参数不是很漂亮。 通过添加显式类型参数,如下所示,代码片段在Java 8之前的版本中进行了干净编译:
1 | // Explicit type parameter - required prior to Java 8 |
接下来让我们把注意力转向条目 30中的max
方法。这里是原始声明:
1 | public static <T extends Comparable<T>> T max(List<T> list) |
以下是使用通配符类型的修改后的声明:
1 | public static <T extends Comparable<? super T>> T max(List<? extends T> list) |
为了从原来到修改后的声明,我们两次应用了PECS。首先直接的应用是参数列表。 它生成T
实例,所以将类型从List <T>
更改为List<? extends T>
。 棘手的应用是类型参数T
。这是我们第一次看到通配符应用于类型参数。 最初,T
被指定为继承Comparable <T>
,但Comparable
的T
消费T
实例(并生成指示顺序关系的整数)。 因此,参数化类型Comparable <T>
被替换为限定通配符类型Comparable<? super T>
。 Comparable
实例总是消费者,所以通常应该使用Comparable<? super T>优于Comparable 。 Comparator
也是如此。因此,通常应该使用Comparator<? super T>优于Comparator。
修改后的max
声明可能是本书中最复杂的方法声明。 增加的复杂性是否真的起作用了吗? 同样,它的确如此。 这是一个列表的简单例子,它被原始声明排除,但在被修改后的版本里是允许的:
1 | List<ScheduledFuture<?>> scheduledFutures = ... ; |
无法将原始方法声明应用于此列表的原因是ScheduledFuture
不实现Comparable <ScheduledFuture>
。 相反,它是Delayed
的子接口,它继承了Comparable <Delayed>
。 换句话说,一个ScheduledFuture
实例不仅仅和其他的ScheduledFuture
实例相比较: 它可以与任何Delayed
实例比较,并且足以导致原始的声明拒绝它。 更普遍地说,通配符要求来支持没有直接实现Comparable
(或Comparator
)的类型,但继承了一个类型。
还有一个关于通配符相关的话题。 类型参数和通配符之间具有双重性,许多方法可以用一个或另一个声明。 例如,下面是两个可能的声明,用于交换列表中两个索引项目的静态方法。 第一个使用无限制类型参数(条目 30),第二个使用无限制通配符:
1 | // Two possible declarations for the swap method |
这两个声明中的哪一个更可取,为什么? 在公共API中,第二个更好,因为它更简单。 你传入一个列表(任何列表),该方法交换索引的元素。 没有类型参数需要担心。 通常,如果类型参数在方法声明中只出现一次,请将其替换为通配符。 如果它是一个无限制的类型参数,请将其替换为无限制的通配符; 如果它是一个限定类型参数,则用限定通配符替换它。
第二个swap
方法声明有一个问题。 这个简单的实现不会编译:
1 | public static void swap(List<?> list, int i, int j) { |
试图编译它会产生这个不太有用的错误信息:
1 | Swap.java:5: error: incompatible types: Object cannot be |
看起来我们不能把一个元素放回到我们刚刚拿出来的列表中。 问题是列表的类型是List <?>
,并且不能将除null外的任何值放入List <?>
中。 幸运的是,有一种方法可以在不使用不安全的转换或原始类型的情况下实现此方法。 这个想法是写一个私有辅助方法来捕捉通配符类型。 辅助方法必须是泛型方法才能捕获类型。 以下是它的定义:
1 | public static void swap(List<?> list, int i, int j) { |
swapHelper
方法知道该列表是一个List <E>
。 因此,它知道从这个列表中获得的任何值都是E类型,并且可以安全地将任何类型的E
值放入列表中。 这个稍微复杂的swap
的实现可以干净地编译。 它允许我们导出基于通配符的漂亮声明,同时利用内部更复杂的泛型方法。 swap
方法的客户端不需要面对更复杂的swapHelper
声明,但他们从中受益。 辅助方法具有我们认为对公共方法来说过于复杂的签名。
总之,在你的API中使用通配符类型,虽然棘手,但使得API更加灵活。 如果编写一个将被广泛使用的类库,正确使用通配符类型应该被认为是强制性的。 记住基本规则: producer-extends, consumer-super(PECS)。 还要记住,所有Comparable
和Comparator
都是消费者。
32. 合理地结合泛型和可变参数
在Java 5中,可变参数方法(条目 53)和泛型都被添加到平台中,所以你可能希望它们能够正常交互; 可悲的是,他们并没有。 可变参数的目的是允许客户端将一个可变数量的参数传递给一个方法,但这是一个脆弱的抽象( leaky abstraction):当你调用一个可变参数方法时,会创建一个数组来保存可变参数;那个应该是实现细节的数组是可见的。 因此,当可变参数具有泛型或参数化类型时,会导致编译器警告混淆。
回顾条目 28,非具体化( non-reifiable)的类型是其运行时表示比其编译时表示具有更少信息的类型,并且几乎所有泛型和参数化类型都是不可具体化的。 如果某个方法声明其可变参数为非具体化的类型,则编译器将在该声明上生成警告。 如果在推断类型不可确定的可变参数参数上调用该方法,那么编译器也会在调用中生成警告。 警告看起来像这样:
1 | warning: [unchecked] Possible heap pollution from |
当参数化类型的变量引用不属于该类型的对象时会发生堆污染(Heap pollution)[JLS,4.12.2]。 它会导致编译器的自动生成的强制转换失败,违反了泛型类型系统的基本保证。
例如,请考虑以下方法,该方法是第127页上的代码片段的一个不太明显的变体:
1 | // Mixing generics and varargs can violate type safety! |
此方法没有可见的强制转换,但在调用一个或多个参数时抛出ClassCastException异常。 它的最后一行有一个由编译器生成的隐形转换。 这种转换失败,表明类型安全性已经被破坏,并且将值保存在泛型可变参数数组参数中是不安全的。
这个例子引发了一个有趣的问题:为什么声明一个带有泛型可变参数的方法是合法的,当明确创建一个泛型数组是非法的时候呢? 换句话说,为什么前面显示的方法只生成一个警告,而127页上的代码片段会生成一个错误? 答案是,具有泛型或参数化类型的可变参数参数的方法在实践中可能非常有用,因此语言设计人员选择忍受这种不一致。 事实上,Java类库导出了几个这样的方法,包括Arrays.asList(T... a)
,Collections.addAll(Collection<? super T> c, T... elements)
,EnumSet.of(E first, E... rest)
。 与前面显示的危险方法不同,这些类库方法是类型安全的。
在Java 7中,SafeVarargs
注解已添加到平台,以允许具有泛型可变参数的方法的作者自动禁止客户端警告。 实质上,SafeVarargs注解构成了作者对类型安全的方法的承诺。 为了交换这个承诺,编译器同意不要警告用户调用可能不安全的方法。
除非它实际上是安全的,否则注意不要使用@SafeVarargs
注解标注一个方法。 那么需要做些什么来确保这一点呢? 回想一下,调用方法时会创建一个泛型数组,以容纳可变参数。 如果方法没有在数组中存储任何东西(它会覆盖参数)并且不允许对数组的引用进行转义(这会使不受信任的代码访问数组),那么它是安全的。 换句话说,如果可变参数数组仅用于从调用者向方法传递可变数量的参数——毕竟这是可变参数的目的——那么该方法是安全的。
值得注意的是,你可以违反类型安全性,即使不会在可变参数数组中存储任何内容。 考虑下面的泛型可变参数方法,它返回一个包含参数的数组。 乍一看,它可能看起来像一个方便的小工具:
1 | // UNSAFE - Exposes a reference to its generic parameter array! |
这个方法只是返回它的可变参数数组。 该方法可能看起来并不危险,但它是! 该数组的类型由传递给方法的参数的编译时类型决定,编译器可能没有足够的信息来做出正确的判断。 由于此方法返回其可变参数数组,它可以将堆污染传播到调用栈上。
为了具体说明,请考虑下面的泛型方法,它接受三个类型T
的参数,并返回一个包含两个参数的数组,随机选择:
1 | static <T> T[] pickTwo(T a, T b, T c) { |
这个方法本身不是危险的,除了调用具有泛型可变参数的toArray
方法之外,不会产生警告。
编译此方法时,编译器会生成代码以创建一个将两个T
实例传递给toArray
的可变参数数组。 这段代码分配了一个Object []
类型的数组,它是保证保存这些实例的最具体的类型,而不管在调用位置传递给pickTwo
的对象是什么类型。 toArray
方法只是简单地将这个数组返回给pickTwo
,然后pickTwo
将它返回给调用者,所以pickTwo
总是返回一个Object []
类型的数组。
现在考虑这个测试pickTw
的main
方法:
1 | public static void main(String[] args) { |
这种方法没有任何问题,因此它编译时不会产生任何警告。 但是当运行它时,抛出一个ClassCastException异常,尽管不包含可见的转换。 你没有看到的是,编译器已经生成了一个隐藏的强制转换为由pickTwo
返回的值的String []
类型,以便它可以存储在属性中。 转换失败,因为Object []
不是String []
的子类型。 这种故障相当令人不安,因为它从实际导致堆污染(toArray
)的方法中移除了两个级别,并且在实际参数存储在其中之后,可变参数数组未被修改。
这个例子是为了让人们认识到给另一个方法访问一个泛型的可变参数数组是不安全的,除了两个例外:将数组传递给另一个可变参数方法是安全的,这个方法是用@SafeVarargs
正确标注的, 将数组传递给一个非可变参数的方法是安全的,该方法仅计算数组内容的一些方法。
这里是安全使用泛型可变参数的典型示例。 此方法将任意数量的列表作为参数,并按顺序返回包含所有输入列表元素的单个列表。 由于该方法使用@SafeVarargs
进行标注,因此在声明或其调用站位置上不会生成任何警告:
1 | // Safe method with a generic varargs parameter |
决定何时使用SafeVarargs
注解的规则很简单:在每种方法上使用@SafeVarargs
,并使用泛型或参数化类型的可变参数,这样用户就不会因不必要的和令人困惑的编译器警告而担忧。 这意味着你不应该写危险或者toArray
等不安全的可变参数方法。 每次编译器警告你可能会受到来自你控制的方法中泛型可变参数的堆污染时,请检查该方法是否安全。 提醒一下,在下列情况下,泛型可变参数方法是安全的: 1.它不会在可变参数数组中存储任何东西
2.它不会使数组(或克隆)对不可信代码可见。 如果违反这些禁令中的任何一项,请修复。
请注意,SafeVarargs
注解只对不能被重写的方法是合法的,因为不可能保证每个可能的重写方法都是安全的。 在Java 8中,注解仅在静态方法和final实例方法上合法; 在Java 9中,它在私有实例方法中也变为合法。
使用SafeVarargs
注解的替代方法是采用条目 28的建议,并用List
参数替换可变参数(这是一个变相的数组)。 下面是应用于我们的flatten
方法时,这种方法的样子。 请注意,只有参数声明被更改了:
1 | // List as a typesafe alternative to a generic varargs parameter |
然后可以将此方法与静态工厂方法List.of
结合使用,以允许可变数量的参数。 请注意,这种方法依赖于List.of
声明使用@SafeVarargs
注解: audience = flatten(List.of(friends, romans, countrymen));
这种方法的优点是编译器可以证明这种方法是类型安全的。 不必使用SafeVarargs
注解来证明其安全性,也不用担心在确定安全性时可能会犯错。 主要缺点是客户端代码有点冗长,运行可能会慢一些。
这个技巧也可以用在不可能写一个安全的可变参数方法的情况下,就像第147页的toArray
方法那样。它的列表模拟是List.of
方法,所以我们甚至不必编写它; Java类库作者已经为我们完成了这项工作。 pickTwo
方法然后变成这样:
1 | static <T> List<T> pickTwo(T a, T b, T c) { |
main
方变成这样:
1 | public static void main(String[] args) { |
生成的代码是类型安全的,因为它只使用泛型,不是数组。
总而言之,可变参数和泛型不能很好地交互,因为可变参数机制是在数组上面构建的脆弱的抽象,并且数组具有与泛型不同的类型规则。 虽然泛型可变参数不是类型安全的,但它们是合法的。 如果选择使用泛型(或参数化)可变参数编写方法,请首先确保该方法是类型安全的,然后使用@SafeVarargs
注解对其进行标注,以免造成使用不愉快。
33. 优先考虑类型安全的异构容器
泛型的常见用法包括集合,如Set <E>
和Map <K,V>
和单个元素容器,如ThreadLocal <T>
和AtomicReference <T>
。 在所有这些用途中,它都是参数化的容器。 这限制了每个容器只能有固定数量的类型参数。 通常这正是你想要的。 一个Set
有单一的类型参数,表示它的元素类型; 一个Map
有两个,代表它的键和值的类型;等等。
然而有时候,你需要更多的灵活性。 例如,数据库一行记录可以具有任意多列,并且能够以类型安全的方式访问它们是很好的。 幸运的是,有一个简单的方法可以达到这个效果。 这个想法是参数化键(key)而不是容器。 然后将参数化的键提交给容器以插入或检索值。 泛型类型系统用于保证值的类型与其键一致。
作为这种方法的一个简单示例,请考虑一个Favorites类,它允许其客户端保存和检索任意多种类型的favorite
实例。 该类型的Class对象将扮演参数化键的一部分。其原因是这Class
类是泛型的。 类的类型从字面上来说不是简单的Class
,而是Class <T>
。 例如,String.class
的类型为Class <String>
,Integer.class的
类型为Class <Integer>
。 当在方法中传递字面类传递编译时和运行时类型信息时,它被称为类型令牌(type token)[Bracha04]。
Favorites
类的API很简单。 它看起来就像一个简单Map类,除了该键是参数化的以外。 客户端在设置和获取favorites
实例时呈现一个Class对象。 这里是API:
1 | // Typesafe heterogeneous container pattern - API |
下面是一个演示Favorites
类,保存,检索和打印喜欢的String
,Integer
和Class
实例:
1 | // Typesafe heterogeneous container pattern - client |
正如你所期望的,这个程序打印Java cafebabe Favorites
。 请注意,顺便说一下,Java的printf
方法与C语言的不同之处在于,应该使用%n
,而在C中使用\n
。%n
生成适用的特定于平台的行分隔符,该分隔符在很多但不是所有平台上都是\n
。
Favorites
实例是类型安全的:当你请求一个字符串时它永远不会返回一个整数。 它也是异构的:与普通Map不同,所有的键都是不同的类型。 因此,我们将Favorites
称为类型安全异构容器(typesafe heterogeneous container.)。
Favorites
的实现非常小巧。 这是完整的代码:
1 | // Typesafe heterogeneous container pattern - implementation |
这里有一些微妙的事情发生。 每个Favorites
实例都由一个名为favorites
私有的Map<Class<?>, Object>
来支持。 你可能认为无法将任何内容放入此Map中,因为这是无限定的通配符类型,但事实恰恰相反。 需要注意的是通配符类型是嵌套的:它不是通配符类型的Map类型,而是键的类型。 这意味着每个键都可以有不同的参数化类型:一个可以是Class <String>
,下一个Class <Integer>
等等。 这就是异构的由来。
接下来要注意的是,favorites的Map的值类型只是Object。 换句话说,Map不保证键和值之间的类型关系,即每个值都是由其键表示的类型。 事实上,Java的类型系统并不足以表达这一点。 但是我们知道这是真的,并在检索一个favorite时利用了这点。
putFavorite
实现很简单:只需将给定的Class对象映射到给定的favorites的实例即可。 如上所述,这丢弃了键和值之间的“类型联系(type linkage)”;无法知道这个值是不是键的一个实例。 但没关系,因为getFavorites
方法可以并且确实重新建立这种关联。
getFavorite
的实现比putFavorite
更复杂。 首先,它从favorites Map中获取与给定Class对象相对应的值。 这是返回的正确对象引用,但它具有错误的编译时类型:它是Object(favorites map的值类型),我们需要返回类型T
。因此,getFavorite
实现动态地将对象引用转换为Class对象表示的类型,使用Class的cast
方法。
cast
方法是Java的cast操作符的动态模拟。它只是检查它的参数是否由Class对象表示的类型的实例。如果是,它返回参数;否则会抛出ClassCastException
异常。我们知道,假设客户端代码能够干净地编译,getFavorite
中的强制转换不会抛出ClassCastException
异常。 也就是说,favorites map中的值始终与其键的类型相匹配。
那么这个cast
方法为我们做了什么,因为它只是返回它的参数? cast
的签名充分利用了Class类是泛型的事实。 它的返回类型是Class对象的类型参数:
1 | public class Class<T> { |
这正是getFavorite
方法所需要的。 这正是确保Favorites类型安全,而不用求助一个未经检查的强制转换的T
类型。
Favorites类有两个限制值得注意。 首先,恶意客户可以通过使用原始形式的Class对象,轻松破坏Favorites实例的类型安全。 但生成的客户端代码在编译时会生成未经检查的警告。 这与正常的集合实现(如HashSet和HashMap)没有什么不同。 通过使用原始类型HashSet(条目 26),可以轻松地将字符串放入HashSet <Integer>
中。 也就是说,如果你愿意为此付出一点代价,就可以拥有运行时类型安全性。 确保Favorites永远不违反类型不变的方法是,使putFavorite
方法检查该实例是否由type表示类型的实例,并且我们已经知道如何执行此操作。只需使用动态转换:
1 | // Achieving runtime type safety with a dynamic cast |
java.util.Collections
中有一些集合包装类,可以发挥相同的诀窍。 它们被称为checkedSet
,checkedList
,checkedMap
等等。 他们的静态工厂除了一个集合(或Map)之外还有一个Class对象(或两个)。 静态工厂是泛型方法,确保Class对象和集合的编译时类型匹配。 包装类为它们包装的集合添加了具体化。 例如,如果有人试图将Coin
放入你的Collection <Stamp>
中,则包装类在运行时会抛出ClassCastException
。 这些包装类对于追踪在混合了泛型和原始类型的应用程序中添加不正确类型的元素到集合的客户端代码很有用。
Favorites类的第二个限制是它不能用于不可具体化的(non-reifiable)类型(条目 28)。 换句话说,你可以保存你最喜欢的String
或String []
,但不能保存List <String>
。 如果你尝试保存你最喜欢的List <String>
,程序将不能编译。 原因是无法获取List <String>
的Class对象。 List <String> .class
是语法错误,也是一件好事。 List <String>
和List <Integer>
共享一个Class对象,即List.class
。 如果“字面类型(type literals)”List <String> .class
和List <Integer> .class
合法并返回相同的对象引用,那么它会对Favorites对象的内部造成严重破坏。 对于这种限制,没有完全令人满意的解决方法。
Favorites使用的类型令牌( type tokens)是无限制的:getFavorite
和putFavorite
接受任何Class对象。 有时你可能需要限制可传递给方法的类型。 这可以通过一个有限定的类型令牌来实现,该令牌只是一个类型令牌,它使用限定的类型参数(条目 30)或限定的通配符(条目 31)来放置可以表示的类型的边界。
注解API(条目 39)广泛使用限定类型的令牌。 例如,以下是在运行时读取注解的方法。 此方法来自AnnotatedElement
接口,该接口由表示类,方法,属性和其他程序元素的反射类型实现:
1 | public <T extends Annotation> |
参数annotationType
是表示注解类型的限定类型令牌。 该方法返回该类型的元素的注解(如果它有一个);如果没有,则返回null。 本质上,注解元素是一个类型安全的异构容器,其键是注解类型。
假设有一个Class <?>
类型的对象,并且想要将它传递给需要限定类型令牌(如getAnnotation
)的方法。 可以将对象转换为Class<? extends Annotation>
,但是这个转换没有被检查,所以它会产生一个编译时警告(条目 27)。 幸运的是,Class类提供了一种安全(动态)执行这种类型转换的实例方法。 该方法被称为asSubclass
,并且它转换所调用的Class对象来表示由其参数表示的类的子类。 如果转换成功,该方法返回它的参数;如果失败,则抛出ClassCastException
异常。
以下是如何使用asSubclass
方法在编译时读取类型未知的注解。 此方法编译时没有错误或警告:
1 | // Use of asSubclass to safely cast to a bounded type token |
总之,泛型API的通常用法(以集合API为例)限制了每个容器的固定数量的类型参数。 你可以通过将类型参数放在键上而不是容器上来解决此限制。 可以使用Class对象作为此类型安全异构容器的键。 以这种方式使用的Class对象称为类型令牌。 也可以使用自定义键类型。 例如,可以有一个表示数据库行(容器)的DatabaseRow
类型和一个泛型类型Column <T>
作为其键。
34. 使用枚举类型替代整型常量
枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星或一副扑克牌中的套装。 在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为int的常量,每个类型的成员都有一个常量:
1 | // The int enum pattern - severely deficient! |
这种被称为int枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 如果你将一个Apple传递给一个需要Orange的方法,那么编译器不会出现警告,还会用==
运算符比较Apple与Orange,或者更糟糕的是:
1 | // Tasty citrus flavored applesauce! |
请注意,每个Apple常量的名称前缀为APPLE_
,每个Orange
常量的名称前缀为ORANGE_
。 这是因为Java不为int枚举组提供名称空间。 当两个int枚举组具有相同的命名常量时,前缀可以防止名称冲突,例如在ELEMENT_MERCURY
和PLANET_MERCURY
之间。
使用int枚举的程序很脆弱。 因为int枚举是编译时常量[JLS,4.12.4],所以它们的int值被编译到使用它们的客户端中[JLS,13.1]。 如果与int枚举关联的值发生更改,则必须重新编译其客户端。 如果没有,客户仍然会运行,但他们的行为将是不正确的。
没有简单的方法将int枚举常量转换为可打印的字符串。 如果你打印这样一个常量或者从调试器中显示出来,你看到的只是一个数字,这不是很有用。 没有可靠的方法来迭代组中的所有int枚举常量,甚至无法获得int枚举组的大小。
你可能会遇到这种模式的变体,其中使用了字符串常量来代替int常量。 这种称为字符串枚举模式的变体更不理想。 尽管它为常量提供了可打印的字符串,但它可以导致初级用户将字符串常量硬编码为客户端代码,而不是使用属性名称。 如果这种硬编码的字符串常量包含书写错误,它将在编译时逃脱检测并导致运行时出现错误。 此外,它可能会导致性能问题,因为它依赖于字符串比较。
幸运的是,Java提供了一种避免int和String枚举模式的所有缺点的替代方法,并提供了许多额外的好处。 它是枚举类型[JLS,8.9]。 以下是它最简单的形式:
1 | public enum Apple { FUJI, PIPPIN, GRANNY_SMITH } |
从表面上看,这些枚举类型可能看起来与其他语言类似,比如C,C ++和C#,但事实并非如此。 Java的枚举类型是完整的类,比其他语言中的其他语言更强大,其枚举本质本上是int值。
Java枚举类型背后的基本思想很简单:它们是通过公共静态final属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是final的。 由于客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第6页)。 它们是单例(条目 3)的泛型化,基本上是单元素的枚举。
枚举提供了编译时类型的安全性。 如果声明一个参数为Apple类型,则可以保证传递给该参数的任何非空对象引用是三个有效Apple值中的一个。 尝试传递错误类型的值将导致编译时错误,因为会尝试将一个枚举类型的表达式分配给另一个类型的变量,或者使用==
运算符来比较不同枚举类型的值。
具有相同名称常量的枚举类型可以和平共存,因为每种类型都有其自己的名称空间。 可以在枚举类型中添加或重新排序常量,而无需重新编译其客户端,因为导出常量的属性在枚举类型与其客户端之间提供了一层隔离:常量值不会编译到客户端,因为它们位于int枚举模式中。 最后,可以通过调用其toString
方法将枚举转换为可打印的字符串。
除了纠正int枚举的缺陷之外,枚举类型还允许添加任意方法和属性并实现任意接口。 它们提供了所有Object方法的高质量实现(第3章),它们实现了Comparable
(条目 14)和Serializable
(第12章),并针对枚举类型的可任意改变性设计了序列化方式。
那么,为什么你要添加方法或属性到一个枚举类型? 对于初学者,可能想要将数据与其常量关联起来。 例如,我们的Apple和Orange类型可能会从返回水果颜色的方法或返回水果图像的方法中受益。 还可以使用任何看起来合适的方法来增强枚举类型。 枚举类型可以作为枚举常量的简单集合,并随着时间的推移而演变为全功能抽象。
对于丰富的枚举类型的一个很好的例子,考虑我们太阳系的八颗行星。 每个行星都有质量和半径,从这两个属性可以计算出它的表面重力。 从而在给定物体的质量下,计算出一个物体在行星表面上的重量。 下面是这个枚举类型。 每个枚举常量之后的括号中的数字是传递给其构造方法的参数。 在这种情况下,它们是地球的质量和半径:
1 | // Enum type with data and behavior |
编写一个丰富的枚举类型比如Planet
很容易。 要将数据与枚举常量相关联,请声明实例属性并编写一个构造方法,构造方法带有数据并将数据保存在属性中。 枚举本质上是不变的,所以所有的属性都应该是final的(条目 17)。 属性可以是公开的,但最好将它们设置为私有并提供公共访问方法(条目16)。 在Planet
的情况下,构造方法还计算和存储表面重力,但这只是一种优化。 每当重力被SurfaceWeight
方法使用时,它可以从质量和半径重新计算出来,该方法返回它在由常数表示的行星上的重量。
虽然Planet
枚举很简单,但它的功能非常强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在所有八个行星上的重量(以相同单位):
1 | public class WeightTable { |
请注意,Planet
和所有枚举一样,都有一个静态values
方法,该方法以声明的顺序返回其值的数组。 另请注意,toString
方法返回每个枚举值的声明名称,使println
和printf
可以轻松打印。 如果你对此字符串表示形式不满意,可以通过重写toString
方法来更改它。 这是使用命令行参数185运行WeightTable
程序(不重写toString)的结果:
1 | Weight on MERCURY is 69.912739 |
直到2006年,在Java中加入枚举两年之后,冥王星不再是一颗行星。 这引发了一个问题:“当你从枚举类型中移除一个元素时会发生什么?”答案是,任何不引用移除元素的客户端程序都将继续正常工作。 所以,举例来说,我们的WeightTable
程序只需要打印一行少一行的表格。 什么是客户端程序引用删除的元素(在这种情况下,Planet.Pluto
)? 如果重新编译客户端程序,编译将会失败并在引用前一个星球的行处提供有用的错误消息; 如果无法重新编译客户端,它将在运行时从此行中引发有用的异常。 这是你所希望的最好的行为,远远好于你用int枚举模式得到的结果。
一些与枚举常量相关的行为只需要在定义枚举的类或包中使用。 这些行为最好以私有或包级私有方式实现。 然后每个常量携带一个隐藏的行为集合,允许包含枚举的类或包在与常量一起呈现时作出适当的反应。 与其他类一样,除非你有一个令人信服的理由将枚举方法暴露给它的客户端,否则将其声明为私有的,如果需要的话将其声明为包级私有(条目 15)。
如果一个枚举是广泛使用的,它应该是一个顶级类; 如果它的使用与特定的顶级类绑定,它应该是该顶级类的成员类(条目 24)。 例如,java.math.RoundingMode
枚举表示小数部分的舍入模式。 BigDecimal
类使用了这些舍入模式,但它们提供了一种有用的抽象,它并不与BigDecimal
有根本的联系。 通过将RoundingMode
设置为顶层枚举,类库设计人员鼓励任何需要舍入模式的程序员重用此枚举,从而提高跨API的一致性。
1 | // Enum type that switches on its own value - questionable |
此代码有效,但不是很漂亮。 如果没有throw
语句,就不能编译,因为该方法的结束在技术上是可达到的,尽管它永远不会被达到[JLS,14.21]。 更糟的是,代码很脆弱。 如果添加新的枚举常量,但忘记向switch语句添加相应的条件,枚举仍然会编译,但在尝试应用新操作时,它将在运行时失败。
幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply
方法,并用常量特定的类主体中的每个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的方法实现:
1 | // Enum type with constant-specific method implementations |
如果向第二个版本的操作添加新的常量,则不太可能会忘记提供apply
方法,因为该方法紧跟在每个常量声明之后。 万一忘记了,编译器会提醒你,因为枚举类型中的抽象方法必须被所有常量中的具体方法重写。
特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是Operation
的一个版本,它重写toString
方法以返回通常与该操作关联的符号:
1 | // Enum type with constant-specific class bodies and data |
显示的toString
实现可以很容易地打印算术表达式,正如这个小程序所展示的那样:
1 | public static void main(String[] args) { |
以2和4作为命令行参数运行此程序会生成以下输出:
1 | 2.000000 + 4.000000 = 6.000000 |
枚举类型具有自动生成的valueOf(String)
方法,该方法将常量名称转换为常量本身。 如果在枚举类型中重写toString
方法,请考虑编写fromString
方法将自定义字符串表示法转换回相应的枚举类型。 下面的代码(类型名称被适当地改变)将对任何枚举都有效,只要每个常量具有唯一的字符串表示形式:
1 | // Implementing a fromString method on an enum type |
请注意,Operation
枚举常量被放在stringToEnum
的map中,它来自于创建枚举常量后运行的静态属性初始化。前面的代码在values()
方法返回的数组上使用流(第7章);在Java 8之前,我们创建一个空的hashMap
并遍历值数组,将字符串到枚举映射插入到map中,如果愿意,仍然可以这样做。但请注意,尝试让每个常量都将自己放入来自其构造方法的map中不起作用。这会导致编译错误,这是好事,因为如果它是合法的,它会在运行时导致NullPointerException
。除了编译时常量属性(条目 34)之外,枚举构造方法不允许访问枚举的静态属性。此限制是必需的,因为静态属性在枚举构造方法运行时尚未初始化。这种限制的一个特例是枚举常量不能从构造方法中相互访问。
另请注意,fromString
方法返回一个Optional<String>
。 这允许该方法指示传入的字符串不代表有效的操作,并且强制客户端面对这种可能性(条目 55)。
特定于常量的方法实现的一个缺点是它们使得难以在枚举常量之间共享代码。 例如,考虑一个代表工资包中的工作天数的枚举。 该枚举有一个方法,根据工人的基本工资(每小时)和当天工作的分钟数计算当天工人的工资。 在五个工作日内,任何超过正常工作时间的工作都会产生加班费; 在两个周末的日子里,所有工作都会产生加班费。 使用switch语句,通过将多个case
标签应用于两个代码片段中的每一个,可以轻松完成此计算:
1 | // Enum that switches on its value to share code - questionable |
这段代码无可否认是简洁的,但从维护的角度来看是危险的。 假设你给枚举添加了一个元素,可能是一个特殊的值来表示一个假期,但忘记在switch语句中添加一个相应的case条件。 该程序仍然会编译,但付费方法会默默地为工作日支付相同数量的休假日,与普通工作日相同。
要使用特定于常量的方法实现安全地执行工资计算,必须为每个常量重复加班工资计算,或将计算移至两个辅助方法,一个用于工作日,另一个用于周末,并调用适当的辅助方法来自每个常量。 这两种方法都会产生相当数量的样板代码,大大降低了可读性并增加了出错机会。
通过使用执行加班计算的具体方法替换PayrollDay
上的抽象overtimePa
y方法,可以减少样板。 那么只有周末的日子必须重写该方法。 但是,这与switch语句具有相同的缺点:如果在不重写overtimePay
方法的情况下添加另一天,则会默默继承周日计算方式。
你真正想要的是每次添加枚举常量时被迫选择加班费策略。 幸运的是,有一个很好的方法来实现这一点。 这个想法是将加班费计算移入私有嵌套枚举中,并将此策略枚举的实例传递给PayrollDay
枚举的构造方法。 然后,PayrollDay
枚举将加班工资计算委托给策略枚举,从而无需在PayrollDay
中实现switch语句或特定于常量的方法实现。 虽然这种模式不如switch语句简洁,但它更安全,更灵活:
1 | // The strategy enum pattern |
如果对枚举的switch语句不是实现常量特定行为的好选择,那么它们有什么好处呢?枚举类型的switch有利于用常量特定的行为增加枚举类型。例如,假设Operation
枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。你可以用以下静态方法模拟效果:
1 | // Switch on an enum to simulate a missing method |
如果某个方法不属于枚举类型,则还应该在你控制的枚举类型上使用此技术。 该方法可能需要用于某些用途,但通常不足以用于列入枚举类型。
一般而言,枚举通常在性能上与int常数相当。 枚举的一个小小的性能缺点是加载和初始化枚举类型存在空间和时间成本,但在实践中不太可能引人注意。
那么你应该什么时候使用枚举呢? 任何时候使用枚举都需要一组常量,这些常量的成员在编译时已知。 当然,这包括“天然枚举类型”,如行星,星期几和棋子。 但是它也包含了其它你已经知道编译时所有可能值的集合,例如菜单上的选项,操作代码和命令行标志。** 一个枚举类型中的常量集不需要一直保持不变**。 枚举功能是专门设计用于允许二进制兼容的枚举类型的演变。
总之,枚举类型优于int常量的优点是令人信服的。 枚举更具可读性,更安全,更强大。 许多枚举不需要显式构造方法或成员,但其他人则可以通过将数据与每个常量关联并提供行为受此数据影响的方法而受益。 使用单一方法关联多个行为可以减少枚举。 在这种相对罕见的情况下,更喜欢使用常量特定的方法来枚举自己的值。 如果一些(但不是全部)枚举常量共享共同行为,请考虑策略枚举模式。
35. 使用实例属性替代序数
许多枚举通常与单个int值关联。所有枚举都有一个ordinal
方法,它返回每个枚举常量类型的数值位置。你可能想从序数中派生一个关联的int值:
1 | // Abuse of ordinal to derive an associated value - DON'T DO THIS |
虽然这个枚举能正常工作,但对于维护来说则是一场噩梦。如果常量被重新排序,numberOfMusicians
方法将会中断。 如果你想添加一个与你已经使用的int值相关的第二个枚举常量,则没有那么好运了。 例如,为双四重奏(double quartet)添加一个常量可能会很好,它就像八重奏一样,由8位演奏家组成,但是没有办法做到这一点。
此外,如果没有给所有这些int值添加常量,也不能为某个int值添加一个常量。例如,假设你想要添加一个常量,表示一个由12位演奏家组成的三重四重奏(triple quartet)。对于由11个演奏家组成的合奏曲,并没有标准的术语,因此你不得不为未使用的int值(11)添加一个虚拟常量(dummy constant)。最多看起来就是有些不好看。如果许多int值是未使用的,则是不切实际的。
幸运的是,这些问题有一个简单的解决方案。 永远不要从枚举的序号中得出与它相关的值; 请将其保存在实例属性中:
1 | public enum Ensemble { |
枚举规范对此ordinal
方法说道:“大多数程序员对这种方法没有用处。 它被设计用于基于枚举的通用数据结构,如EnumSet
和EnumMap
。“除非你在编写这样数据结构的代码,否则最好避免使用ordinal
方法。
36. 使用EnumSet替代位属性
如果枚举类型的元素主要用于集合中,一般来说使用int枚举模式(条目 34),下面将2的不同倍数赋值给每个常量:
1 | // Bit field enumeration constants - OBSOLETE! |
这种表示方式允许你使用按位或(or)运算将几个常量合并到一个称为位属性(bit field)的集合中:
1 | text.applyStyles(STYLE_BOLD | STYLE_ITALIC); |
位属性表示还允许你使用按位算术有效地执行集合运算,如并集和交集。 但是位属性具有int枚举常量等的所有缺点。 当打印为数字时,解释位属性比简单的int枚举常量更难理解。 没有简单的方法遍历所有由位属性表示的元素。 最后,必须预测在编写API时需要的最大位数,并相应地为位属性(通常为int或long)选择一种类型。 一旦你选择了一个类型,你就不能超过它的宽度(32或64位)而不改变API。
一些程序员使用枚举优于int常量,当他们需要传递常量集合时仍然使用位属性。 没有理由这样做,因为存在更好的选择。 java.util包提供了EnumSet
类来有效地表示从单个枚举类型中提取的值集合。 这个类实现了Set接口,提供了所有其他Set实现的丰富性,类型安全性和互操作性。 但是在内部,每个EnumSet都表示为一个位矢量(bit vector)。 如果底层的枚举类型有64个或更少的元素,并且大多数情况下,整个EnumSet
用单个long表示,所以它的性能与位属性的性能相当。 批量操作(如removeAll和retainAll)是使用按位算术实现的,就像你为位属性手动操作一样。 但是完全避免了手动位混乱的丑陋和错误倾向:EnumSet
为你做了很大的努力。
下面是前一个使用枚举和枚举集合替代位属性的示例。 它更短,更清晰,更安全:
1 | // EnumSet - a modern replacement for bit fields |
这里是将EnumSet
实例传递给applyStyles方法的客户端代码。 EnumSet
类提供了一组丰富的静态工厂,可以轻松创建集合,其中一个代码如下所示:
1 | text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC)); |
请注意,applyStyles
方法采用Set<Style>
而不是EnumSet<Style>
参数。 尽管所有客户端都可能会将EnumSet
传递给该方法,但接受接口类型而不是实现类型通常是很好的做法(条目 64)。 这允许一个不寻常的客户端通过其他Set实现的可能性。
总之,仅仅因为枚举类型将被用于集合中,所以没有理由用位属性来表示它。 EnumSet
类将位属性的简洁性和性能与条目 34中所述的枚举类型的所有优点相结合。EnumSet
的一个真正缺点是,它不像Java 9那样创建一个不可变的EnumSet
,但是在即将发布的版本中可能会得到补救。 同时,你可以用Collections.unmodifiableSet
封装一个EnumSet
,但是简洁性和性能会受到影响。
37. 使用EnumMap替代序数索引
有时可能会看到使用ordinal
方法(条目 35)来索引到数组或列表的代码。 例如,考虑一下这个简单的类来代表一种植物:
1 | class Plant { |
现在假设你有一组植物代表一个花园,想要列出这些由生命周期组织的植物(一年生,多年生,或双年生)。为此,需要构建三个集合,每个生命周期作为一个,并遍历整个花园,将每个植物放置在适当的集合中。一些程序员可以通过将这些集合放入一个由生命周期序数索引的数组中来实现这一点:
1 | // Using ordinal() to index into an array - DON'T DO THIS! |
这种方法是有效的,但充满了问题。 因为数组不兼容泛型(条目 28),程序需要一个未经检查的转换,并且不会干净地编译。 由于该数组不知道索引代表什么,因此必须手动标记索引输出。 但是这种技术最严重的问题是,当你访问一个由枚举序数索引的数组时,你有责任使用正确的int值; int不提供枚举的类型安全性。 如果你使用了错误的值,程序会默默地做错误的事情,如果你幸运的话,抛出一个ArrayIndexOutOfBoundsException
异常。
有一个更好的方法来达到同样的效果。 该数组有效地用作从枚举到值的映射,因此不妨使用Map。 更具体地说,有一个非常快速的Map实现,设计用于枚举键,称为java.util.EnumMap
。 下面是当程序重写为使用EnumMap
时的样子:
1 | // Using an EnumMap to associate data with an enum |
这段程序更简短,更清晰,更安全,运行速度与原始版本相当。 没有不安全的转换; 无需手动标记输出,因为map键是知道如何将自己转换为可打印字符串的枚举; 并且不可能在计算数组索引时出错。 EnumMap与序数索引数组的速度相当,其原因是EnumMap
内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节,将Map的丰富性和类型安全性与数组的速度相结合。 请注意,EnumMap
构造方法接受键类型的Class对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息(条目 33)。
通过使用stream(条目 45)来管理Map,可以进一步缩短以前的程序。 以下是最简单的基于stream的代码,它们在很大程度上重复了前面示例的行为:
1 | // Naive stream-based approach - unlikely to produce an EnumMap! |
这个代码的问题在于它选择了自己的Map实现,实际上它不是EnumMap
,所以它不会与显式EnumMap
的版本的空间和时间性能相匹配。 为了解决这个问题,使用Collectors.groupingBy
的三个参数形式的方法,它允许调用者使用mapFactory
参数指定map的实现:
1 | // Using a stream and an EnumMap to associate data with an enum |
这样的优化在像这样的示例程序中是不值得的,但是在大量使用Map的程序中可能是至关重要的。
基于stream版本的行为与EmumMap
版本的行为略有不同。 EnumMap版本总是为每个工厂生命周期生成一个嵌套map类,而如果花园包含一个或多个具有该生命周期的植物时,则基于流的版本才会生成嵌套map类。 因此,例如,如果花园包含一年生和多年生植物但没有两年生的植物,plantByLifeCycle
的大小在EnumMap
版本中为三个,在两个基于流的版本中为两个。
你可能会看到数组索引(两次)的数组,用序数来表示从两个枚举值的映射。例如,这个程序使用这样一个数组来映射两个阶段到一个阶段转换(phase transition)(液体到固体表示凝固,液体到气体表示沸腾等等):
1 | // Using ordinal() to index array of arrays - DON'T DO THIS! |
这段程序可以运行,甚至可能显得优雅,但外观可能是骗人的。 就像前面显示的简单的花园示例一样,编译器无法知道序数和数组索引之间的关系。 如果在转换表中出错或者在修改Phase
或Phase.Transition
枚举类型时忘记更新它,则程序在运行时将失败。 失败可能是ArrayIndexOutOfBoundsException
,NullPointerException
或(更糟糕的)沉默无提示的错误行为。 即使非空条目的数量较小,表格的大小也是phase的个数的平方。
同样,可以用EnumMap
做得更好。 因为每个阶段转换都由一对阶段枚举来索引,所以最好将关系表示为从一个枚举(from 阶段)到第二个枚举(to阶段)到结果(阶段转换)的map。 与阶段转换相关的两个阶段最好通过将它们与阶段转换枚举相关联来捕获,然后可以用它来初始化嵌套的EnumMap
:
1 | // Using a nested EnumMap to associate data with enum pairs |
初始化阶段转换的map的代码有点复杂。map的类型是Map<Phase, Map<Phase, Transition>>
,意思是“从(源)阶段映射到从(目标)阶段到阶段转换映射。”这个map的map使用两个收集器的级联序列进行初始化。 第一个收集器按源阶段对转换进行分组,第二个收集器使用从目标阶段到转换的映射创建一个EnumMap
。 第二个收集器((x, y) -> y))
中的合并方法未使用;仅仅因为我们需要指定一个map工厂才能获得一个EnumMap,并且Collectors
提供伸缩式工厂,这是必需的。 本书的前一版使用显式迭代来初始化阶段转换map。 代码更详细,但可以更容易理解。
现在假设想为系统添加一个新阶段:等离子体或电离气体。 这个阶段只有两个转变:电离,将气体转化为等离子体; 和去离子,将等离子体转化为气体。 要更新基于数组的程序,必须将一个新的常量添加到Phase,将两个两次添加到Phase.Transition,并用新的十六个元素版本替换原始的九元素阵列数组。 如果向数组中添加太多或太少的元素或者将元素乱序放置,那么如果运气不佳:程序将会编译,但在运行时会失败。 要更新基于EnumMap
的版本,只需将PLASMA
添加到阶段列表中,并将IONIZE(GAS, PLASMA)
和DEIONIZE(PLASMA, GAS)
添加到阶段转换列表中:
1 | // Adding a new phase using the nested EnumMap implementation |
该程序会处理所有其他事情,并且几乎不会出现错误。 在内部,map的map是通过数组的数组实现的,因此在空间或时间上花费很少,以增加清晰度,安全性和易于维护。
为了简便起见,上面的示例使用null来表示状态更改的缺失(其从目标到源都是相同的)。这不是很好的实践,很可能在运行时导致NullPointerException
。为这个问题设计一个干净、优雅的解决方案是非常棘手的,而且结果程序足够长,以至于它们会偏离这个条目的主要内容。
总之,使用序数来索引数组很不合适:改用EnumMap。 如果你所代表的关系是多维的,请使用EnumMap <...,EnumMap <... >>
。 应用程序员应该很少使用Enum.ordinal
(条目 35),如果使用了,也是一般原则的特例。