[Effective Java 3/E] ITEM 34. int 상수 대신 열거 타입을 사용하라

대연.

·

2021. 3. 28. 17:34

열거(이하 Enum) 타입이 도입되기 전에는 상수를 정의할 때에는 정수 열거 패턴(int enum pattern)을 사용했다.

 

//DO NOT
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;

 

쓰지 말아야 할 이유

  1. 이 정수 열거 패턴은 type safe를 보장할 수 없다.(APPLE_FUJI==ORANGE_NAVEL의 결과는?)
  2. 개발자의 의도는 APPLE과 ORANGE를 분리하여 사용하고자 했을 것이다. namespace를 컴파일러가 아닌 개발자에게 맡겨버렸다.
  3. 컴파일하면 클라이언트 파일에 그대로 새겨진다.
  4. 상수의 값을 바꾸려면 다시 컴파일해야 했다.
  5. 디버깅할 때 의미나 문자열이 아닌 숫자로 보이기 때문에 디버그하기 힘들다.
  6. 순회를 하기 힘들다. 해당 가상의 namespace 안에 몇 개의 상수가 있을까?

쓰지 말아야할 이유는 차고 넘친다.

 

Enum 타입이란?

일정 갯수의 상수 값을 정의한 다음 그 외의 값은 허용하지 않는 타입이다. 상수의 namespace 역할을 같이 한다. 카드의 종류, 사계절 등이 좋은 예가 된다.

 

Enum 타입의 특징

  1. 열거 타입은 완전한 형태의 클래스다.
  2. 상수 하나당 인스턴스가 하나씩 만들어 진다. (public static final)
  3. 생성자를 제공하지 않는다.
  4. 열거타입 선언으로 생성된 인스턴스는 유일함이 보장된다.
  5. 컴파일 타임 safety check가 가능하다.
  6. 위에서 살펴본 int enum pattern에서 발생하는 type safe 문제가 발생하지 않는다. 오렌지 타입과 사과 타입의 변수는 다른 타입 취급을 받는다.
  7. 열거 타입의 상수의 순서를 바꾸거나 추가해도 재컴파일이 필요하지 않다.
  8. 참고 : https://stackoverflow.com/questions/17592584/enum-requires-no-recompilation-of-its-clients-how
  9. 열거 타입에는 임의의 메서드나 필드를 추가할 수 있다만약 특정 상수를 제거하고, 그 상수를 참조하는 프로그램은? 컴파일 단계에서 문제를 잡아낼 수 있다. int enum pattern에서는 기대도 할 수 없던 장점이다.
  10. 클래스이기에 가능한 일이다. 앞의 과일의 예를 든다면, 과일의 색을 알려주거나 과일 이미지를 반환하는 메서드도 구현할 수 있다. 이를 통해 고차원의 추상 개념 하나를 완벽하게 표현할 수 있다.
  11. 인터페이스도 구현할 수 있다.
  12. 잘 만들어졌다.
  13. Comparable과 Serializable을 높은 품질로 구현해냈다.
  14. 열거 타입은 근본적으로 불변이다.
  15. 순회가 쉽다. 
  16. public class WeightTable { public static void main(String[] args) { double earthWeight = Double.parseDouble(args[0]); double mass = earthWeight / Planet.EARTH.surfaceGravity(); for (Planet p : Planet.values()) System.out.printf("%s에서의 무게는 %f이다.%n", p, p.surfaceWeight(mass)); } }
  17. 아래 예시에서 보여지는 Planet enum type을 기준으로, 각 행성에서의 무게를 출력하는 코드도 가능하다.

 

예제1 : 태양계의 행성

