3 minute read

ordinal 인덱싱

간혹 배열 혹은 리스트에서 원소를 꺼낼 때 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;
    }
}
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.Lifecycle.values().length];  // 형변환
for (int i = 0 ; i < plantsByLifeCycle.length ; i++) {  
    plantsByLifeCycle[i] = new HashSet<>();  
}  

List<Plant> garden = Arrays.asList(  
        new Plant("ANNUAL_TREE_1", Plant.Lifecycle.ANNUAL),  
        new Plant("ANNUAL_TREE_2", Plant.Lifecycle.ANNUAL),  
        new Plant("ANNUAL_TREE_3", Plant.Lifecycle.ANNUAL),  
        new Plant("BIENNIAL_TREE_1", Plant.Lifecycle.BIENNIAL),  
        new Plant("PERENNIAL_TREE_1", Plant.Lifecycle.PERENNIAL)  
);
  
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]);  // 직접 작성해 주어야 한다.
}

  • 배열은 제네릭과 호환되지 않으므로, 비검사 현변환을 수행해야 하며 깔끔하게 컴파일되지 않을 것이다.
  • 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
  • 정확한 값을 사용한다는 것을 클라이언트 코드 작성자가 직접 보증해야 한다. (not type-safe)

EnumMap을 사용해 매핑하기

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

  • toString 이 적절하게 구현되어 있으므로 별도 포매팅이 필요없다.
  • 로직이 더욱 간단해졌다.
  • 안전하지 않은 형변환을 쓰지 않는다.
  • 배열 인덱스 계산 과정에서 오류 가능성도 사라졌다.
  • Set[] 을 사용하여 관리하는 것보다 EnumMap을 사용하는 것이 간결하고 성능도 비슷하다.
    • EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다. 구현을 내부로 숨겨 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것이다.
  • EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다. item 33

Stream 을 사용한 코드 개선

Map<Plant.Lifecycle, List<Plant>> newGarden =  
        garden.stream()  
                // ANNUAL -> BIENNIAL -> PERENNIAL 순으로 확인
                .sorted((o1, o2) -> o2.lifeCycle.ordinal() - o1.lifeCycle.ordinal())  
                .collect(groupingBy(p -> p.lifeCycle));  
  
System.out.println(newGarden);

  • 이 코드는 EnumMap이 아닌 고유한 맵 구현체를 사용하기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 발생한다.
EnumMap<Plant.Lifecycle, Set<Plant>> newgarden = garden.stream()  
        .collect(groupingBy(  
                        p -> p.lifeCycle, 
                        () -> new EnumMap<>(Plant.Lifecycle.class), toSet()));  
                        // mapFactory 매개변수에 원하는 구현체 명시해 호출한다.
  
System.out.println(newgarden);
  • 매개변수 3개짜리 Collections.groupingBy 메서드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.
  • 위 코드는 Map을 빈번하게 사용하는 프로그램에서 최적화하는 방법이다.
// Collectors의 groupingBy 메서드
public static <T, K, D, A, M extends Map<K, D>>  
Collector<T, ?, M> groupingBy(Function<? super T, ? extends K> classifier,  
                              Supplier<M> mapFactory,  
                              Collector<? super T, A, D> downstream)

중첩 EnumMap

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()]; // 이렇게 쓰지 말자..
        }
    }
}

위는 두 개의 열거 타입을 억지로 매핑하기 위해 ordinal을 두 번이나 쓴 잘못된 방법의 예시이다.

  • 컴파일러가 ordinal과 배열 인덱스의 관계를 알 수 없다.
  • PhaseTransition 열거 타입을 수정하면서 배열 TRANSITIONS 를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 발생할 것이다.
  • ArrayIndexOutOfBoundsException 이나 NullPointerException 을 던질 수도 있고, 예외도 발생시키지 않고 이상하게 동작할 수 있다.
  • 상전이 표(배열)의 크기는 상태의 가짓수가 늘어나면 제곱해서 커지며, null로 채워지는 칸도 늘어날 것이다.

이렇게 사용하지 말고 중첩된 EnumMap을 사용하자.

public 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;
        }
        
        // 상전이 맵 초기화 (이전 상태에서 이후 상태 & 전이 InnerMap 에 대응시키는 맵)
        private static final Map<Phase, Map<Phase, Transition>> m =
                Stream.of(values()) // enum 타입 두 개를 매핑한 필드 리스트
                        .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);
        }
    }
}

위와 같이 중첩 EnumMap을 사용하는 경우 2차원 배열로 선언하였을 때보다 새로운 상태를 추가하기도 용이할 것이다.

public enum Phase {
    SOLID, LIQUID, GAS,
    // 신규 PLASMA 추가
    PLASMA;

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

        ...
}

상태 목록에 PLASMA를 추가하고, 전이 목록에 IONIZE(GAS, PLASMA)DEIONIZE(PLASMA, GAS)만 추가하면 끝이다. 반면 이전 코드에서 새로운 상수를 추가할 경우, 상수 3가지 뿐만 아니라 - 원소 9개 짜리인 2차원 배열을 원소 16개로 교체해야 한다. 런타임에 문제가 생길 가능성 또한 높다.

Leave a comment