Post

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

[책에 나온 예시] Plant 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
    }
}

식물들을 배열 하나로 관리하고, 이들을 생애주기(한해살이, 여러해살이, 두해살이)별로 묶자.

생애주기별로 총 3개의 집합을 만들고 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣는다.

집합들을 배열 하나에 넣고 생애주기의 ordinal 값을 그 배열의 인덱스로 사용하는 코드가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ordinal()을 배열 인덱스로 사용 - 따라 하지 말 것!
Set<Plant> plantsByLifeCycle = new Set[Plant.LifeCycle.values().length];

for (int i = 0; i < plantsByLifeCycle.length; i++) {
    plantsByLifeCycle = 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]);
}

위 코드는 문제가 많다.

  • 배열은 제네릭과 호환되지 않아 비검사 형변환을 수행해야 한다. (깔끔하지 않은 컴파일)
  • 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아줘야 한다.
  • 정확한 정숫값을 사용한다는 것을 개발자가 직접 보증해야 한다.
    • 정수는 열거 타입과 달리 타입 안전하지 않다.
    • 잘못된 값 → 잘못된 동작 or ArrayIndexOutOfBoundsException 발생

[해결책] EnumMap

열거 타입을 키로 사용하도록 설계한 Map의 구현체 EnumMap을 사용하자.

위 코드를 EnumMap으로 바꿔서 아래와 같이 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
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 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰이다.
    • 런타임 제네릭 타입 정보를 제공한다. (아이템 33)

위 코드를 스트림을 사용하여 최적화 할 수도 있다.

1
2
3
4
// EnumMap을 이용해 데이터와 열거 타입을 매핑했다.
System.out.println(Arrays.stream(garden)
    .collect(groupingBy(p -> p.lifeCycle,
        () -> new EnumMap<>(LifeCycle.class), toSet())));
  • 스트림을 사용한 코드는 EnumMap만 사용했을 때와는 살짝 다르게 동작한다.
    • EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩맵을 만든다.
    • 스트림 버전은 해당 생애주기에 속하는 식물이 있을 때만 만든다.

두 열거 타입 값들을 매핑하는 방법

두 가지 상태(Phase)를 전이(Transition)와 매핑하는 프로그램

  • 액체(LIQUID)에서 고체(SOLID)로의 전이는 응고(FREEZE)
  • 액체에서 기체(GAS)로의 전이는 기화(BOIL)…

두 열거 타입 값들을 매핑하기 위해서 ordinal을 두 번 사용하는 방법

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
// 배열들의 배열의 인덱스에 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()];
        }
    }
}
  • 컴파일러가 ordinal과 배열 인덱스의 관계를 알 도리가 없다.
  • PhasePhase.Transition 열거 타입을 수정하면서 표 TRANSITIONS를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 발생한다.
    • ArrayIndexOutOfBoundsException이나 NullPointerException을 던질 수도 있다.
    • 운 안좋으면 예외도 발생하지 않고 이상하게 동작한다.
  • 표의 크기는 상태 가짓수가 늘어나면 제곱해서 커지고, 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);
        }
    }
}
  • 맵 2개를 중첩하면 된다.
    • 안쪽 맵은 이전 상태전이를 연결한다.
    • 바깥 맵은 이후 상태안쪽 맵을 연결한다.
  • Map<Phase, Map<Phase, Transition>>
    • “이전 상태에서 ‘이후 상태에서 전이로의 맵’에 대응시키는 맵”이라는 뜻.
    • 맵의 맵을 초기화하기 위해 수집기(java.util.stream.Collector) 2개를 차례로 사용했다.
    • 첫번째 수집기인 groupingBy에서는 전이이전 상태 기준으로 묶었다.
    • 두번째 수집기인 toMap에서는 이후 상태전이에 대응시키는 EnumMap을 생성한다.
    • 두번째 수집기의 병합 함수인 (x, y) → y는 선언만 하고 실제로는 쓰이진 않는다.
      • EnumMap을 얻으려면 맵 팩터리가 필요하고 수집기들은 점층적 팩토리를 제공하므로.

새로운 상태를 추가하려면?

상태 추가

  • 플라스마(PLASMA)

전이 추가

  • 기체에서 플라스마로 변하는 이온화(IONIZE), 플라스마에서 기체로 변하는 탈이온화(DEIONIZE)

(1) 배열로 만든 코드에서 새로운 상태를 추가할 때

  • 새로운 상수를 Phase에 1개, Phase.Transition에 2개를 추가해야 함.
  • 원소 9개짜리인 배열들의 배열을 원소 16개짜리로 교체해야 한다.
    • 잘못된 순서로 원소를 나열하거나, 원소 수를 맞추지 못할 경우 런타임 오류 발생.

(2) EnumMap으로 만든 코드에서 새로운 상태를 추가할 때

  • 상태 목록에 PLASMA 추가
  • 전이 목록에 IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS) 추가
1
2
3
4
5
6
7
8
9
10
11
12
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, PLASMA), DEIONIZE(PLASMA, GAS);
		
        // ... 나머지 코드는 그대로다.
    }
}
  • 잘못 수정할 가능성이 극히 작다.
  • 맵들의 맵이 배열들의 배열로 구현되니 낭비되는 공간과 시간도 거의 없다.
    • 명확하고 안전하고 유지보수하기에 좋다.

💡 핵심 정리

  • 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 좋지 않으니 EnumMap을 사용하라.
  • 다차원 관계는 EnumMap을 중첩해서 사용하라.
This post is licensed under CC BY 4.0 by the author.