본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

[아이템 34] int 상수 대신 열거 타입을 사용하라

[Why]

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;

정수 열거 패턴(int enum pattern) 기법에는 단점이 많다.

  • 타입 안전을 보장할 방법이 없다.
  • 오렌지를 건네야 할 메서드에 사과를 보내고 동등 연산자(==)로 비교하더라도 컴파일러는 아무런 경고 메시지를 출력하지 않는다.
  • 자바가 정수 열거 패턴을 위한 별도 이름공간(namespace)을 지원하지 않는다.
    어쩔 수 없이 접두어(APPLE_ , ORANGE_)를 써서 이름 충돌을 방지한다.
  • 평범한 상수를 나열한 것이기 때문에 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다.
    상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.
  • 정수 상수는 문자열로 출력하기가 다소 까다롭다.
    값을 출력하거나 디버거로 살펴보면 의미가 아닌 숫자로만 보여서 도움이 되지 않는다.
  • 정수 대신 문자열 상수를 사용하는, 문자열 열거 패턴(string enum pattern)도 있는데 이것은 더 나쁘다.
    상수의 의미를 출력할 수는 있는데, 문자열 값을 그대로 하드코딩하게 만들기 때문이다.

 

[What]

열거 타입(enum type)

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
  • 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다.
  • 싱글턴(아이템3)은 원소가 하나뿐인 열거 타입이라 할 수 있다.

열거 타입의 장점

  • 컴파일타임 타입 안전성을 제공한다.
    Apple 열거 타입을 매개변수로 받는 메서드를 선언했다면, 건네받은 참조는 null 아닌 한 Apple의 세 가지 값 중 하나임이 확실하다.
  • 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.
  • 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다.
  • toString 메서드가 출력하기에 적합한 문자열을 내어준다.
  • 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.
  • Object 메서드들을 높은 품질로 구현해놨고, Comparable과 Serializable을 구현했으며 그 직렬화 형태도 웬만큼 변형을 가해도 문제없이 동작하게끔 구현해놨다.
  • 제거된 상수를 참조하는 클라이언트는 컴파일 시 해당 라인에서 디버깅에 유용한 메시지를 담은 컴파일 오류를 발생시킨다.

 

[When]

필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.

 

[How]

메서드나 필드 추가

  • 각 상수와 연관된 데이터를 해당 상수 자체에 내재시키고 싶을 때
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);
    
    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;
    
    // 생성자
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
    }
    
    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
    }
}
  • 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
  • 열거 타입은 근본적으로 불변이라 모든 필드는 final이어야 한다. (아이템17)
  • 필드를 public으로 선언해도 되지만, private으로 두고 별도의 public 접근자 메서드를 두는 게 낫다. (아이템16)
  • 열거 타입은 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. 값들은 선언된 순서로 저장된다.

더 다양한 기능을 제공하는 상수

  • 상수마다 동작이 달라져야 하는 상황
public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;
    
    // 상수가 뜻하는 연산을 수행한다.
    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 DIVIDE: return x / y;
        }
        throw new AssertionError("알 수 없는 연산: " + this);
    }
}
  • 동작하지만 예쁘지 않다.
  • 마지막 throw 문은 실제로 도달할 수 없지만 생략하면 컴파일이 안 된다.
  • 새로운 상수를 추가하면 해당 case문도 추가해야 한다.
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);
}
  • 열거 타입에 apply라는 추상 메서드를 선언하고 각 상수별 클래스 몸체(constant-specific class body)
    즉, 각 상수에서 자신에 맞게 재정의하는 방법이다.
  • 이를 상수별 메서드 구현(constant-specific method implementation)이라 한다.
  • apply가 추상 메서드이므로 재정의하지 않았다면 컴파일 오류로 알려준다.

toString 메서드의 재정의

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

 

  • 열거 타입의 toString 메서드를 재정의하려거든, toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 fromString 메서드도 함께 제공하는 걸 고려해보자.
  • Operation 상수가 stringToEnum 맵에 추가되는 시점은 열거 타입 상수 생성 후, 정적 필드가 초기화될 때다.
  • 위 코드는 values 메서드가 반환하는 배열 대신 스트림을 사용했다.
    • values가 반환한 배열을 순회하며 {문자열, 열거 타입 상수} 쌍을 맵에 추가하는 방식을 써도 된다.
    • 하지만 열거 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다. 컴파일 오류가 난다.
    • 열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐이다. (아이템24)
    • 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 꼭 필요하다.
  • fromString이 Optional<Operation>을 반환하는 점도 주의하자.

상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.

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;
    }
}
  • 이 열거 타입은 직원의 (시간당) 기본 임금과 그날 일한 시간(분 단위)이 주어지면 일당을 계산해주는 메서드를 갖고 있다.
    • 주중에 오버타임이 발생하면 잔업수당이 주어지고, 주말에는 무조건 잔업수당이 주어진다.
  • 분명 간결하지만, 관리 관점에서는 위험한 코드다.
    • 휴가와 같은 새로운 값을 열거 타입에 추가하려면 그 값을 처리하는 case 문을 잊지 말고 쌍으로 넣어줘야 한다.
    • 만약 깜빡하게 되면 휴가 기간에 열심히 일해도 평일과 똑같은 임금을 받게 된다.
  • 상수별 메서드 구현으로 급여를 정확히 계산하는 방법은 다음과 같다.
    • 첫째, 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣으면 된다.
    • 둘째, 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 적절히 호출하며 된다.
    • 하지만 두 방식 모두 코드가 장황해져 가독성이 크게 떨어지고 오류 발생 가능성이 높아진다.
    • 가장 깔끔한 방법은 새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것이다.
enum PayrollDay {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
    THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);
    
    private final PayType payType;
    
    PayrollDay(PayType payType) { this.payType = payType; }
    
    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);
        }
    }
}
  • 잔업수당 계산을 private 중첩 열거 타입으로 옮기고 PayrollDay 열거 타입의 생성자에서 이중 적당한 것을 선택한다.
    그러면 PayrollDay 열거 타입은 잔업수당 계산을 그 전략 열거 타입에 위임하여, switch문이나 상수별 메서드 구현이 필요없게 된다.
  • switch 문은 열거 타입의 상수별 동작을 구현하는 데 적합하지 않다.
    • 하지만 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch문이 좋은 선택이 될 수 있다.
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("알 수 없는 연산: " + op);
    }
}
  • 추가하려는 메서드가 의미상 열거 타입에 속하지 않는다면 직접 만든 열거 타입이라도 이 방식을 적용하는 게 좋다.
  • 종종 쓰이지만 열거 타입 안에 포함할만큼 유용하지 않은 경우도 마찬가지다.