[Effective Java] ordinal 인덱싱 대신 EnumMap을 사용하라

들어가며

배열이나 리스트에서 원소를 꺼낼 때 ordinal 메소드로 인덱스를 얻는 코드가 있다. 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public 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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void usingOrdinalAsArrayIndex(){
Plant[] garden = getPlants();
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(Set<Plant> plants : plantsByLifeCycle)
for(Plant plant : plants)
System.out.println(plant.toString() + " " + plant.lifeCycle);
}

static Plant[] getPlants() {
return new Plant[] { new Plant("plant A", Plant.LifeCycle.ANNUAL),
new Plant("plant B", Plant.LifeCycle.ANNUAL),
new Plant("plant C", Plant.LifeCycle.BIENNIAL),
new Plant("plant D", Plant.LifeCycle.BIENNIAL),
new Plant("plant E", Plant.LifeCycle.PERENNIAL),
new Plant("plant F", Plant.LifeCycle.PERENNIAL)};
}

이는 동작은 하나 문제가 많다. 제네릭과 배열은 호환이 되지 않아 비검사 형변환을 해야하고 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다. 또한 정확한 정숫값을 사용해야한다는 것을 사용자가 직접 보증해야 한다. 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void usingEnumMap(){
Plant[] garden = getPlants();

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);

for(Plant plant : plantsByLifeCycle.get(Plant.LifeCycle.ANNUAL))
System.out.println(plant.toString() + " " + plant.lifeCycle);

for(Plant plant : plantsByLifeCycle.get(Plant.LifeCycle.BIENNIAL))
System.out.println(plant.toString() + " " + plant.lifeCycle);

for(Plant plant : plantsByLifeCycle.get(Plant.LifeCycle.PERENNIAL))
System.out.println(plant.toString() + " " + plant.lifeCycle);
}

더 짧고 명료하고 안전하고 성능도 원래 것과 비등하다. 안전하지 않은 형변환은 쓰지 않고 맵의 키인 enum 타입이 그 자체로 출력용 문자열을 제공하니 직접 레이블을 달지 않아도 된다. 또한 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 봉쇄된다. EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용해 내부 구현을 안으로 숨겨 Map의 타입 안전성과 배열의 성능을 모두 얻어 내는데 있다. 또한 EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로 런타임 제네릭 타입 정보를 제공한다.

1
2
3
4
5
6
7
8
9
10
11
/**
* Creates an empty enum map with the specified key type.
*
* @param keyType the class object of the key type for this enum map
* @throws NullPointerException if <tt>keyType</tt> is null
*/
public EnumMap(Class<K> keyType) {
this.keyType = keyType;
keyUniverse = getKeyUniverse(keyType);
vals = new Object[keyUniverse.length];
}

스트림을 사용하면 코드를 더 줄일 수 있다. 다음 코드를 보자.

1
2
3
4
5
static void usingStreamMap() {
Plant[] garden = getPlants();

System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle)));
}

위 코드는 Map을 사용했기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다. 다음 코드를 보자.

1
2
3
4
5
6
7
static void usingStreamEnumMap() {
Plant[] garden = getPlants();

System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle, //key
() -> new EnumMap<>(Plant.LifeCycle.class), //type
Collectors.toSet()))); //value
}

위 코드를 통해, 매개변수 3개 짜리 groupingBy를 통해 mapFactory 매개변수에 원하는 맵 구현체를 명시할 수 있다. 또한 스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다. EnumMap은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

1
2
3
4
5
6
7
8
ANNUAL
PERENNIAL
plant F PERENNIAL
plant E PERENNIAL
BIENNIAL
plant D BIENNIAL
plant C BIENNIAL
{PERENNIAL=[plant F, plant E], BIENNIAL=[plant D, plant C]}

또한 두 열거 타입 값들을 매핑하느라 ordinal을 쓴 2D 배열을 본 적이 있을 것이다. 다음 코드가 그러하다.

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

이 또한 깨지기 쉬운 코드다. orinal과 배열 인덱스의 관계를 알 도리가 없고 열거 타입을 수정하면서 표의 관계도 함께 수정해줘야 한다. 또한 크기가 늘어마녀 제곱해서 커지며 null로 채워지는 칸도 늘어난다. 이제 다음 코드를 보자.

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
29
30
31
32
public enum Phase {
SOLID, LIQUID, GAS;

public enum Transition {
MELT(SOLID, LIQUID),
FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS),
CONDENSE(GAS, SOLID),
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;
}

public static final Map<Phase, Map<Phase, Transition>> m = Stream.of(values())
.collect(groupingBy(t -> t.from, () -> new EnumMap<>(Phase.class),
toMap(t -> t.to, //key-mapper
t -> t, //value-mapper
(x, y) -> y, //merge-function
() -> new EnumMap<>(Phase.class))));

public static Transition from(Phase from, Phase to) {
return m.get(from).get(to);
}
}
}

위 코드에서 첫번째 수집기에선 상태를 묶고 두번째 수집기에서는 이에 대응하는 EnumMap을 생성한다. 여기서 새로운 코드를 추가하는 것도 쉽다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public enum Phase {

SOLID, LIQUID, GAS, PLASMA;

public enum Transition {
MELT(SOLID, LIQUID),
FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS),
CONDENSE(GAS, SOLID),
SUBLIME(SOLID, GAS),
DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA),
DEIONIZE(PLASMA, GAS);

....
}

나머지는 기존 로직에서 잘 처리해준다. 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 바람직하지 않으니 EnumMap을 사용하자.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/13/Languages/Effective%20JAVA/item37/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.