public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS  (4.869e+24, 6.052e6),
    EARTH  (5.975e+24, 6.378e6),
    MARS   (6.419e+23, 3.393e6),
    JUPITER(1.899e+27, 7.149e7),
    SATURN (5.685e+26, 6.027e7),
    URANUS (8.683e+25, 2.556e7),
    NEPTUNE(1.024e+26, 2.477e7); // 안의 value는 각각 mass, radius로 대응

    private final double mass;           // 질량(단위: 킬로그램)
    private final double radius;         // 반지름(단위: 미터)
    private final double surfaceGravity; // 표면중력(단위: m / s^2)

    // 중력상수(단위: m^3 / kg s^2)
    private static final double G = 6.67300E-11;

    // 생성자. 특징2번 참조. MERCURY, VENUS...가 각각의 인스턴스가 되고
        // 생성자를 호출할 때 괄호안의 value들이 순서대로 args로 들어간다.
        // 설명 1번.
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
                //표면중력을 계산해서 필드로 따로 가지고 있다. 
                //표면중력을 매번 필요할 때 마다 계산하는 대신
                //mass와 radius가 변하지 않아 surfaceGravity도 변하지 않기 때문에
                //미리 캐싱해 가지고 있는다.
    }

    public double mass()           { return mass; }
    public double radius()         { return radius; }
    public double surfaceGravity() { return surfaceGravity; }

    public double surfaceWeight(double mass) {
        return mass * surfaceGravity;  // F = ma
    }
}
  1. enum 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 나열도니 순서대로 받아 인스턴스 필드에 저장한다.
  2. 불변이기 때문에 모든 필드는 final이어야 한다.
  3. private으로 각 멤버 필드를 선언하고 public getter를 선언하는게 낫다. (ITEM 16 참조)
  4. 위 예제에는 없지만, private와 public을 잘 구분해서 구현해야 한다.(ITEM 15)
  5. 널리 쓰이는 enum 타입은 top-level 클래스로 만든다.(1파일 1클래스). 특정 클래스에서만 쓰이는 enum 타입은 해당 클래스의 멤버 클래스로 만든다.

 

예제2 : 계산기

//DO NOT

public enum Operation {
    PLUS,MINUS,TIMES,DIVDE;

    public double apply(double x, double y) {
        switch (this) {
            case PLUS:
                return x + y;
            case MINUS:
                return x - y;
            case TIMES:
                return x * y;
            case DIVDE:
                return x / y;
        }
        throw new AssertionError("알 수 없는 연산:" + this);
    }
}

 

사칙연산을 하는 계산기를 enum 타입으로 구현한다면 어떻게 해야할까? 위의 예제대로 하는 것은 좋은 방법이 아니다. 그 이유는

  1. throw문을 생략할 수 없다.참조 : https://docs.oracle.com/javase/specs/jls/se8/html/jls-14.html#jls-14.21
  2. 실제 실행에선 throw문이 호출될 일은 없지만, 기술적으로는 도달할 수 있다.
  3. 깨지기 쉬운 코드다.
  4. 새로운 연산자를 추가하면 case문도 하나 추가하여야 한다. 만약 개발자가 상수를 추가하고 case문 추가를 까먹었다면, 런타임 오류를 띄우게 된다.

 

대신에 enum에서는 상수별로 다르게 동작하는 코드를 구현하는 apply라는 키워드를 제공한다. apply라는 추상 메서드를 선언하고 각 상수별 클래스 몸체(constant-specific class body), 즉 상수별 메서드 구현(constant-specific method implementation)을 하는 것이다.

public enum Operation {
    PLUS("+") {
        public double apply(double x, double y) { return x + y; }
    },
    MINUS("-") {
        public double apply(double x, double y) { return x - y; }
    },
    TIMES("*") {
        public double apply(double x, double y) { return x * y; }
    },
    DIVIDE("/") {
        public double apply(double x, double y) { return x / y; }
    };

    private final String symbol;

    Operation(String symbol) { this.symbol = symbol; }

    @Override public String toString() { return symbol; }

    public abstract double apply(double x, double y);

    // 코드 34-7 열거 타입용 fromString 메서드 구현하기 (216쪽)
    private static final Map<String, Operation> stringToEnum =
            Stream.of(values()).collect(
                    toMap(Object::toString, e -> e));

