ordinal 인덱싱 대신 EnumMap을 사용하라

oridinal 메서드

모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal 메서드를 제공한다.

ordinal 잘못 사용한 예

public enum Ensemble {
    SOLO, DUET, TRIO, QUARTET, QUINTET,
    SEXTET, SEPTET, OCTET, NONET, DECTET;

    public int numberOfMusicians() {return ordinal() + 1;}
}

상수 선언 순서를 바꾸는 순간 오동작하며, 이미 사용 중인 정수와 값이 같은 상수는 추가할 방법이 없다.

oridinal 메서드 대신 인스턴스 필드를 사용

public enum Ensemble {
    SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
    SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
    NONET(9), DECTET(10), TRIPLE_QUARTET(12);

    private final int numberOfMusicians;
    Ensemble(int size) { this.numberOfMusicians = size; }
    public int numberOfMusicians() { return numberOfMusicians; }
}

ordinal 메서드는 EnumSet과 EnumMap 같이 열거 타입기반의 범용 자료구조에 쓸 목적으로 설계되었다. 따라서 이런 용도가 아니라면 사용하지 말자.

ordinal 인덱싱과 EnumMap

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

ordinal()을 배열 인덱스로 사용한 잘못된 예

    Set<Plant>[] plantsByLifeCycleArr =
            (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
    for (int i = 0; i < plantsByLifeCycleArr.length; i++)
        plantsByLifeCycleArr[i] = new HashSet<>();
    for (Plant p : garden)
        plantsByLifeCycleArr[p.lifeCycle.ordinal()].add(p);

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

동작은 하지만 문제가 많다. 배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다. 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다. 가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다는 점이다. 정수는 열거 타입과 달리 타입 안전하지 않기 때문이다.

EnumMap을 사용한 데이터와 열거타입 매핑

EnumMap은 열거 타입을 키로 사용하도록 구현되었다.

    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 사용하지 않은 코드

Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle))

EnumMap이 아닌 고유한 맵 구현체를 사용하면 공간과 성능상의 이점이 사라진다. 단순한 프로그램에서는 최적화가 굳이 필요 없지만, 맵을 빈번히 사용하는 프로그램에서는 꼭 필요할 것이다.

EnumMap 사용한 코드

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

스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다. EnumMap 버전은 언제나 p.lifeCycle 마다 하나씩 중첩 맵을 만들지만, 스트림 버전에서는 해당 lifeCycle에 속하는 원소가 있을 때만 만든다. EnumMap 버전에서는 맵을 3개 만들 수 있고, 스트림 버전에서는 2개만 만들 수 있다.

중첩 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);

//        EnumMap 버전에 새로운 상태 추가
//        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);
        }
    }

    public static void main(String[] args) {
        for (Phase src : Phase.values()) {
            for (Phase dst : Phase.values()) {
                Transition transition = Transition.from(src, dst);
                if (transition != null)
                    System.out.printf("%s에서 %s로 : %s %n", src, dst, transition);
            }
        }
    }
}

맵 2개를 중첩하여 안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결한다. 전이 전후의 두 상태를 전이 열거 타입 Transition의 입력으로 받아, 이 Transition 상수들로 중첩된 EnumMap을 초기화한다.


배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라. 다차원 관계는 EnumMap<..., EnumMap<...>>으로 표현하라. 애플리케이션 프로그래머는 Enum.ordinal을 웬만하면 사용하지 말아야 한다.

Comments