Effective Java 枚舉和註解 第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 枚舉類型的時候忘記將它更新,程序就會在運行時失敗。

這種失敗的形式可能爲 ArrayIndexOutOfBoundsException 、 NullPointerException 或者(更糟糕的是)沒有任何提示的錯誤行爲。這張表的大小是階段個數的平方,即使非 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 列表,並將 IONIZE ( GAS , PLASMA )和 DEIONIZE ( PLASMA , GAS )添加到 Phase.Transition 的列表中。程序會自行處理所有其他的事情,你幾乎沒有機會出錯。從內部來看, Map 的 Map 被實現成了數組的數組,因此在提升了清楚性、安全性和易維護性的同時,在空間或者時間上還幾乎不用任何開銷。

總而言之,最好不要用序數來索引數組,而要使用 EnumMap 。如果你所表示的這種關係是多維的,就使用 EnumMap<…, EnumMap<…>> 。應用程序的程序員在一般情況下都不使用 Enum.ordinal ,即使要用也很少,因此這是一種特殊情況(見第31條)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章