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

Loopy·2022년 8월 12일
0

이펙티브 자바

목록 보기
33/76
post-thumbnail

1️⃣ 열거 타입 개념

열거 타입이란, 일정 개수의 상수 값을 정의한 다음 그 외의 값은 허용하지 않는 타입이다.

열거 타입이 생기기 이전에는, 다음 코드처럼 상수를 한 묶음 선언해서 사용하고는 했다.

🔗 정수 열거 패턴 단점

▶️ 정수 열거 패턴

public static final int APPLE_FUHI = 0;
public static final int APPLE_PIPPEN = 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. 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
    오렌지를 건네야 할 메서드에 사과를 보내고 동등 연산자(==)로 비교하더라도 아무런 경고 메시지를 출력하지 않기 때문이다.

  2. 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.

  3. 정수 상수는 숫자로 표현되기 때문에 toString과 같이 의미가 담겨 잇는 문자열로 출력하기가 힘들다.

따라서 자바는, 정수 열거 패턴과 문자열 열거 패턴의 단점들을 극복하고 여러 장점을 안겨주는 "열거 타입"이라는 대안을 제시했다.

🔗 열거 타입의 특징

public enum Apple {FUJI, PIPPN, GRANNY_SMITH}
public enum Orange {NAVEL, TEMPLE, BLOOD}

1️⃣ 열거 타입은 인스턴스 통제된다.

열거 타입 자체는 클래스 이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.

밖에서 접근할 수 있는 생성자를 제공하지 않으므로, 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니 이러한 인스턴스들은 딱 하나씩만 존재하게 된다. 이러한 특징으로 싱글턴을 보장할 때 사용된다.

참고) JVM의 메모리 영역은 메소드 영역, 힙 영역, 스택 영역으로 나누어진다.
메소드 영역에는 클래스와 클래스 변수(static variable)가 올라가므로, 열거 타입 클래스도 이 메소드 영역에 올라가게 된다.또한, 힙 영역에는 객체 인스턴스가 올라간다.

Week holiday = Week.MONDAY;
System.out.println(holiday == Week.MONDAY);

메소드 영역에서 스택 영역으로 주소값만 복사하기 때문에, 결국 같은 열거 객체를 가리키는 것을 확인할 수 있다. 따라서 위 코드의 결과는 true 이다.

2️⃣ 열거 타입은 컴파일타입 타입 안전성을 제공한다.

Apple 열거 타입을 매개변수로 받는 메서드를 선언했다면, 건네 받은 참조는 세 가지 값중 하나임이 보장된다.(다른 타입의 값을 넘기면 컴파일 오류남)

3️⃣ 열거 타입에는 각자의 이름 공간이 있어서, 이름 중복을 허용한다.

공개되는 것은 오직 필드의 이름뿐이지 값이 아니기 때문에, 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다.

4️⃣ 열거 타입에 임의의 메서드나 필드를 추가하거나, 인터페이스를 구현할 수 있다.

2️⃣ 메서드와 필드를 갖는 열거 타입

각 상수와 연관된 데이터를 해당 상수 자체에 내재시키고 싶거나, 과일의 색을 알려주거나 과일 이미지를 반환하는 메서드를 추가하고 싶을 때가 있을 것이다. 이런 경우 아래와 같이 사용하면 좋다.

▶️ 데이터와 메서드를 갖는 열거 타입

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

이와 같이, 열거 타입 상수 각가을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

또한 열거 타입이 널리 쓰인다면 위의 코드와 같이 톱 레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스(아이템 24)로 만들자.

주의 사항 : 열거 타입은 불변이라 모든 필드는 final이어야 한다!(아이템 17)

어떤 객체의 지구에서의 무게를 입력받아, 여덟 행성에서의 무게를 출력하는 것은 Enum을 활용하면 너무 간단해진다.

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));
   }
}
  • Enum.values() : 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드(값들은 선언된 순서대로 저장)

🔗 값에 따라 분기하는 열거 타입

👉 하나의 메서드가 상수별로 다르게 동작하는 경우이다.

▶️ V1 : switch 문으로 분기하는 열거 타입

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;
        ...
    }
    throw new AssertionError("알 수 없는 연산 : " + this);
   }
}

해당 코드는 깨지기 쉽다. 새로운 상수를 추가하면 case문도 추가해야 하기 때문이다.

2️⃣ V2 : 상수별 메서드 구현을 활용한 열거 타입

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; }};
    
    public abstract double apply(double x, double y);
    

apply()가 추상 메서드이므로, 재정의하지 않는다면 컴파일 오류로 알려준다. 뿐만 아니라, 상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다.

▶️ V3 : 상수별 클래스 몸체(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);

또한 위와 같이 toString() 메서드를 재정의한다면, toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 fromString() 메서드도 함께 제공하면 좋다.(단, 타입 이름을 적절히 바꿔야 하고 모든 상수 문자열 표현이 고유해야 한다)

▶️ 열거 타입용 fromString 메서드 구현

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

Operation 상수가 stringToEnum 맵에 추가되는 시점은, 열거 타입 상수 생성 후 정적 필드가 초기화될 때 이다.

열거 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다. 열거 타입의 정적 필드 중 생성자에서 접근 할 수 있는 것은 상수 변수 뿐이기 때문이다.(아이템 24)

열거 타입 생성자가 실행되는 시점에는 정적 필드들이 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 필요하다. 이 제약의 특수한 예로, 열거 타입 생성자에서 같은 열거 타입의 상수에도 접근 할 수 없다.

public static final Day MONDAY = new Day();

🔗 전략 열거 타입 패턴

👉 열거 타입 상수 일부가 같은 동작을 공유하는 경우이다.

상수별 메서드 구현으로 급여를 정확히 계산하려면 어덯게 해야 할까?
가장 깔끔한 방법은, 새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것이다.

▶️ 값에 따라 분기하여 코드를 공유하는 열거 타입 : BAD

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){  //switch문을 통한 분기
        	case SATURDAY: CASE SUNDAY:
            	overtimePay = basePAy / 2;
                break;
            default:
            	overtimePay = minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
                
    }
    return basePay + overtimePay;

하지만 잔업수당 계산을 private 중첩 열거 타입(PayType)으로 옮기면, PayrollDay 열거 타입은 잔업 수당 계산을 그 전략 열거 타입에 위임하기 때문에 switch문이나 상수별 메서드 구현이 필요 없게 된다.

▶️ 전략 열거 타입 패턴

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(){
                 return minsWorked <= MINS_PER_SHIFT ? 0 :
                 (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND{
        	int overtimePay(){
              return minsWorked * payRate / 2;
            }
        };
        
        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT =.  * 60;
        
        int pay(int minutesWorked, int payRate){
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
     }
}

이 패턴은 switch 문보다는 복잡하지만 더 안전하고 유연하다.

3️⃣ 정리

Q. 열거 타입은 언제 쓰면 좋을까?
필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자. 여러 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.

📚 핵심 정리
열거 타입은 정수 상수보다 더 읽기 쉽고 안전하고 강력하다. 대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작할 때는 필요하다. 드물게 하나의 메서드가 상수별로 다르게 동작해야 할 떄는, switch문 대신 상수별 메서드 구현을 사용하자. 열거 타입 상수 일부가 같은 동작을 공유한다면, 전략 열거 타입 패턴을 사용하면 된다.

참조
https://hudi.blog/java-enum/

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글