💡 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것을 일반적으로 좋지 않으니, EnumMap을 사용하라.
📌 As-is 1. ordinal()을 배열 인덱스로 사용한 경우
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;
}
}
정원에 심은 Plant들을 배열 하나로 관리하고, 이들을 LifeCycle(한해살이, 여러해살이, 두해살이)별로 묶는다면 어떻게 하는 것이 좋을까?
LifeCycle 별로 총 3개의 집합을 만들고, 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣으면 될 것이다.
문제는 LifeCycle 별로 Set을 만들고, 이 Set을 묶기 위해 LifeCycle의 ordinal 값을 배열의 인덱스로 사용해 묶는 방법을 시도하려는 프로그래머가 있을 것이다.
public class Main {
public static void main(String[] args) {
// garden 더미 데이터 생성
Plant[] garden = {
new Plant("바질", Plant.LifeCycle.ANNUAL),
new Plant("캐러웨이", Plant.LifeCycle.BIENNIAL),
new Plant("딜", Plant.LifeCycle.ANNUAL),
new Plant("라벤더", Plant.LifeCycle.PERENNIAL),
new Plant("파슬리", Plant.LifeCycle.BIENNIAL),
new Plant("로즈마리", Plant.LifeCycle.PERENNIAL)
};
// ordinal() 메서드를 배열 인덱스로 사용 - 이는 잘못된 사용법!
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) 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]);
}
}
}
- 배열은 제네릭과 호환되지 않으니, 비검사 형변환을 수행해야 한다. (Item 28)
- 배열은 각 인덱스 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
- 정확한 정숫값을 사용한다는 것을 사용자가 직접 보증해야 한다. (정수는 열거 타입과 달리 타입 안전하지 않다.)
- 잘못된 값을 사용하면 ArrayIndexOutOfBoundsException을 던질 것이고, 운이 없다면 잘못된 동작을 묵묵히 수행할 것이다.
📌 To-be 1. EnumMap & Stream
public class Main {
public static void main(String[] args) {
// EnumMap을 사용해 데이터와 열거 타입을 매핑
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle2 =
new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle2.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycle2.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle2);
}
}
- 더 짧고 명시적이며 안전하고 성능도 비슷하다.
- 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하므로 직접 레이블을 달 필요가 없다.
- 배열 인덱스 계산 과정에서 오류가 날 가능성이 없다.
✒️ EnumMap
EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다.
내부 구현 방식을 안으로 숨겨서 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것이다.
public class EnumMap<K extends Enum<K>, V> extends AbstractMap<K, V>
implements java.io.Serializable, Cloneable
{
private final Class<K> keyType;
...
public EnumMap(Class<K> keyType) {
this.keyType = keyType;
keyUniverse = getKeyUniverse(keyType);
vals = new Object[keyUniverse.length];
}
...
}
스트림(Item 45)을 사용하여 맵을 관리하면 코드를 더 줄일 수도 있다.
1️⃣ Stream을 사용한 코드 1 - EnumMap을 사용하지 않는다!
public class Main {
public static void main(String[] args) {
// 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다!
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle)));
}
}
- 앞의 예시를 거의 그대로 모방한 가장 단순한 형태의 스트림 기반 코드
- EnumMap이 아닌 고유한 Map 구현체를 사용하여, EnumMap을 써서 얻은 공간과 성능 이점이 사라졌다.
2️⃣ Stream을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입 매핑
public class Main {
public static void main(String[] args) {
// 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다.
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(Plant.LifeCycle.class), toSet())));
}
}
- Collectors.groupingBy 메서드는 3개의 매개변수로 mapFactory 매개변수에 원하는 맵 구현체를 명시할 수 있다.
- 단순한 프로그램에서는 굳이 필요 없지만, Map을 빈번히 사용하는 프로그램에선 꼭 필요할 것이다.
- EnumMap만 사용했을 때와는 달리, LifeCycle에 속하는 식물이 있을 때만 Map을 생성한다.
- 예를 들어 garden 배열에 두해살이 식물이 없다면, 2개의 Map만 생성한다.
📌 As-is 2. 배열들의 배열 인덱스에 ordinal()을 사용
// 배열들의 배열의 인덱스에 ordinal()을 사용 - 따라하지 말 것!
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()];
}
}
}
- SOLID, LIQUID, GAS 상태 전이표를 Transition 3X3 배열로 표현하고 있다.
- 컴파일러는 ordinal과 배열 인덱스의 관계를 알 수 없다. 따라서 열거 타입 수정 시, Transition 수정을 깜빡한다면 오류가 나거나 이상하게 동작할 것이다.
- 상태가 하나 늘어날 때마다 상전이 표는 제곱해서 커지며, null로 채워지는 칸도 늘어난다.
📌 To-be 2. 중첩 EnumMap
// 중첩 EnumMap으로 데이터와 열거 타입 쌍을 연결했다.
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);
}
}
}
- 안쪽 맵은 이전 상태(from)와 전이(Transition)를 연결하고, 바깥 맵은 이후 상태(to)와 안쪽 맵을 연결한다.
- Map<Phase, Map<Phase, Transition>> : "이전 상태(from)에서 '이후 상태(to)에서 전이(Transition)로의 맵'에 대응시키는 맵"
- groupingBy : 첫 번째 collector, 전이를 이전 상태를 기준으로 묶음
- 첫 번째 매개변수: 그룹화할 기준을 정의하는 함수
- 두 번째 매개변수: 그룹화된 결과를 수집할 맵 팩토리
- 세 번째 매개변수: 그룹화된 결과를 맵에 추가하는 방법을 정의하는 매핑 함수
- toMap : 두 번째 collector, 이후 상태를 전이에 대응시키는 EnumMap 생성
- 병합함수 '(x, y) -> y'는 선언은 되었으나, 실제로 사용되지는 않는다. 두 개의 충돌하는 값이 있으면 항상 두 번째 값을 선택하도록 정의되었으나, 애초에 toMap 메서드가 호출되지 않는다. 단지, EnumMap을 얻기 위해 맵 팩터리가 필요하고 collector들은 점층적 팩터리(다중 수준 그룹화)를 제공하기 때문이다.
- 여기서 상태가 추가된다면 Phase 1개, Phase.Transition 2개, 원소 16개 배열로 교체 작업이 필요하던 이전 방식과 달리 상태 목록에 1개, 전이 목록에 2개의 Transition만 추가하면 끝난다.
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);
...
}