티스토리 뷰

이따금 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드(아이템 35)로 인덱스를 얻는 코드가 있습니다.

식물을 간단히 나타낸 다음 클래스를 예로 살펴봅시다.

class Plant {
    enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }

    final String name;
    final LifeCycle lifeCycle;

    Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

    @Oveeride public String toString() {
        return name;
    }
}

이제 정원에 심은 식물들을 배열 하나로 관리하고, 이들을 생애주기별로 묶어봅시다.

생애주기별로 총 3개의 집합을 만들고 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣습니다.

이 때 어떤 프로그래머는 집합들을 배열 하나에 넣고 생애주기의 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);

// 결과 출력
for (int i = 0;i < plantsByLifeCycle.length; i++) {
    System.out.printf("%s : %s%n",
        Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

동작은 하지만 문제가 한가득입니다.

배열은 제네릭과 호환되지 않으니(아이템 28) 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것입니다.

배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 하빈다.

가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 여러분이 직접 보증해야 한다는 점입니다.

정수는 열거 타입과 달리 타입 안전하지 않기 때문입니다.

잘못된 값을 사용하면 잘못된 동작을 묵묵히 수행하거나 (운이 좋다면) ArrayIndexOutOfBoundsException을 던질 것입니다.

 

훨씬 멋진 해결책이 있으니 걱정마십시오.

여기서 배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 합니다.

그러니 Map을 사용할 수도 있을 것입니다.

사실 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 존재하는데, 바로 EnumMap입니다.

다음은 위 코드를 수정하여 EnumMap을 사용하도록 한 코드입니다.

Map<Plant.LifeCylce, 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);

더 짧고 명료하고 안전하고 성능도 원래 버전과 비등합니다.

안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없습니다.

나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄됩니다.

EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문입니다.

내부 구현 방식을 안으로 숨겨서 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것입니다.

여기서 EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공합니다. (아이템 33)

 

스트림(아이템 45)을 사용해 맵을 관리하면 코드를 더 줄일 수 있습니다.

다음은 앞 예의 동작을 거의 그대로 모방한 가장 단순한 형태의 스트림 기반 코드입니다.

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

이 예처럼 단순한 프로그램에서는 최적화가 굳이 필요 없지만, 맵을 빈번히 사용하는 프로그램에서는 꼭 필요할 것입니다.

스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작합니다.

EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만듭니다.

예컨대 정원에 한해살이와 여러해살이 식물만 살고 두해살이는 없다면, EnumMap 버전에서는 맵을 3개 만들고 스트림 버전에서는 2개만 만듭니다.

두 열거 타입 값들을 매핑하느라 ordinal을 (두 번이나) 쓴 배열들의 배열을 본 적이 있을 것입니다.

다음은 이 방식을 적용해 두 가지 상태(Phase)를 전이(Transition)와 매핑하도록 구현한 프로그램입니다.

예컨대 액체(LIQUID)에서 고체(SOLID)로의 전이는 응고(FREEZE)가 되고, 액체에서 기체(GAS)로의 전이는 기화(BOIL)가 됩니다.

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과 배열 인덱스의 관계를 알 도리가 없습니다.

즉, Phase나 Phase.Transition 열거 타입을 수정하면서 상전이 표 TRANSITIONS를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 날 것입니다.

ArrayIndexOutOfBoundsException이나 NullPointerException을 던질 수도 있고, (운이 나쁘면) 예외도 던지지 않고 이상하게 동작할 수도 있습니다.

그리고 상전이 표의 크기는 상태의 가짓수가 늘어나면 제곱해서 커지며 null로 채워지는 칸도 늘어날 것입니다.

다시 이야기하지만 EnumMap을 사용하는 편이 훨씬 낫습니다.

전이 하나를 얻으려면 이전 상태(from)와 이후 상태(to)가 필요하니, 맵 2개를 중첩하면 쉽게 해결할 수 있습니다.

안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결합니다.

전이 전후의 두 상태를 전이 열거 타입 Transition의 입력으로 받아, 이 Transition 상수들로 중첩된 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 static final Map<Phase, Map<Phase, Transition>>
            m = Stream.of(values()).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);
        }
    }
}

상전이 맵을 초기화하는 코드는 제법 복잡합니다.

이 맵의 타입인 Map<Phase, Map<Phase, Transition>>은 "이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵" 이라는 뜻입니다.

이러한 맵을 초기화하기 위해 수집기(java.util.stream.Collector) 2개를 차례로 사용했습니다.

첫 번째 수집기인 groupingBy에서는 전이를 이전 상태를 기준으로 묶고, 두 번째 수집기인 toMap에서는 이후 상태를 전이에 대응시키는 EnumMap을 생성합니다.

두 번째 수집기의 병합 함수인 (x, y) → y는 선언만 하고 실제로는 쓰이지 않는데, 이는 단지 EnumMap을 얻으려면 맵 팩터리가 필요하고 수집기들은 점층적 팩터리를 제공하기 때문입니다.

이제 여기에 새로운 상태인 플라스마(PLASMA)를 추가해봅시다.

이 상태와 연결된 전이는 2개입니다.

기체에서 플라스마로 변하는 이온화(IONIZE), 플라스마에서 기체로 변하는 탈이온화(DEIONIZE)입니다.

public enum Phase {
    SOLID, LIQUID, GAS, PLASMA;

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

        // 상전이 맵을 초기화한다.
        private static final Map<Phase, Map<Phase, Transition>>
            m = Stream.of(values()).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);
        }
    }
}

나머지는 기존 로직에서 잘 처리해주어 잘못 수정할 가능성이 극히 작습니다.

실제 내부에서는 맵들의 맵이 배열들의 배열로 구현되니 낭비되는 시간과 공간도 거의 없이 명확하고 안전하고 유지보수하기 좋습니다.

정리

  • 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라.
  • 다차원 관계는 EnumMap<..., EnumMap<...>>으로 표현하라.
  • 애플리케이션 프로그래머는 Enum.ordinal을 (웬만하면) 사용하지 말아야 한다는 일반 원칙의 특수 사례다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
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
글 보관함