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

2023. 12. 13. 22:13Book/이펙티브 자바

배열이나 리스트에서 원소를 꺼낼 대 ordinal 메서드로 인덱스를 얻는 코드가 있다.

 

식물을 간단히 나타낸 다음 클래스가 있다.

public class Plant {
    enum LifeCycle {ANNUAL, PERENNIAL, BIENNIAL}

    final String name;
    final LifeCycle lifeCycle;

    public Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

    @Override
    public String toString() {
        return "Plant{" +
                "name='" + name + '\'' +
                ", lifeCycle=" + lifeCycle +
                '}';
    }
}

 

정원에 심은 식물들을 배열 하나로 관리하고, 생애주기별로 묶어보자. 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣는다. 이때 배열의 인덱스로 ordinal 값을 넣으려 할 것이다.

 

public static void main(String[] args) {

    List<Plant> gargen = List.of(
            new Plant("하루살이", Plant.LifeCycle.BIENNIAL),
            new Plant("두루살이", Plant.LifeCycle.BIENNIAL)
    );

    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 : gargen) {
        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]);
    }
}
=>
ANNUAL: []
PERENNIAL: []
BIENNIAL: [Plant{name='두루살이', lifeCycle=BIENNIAL}, Plant{name='하루살이', lifeCycle=BIENNIAL}]

 

동작은 하지만 문제가 있다.

1. 배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 한다.

2. 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.

3. 정확한 정숫값을 사용한다는 것을 보증해야 한다.

 

훨씬 멋진 해결책이 있다. 바로 EnumMap이다.

 

public class MapExample {

    public static void main(String[] args) {

        Plant[] gargen = new Plant[]{
            new Plant("하루살이", Plant.LifeCycle.BIENNIAL),
            new Plant("두루살이", Plant.LifeCycle.BIENNIAL)
        };

        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 : gargen) {
            plantByLifeCycle.get(p.lifeCycle).add(p);
        }

        System.out.println(plantByLifeCycle);
    }
}

=>
{ANNUAL=[],
PERENNIAL=[],
BIENNIAL=[Plant{name='두루살이', lifeCycle=BIENNIAL}, Plant{name='하루살이', lifeCycle=BIENNIAL}]}

 

짧고 명료하며 성능도 비등하다. 타입 안전하고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 레이블을 달 일도 없다. 또한 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 없다.

 

스트림을 사용해 맵을 관리하면 코드를 더 줄일 수 있다.

public static void main(String[] args) {

    Plant[] garden = new Plant[]{
            new Plant("하루살이", Plant.LifeCycle.BIENNIAL),
            new Plant("두루살이", Plant.LifeCycle.BIENNIAL)
    };

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

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

=>
{BIENNIAL=[Plant{name='두루살이', lifeCycle=BIENNIAL}, Plant{name='하루살이', lifeCycle=BIENNIAL}]}

 

스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다.

EnumMap 버전은 모든 열거 타입의 값을 순회하면서 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

 

중첩 열거 타입 값들을 매핑하느라 ordinal을 쓰느 배열들도 마찬가지다.

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

    public static void main(String[] args) {
        System.out.println(Transition.from(Phase.GAS, Phase.LIQUID));
    }
}
=>
CONDENSE

 

Phase나 Phase.Transition 열거 타입을 수정하면서 상전이 표 TRANSITIONS를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 날 것이다.

 

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

    public static void main(String[] args) {
        System.out.println(Transition.from(Phase.GAS, Phase.LIQUID));
    }
}

 

상전이 맵을 초기화하는 코드는 복잡하다.

이 맵의 타입인

Map<Phase, Map<Phase, Transition>>은

"이전 상태에서 '이후 상태에서 전이로의 맵' 에 대응시키는 맵" 이라는 뜻이다.