ordinal 인덱싱 대신 EnumMap을 사용하라
ordinal 인덱싱의 문제점
item35 에서 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;
}
}
정원의 식물을 표현한 예제이다. 이 클래스에서 식물들을 배열 하나로 관리하고, 생애주기별(한해살이, 여러해살이, 두해살이)로 묶는다고 해보자.
이 때 식물의 집합을 배열 하나에 넣고 생애주기의 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)
}
코드는 동작하지만 문제가 있다.
먼저 배열은 제네릭과 호환되지 않아 비검사 형변환이 필요하다.
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
코드를 살펴보면 Set 배열을 제네릭 Set 배열로 캐스팅하고 있으며, 이는 비검사 형변환에 해당한다.
이는 Set
또한 배열은 각 인덱스의 의미를 모르기 때문에 출력 결과에 직접 레이블을 달아야하고, 정확한 정숫값을 사용한다는 것을 개발자가 직접 보증해야한다는 문제점이 발생한다.
이를 해결하기 위해 item37이 제시한 해결법은 바로 EnumMap이다.
EnumMap
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle
= new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle.put(lc, new HashMap<>());
for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
열거형 타입을 map의 key로 사용한 EnumMap을 사용해 위 코드를 개선한 코드이다.
EnumMap은 내부적으로 열거형의 순서와 일치하는 크기의 배열을 사용하여 데이터를 저장한다.
이 배열을 사용하면 열거형의 모든 값에 대해 직접적이고 효율적인 접근이 가능하고, 배열은 열거형 값의 순서에따라 인덱스가 할당되기 때문에 열거형 값 간의 대응 관계가 명확해진다.
EnumMap의 API를 살펴보면
/**
* 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 {@code keyType} is null
*/
public EnumMap(Class<K> keyType) {
this.keyType = keyType;
keyUniverse = getKeyUniverse(keyType);
vals = new Object[keyUniverse.length];
}
생성자에서 빈 EnumMap을 생성한다. keyType은 EnumMap의 키로 사용되는 열거형의 클래스 객체로, 중요한 부분은 Class
한정적 타입 토큰은 제네릭 클래스나 메서드에서 사용되는 타입 정보를 런타임에도 유지하기 위한 방법 중 하나인데, 열거형은 컴파일 타임에만 알려져 있기 때문에 런타임에서도 이 정보를 사용하려면 Class
Stream을 사용한 EnumMap
Java 8의 Stream을 사용해 map을 사용하면 코드를 좀더 줄일 수 있다.
System.out.println(Arrays.stream(garden)
.collect(Collectors.groupingBy(p -> p.lifeCycle)));
하지만 Collectors.groupingBy는 스트림의 요소를 특정 기준으로 그룹화하여 Plant 객체를 LifeCycle 열거형 값에 따라 그룹화하여 결과적으로 Map<Plant.LifeCycle, List
고유한 map 구현체를 사용했기 때문에 EnumMap을 사용하여 얻은 공간과 성능 이점이 사라진다는 문제가 있다. 이를 위해 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.
System.out.println(Arrays.stream(garden)
.collect(Collectors.groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), Collectors.toSet())));
이 때 유의할 점은 스트림을 사용하면 EnumMap만을 사용했을 때와 약간 다르게 동작한다는 것이다.
EnumMap만 사용했을때는 항상 식물의 생애주기(LifeCycle) 당 중첩 맵을 한개씩 만들지만, 스트림을 사용한 버전에서는 해당 생애주기에 속하는 식물이 있을때만 만들게 된다. 무슨 의미인지 코드로 살펴보자.
// Only Enum
System.out.println("only Enum");
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);
// Stream
System.out.println("using Stream");
Map<LifeCycle, List<Plant>> collect = Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle));
System.out.println(collect);
// Stream with enumMap
System.out.println("stream with EnumMap");
EnumMap<LifeCycle, Set<Plant>> collect1 = Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet()));
System.out.println(collect1);
// 출력 결과
only Enum
{ANNUAL=[basil, rosemary], PERENNIAL=[], BIENNIA=[parsley, caraway]}
using Stream
{ANNUAL=[basil, rosemary], BIENNIA=[caraway, parsley]}
stream with EnumMap
{ANNUAL=[basil, rosemary], BIENNIA=[parsley, caraway]}
이처럼 스트림 사용 시, 열거형을 사용했을 때와 비교했을 때 생성되는 맵의 갯수가 다른 것을 확인할 수 있다.
마무리
보면서 틀리거나 이상한 내용이 있으면 언제든지 피드백 부탁드립니다.
실제로 틀린 내용이지만 이를 고치지 않으면 잘못된 지식을 알고 있는 것인데 그게 두렵습니다.
읽어 주셔서 감사합니다.
참고자료
- 이펙티브자바 3판
- https://docs.oracle.com/javase/7/docs/api/java/util/EnumMap.html - enummap docs