본문 바로가기
JAVA/Effective Java

item 37. ordinal 인덱싱 대신 EnumMap을 사용하라

by Garonguri 2022. 6. 3.
728x90

Ordinal 메소드를 기억하고 있나요

: 열거 타입 내에서 열거형 인스턴스의 순서를 반환하는 메소드 인 것을 기억 할 것이다. 어떤 상수의 위치를 반환한다고 생각하면 된다.

 

 

정원에 심은 식물들을 생애 주기별로 관리하는 예제를 확인해보자.

public static 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;
    }
}

 

 

1. 어떤 프로그래머들은 위 생애 주기를 배열로 관리하며 위 배열의 인덱스는 ordinal 값으로 사용 할 것이다.

public static void indexByOrdinal(List<Plant> garden){

	//배열은 제네릭과 호환되지 않으므로 비검사 형변환을 수행해야 하고, 깔끔히 컴파일 되지도 않는다.
    Set<Plant>[] plantsByLifeCycle = 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]);
    }
}

배열 인덱스로 ordinal()을 사용했을 시의 문제점은 다음과 같다.

  1. 배열은 제네릭과 호환되지 않으므로 비검사 형변환을 수행해야 하고, 깔끔히 컴파일되지 않는다.
  2. 배열은 각 인덱스의 의미를 모르므로 출력 결과에 직접 레이블을 달아야 한다.
  3. 정확한 정수값을 사용한다는 것을 프로그래머가 집접 보증해야 한다.
    1. 정수는 열거 타입과 달리 타입 안전하지 않기 때문이다.
    2. 잘못된 동작임에도 계속해서 진행하거나 ArrayIndexOutOfBoundsException을 던질 수 있다.

상당히 구리다. 어떻게 하면 이 문제점을 해결할 수 있을까?

답은 EnumMap을 사용하는 것이다.

 

2.  위 생애 주기를 EnumMap을 사용해 데이터와 열거 타입을 매핑하는 방법

public static void indexByEnumMap(List<Plant> garden) {
    Map<Plant.LifeCycle, Set<Plant>> plantByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);

    for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
        plantByLifeCycle.put(lc, new HashSet<>());
    }

    for (Plant p : garden) {
        plantByLifeCycle.get(p.lifeCycle).add(p);
    }

    //자체적으로 출력용 문자열을 제공한다.
    System.out.println(plantByLifeCycle);
}

EnumMap을 이용해 데이터와 열거 타입을 매핑하면, 다음과 같은 장점이 존재한다.

  1. ordinal()을 사용한 것과 비슷한 성능을 내지만 코드는 더 간단명료해진다.
  2. 형변환을 사용하지 않으므로 타입 안전하다.
  3. 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공해 직접 레이블을 달 일이 없다.

왜 EnumMap을 사용하면 ordinal을 사용한거랑 비슷한 성능을 가지게 될까?

  • EnumMap의 내부에서 배열을 사용하기 때문이다.
  • 내부 구현 방식을 안으로 숨겨 Map의 타입 안정성과 배열의 성능을 모두 얻은 방법이다.
  • EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰이다. 런타임 제네릭 타입 정보를 제공한다.

코드를 더 줄이기 위해 Stream을 사용하는 방법도 있다.

 

3. Stream을 사용해 Map을 관리하는 방법 -1

static private List<Plant> garden;

public static void main(String[] args) {
    garden = Arrays.asList(
            new Plant("index:0_ANNUAL_1", Plant.LifeCycle.ANNUAL),
            new Plant("index:1_ANNUAL_2", Plant.LifeCycle.ANNUAL),
            new Plant("index:2_ANNUAL_3", Plant.LifeCycle.ANNUAL),
            new Plant("index:3_BIENNIAL_1", Plant.LifeCycle.BIENNIAL),
            new Plant("index:4_PERENNIAL_1", Plant.LifeCycle.PERENNIAL)
    );

    "stream을 사용한다."
    System.out.println(item37.garden.stream().collect(groupingBy(p->p.lifeCycle)));
}

하지만 얘는 EnumMap이 아닌 고유한 맵 구현체를 사용한다. 

따라서 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다.

 

3. Stream을 사용해 Map을 관리하는 방법 -2

"EnumMap을 사용해 데이터와 열거 타입을 매핑한 방법"
System.out.println(item37.garden.stream().collect(groupingBy(p->p.lifeCycle,
        () -> new EnumMap<>(Plant.LifeCycle.class), toSet())));
        //식물의 생애 주기 당 하나씩 중첩 맵을 만든다.

mapFactory에 원하는 맵 구현체를 명시해 호출할 수 있다.


Stream 버전 (3-1)의 경우 식물의 생애 주기에 속하는 식물이 있을 때만 Map을 만든다.

EnumMap 버전 (3-2)의 경우 식물의 생애 주기당 하나씩 Map을 만든다.


언제나 EnumMap은 ordinal()을 사용하는 것 보다 낫다. 이차원 배열(?)의 경우에도 마찬가지이다.

 

public enum Phase {
    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

		//행은 from, 열은 to
        private static final Transition[][] TRANSITIONS = {
                {null, MELT, SUBLIME},
                {FREEZE, null, BOIL},
                {DEPOSIT, CONDENSE, null}
        };

		"억지로 억지로~ ordinal을 두 번 사용했다."
        public static Transition from(Phase from, Phase to) {
            return TRANSITIONS[from.ordinal()][to.ordinal()];
        }
    }
}

두 열거타입 값들을 매핑하느라 ordinal을 두 번이나 쓴 나쁜 예가 있다.

이를 사용한다면 다음과 같은 단점이 존재한다.

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

이 예시도 EnumMap을 사용하면 위와 같은 단점을 예방할 수 있다.

 

from, to를 표현하기 위해 Map2개를 중첩하여 표현한다.

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 final Phase from;
        private final Phase to;

        Transition(Phase from, Phase to) {
            this.from = from;
            this.to = to;
        }

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

groupingBy에서 전이를 이전 상태(from) 기준으로 묶고, toMap에서는 이후 상태(to)를 대응시키는 EnumMap을 생성한다.

새로운 상태를 추가하더라도 상태 목록과 전이 목록에만 추가하면 된다.

 

참 쉽죠?

핵심 정리

배열의 인덱스를 얻기 위해 ordinal을 쓰지 말아라. EnumMap을 사용해라.
728x90

댓글