Skip to content

Latest commit

 

History

History
173 lines (127 loc) · 10.6 KB

第37条:用EnumMap代替序数索引 b06a5801426749bf8fa6b20f7041d107.md

File metadata and controls

173 lines (127 loc) · 10.6 KB

第37条:用EnumMap代替序数索引

有时你可能会见到利用ordinal方法来索引数组或列表。例如下面这个超级简化的类,用来表示一种烹饪用的香草:

public 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;
    }
}

现在假设有一个香草数组,表示已座花园中的植物,你想要按照类型(一年生、多年生或者两年生)进行植物组织之后将这些植物列出来。如果要这么做,需要构建三个集合,每种类型一个,并且遍历整座花园,将每种香草放到相应的集合中。有些程序员会将这些集合放到一个按照类型的序数进行索引的数组中实现这一点:

Set<Plant>[] plantByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantByLifeCycle.length; i++){
    plantByLifeCycle[i] = new HashSet<>();
}

for (Plant p: garden){
    plantByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

for(int i = 0; i < plantByLifeCycle.length; i++){
    System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i],plantByLifeCycle[i]);
}

这种方法却是可行,但是隐藏着许多问题。因为数组不能与泛型兼容。程序需要进行未受检的转换,并且不能正确无误的进行编译。因为数组不知道它的索引代表着什么,你必须手工标注这些索引的输出。但是这种方法最严重的问题在于,当你访问一个按照类型枚举的序数进行索引的数组时,使用正确的int值就是你的职责了;int不能提供枚举的类型安全。你如果使用了错误的值,程序就会悄悄地完成错误地工作,或者幸运地话,抛出ArrayIndexOutOfException异常。

有一种更好的方法可以达到同样的效果。数组实际上充当着从枚举到值得映射,因此可能还要用到Map。更具体地说,有一种非常快速的Map实现专门用于枚举键,称作java.util.EnumMap。以下就是用EnumMap改写后的程序:

Map<Plant.LifeCycle, Set<Plant>> plantByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);

for (Plant.LifeCycle lc : Plant.LifeCycle.values()){
    plantByLifeCycle.put(lc, new HashSet<>());
}

for (Plant p: garden)
    plantByLifeCycle.get(p.lifeCycle).add(p);

System.out.println(plantByLifeCycle);

这段程序更简短、更清楚,也更加安全,运行速度方面可以与使用序数的程序相媲美。它没有不安全的转换;不必手工标注这些索引的输出,因为映射键知道如何将自身翻译成可打印字符串的枚举;计算数组索引时也不可能出错。EnumMap在运行速度方面之所以能与通过序数索引的数组相媲美,正是因为EnumMap在内部使用了这种数组。但是它对程序员隐藏了这种实现细节,集Map的丰富功能和类型安全的数组于一身。注意EnumMap构造器采用了键类型的Class对象:这是一个有限制的类型令牌,它提供了运行时的泛型信息。

上一段程序可能用stream管理映射要简短的多。下面是基于stream的最简单的代码,大量复制了上一个示例的行为:

System.out.println(Arrays.stream(garden).collect(groupingBy(p->p.lifeCycle)));

这段代码的问题在于它选择了自己的映射实现,实际上不会是一个EnumMap,因此与显示EnumMap版本的空间及时间性能不吻合,要使用有三种参数形式的collectors.groupingBy方法,它允许调用者利用mapFactory参数定义映射实现:

System.out.println(Arrays.stream(garden).collect(
									groupingBy(p->p.lifeCycle, 
													()-> new EnumMap<>(LifeCycle.class), toSet())));

在这样一个玩具中不值得进行优化,但是大量使用映射的程序中就很重要了。

基于stream的代码版本的行为与EnumMap版本的稍有不同。EnumMap版本总是给每一个植物生命周期都设计一个嵌套映射,基于stream的版本则仅当花园中包含了一种或多种植物带有该声明周期时才会设计一个嵌套映射。因此,假如花园中包含了一年生或者多年生植物,但没有两年生植物,plantByLifeCycle的数量在EnumMap版本中应该是三种,在基于stream的版本中则都是两种。

