peaches-book-study / effective-java

이펙티브 자바 3/E
0 stars 2 forks source link

Item 37. ordinal 인덱싱 대신 EnumMap을 사용하라. #36

Open youngkimi opened 3 months ago

youngkimi commented 3 months ago

Chapter : 6. 열거 타입과 애너테이션

Item : 37. ordinal 인덱싱 대신 EnumMap을 사용하라.

Assignee : youngkimi


🍑 서론

배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 코드가 있다. 동물을 간단히 나타낸 아래 클래스를 살펴보자.

public class Animal {
    // 잡식성, 육식성, 초식성 
    enum Dietary { Omnivore, Carnivore, Herbivore}

    final String name;
    final Dietary dietary;

    Animal(String name, Dietary dietary) {
        this.name = name;
        this.dietary = dietary;
    }

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

동물원의 동물들을 배열 하나로 관리하고, 이것들을 식성 별로 분류해보자. 3개의 집합을 만들고 동물원의 동물들을 하나하나 식성별로 해당하는 집합에 넣는다. 어떤 프로그래머는 다음처럼 할 것이다.

ordinal 인덱싱 활용

// 동작 하지만 사용하지 말 것.
Set<Animal>[] animalByDietary = (Set<Animal>[]) new Set[Animal.Dietary.values().length];

for (int i = 0; i < animalByDietary.length; i++) {
    animalByDietary[i] = new HashSet<>();
}

for (Animal a : zoo) {
    animalByDietary[a.dietary.ordinal()].add(a);
}

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

결과

Omnivore: [Human]
Carnivore: [Tiger]
Herbivore: [Goat]

문제

  1. 배열은 제네릭과 호환되지 않으므로 (item 28) 비검사 형변환을 해야한다. 깔끔하게 컴파일되지 않는다.
  2. 배열은 각 인덱스의 의미에 대해 모른다. 출력 값에 직접 레이블을 달아야 한다.
  3. 정확한 정수값을 사용하는 것을 직접 보증해야한다. 정수는 열거 타입과 달리 타입 안전하지 않다. 잘못된 값 사용 시 잘못된 동작을 수행하거나, ArrayIndexOutOfBoundsException이 발생할 것이다.

이보다 더 좋은 방법이 있다. 바로 EnumMap을 사용하는 것이다.

🍑 본론

위 코드에서 배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 한다. 즉, Map을 사용할 수도 있다. 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 존재하는데, 그것이 바로 EnumMap이다.

EnumMap 활용

// Key를 명시해줘야 한다. 런타임 제네릭 타입 정보 제공. 
EnumMap<Animal.Dietary, Set<Animal>> animalByDietary = new EnumMap<>(Animal.Dietary.class);

for (Animal.Dietary dt : Animal.Dietary.values()) {
    animalByDietary.put(dt, new HashSet<>());
}

for (Animal a : zoo) {
    animalByDietary.get(a.dietary).add(a);
}
System.out.println(animalByDietary);

결과

{Omnivore=[Human], Carnivore=[Tiger], Herbivore=[Goat]}
  1. 더 짧고, 명료하고, 안전하고, 성능도 원래와 비등하다. 안전하지 않은 형 변환은 사용하지 않았고, 열거 타입이 그 자체로 출력용 문자열을 제공하므로 레이블을 달지 않아도 된다.
  2. 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 없다.
  3. ordinal을 사용한 배열과 유사한 성능을 기대할 수 있다. 이는 EnumMap 내부에서 배열을 사용하기 때문이다. 내부 구현 방식을 안으로 숨겨 Map의 타입 안정성과 배열의 성능을 모두 얻었다.
  4. 여기서 EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타입 제네릭 타입 정보를 제공한다.

이후 나올 스트림을 사용하면 코드를 더 줄일 수 있다.

스트림 (EnumMap 사용 X)

System.out.println(zoo.stream()
    .collect(groupingBy(p -> p.dietary))
);

위 코드는 앞의 동작을 그대로 모방한 단순한 형태의 스트림 코드이다. 하지만 고유한 맵 구현체를 사용하여 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다. 매개변수 3개짜리 Collectors.groupingBy 메서드로는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.

스트림 (EnumMap 사용)

System.out.println(zoo.stream()
    .collect(groupingBy(p -> p.dietary,
        () -> new EnumMap<>(Animal.Dietary.class), toSet())
    )
);

위와 같은 방식으로 EnumMap을 호출하여 최적화를 할 수 있다.

스트림을 사용할 때는 EnumMap만 사용했을 때와는 다르게 동작한다. 이전 EnumMap만 사용했을 때에는 모든 식성에 하나의 중첩 맵을 만들지만, 스트림에서는 존재하는 식성만 만든다. 즉, 잡식 동물이 없다면 잡식 동물 Map을 만들지 않는다.

2차원 배열의 EnumMap

두 열거 타입을 매핑하기 위해 ordinal을 두 번 사용한 배열의 배열을 본 적이 있을 것이다. 다음은 이 방식으로 두 가지 상태를 전이와 매핑하도록 구현한 프로그램이다.

ordinal 두 번 사용

public enum Phase {
    // 상태 {고체, 액체, 기체}
    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

        // 행은 from의 ordinal을, 열은 to의 ordinal을 인덱스로 쓴다.
        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()];
        }
    }
}

문제

위와 비슷하다.

  1. 컴파일러는 ordinal과 배열 인덱스의 관계를 알 도리가 없다. PhasePhase.Transition 열거 타입을 수정하면서 위 상전이표를 수정하지 않거나 잘못 수정하면 런타임 오류가 날 수도, 혹은 의도하지 않은대로 동작할 수도 있다.
  2. 상전이표의 크기는 상태 가짓수가 늘어남에 따라 제곱해서 커지며, null로 채워지는 칸도 늘어날 것이다.

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>> map =
            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 map.get(from).get(to);
        }
    }
}

상전이 맵의 초기화 과정이 다소 어렵게 느껴진다. 먼저 이전 상태를 기준으로 묶으며 새로 EnumMap<>을 초기화한다. 그 뒤,toMap에서 이후 상태를 전이에 대응시키는 EnumMap을 생성한다.

여기에 새로운 상태(Phase)인 플라즈마(Plasma)를 추가해보자. 이 상태와 연결된 전이는 2개이다. 첫 번째는 기체에서 플라즈마로 변하는 이온화(Ionize), 둘째는 플라즈마에서 기체로 변하는 탈이온화(Deionize)이다.

기존 배열 코드를 변경하려면 상수를 Phase에 1개, Phase.Transition에 2개를 추가하고, 이차원 배열을 9개에서 16개로 늘려야한다. 이 과정에서 원소 수를 정확히 맞추지 않거나 순서를 잘못 나열하면 런타임에 문제를 발생시킬 것이다. 반면, EnumMap에서는 상태 목록에 PLAZMA를 추가하고, 전이 목록에 IONIZE(GAS, PLAZMA), DEIONIZE(PLAZMA, GAS)를 추가하면 끝이다.

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, PLAZMA),
        DEIONIZE(PLAZMA, GAS);
    }

    // ... 나머지는 그대로
}

이러한 방식으로 기존 로직을 수정하는 과정에서의 에러를 줄일 수 있고, 실제 내부에서 맵들의 맵이 이차원 배열로 구현되니 낭비되는 공간, 시간이 거의 없이 명확하고 안전하다.

🍑 결론


Referenced by

-

hyunsoo10 commented 3 months ago

내용은 이해가 쉬운데 초기화 코드 부분이 어렵네요