ITEM 37: USE ENUMMAP INSTEAD OF ORDINAL INDEXING
偶尔您可能会看到使用序号方法(item 35)索引数组或列表的代码。例如,考虑这个表示植物的简单类:
class Plant {
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
final String name;
final LifeCycle lifeCycle;
Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override public String toString() { return name;}
}
现在假设您有一个代表花园的植物数组,您想要列出这些按生命周期(一年生、多年生或两年生)组织的植物。要做到这一点,需要构造三个集合,每个生命周期对应一个集合,然后遍历花园,将每棵植物放入相应的集合中。
// Using ordinal() to index into an array - DON'T DO THIS!
Set<Plant>[] plantsByLifeCycle =(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++)
plantsByLifeCycle[i] = new HashSet<>();
for (Plant p : garden)
plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
// Print the results
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
这种方法是有效的,但也存在很多问题。由于数组与泛型不兼容(item 28),程序需要进行强制转换,因此无法干净地编译。因为数组不知道它的索引表示什么,所以必须手动标记输出。但是这种实现最严重的问题是,当您访问由枚举序号索引的数组时,您有责任使用正确的 int 值;int 不能提供枚举的类型安全性。如果您使用了错误的值,程序将无声地执行错误的操作 — 如果幸运的话 — 抛出 ArrayIndexOutOfBoundsException。
有一个更好的方法可以达到同样的效果。数组是一种效率很高的从枚举到值映射的实现方式,这里我们不妨使用 Map。更具体地说,使用一个非常快速的 Map 实现,它被设计用于 enum 键,称为 java.util.EnumMap。下面是程序被重写为使用 EnumMap 时的样子:
// Using an EnumMap to associate data with an enum
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle);
这个程序比原来的版本更短、更清晰、更安全、速度也很接近。没有不安全的类型转换,不需要手动标记输出,因为映射键知道如何将自己转换为可打印字符串的枚举;在计算数组索引时不可能有误差。EnumMap 在速度上与序号索引数组相当的原因是 EnumMap 在内部使用数组实现,但是它向程序员隐藏了这个实现细节,将映射的丰富性和类型安全性与数组的速度结合起来。注意,EnumMap 构造函数接受 key 类型的类对象:这是一个有界类型令牌,它提供了运行时泛型类型信息(item 33)。
通过使用流(item 45)管理映射,可以进一步缩短前面的程序。下面是最简单的基于流的代码,它在很大程度上复制了前面例子的行为:
// Naive stream-based approach - unlikely to produce an EnumMap!
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle)));
这段代码的问题在于,它选择了自己的 map 实现,而实际上它不是 EnumMap,所以它不会将版本的空间和时间性能与显式 EnumMap 匹配。为了纠正这个问题,可以使用收集器的三参数形式 Collectors.groupingBy,它允许调用者使用 mapFactory 参数指定 map 实现:
// Using a stream and an EnumMap to associate data with an enum
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,() -> new EnumMap<>(LifeCycle.class), toSet())));
这种优化在像这样的玩具程序中不值得做,但在大量使用地图的程序中可能是关键的。
基于流的版本的行为与 EmumMap 版本略有不同。EnumMap 版本总是为每个植物生命周期生成一个嵌套映射,而基于流的版本只在花园包含一个或多个具有该生命周期的植物时才生成嵌套映射。例如,如果花园包含一年生植物和多年生植物,但是没有两年生植物,那么plantsByLifeCycle 的大小在 EnumMap 版本中是3,在基于流的版本中都是2。
您可能会看到由序号索引的数组(两次!)组成的数组,用于表示两个枚举值之间的映射。例如,这个程序使用这样一个数组来映射两个相到一个相变(液体到固体是冻结的,液体到气体是沸腾的,等等):
// Using ordinal() to index array of arrays - DON'T DO THIS!
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
// Rows indexed by from-ordinal, cols by to-ordinal
private static final Transition[][] TRANSITIONS = {{ null, MELT, SUBLIME },
{ FREEZE, null, BOIL },
{ DEPOSIT, CONDENSE, null }};
// Returns the phase transition from one phase to another
public static Transition from(Phase from, Phase to) {
return TRANSITIONS[from.ordinal()][to.ordinal()];
}
}
}
这个程序可以工作,甚至可能看起来很优雅,但外表可能具有欺骗性。与前面展示的更简单的花园示例一样,编译器无法知道序号和数组索引之间的关系。如果您在转换表中出错,或者在修改阶段或阶段时忘记更新它。转换枚举类型,您的程序将在运行时失败。失败可能是ArrayIndexOutOfBoundsException、一个NPE 或(更糟的)静默错误行为。表的大小在相位数上是二次的,即使非空项的个数更小。
同样,使用 EnumMap 可以做得更好。因为每个阶段转换都由一对阶段枚举索引,所以最好将关系表示为从一个枚举(“from”阶段)到第二个枚举(“to”阶段)到结果(“相变”)的映射。与相变相关联的两个阶段最好通过将它们与相变枚举相关联来捕获,然后可以使用相变枚举初始化嵌套枚举映射:
// Using a nested EnumMap to associate data with enum pairs
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT(SOLID, LIQUID),
FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS),
CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS),
DEPOSIT(GAS, SOLID);
private final Phase from; private final Phase to;
Transition(Phase from, Phase to) {
this.from = from;
this.to = to;
}
// Initialize the phase transition map
private static final Map<Phase, Map<Phase, Transition>> m = Stream.of(values())
.collect(groupingBy(t -> t.from, () -> new EnumMap<>(Phase.class), toMap(t -> t.to, t -> t,
(x, y) -> y, () -> new EnumMap<>(Phase.class))));
public static Transition from(Phase from, Phase to) {
return m.get(from).get(to);
}
}
}
初始化相变映射的代码有点复杂。map的类型是 Map<Phase, Map<Phase, Transition>>,意思是“从(源)阶段映射到(目标)阶段到产品化阶段的映射”。这个map-of-maps是使用两个收集器的级联序列初始化的。第一个收集器按源阶段对转换进行分组,第二个收集器使用从目标阶段到转换的映射创建 EnumMap。第二个收集器 ((x, y) -> y) 中的 merge 函数未使用;之所以需要它,只是因为我们需要指定一个 map 工厂来获得 EnumMap,而收集器提供了可伸缩的工厂。本书的前一版本使用显式迭代初始化阶段转换映射。代码更冗长,但可能更容易理解。
现在假设你想给系统添加一个新的相:等离子体,或者电离气体。这个阶段只有两个转变:电离,它把气体带到等离子体;去离子化,把等离子体变成气体。要更新基于数组的程序,必须向Phase 添加一个新常量,并向 Transition 添加两个新常量。并使用新的16个元素版本替换原来的9个元素数组。如果向数组中添加过多或过少的元素,或者将某个元素打乱了顺序,那么您就不走运了:程序能编译通过,但在运行时将失败。要更新基于 enummap 的版本,只需将等离子体添加到相列表中,并将电离(气体,等离子体)和去电离(等离子体,气体)添加到相变列表中:
// Adding a new phase using the nested EnumMap implementation
public enum Phase {
SOLID, LIQUID, GAS, PLASMA;
public enum Transition {
MELT(SOLID, LIQUID),
FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS),
CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS),
DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA),
DEIONIZE(PLASMA, GAS); ... // Remainder unchanged
}
}
程序会处理所有其他的事情,不会给您留下任何出错的机会。在内部,map的map是用数组实现的,因此您只需花费很少的空间或时间来增加清晰度、安全性和易于维护。为了简单起见,上面的示例使用 null 表示不存在状态更改(其中to和from是相同的)。这不是很好的实践,可能会在运行时导致 NPE。为这个问题设计一个干净、优雅的解决方案是非常棘手的,并且产生的程序足够长,以至于会偏离本项目中的主要内容。
总之,很少适合使用序数来索引数组:而是使用EnumMap。如果要表示的关系是多维关系,请使用 EnumMap<…,EnumMap <…> >。使用 Enum.ordinal(item 35)是一种比较少见的特例。
网友评论