    // 지정한 문자열에 해당하는 Operation을 (존재한다면) 반환한다.
    public static Optional<Operation> fromString(String symbol) {
        return Optional.ofNullable(stringToEnum.get(symbol));
    }

    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        for (Operation op : Operation.values())
            System.out.printf("%f %s %f = %f%n",
                    x, op, y, op.apply(x, y));
    }
}
  1. apply 메서드를 abstract로 선언함으로써 새로운 타입을 추가하더라도 overriding을 컴파일러가 강제함으로써 빠뜨릴 가능성이 없다.
  2. toString 메서드는 symbol을 리턴한다
  3. toString을 구현했으니 그 역함수 역할을 하는 fromString도 구현할 수 있다.참고 : http://homoefficio.github.io/2019/10/03/Java-Optional-바르게-쓰기/
  4. Operation 상수는 static 필드가 초기화될 때 stringToEnum의 map으로 추가된다. return type이 Optional이다.
  5. 해당 방법은 코드를 공유할 수 없는 단점이 있다.
  6. 중복되는 부분이 많으면 중복되는 모든 코드를 상수에 작성하거나 혹은 helper 메서드를 작성하는 방법이 있다. 아래 전략 열거 타입패턴을 참조.

 

예제3 : 전략 열거 타입 패턴

public enum PayrollDay {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minutesWorked, int payRate) {

        int basePay = minutesWorked * payRate;

        int overtimePay;
        switch (this) {
            case SATURDAY: case SUNDAY:             // 주말
                overtimePay = basePay / 2;
                break;

            default: // 주중
                overtimePay = minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }

        return basePay + overtimePay;
    }
}

 

주말과 주중의 수당 계산을 하는 코드다. 평일용 수당인 overtimePay 메서드를 정의하고 주말 상수에서만 재정의 하여 고치면 코드의 양은 줄겠지만, 만약 새로운 상수를 추가하면서 overtimePay를 재정의하는 것을 잊는다면 평일 수당 계산을 받을 것이다.

 

enum PayrollDay {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
    THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);
    // (역자 노트) 원서 1~3쇄와 한국어판 1쇄에는 위의 3줄이 아래처럼 인쇄돼 있습니다.
    // 
    // MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    // SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
    //
    // 저자가 코드를 간결하게 하기 위해 매개변수 없는 기본 생성자를 추가했기 때문인데,
    // 열거 타입에 새로운 값을 추가할 때마다 적절한 전략 열거 타입을 선택하도록 프로그래머에게 강제하겠다는
    // 이 패턴의 의도를 잘못 전달할 수 있어서 원서 4쇄부터 코드를 수정할 계획입니다.

    private final PayType payType;

    PayrollDay(PayType payType) { this.payType = payType; }
    // PayrollDay() { this(PayType.WEEKDAY); } // (역자 노트) 원서 4쇄부터 삭제

    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate);
    }

    // 전략 열거 타입
    enum PayType {
        WEEKDAY {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 :
                        (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };

        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;

        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}

 

상수에 잔업수당을 계산할 전략을 포함시키고, 중첩한 enum 클래스에 전략에 따른 계산을 위임하는 식으로 작성한다. 코드가 좀 복잡하지만 안전하고 유연하다.

예제 4: 기존 enum 타입에 원래 열거타입에 없는 상수별 동작을 구현할 때는 switch도 좋은 대안이 될 수 있다.

 

public class Inverse {
    public static Operation inverse(Operation op) {
        switch(op) {
            case PLUS:   return Operation.MINUS;
            case MINUS:  return Operation.PLUS;
            case TIMES:  return Operation.DIVIDE;
            case DIVIDE: return Operation.TIMES;

            default:  throw new AssertionError("Unknown op: " + op);
        }
    }
}

 

결론

  • 컴파일 타입에 필요한 원소를 다 알 수 있는 상수의 집합이라면 열거 타입을 사용하자.
  • 카드 종류같은 집합은 물론 메뉴의 종류, 명령줄 플래그 등 허용되는 값이 정해져 있는 경우에도 마찬가지다.

0개의 댓글