Home [Effective Java] Item37. ordinal 인덱싱 대신 EnumMap을 사용하라!
Post
Cancel

[Effective Java] Item37. ordinal 인덱싱 대신 EnumMap을 사용하라!

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

ordinal()의 사용과 EnumMap

  • 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스을 얻어 사용하는 코드가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public 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;
    }
}
  • 정원에 심은 식물들을 배열 하나로 관리한다. 이들을 생애주기(LifeCycle)를 기준으로 묶어보자.
    • 생애주기별로 총 3개의 집합을 만들 것이다. 정원을 돌며 각 식물을 해당 집합에 넣는다.
      • 이 때, 집합들을 배열 하나에 넣고, 생애주기의 ordinal 값을 그 배열의 인덱스로 사용하려 할 수 있다.
        • 하지 말자!!!

ordinal()을 배열 인덱스로 사용 - 하지 말 것

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Set<Plant>[] plantsByLifeCycle = new Set[Plant.LifeCycle.values().length];

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

for (Plant p : garden) {
  plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

// 결과
for (int i = 0; i < plantsByLifeCycle.length; i++) {
  System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
  • 동작은 한다. 하지만 문제가 한가득이다.
  • 배열은 제네릭과 호환되지 않는다.
    • 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.
  • 배열은 각 인덱스의 의미를 모른다. 그러므로 직접 출력 결과에 레이블을 추가해야 한다.
  • 정확한 정수값을 사용한다는 것을 직접 보증해야 한다. 정수는 열거 타입과 달리 타입 안전이 보장되지 않는다.
    • 잘못된 동작도 수행하거나, ArrayIndexOutOfBoundsException 던진다.

해결책

  • 이 예제에서 배열은 실질적으로 열거 타입 매핑을 담당한다.
  • 이 매핑 역할을 Map에 넘긴다.
    • 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현제인 EnumMap을 사용하면 될 것이다.
1
2
3
4
5
6
7
8
9
10
11
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 : garden) {
		plantByLifeCycle.get(p.lifeCycle).add(p);
}

System.out.println(plantByLifeCycle);
  • 더 짧고 명료하고 안전하다. 성능도 기존 예제와 비등하다.
    • EnumMap의 성능이 ordinal을 사용한 배열과 비등한 이유는 그 내부 로직이 배열을 사용하기 때문이다.
      • 내부 구현 방식을 안으로 숨겨 Map의 타입 안전성과 배열의 성능 모두를 얻었다.
  • EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다.

스트림을 사용해 더 간단 명료하게 구성

1
2
3
System.out.println(Arrays.stream(garden)
				.collect(groupingBy(p -> p.lifeCycle, 
						() -> new EnumMap<>(LifeCycle.class), toSet())));
  • 이 예제처럼 단순한 경우 최적화가 굳이 필요하진 않지만, 맵을 빈번히 사용한다면 필요하다.

스트림과 EnumMap만 사용했을 때의 차이점

EnumMap만 사용하는 경우

  • 언제나 식물의 생애주기당 하나씩의 중첩 맵을 생성한다.

스트림을 사용하는 경우

  • 생애주기에 해당하는 식물이 있을 때만 그 생애주기 맵을 생성해 사용한다.

두 열거 타입 값 매핑을 위해 ordinal을 (두 번이나) 사용한 배열

  • 두 열거 타입을 강제로 매핑하기 위해 사용한 잘못된 방법이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()];
        }
    }
}
  • 잘못된 방식을 사용해 두 가지 상태(Phase)를 전이(Transition) 및 매핑하도록 구현한 예제다.
  • 멋져 보이지만 속지말자. 앞서 설명한 것과 같이 컴파일러는 ordinal과 배열 인덱스 관계를 알 수 없다.
    • 즉, Phase나 Phase.Transition 열거 타입을 수정하고, TRANSITIONS를 수정하지 않거나 실수한다면 런타임 에러가 난다.
    • IndexOutOfBoundsException, NullPointerException을 던질 가능성도 있다.
    • 상태의 가짓수가 늘어갈수록, TRANSITIONS 표의 null 값도 증가해 비효율적이다.
  • 이 경우도 마찬가지로 그냥 EnumMap을 사용해 해결하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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);
        }
    }
}
  • 상전이 맵 초기화 코드는 복잡하다.

  • Map<Phase, Map<Phase, Transition» : 이전 상태에서 ‘이후 상태에서 전이로의 맵’에 대응시키는 맵
  • 첫 번째 수집기인 groupingBy에서는 전이를 이전 상태 기준으로 묶는다.
  • 두 번째 수집기인 toMap에서는 이후 상태 전이에 대응시키는 EnumMap 생성을 한다.
    • 병합 함수인 (x, y) -> y는 선언만 하고 실제로 쓰이진 않는다.
      • 이는 단지 EnumMap을 얻으려면 맵 팩터리가 필요하고, 수집기들은 점층적 팩터리를 제공하기 때문이다.

새로운 상태 추가하기

  • EnumMap 사용 시 새로운 상태를 추가하는 것은 쉬운 일이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum Phase {
    SOLID, LIQUID, GAS,
    // PLASMA 추가
    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); // 전이 상태 추가

        // ... 나머지는 동일하니 생략
    }
}
  • 기체에서 플라스마로 변하는 이온화와 반대인 탈이온화를 추가했다.
    • 배열로 만든 코드였다면 Phase, Phase.Transition, 상전이표 변경 등의 복잡한 작업이 필요하고, 잘못 나열하면 문제를 일으킨다.
    • 하지만 EnumMap의 사용을 통해 손쉬운 상태 추가를 할 수 있다.

핵심 정리

  • 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라.
  • 다차원 관계는 EnumMap<…, EnumMap<…»으로 표현하라.
  • “애플리케이션 프로그래머는 Enum.ordinal을 웬만해서는 사용하지 말아야 한다.”는 일반 원칙의 특수 사례다.
This post is licensed under younghwani by the author.

[Effective Java] Item36. 비트 필드 대신 EnumSet을 사용하라!

[Effective Java] Item38. 확장할 수 있는 열거타입이 필요하면 인터페이스를 사용하라!

Comments powered by Disqus.