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
과 배열 인덱스의 관계를 알 도리가 없다. Phase
나Phase.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.