[Effectiva Java][아이템37] ordinal 인덱싱 대신 EnumMap을 사용하라
아이템37. ordinal 인덱싱 대신 EnumMap을 사용하라
WHY: oridnal 인덱싱은 유지보수가 어렵다.
예제1)식물를 생애주기별로 분류하기 - ordinal버전
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set(Plant.LifeCycle.values().length]; // ㄷ3ㄱㅐㅡㅣ ㅈㅣㅂ
for (int i = 0; i < plantsByLifeCycle.lengh; 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.println("%s: %s%n",
Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
식물을 배열 하나로 관리하고, 이들을 생애주기별로 묶어보자.
class Plant { // 식물의 생애주기를 관리하는 클래스
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL} // ㅅㅐㅇㅇㅐㅈㅜㄱㅣ
final String name;
final LifeCyle lifeCycle;
Plant(String name, LifeCyce lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override public String toString() {
return name;
}
}
단점
1. 배열은 제네릭과 호환되지 않는다.
때문에 비검사 형변환(아이템28: )을 수행해야 한다.
2. 출력 결과에 직접 레이블을 달아야 한다.
배열은 인덱스의 의미를 모르기 때문에 직접 출력 레이블을 달아야 한다.
3. 정확한 정숫값을 사용해야 한다는 것을 개발자가 직접 보증해야 한다.
정수는 타입 안전하지 않기 때문에! ArrayIndexOutOfBoundsException을 던질 수 있다.
예제2) 두 열거타입을 매핑하기 위해 ordinal을 두 번 쓴 예시 (배열의 배열)
상태(Phase)를 전이(Transition)와 매핑하도록 구현한 예시이다.
예를 들어 액체(LIQUID)에서 고체(SOLID)의 전이(TRANSITION)은 응고(FREEZE)가 되고
액체에서 기체(GAS)로의 전이(TRANSITION)는 기화(BOIL)된다.
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
//
privte static final Transition[][] TRANSITION = {
{null, MELT, SUBLIME}, // SOLID -> to
{FREEZE, null, BOIL}, // LIQUID -> to
{DEPOSIT, CONDENSE, null} // GAS -> to
}
//
public static Transition from(Phase from, Phase to) {
return TRANSITION[from.ordinal()][to.ordinal()];
}
}
}
단점
1. 컴파러가 ordinal과 배열 인덱스의 관계를 알 수 없다.
2. 열거타입을 수정하면서 상태전이표를 함께 수정하지 않거나 실수로 잘못수정하면 런타임 오류가 발생할 수 있다.
3. 상태의 가짓수가 늘어나면 제곱해서 커지면서 null로 채워지는게 늘어날 것이다.
HOW: EnumMap으로 변환
예제3) 식물를 생애주기별로 분류하기 - EnumMap 버전
Map<Plant.LifCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values()) //keyㅅㅔㅅㅌㅣㅇ
plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p: graden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle);
- 안전하지 않은 형변환은 쓰지 않아 타입 안전하다.
- 열거타입 그 자체로 출력문 문자열을 제공한다.
- 배열 인덱스를 계산하는 과정에서 오류가 날 일도 없다.
예제4) 중첩 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(transition -> transition.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<Pahse, Transition>>은 이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵이라는 뜻이다.
때문에 맵의 맵을 초기화하기 위해 Collector 2개를 사용한다.
첫번째 Collector의 groupingBy에서는 전이를 이전 상태를 기준으로 묶고,
두번째 Collector인 toMap에서는 이후 상태를 전이에 대응시키는 EnumMap을 생성한다.
이제 새로운 상태인 플라스(PLASMA)를 추가해보자.
이 상태와 연결된 전이는 두 가지이다.
1. 플라스마 -> 기체 : 탈이온화(DEIONIZE)
2. 기체 -> 플라스마: 이온화(IONIZE)
Why예시에서의 as-is 코드를 활용한다면 새로운 상수 Phase에 1개, Transitiond에 2개를 추가하고,
원소 9개짜리인 배열을 16개짜리로 교체해야 한다.
원소 수를 잘못 기입하거나 잘못된 순서로 나열하면 이 프로그램은 통과하더라도 런타임에 문제를 일으킬 수 있다.
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); // 새롭게 추가되었다.
// ...
}
반면, EnumMap버전에서는 상태에 PLASMA를 추가하고, 전이 목록에 IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS)만 추가하면 된다.
나머지는 기존 로직에서 잘 처리해주어 잘못 수정할 가능성이 극히 작다. 내부에서는 맵들의 맵이 배열들의 배열로 구현되니 낭비되는 공간과 시간도 거의 없이 명확하고 안전하며 유지보수하기도 좋다.
예제5) Stream을 활용한 예제
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle)));
EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에
EnumMap의 공간과 성능 이점이 사라진다.
groupingBy 메서드는 mapFactory를 이용해 원하는 맵 구현체를 명시해 호출할 수 있다.
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(Lifecycle.class), toSet())));