ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 34. int 상수 대신 열거 타입을 사용하라
    Book/Effective Java 3E 2022. 12. 28. 23:20
    반응형

      열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입입니다. 사계절, 태양계의 행성, 카드게임의 카드 종류 등이 좋은 예입니다. 자바에서 열거 타입을 지원하기 전에는 다음 코드처럼 정수 열거 패턴을 사용했습니다.

    // 코드 34-1 정수 열거 패턴 - 상당히 취약하다!
    public static final int APPLE_FUJI         = 0;
    public static final int APPLE_PIPPIN       = 1;
    public static final int APPLE_GRANNY_SMITH = 2;

      정수 열거 패턴(int enum pattern) 기법은 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않습니다. 또한 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 합니다. 다시 컴파일하지 않은 클라이언트는 실행이 되더라도 엉뚱하게 동작할 것입니다.

     

    열거 타입(enum type)

    // 코드 34-2 가장 단순한 열거 타입
    public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }

      C, C++, C# 같은 다른 언어의 열거 타입과 비슷하지만, 자바의 열거 타입은 완전한 형태의 클래스라서 (단순한 정숫값일 뿐인) 다른 언어의 열거 타입보다 훨씬 강력합니다.

    • 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개합니다.
    • 열거 타입 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final입니다.
    • 싱글턴(Item 3)은 원소가 하나뿐인 열거 타입이라 할 수 있고, 거꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있습니다.
    • 열거 타입은 컴파일타임 타입 안전성을 제공합니다. 코드 34-2의 Apple 열거 타입을 매개변수로 받는 메서드를 선언했다면, 건네받은 참조는 (null이 아니라면) Apple의 세 가지 값 중 하나입니다. 다른 타입의 값을 넘기려 하면 컴파일 오류가 납니다.
    • 열거 타입에는 각자의 이름공간(코드 34-2의 Apple)이 있어서 이름이 같은 상수도 공존합니다.
    • 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 됩니다. 공개되는 것이 오직 필드의 이름뿐이라, 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문입니다.
    • 열거 타입의 toString 메서드는 출력하기에 적합한 문자열을 내어줍니다.
    • 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있습니다.

     

      태양계의 여덟 행성은 거대한 열거 타입을 설명하기에 좋은 예입니다. 각 행성에는 질량과 반지름이 있고, 이 두 속성을 이용해 표면중력을 계산할 수 있습니다. 따라서 어떤 객체의 질량이 주어지면 그 객체가 행성 표면에 있을 때의 무게도 계산할 수 있습니다. 이 열거 타입의 모습은 다음과 같습니다. 각 열거 타입 상수 오른쪽 괄호 안 숫자는 생성자에 넘겨지는 매개변수로, 이 예에서는 행성의 질량과 반지름입니다.

    package effectivejava.chapter6.item34;
    
    // 코드 34-3 데이터와 메서드를 갖는 열거 타입
    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
        }
    }

     

      열거 타입은 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공합니다. 값들은 선언된 순서로 저장됩니다. 각 열거 타입 값의 toString 메서드는 상수 이름을 문자열로 반환하므로 println과 printf로 출력하기에 안성맞춤입니다. 기본 toString이 제공하는 이름이 내키지 않으면 재정의하면 됩니다.

      어떤 객체의 지구에서의 무게를 입력받아 여덟 행성에서의 무게를 출력하는 일은 다음처럼 짧은 코드로 작성할 수 있습니다.

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

     

      상수마다 동작이 달라져야 하는 상황도 있을 것입니다. 예컨대 사칙연산 계산기의 연산 종류를 열거 타입으로 선언하고, 실제 연산까지 열거 타입 상수가 직접 수행했으면 한다고 가정하면 다음과 같이 구현할 수 있습니다. 열거 타입에 apply라는 추상 메서드를 선언하고 각 상수별 클래스 몸체(constant-specific class body), 즉 각 상수에서 자신에 맞게 재정의하는 방법입니다. 이를 상수별 메서드 구현(constant-specific method implementation)이라 합니다.

    package effectivejava.chapter6.item34;
    import java.util.*;
    import java.util.stream.Stream;
    
    import static java.util.stream.Collectors.toMap;
    
    // 코드 34-6 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입
    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 메서드 구현하기
        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));
        }
    }

     

      , 상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있습니다. 급여명세서에서 쓸 요일을 표현하는 열거 타입을 예로 생각해 봅시다. 이 열거 타입은 직원의 (시간당) 기본 임금과 그날 일한 시간(분 단위)이 주어지면 일당을 계산해주는 메서드를 갖고 있습니다. 주중에 오버타임이 발생하면 잔업수당이 주어지고, 주말에는 무조건 잔업수당이 주어집니다. switch 문과 상수별 메서드 구현으로도 가능하지만, 가장 깔끔한 방법은 새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것입니다. 잔업수당 계산을 private 중첩 열거 타입(다음 코드의 PayType)으로 옮기고 PayrollDay 열거 타입의 생성자에서 이 중 적당한 것을 선택합니다.

    package effectivejava.chapter6.item34;
    
    import static effectivejava.chapter6.item34.PayrollDay.PayType.*;
    
    // 코드 34-9 전략 열거 타입 패턴
    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);
            }
        }
    
        public static void main(String[] args) {
            for (PayrollDay day : values())
                System.out.printf("%-10s%d%n", day, day.pay(8 * 60, 1));
        }
    }

     

      대부분의 경우 열거 타입의 성능은 정수 상수와 별반 다르지 않습니다. 열거 타입을 메모리에 올리는 공간과 초기화하는 시간이 들긴 하지만 체감될 정도는 아닙니다.

     

    열거 타입은 확실히 정수 상수보다 뛰어나다.

    더 읽기 쉽고 안전하고 강력하다.

    대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다.

    드물게는 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다.

    이런 열거 타입에서는 switch문 대신 상수별 메서드 구현을 사용하자.

    열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자.

     

    [참고 정보]

    이펙티브 자바 Effective Java 3/E 도서 [조슈아 블로크 ]

    이펙티브 자바 깃허브 저장소

    <이펙티브 자바, 3판> 번역 용어 해설

    반응형

    댓글

Designed by Tistory.