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

有时候,你可能会见到利用ordinal方法(见第31条)来索引数组的代码。例如下面这个过于简化的类,用来表示一种烹饪用的香草:

public class Hurb {
    public enum Type { ANNUAL, PERENNIAL, BIENNIAL }

    private final String name,
    private final Type type;

    Herb(String name, Type type) {
        this.name = name;
        this.type = type;
    }

    @Override public String toString() {
        retuan name;
    }
}

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

// Using ordinal() to index an array - DON'T DO THIS!
Herb[] garden = ...;

Set<Herb>[] herbsByType = // Indexed by Herb.type.ordinal()
    (Set<Herb>[]) new Set[Herb.Type.values().lenght];
for(int i = 0; i < herbsByType.length; i++)
    herbsByType[i] = new HashSet<Herb>();

for(Herb h : garden)
    herbsByTyp[h.type.ordinal()].add(h);

// Print the results
for(int i = 0; i < herbsByType.length; i++) {
    System.out.printf("%s: %s%n",
                      Herb.Type.values()[i], herbsByType[i]) ;
}

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

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

// Using an EnumMap to associate data with an enum
Map<Herb.Type, Set<Herb>> herbsByType = 
    new EnumMap<Herb.Type, Set<Herb>>(Herb.Type.class);
for (Herb.Type t : Herb.Type.values())
    herbsByType.put(t, new HashSet<Herb>());
for (Herb h : garden)
    herbsByType.get(h.type).add(h);
System.out.println(herbsByType);

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

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

// 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 src-ordinal, cols by dst-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 src, Phase dst) {
            return TRANSITIONS[src.ordinal()][dst.ordinal()];
        }
    }
}

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

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

// 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 src;
        private final Phase dst;

        Transition(Phase src, Phase dst) {
            this.src = src;
            this.dst = dst;
        }
        // Initialize the phase transition map
        private static final Map<Phase, Map<Phase, Transition>> m =
          new EnumMap<Phase, Map<Phase, Transition>>(Phase.class);
        static {
            for (Phase p : Phase.values()) 
                m.put(p, new EnumMap<Phase, Transition>(Phase.class));
            for (Transition trans : Transition.value())
                m.get(trans.src).put(trans.dst, trans);
        }

        public static Transition from(Phase src, Phase dst) {
            return m.get(src).get(dst);
        }  
    }
}

初始化阶段过渡map的代码看起来可能有点复杂,但是还不算太糟糕。map的类型为Map<Phase, Map<Phase, Transition>>,表示是由键为源Phase(即第一个Phase)、值为另一个map组成的Map,其中组成值的Map是由键值对目标Phase(即第二个Phase)、Transition组成的。静态初始化代码块中的第一个循环初始化了外部map,得到了三个空的内容map。代码块中的第二个循环利用每个状态过渡常量提供的起始信息和目标信息初始化了内部map

现在假设想要给系统添加一个新的阶段:plasma(离子)或者电离气体。只有两个过渡与这个阶段关联:电离化,它将气体变成离子;以及消电离化,将离子变成气体。为了更新基于数组的程序,必须给Phase添加一种新常量,给Phase.Transition添加两种新常量,用一种新的 16 个元素的版本取代原来 9 个元素的数组的数组。如果给数组添加的元素过多或者过少,或者元素放置不妥当,可就麻烦了:程序可以编译,但是会在运行时失败。为了更新基于EnumMap的版本,所要做的就是必须将PLASMA添加到Phase列表,并将IONIZEGASPLASMA)和DEIONIZEPLASMAGAS)添加到Phase.Transition的列表中。程序会自行处理所有其他的事情,你几乎没有机会出错。从内部来看,MapMap被实现成了数组的数组,因此在提升了清楚性、安全性和易维护性的同时,在空间或者时间上还几乎不用任何开销。

总而言之,最好不要用序数来索引数组,而要使用EnumMap。如果你所表示的这种关系是多维的,就使用EnumMap<..., EnumMap<...>>。应用程序的程序员在一般情况下都不使用Enum.ordinal,即使要用也很少,因此这是一种特殊情况(见第31条)。

results matching ""

    No results matching ""