배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드(아이템 35)로 인덱스를 얻는 코드가 있다.
public class Plant {
enum LifeCycle {ANNUAL, PERENNIAL, BIENNIAL}
final String name;
final LifeCycle lifeCycle;
public Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override
public String toString() {
return name;
}
}
정원에 심을 식물들을 배열 하나로 관리하고, 이들을 생애주기(한해살이, 여러해살이, 두해살이)별로 묶어보자.
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++) {
plantsByLifeCycle[i] = new HashSet<>();
}
List<Plant> garden = new ArrayList<>(); // 편의상 빈 리스트로 초기화 했다.
for (Plant plant : garden) {
plantsByLifeCycle[plant.lifeCycle.ordinal()].add(plant);
}
// 결과 출력
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s : %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
동작은 하지만 문제가 있는 코드이다. 배열은 제네릭과 호환되지 않으나(아이템 28) 비검사 형변환을 수행해야 하고 깔끔하게 컴파일되지 않을 것이다.
배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다는 점이다.
열거 타입을 키로 사용하도록 설꼐한 아주 빠른 Map 구현체가 존재하는데, 그게 바로 EnumMap이다.
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의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문.
다음의 예외 동작을 거의 그대로 마방한 가장 단순한 형태의 스트림 기반 코드.
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> plant.lifeCycle)));
이 코드에선 EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다.
매개변수 3개짜리 Collectors.groupBy 메서드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.
System.out.println(Arrays.stream(garden).collect(
groupingBy(p-> plant.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));
이 예처럼 단순한 프로그램에선 굳이 최적화할 필요가 없으나, 맵을 빈번히 사용하는 프로그램에서는 꼭 필요할 것이다.
스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다.
EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.
ex) 정원에 한해살이와 여러해살이 식물만 살고 두해살이는 없다면, EnumMap 버전에서 맵을 3개를 만들고 스트림 버전에서는 2개만 만든다.
두 열거 타입 값들을 매핑하느라 ordinal을 (두 번이나) 쓴 배열들의 배열을 본 적이 있을 것이다. 다음은 이 방식을 적용해 두 가지 상태(Phase)를 전이(Transition)와 매핑하도록 구현한 프로그램이다.
ex) 액체(LIQUID)에서 고체(SOLID)로의 전이는 응고(FREEZE)가 되고, 액체에서 기체(GAS)로의 전이는 기화(BOIL)가 된다.
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()];
}
}
}
전이 하나를 얻으려면 이전 상태(from)와 이후 상태(to)가 필요하니, 맵 2개를 중첩하면 쉽게 해결할 수 있다. 안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결한다.
전이 전후의 두 상태를 전이 열거 타입 Transition의 입력으로 받아, 이 Transition 상수들로 중첩된 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<Phase, Transition>>은 "이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵"이라는 뜻이다.
여기서 새로운 상태인 플라스마(PLASMA)를 추가해보자.
이 상태와 연결된 전이는 2개다.
배열로 만든 코드 37-5를 수정하려면 새로운 상수를 Phase에 1개, Phase.Transition에 2개를 추가하고, 원소 9개짜리인 배열들의 배열을 원소 16개 짜리로 교체해야 한다.
원소 수를 너무 적거나 많이 기입하거나, 잘못된 순서로 나열하면 이 프로그램은 (컴파일은 통과하더라도) 런타임에 문제를 일으킬 것이다.
반면, EnumMap 버전에서는 상태 목록에 Plasma를 추가하고, 전이 목록에 IONIZE(GAS, PLASMA)와 DEIONIZE(PLASMA, GAS)만 추가하면 끝이다.
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);
}
나머지는 기존 로직에서 잘 처리해주어 잘못 수정할 가능성이 극히 적다.
실제 내부에선 맵들의 맵이 배열들의 배열로 구현되니 낭비되는 공간과 시간도 거의 없이 명확하고 안전해 유지보수 하기 좋다.