你可能还见到过按照序数进行索引(两次)的数组的数组,该序数表示两个枚举值的映射,例如,下面这个程序就是使用这样一个数组将两个阶段映射到一个阶段过度中(从液体到固体称作为凝固,从液体到气体称作为沸腾,诸如此类):

public enum Phase {
    SOLID, LIQUID,GAS;
    
    public enum Transition{
        MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
        
        private static final Transition[][] TRANSITIONS = {
                {null, MELT, SUBLIME},
                {FREEZE, null, BOIL},
                {DEPOSIT, CONDENSE, null}
        };
        
        public static Transition from(Phase from, Phase to){
            return TRANSITIONS[from.ordinal()][to.ordinal()];
        }
    }
}

这段程序可行,看起来也比较优雅,但是事实并非如此。就像上面那个比较的香草圆的示例一样,编译器无法知道序数和数组索引之间的关系。如果在过度表中出错了,或者修改Phase或者Phase.Transition枚举类型的时候忘记将它更新,程序就会在运行时失败。这种失败的形式可能是ArrayIndexOutOfBoundExecptionNullPointerException或者(更糟糕的是)没有任何提示的错误行为。这张表的大小是阶段数的平方,即使非空项的数量比较少。

同样,利用EnumMap依然可以做得更好一些。因为每个阶段过度都是通过一对枚举仅需索引的,最好将这种关系表示为一个映射,这个映射的键是一个枚举(起始阶段),值为另一个映射,这第二个映射的键为第二个枚举(目标阶段),它的值为结果(过渡阶段),即形成了Map(起始阶段,Map(目标阶段,阶段过度))这种形式。一个阶段过度所关联的两个阶段,最好通过“数据与阶段过度枚举之间的关系”来获取,之后用该阶段过度枚举来初始化嵌套的EnumMap

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;
        }

        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<Phase, Map<Phase, Transition>>,表示由键为源Phase(即第一个阶段)、值得另一个映射组成的Map,其中组成值得Map是由键值对得目标Phase(即第二个阶段)和Transition组成。这个映射的映射是利用两个集合的级联顺序进行初始化的。第一个集合按源Phase对过度进行分组,第二个集合利用目标Phase到过度之间的映射创建一个EnumMap。第二个集合章的merge函数((x,y)→ Y)没有用到;只有当我们因为想要获得一个EnumMap而定义映射工厂时才需要它,同时Collectors提供了重叠工厂。

现在假设想要给系统添加一个新的阶段:plasma(离子)或者电离气体。只有两个过度与这个阶段关联的:电离化,它将气体变成离子;以及消电离化将离子变成气体。为了更新基于数组的程序,必须给Phase添加一种变量,给PhaseTransistion添加两种新的常量,用一种新的16个元素的版本取代原来9个元素的数组的数组。如果给数组添加的元素过多或者过少,或者元素放置不妥当,可就麻烦了:程序可以编译,但是会在运行时失败。为了更新基于EnumMap的版本,所要做的就是必须将PLASMA添加到Phase列表,并将IONIZE(GAS,PLASMA)DEIONIZE(PLASMA,GAS)添加到Transition 的列表中:

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);**

        private final Phase from;
        private final Phase to;

        Transition(Phase from, Phase to){
            this.from = from;
            this.to = to;
        }

        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);
        }
    }
}

程序会自行处理所有其他的事情,这样就几乎没有出错的可能。从内部看,映射的映射被实现成了数组,因此在提升了清晰性、安全性和以维护性的同时,在空间或者时间上也几乎没有多余的开销。

为了简洁起见,上述范例使用null表明状态没有变化(这里to和from是相等的)。这并不是好的实践,可能在运行时导致NullPointerExpection异常。要给这个问题设计一个整洁、优化的解决方案,需要超高的技巧,得到的程序会很长。

总而言之,最好不要用序数来索引数组,而要使用EnumMap。如果你所表示的这种关系是多维的,就使用EnumMap<..., EnumMap<...>>。应用程序的程序员在一般情况下都不使用Enum.ordinal方法,仅仅在极少数情况下才会使用,因此这是一种特殊情况。