배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 코드가 있다.
식물을 간단히 나타낸 Plant 클래스를 보면
식물의 생애주기인 LifeCycle을 열거타입 멤버클래스로 선언하고 이를 이용해서 생애주기별로 식물들을 관리해 볼 것이다.
정원에 심은 식물들을 배열 하나로 관리하고, 생애주기별로 총 3개의 집합을 만들고 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣는다.
어떤 프로그래머는 집합들을 배열 하나에 넣고 생애주기의 ordinal 값을 그 배열의 인덱스로 사용하려 할 것이다.
usingOrdinalArray 메서드의 로직을 보면
- 열거타입 LifeCycle 상수의 갯수 만큼 plantsByLifeCycle이름의 Set 배열을 만든다.
- plantsByLifeCycle 배열을 루프로 돌며 HashSet으로 초기화 한다.
- 넘겨받은 Plant 리스트들을 루프로 돌며 plantsByLifeCycle 배열에 넣어주는데 LifeCycle 열거타입 ordinal 값으로 plantsByLifeCycle 배열의 인덱스를 찾아 추가를 하고있어 각 생애주기별에 해당하는 배열에 값이 들어가게 된다.
- plantsByLifeCycle 배열의 갯수만큼 루프를 돌며 배열에 들어가 있는 내용을 출력한다. LifeCycle 열거타입의 values메서드는 상수가 선언된 순서가 ordinal 값으로 결정되어서 LifeCycle 출력 순서와 plantsByLifeCycle 배열의 순서와 같다.
동작은 잘 하지만 문제가 있는 코드로
- 배열은 제네릭과 호환되지 않아 비검사 형변환을 수행해야 한다.
- 배열은 각 인덱스의 의미를 모르니 출력결과에 직접 레이블을 달아야 한다.
- 가장 심각한 문제는 정확한 정수값을 사용한다는 보장을 프로그래머가 해야한다. 정수는 열거타입과 달리 타입 세이프티하지 않기 때문이다.
이러한 단점의 해결책으로 EnumMap을 사용한다.
배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 한다. 그러니 Map을 사용하는데 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 EnumMap 객체이다.
usingEnumMap 메서드의 로직을 보면
- 안전하지 않은 형변환을 쓰지 않아도 된다.
- EnumMap에서 toString을 제공하니 출력 결과에 직접 레이블을 달 일도 없다.
- 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.
- EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 EnumMap 내부에서 배열을 사용하기 때문이다. 내부 구현 방식을 안으로 숨겨서 Map의 타입 안전성과 배열의 성능을 모두 얻어냈다.
- EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임시 제네릭 타입 정보가 소거되기 때문에 런타임 제네릭 타입 정보 제공을 위해 키 타입의 Class 객체를 넘겨준다.
스트림을 사용해 맵을 관리하면 코드를 더 줄일 수 있다.
StreamV1 메서드는 EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다.
Collectors.groupingBy메서드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.
스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작하는데 EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.
두 열거 타입 값들을 매핑하느라 ordinal을 두번이나 쓴 배열들의 배열을 살펴보자
두 가지 상태(Phase)를 전이(Transition)와 매핑하도록 구현한 프로그램이다. 액체(LIQUID)에서 고체(SOLID)로의 전이는 응고(FREEZE)가 되고, 액체에서 기체(GAS)로의 전이는 기화(BOIL)가 된다.
이 열거타입도 앞선 문제와 같이 컴파일러는 ordinal과 배열 인덱스의 관계를 알 수 없다.
Phase나 Phase.Transition 열거 타입을 수정하면서 TRANSITIONS를 함께 수정하지 않으면 런타임 오류가 날 수 있다.
ArrayIndexOutOfBoundsException이나 NullPointException을 던질 수 있고 운이 나쁘면 예외도 던지지 않고 이상하게 동작할 위험이 있다.
또한 TRANSITIONS의 크기는 상태의 가짓수가 늘어나면 제곱해서 커지며 null로 채워지는 칸도 늘어난다.
EnumMap으로 만들면 전이 하나를 얻으려면 이전 상태(from)와 이후 상태(to)가 필요하니 맵 2개를 중첩하면 쉽게 해결할 수 있다.
안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결한다.
전이 전후의 두 상태를 전이 열거 타입 Transition의 입력으로 받아, 이 Transition 상수들로 중첩된 EnumMap을 초기화 한다.
이 맵의 타입인 Map<Phase, Map<Phase, Transition>>은 각 Phase의 상태 상수를 Map으로 묶고 각 상수마다 상태가 달라지는 전이를 다시 Map으로 감싸서 맵핑하고 있다.
이러한 맵의 맵을 초기화하기 위해 수집기(java.util.stream.Collector) 2개를 차례로 사용했다.
첫 번째 수집기인 groupingBy에서는 전이(Transition)를 이전 상태(from Phase) 기준으로 묶고 두 번째 수집기인 toMap에서는 이후 상태(to Phase)를 전이에 대응시키는 EnumMap을 생성한다.
두 번째 수집기의 병합 함수인 (x, y) -> y 는 선언만 하고 실제로 쓰이지 않는데 key 값이 같은게 존재할 때 value를 기존값(x)으로 할지 새로운값(y)로 갱신할지 정하는 부분이나 이 예제에선 중복되는 key값이 존재하지 않으므로 실제 쓰이진 않는다.
만약 새로운 상태인 플라즈마(PLASMA)를 추가한다고 했을 때 이 상태와 연결된 전이는 2개다. 첫 번째는 기체에서 플라즈마로 변하는 이온화(INONIZE)이고, 둘째는 플라즈마에서 기체로 변하는 탈이온화(DEIONIZE)다.
배열로 만든 코드를 수정하려면 새로운 상수를 Phase에 1개, Phase.Transition에 2개를 추가하고 원소 9개 짜리 배열들의 배열을 원소 16개짜리로 교체해야한다.
원소 수를 너무 적거나 많이 기입하거나, 잘못된 순서로 나열한다면 런타임에 문제가 난다.
반면 EnumMap 버전에서는 상태 목록에 PLASMA를 추가하고 전이 목록에 IONIZE(GAS, PLASMA)와 DEINONIZE(PLASMA, GAS)만 추가하면 끝이다.
배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라
https://javabom.tistory.com/51
'Effective Java > 정리' 카테고리의 다른 글
Item39. 명명 패턴보다 애너테이션을 사용하라 (0) | 2023.05.29 |
---|---|
Item38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2023.05.23 |
Item36. 비트 필드 대신 EnumSet을 사용하라 (0) | 2023.05.21 |
Item35. ordinal 메서드 대신 인스턴스 필드를 사용하라 (1) | 2023.05.16 |
Item34. int 상수 대신 열거 타입을 사용하라 (0) | 2023.05.14 |