Effective Java : 아이템 37. ordinal 인덱싱 대신 EnumMap을 사용하라

    아이템 37. ordinal() 인덱싱 대신 EnumMap을 사용하라.

    • 배열의 인덱스를 얻기 위해 Enum의 ordinal()을 사용하는 것은 좋지 않음. 
    • EnumMap을 사용해라. 
    • 다차원인 경우 EnuMap<..., EnumMap<>>으로 풀어내라

    ordinal 인덱싱을 사용하는 것은 일반적으로 좋지 않다. 2번의 경우 배열에서 인덱싱을 사용할 때, ordinal()의 인덱싱이 배열의 크기를 넘지 않다는 것마저 사용자가 보장해야한다. 이런 이유들 때문에 사용하는 것은 좋지 않다.  

    1. ordinal()에서 제공하는 인덱싱의 의미는 한 눈에 알 수 없다. 
    2. 인덱싱이 제공하는 정보가 정확한 것임을 사용자가 정의해야한다. 

    첫번째 예시 - 안 좋은 예시 (ordinal()로 인덱싱함)

    아래 코드처럼 정의된 PlantBad 클래스가 있고, enum의 ordinal()을 사용해서 엉망인 경우를 살펴보자. 

    public class PlantBad {
        
        enum LifeCycle{ ANNUAL, PERENNIAL, BIENNIAL;}
        
        final String name;
        final LifeCycle lifeCycle;
    
        public PlantBad(String name, LifeCycle lifeCycle) {
            this.name = name;
            this.lifeCycle = lifeCycle;
        }
    
        @Override
        public String toString() {return this.name;}
    }

    아래에서는 다음과 같은 작업을 목표로 한다. 생애주기별 (BIENNIAL, ANNUAL, PERENNIAL)로 배열을 만들고, 각 생애주기에 대응되는 식물을 넣으려는 작업이다. 아래 작업에서는 여러 문제가 발생한다. 

    1. (Set<PlantBad>)의 타입 캐스팅 문제. 배열은 로 타입이고, Set은 제네릭 타입임. 타입 문제를 잠정적으로 내포함. 
      • 사실은 ordinal() 사용과 관련된 문제는 아님.
    2. ordinal()의 의미가 명확하지 않음. 
    3. values.length(), ordinal()의 의미 매칭을 내가 해야함. 
    public static void main(String[] args) {
        List<PlantBad> Garden = List.of(
                new PlantBad("Rose", LifeCycle.BIENNIAL),
                new PlantBad("Flower", LifeCycle.ANNUAL),
                new PlantBad("52", LifeCycle.PERENNIAL));
    
        // 배열을 1,2,3년 살이로 생애주기를 넣을 때
        // Set<Plant>[]
        Set<PlantBad>[] plantsByLifeCycle = (Set<PlantBad>[]) new Set[LifeCycle.values().length];
    
        for (int i = 0; i < plantsByLifeCycle.length; i++) {
            plantsByLifeCycle[i] = new HashSet<>();
        }
    
        for (PlantBad plantBad : Garden) {
            plantsByLifeCycle[plantBad.lifeCycle.ordinal()].add(plantBad);
        }
    }

    첫번째 문제는 배열을 제네릭 타입으로 사용하려고 했기 때문에 발생하는 문제다. 간단히 정리하면 다음 문제다.

    1. 배열은 공변이다. String[]을 Object[]로 변경해서 사용 가능하다.
    2. 제네릭은 불공변이다. 컴파일 시점에 해당 값은 없어지고, CHECKCAST를 통해서 한번 더 캐스팅하는 코드가 들어간다. 

    따라서 저렇게 캐스팅을 해서 사용해야 하는데, plantsByLifeCycle은 다음과 같이 사용해도 컴파일 에러가 발생하지 않는다. 원치 않는 타입의 객체가 배열에 포함될 수 있고, 사용하는 시점에 런타임 에러로 CastException이 발생할 수 있다. 

    Set<PlantBad>[] plantsByLifeCycle = (Set<PlantBad>[]) new Set[LifeCycle.values().length];
    Object[] hello = plantsByLifeCycle;

    두번째, 세번째 문제는 ordinal()에서 기인한다. LifeCycle.BIENNIAL의 ordinal()의 의미는 명확하지 않다. 사용할 때는 숫자 하나만 달랑보일텐데 어떤 내용인지 정확히 유추할 수가 없다. 또한 ordinal()의 인덱싱 결과가 배열 크기보다 작은 것을 사용자가 직접 보장해야하며, 각 인덱스에 어떤 내용이 들어갈지도 개발자가 직접 보장해야한다. 

    이런 코드들을 견고하지 못한 코드다. 약간의 변경만으로도 놓치기가 쉽기 때문이다. 단적인 예로 ANNUAL / BIENNIAL의 위치만 서로 바꿔주어도 전혀 다른 의미의 코드가 될 수 있다. 

     


    첫번째 예시 - 좋은 예시 (EnumMap()로 인덱싱함)

    ordinal()로 인덱싱을 하는 경우라면, 차라리 EnumMap을 사용하는 것을 추천한다. EnumMap은 다음 특징을 가진다.

    • EnumMap의 내부 구현은 배열로 되어있음. 
    • EnumMap은 enum을 Key로 가지는 Map임.

    ordinal()로 enum의 인덱스를 이용하기 보다는 enum 그 자체를 Key로 사용하는 것이 의미상 더 명확하다. 또한 내부는 배열로 구현되어 있어서 속도는 훨씬 빠르다.

    public static void main(String[] args) {
        List<PlantGood> garden = List.of(
                new PlantGood("Rose", LifeCycle.BIENNIAL),
                new PlantGood("Flower", LifeCycle.ANNUAL),
                new PlantGood("52", LifeCycle.PERENNIAL));
    
        EnumMap<LifeCycle, HashSet<PlantGood>> plantsByLifeCycle = new EnumMap<>(LifeCycle.class);
    
        Arrays.asList(LifeCycle.values())
                .forEach(lifeCycle -> plantsByLifeCycle.put(lifeCycle, new HashSet<>()));
    
        garden.forEach(plantGood -> plantsByLifeCycle.get(plantGood.lifeCycle).add(plantGood));
    }

    이렇게 구현하면 위의 세 가지 문제점은 명확히 해결된다.

    1. 타입 캐스팅 문제가 사라짐. 
    2. Map의 Key 타입을 enum으로 사용했기 때문에 의미가 명확해짐. 
    3. 배열 인덱스를 계산할 때 발생할 수 있는 문제도 원천 봉쇄됨. 

    두번째 예시 - 안 좋은 예시. ordinal()을 사용

    두 enum 타입을 맵핑하기 위해 ordinal()을 두 번이나 사용하는 안 좋은 예시가 존재한다. 아래 예시가 있다.

    • Phase : 현재 물질 상태
    • Transation : 상전이 

    두 개의 Enum 클래스를 이용해 각 물질이 어떻게 상전이 되는지를 매칭해서 사용할 수 있다. 

    public enum PhaseBad {
        SOLID, LIQUID, GAS;
        
        public enum TransitionBad {
            MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
    
            private static final TransitionBad[][] TRANSITION_BADS = {
                    {null, MELT, SUBLIME}, // Solid -> Solid, Solid -> Liquid, Solid -> Gas를 의미 
                    {FREEZE, null, BOIL},
                    {DEPOSIT, CONDENSE, null},
            }; 
        }
    
        public static TransitionBad from(PhaseBad from, PhaseBad to) {
            return TransitionBad.TRANSITION_BADS[from.ordinal()][to.ordinal()];
        }    
    }

    TransitionBad라는 2차원 배열을 만든다. 2차원 배열을 [X][Y]로 접근한다고 할 때, X는 상전이가 시작되는 Phase를 나타내고 Y는 목표하는 상전이 Phase를 나타낸다. 그런데 위에서 살펴보면 알겠지만 여러 잠재적 문제점이 생긴다.

    1. 만약 새로운 Phase가 추가되었을 때, 이에 대한 Transation도 같이 추가해야함. 그런데 빼먹을 경우, ordinal()이 배열의 크기보다 더 큰 인덱스를 이야기 하기 때문에 IndexOutOfRange 에러가 발생할 수 있음.  (런타임 에러가 발생함) 
    2. 하나의 상전이가 추가될 경우, 변경 지점이 많아짐. 

    예를 들어 위에서 PLASMA를 추가하게 되는 경우에는 다음과 같이 코드가 수정되어야 한다. 변경 지점이 제법 많고, 개발자가 직접 각 배열을 하나씩 채워야 한다는 것이다. 인덱스가 적을 때는 그나마 하겠지만, Enum이 많아져서 인덱스가 커지면 직접 챙기기가 쉽지 않다. 

    public enum PhaseBadAdd {
        SOLID, LIQUID, GAS, PLASMA;
    
        public enum TransitionBad {
            MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT, IONIZE, DEIONIZE;
            
            private static final TransitionBad[][] TRANSITION_BADS = {
                    {null, MELT, SUBLIME, null}, // Solid -> Solid, Solid -> Liquid, Solid -> Gas를 의미
                    {FREEZE, null, BOIL, null},
                    {DEPOSIT, CONDENSE, null, null},
                    {null, null, IONIZE, DEIONIZE}
            };
        }
    
        public static TransitionBad from(PhaseBadAdd from, PhaseBadAdd to) {
            return TransitionBad.TRANSITION_BADS[from.ordinal()][to.ordinal()];
        }
    }

     


    두번째 예시 - 좋은 예시. EnumMap()으로 연결

    두 개의 Enum을 연결하고 싶다면 EnumMap()을 두 개 사용해서 연결하는 것이 좋다.  아래에서는 EnumMap을 중첩으로 사용해서 From, To를 연결했다. 

    • 바깥 EnumMap은 From을 나타냄. 바깥 EnumMap은 Value로 안쪽 EnumMap을 가짐.
    • 안쪽 EnumMap은 To를 나타냄. 안쪽 EnumMap은 Value로 상전이 enum을 가짐. 
    public enum PhaseGood {
        SOLID, LIQUID, GAS;
    
        public enum TransitionGood {
            
            MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS),
            CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);
            
            PhaseGood from;
            PhaseGood to;
    
            TransitionGood(PhaseGood from, PhaseGood to) {
                this.from = from;
                this.to = to;
            }
    
            private static final EnumMap<PhaseGood, EnumMap<PhaseGood, TransitionGood>> m =
                    Stream
                            .of(values())
                            .collect(
                                    Collectors.groupingBy(t -> t.from,
                                                        () -> new EnumMap<>(PhaseGood.class),
                                    Collectors.toMap(t -> t.to, t -> t, 
                                    (x, y) -> y, () -> new EnumMap<>(PhaseGood.class)
                                    )));
    
            public static TransitionGood from(PhaseGood from, PhaseGood to) {
                return m.get(from).get(to);
            }
        }
    }

    이렇게 했을 때는 위의 세 가지 문제가 모두 해결된다.

    1. 배열의 구성을 직접 고려하지 않아도 됨. Transaction, Phase에 적절한 값만 추가하면 EnumMap은 자동으로 만들어짐. 
    2. 변경 지점이 줄어듦. 

    위에서처럼 플라즈마를 하나 추가한다고 하면, 아래와 Enum에 IONIZED, DEIONIZED를 추가하기만 하면 된다. 그리고 그에 따른 EnumMap은 배열처럼 내가 하나하나 업데이트 하지 않아도 된다. 견고하고 사용성도 더 좋아진다. 

    public enum PhaseGoodAdd {
        SOLID, LIQUID, GAS, PLASMA;
    
        public enum TransitionGood {
    
            MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS),
            CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
            IONIZED(GAS, PLASMA), DEIONIZED(PLASMA, GAS);
    
            PhaseGoodAdd from;
            PhaseGoodAdd to;
    
            TransitionGood(PhaseGoodAdd from, PhaseGoodAdd to) {
                this.from = from;
                this.to = to;
            }
    
            private static final EnumMap<PhaseGoodAdd, EnumMap<PhaseGoodAdd, TransitionGood>> m =
                    Stream
                            .of(values())
                            .collect(
                                    Collectors.groupingBy(t -> t.from,
                                                        () -> new EnumMap<>(PhaseGoodAdd.class),
                                    Collectors.toMap(t -> t.to, t -> t,
                                    (x, y) -> y, () -> new EnumMap<>(PhaseGoodAdd.class)
                                    )));
    
            public static TransitionGood from(PhaseGoodAdd from, PhaseGoodAdd to) {
                return m.get(from).get(to);
            }
        }
    }

     

    댓글

    Designed by JB FACTORY