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

Jimin Lim·2022년 11월 9일
0

Effective Java

목록 보기
24/38
post-thumbnail

아이템 34

int 상수 대신 열거 타입을 사용하라

일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다. 예를 들어 사계절(봄, 여름, 가을, 겨울), 카드게임의 카드 종류 등이 있다.

✅ 정수 열거 패턴

자바에서 열거 타입을 지원하기 전에는 다음과 같이 정수 상수를 한 묶음 선언해서 사용하곤 하였다.

//int enum pattern, 정수 열거 패턴
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;

위와 같은 방법으로 작성한다면, (1) 타입 안전 보장할 방법 없고, (2) 표현력도 좋지 않다.
int 타입이다 보니 어떠한 값이 넘어오더라도 컴파일러는 경고 메시지를 출력하지 않는다.

컴파일러는 정수 열거 패턴을 위한 별도 이름 공간을 지원하지 않기에 접두어를 써서 이름 충돌을 방지하곤 한다.
예를 들어 Mercury로 같은 수은, 수성은 다음과 같이 나타낸다.

  • 수은(원소): ELEMENT_MERCURY
  • 수성(행성): PLANET_MERCURY

🔗 정수 열거 패턴의 문제점

  1. 평범한 상수를 나열한 것 뿐이라 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다.
    상수 값이 바뀐다면 클라이언트도 다시 컴파일 해야 한다. 다시 컴파일하지 않은 클라이언트는 실행이 되더라도 원치않은 동작을 할 것 이다.

  2. 점수 상수는 문자열로 출력하기 까다롭다.
    그 값을 출력하거나 디버거로 살펴보면 의미없이 단지 숫자로만 보여서 도움이 되지 않는다.

  3. 정수 대신 문자열 상수를 사용하더라도 실수하기 쉽다.
    문자열 상수의 이름 대신 문자열 값 그대로 하드코딩할 수도 있다. 이렇게 하드코딩을 한다면 문자열에 오타가 있더라도 발견하기 어렵다.

✅ 열거 타입

public enum Apple (FUJI, PIPPIN, GRANNY_SMITH)

자바는 열거 패턴의 단점을 보완하고 여러 장점을 안겨주는 대안으로 열거 타입을 제시하였다.

🔗 열거 타입의 장점

  1. 열거 타입은 생성자를 제공하지 않으므로 사실상 final이다.
    직접 인스턴스를 생성할 일은 없으니 싱클턴을 일반화한 형태이다.

  2. 열거 타입은 컴파일타임 타입 안전성을 제공한다.
    열거 타입을 매개변수로 받는 메서드를 선언하였다면, 이때 건네받은 참조는 Apple의 세 가지 값 중 하나임이 확실하다. 다른 타입의 값을 넘기려고 한다면 컴파일 오류가 난다.

  3. 각자의 이름공간이 있어 이름이 같은 상수도 공존 가능하다.
    열거 타입에 새로운 상수를 추가하거나, 순서를 바꿔도 다시 컴파일하지 않아도 된다. 공개되는 것은 오직 필드의 이름뿐이라 정수 열거 패턴과 다르게 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.

  4. toString 메서드는 출력하기에 적합한 문자열을 내어준다.

  5. 임의의 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현할 수도 있다.

🔗 활용1 데이터와 연결

전체 코드

public enum Planet {
    MERCURY(3.302e+23,2.439e6),
    VENUS(4.869e+24,6.052e6),
    ...

    private final double mass; //질량
    private final double radius; // 반지름
    private final double surfaceGravity; //표면 중력

    //중력상수
    private static final double G = 6.67300E-11;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        this.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;
    }
}

1. 특정 데이터와 연결

특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        this.surfaceGravity = G * mass / (radius * radius);
    }

2. 필드는 final

열거 타입은 불변이므로 모든 필드는 final이어야 한다. 필드를 public으로 선언은 가능하지만 private으로 두고 getter를 사용하는 것이 좋다.

3. values를 이용해 상수들의 값을 배열 반환

4. 컴파일을 다시 하지 않는다면 런타임 에러를 발견할 수 있다.

제거된 상수를 참조하는 클라이언트는 상수를 참조하는 줄에서 디버깅에 유용한 메시지를 담은 컴파일 오류가 발생할 것이다.

🔗 활용2 상수별 메서드

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 추상 메서드를 선언한 후, 각 상수에서 자신에 맞게 재정의할 수 있다. 또한 toString()을 재정의하여 출력을 편하게 할 수 있다.

✅ 상수별 메서드 구현의 단점

🔗 switch 문 사용시 발생하는 문제

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

만약 위와 같이 switch 문을 사용한다면 새로운 값을 열거 타입에 추가하려면 그 값을 처리하는 case 문을 추가해줘야 하는 단점이 있다.
상수별 메소드 구현으로 급여를 계산하는 방법이 존재한다.

  1. 계산하는 코드를 모든 상수에 중복해서 넣기
  2. 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 적절히 호출
  3. 미리 계산 메서드를 구현해두고 주말 상수에서만 재정의해서 쓰는 방법

1, 2 번의 경우는 장황해져 가독성이 크게 떨어지고 오류 발생 가능성이 높아진다. 3번은 switch문의 단점가 그대로 존재한다. 주말 상수를 추가하면서 재정의를 하지 않으면 평일용 코드를 그대로 물려받는다.

🔗 전략 열거 타입 패턴

새로운 상수를 추가할 때 '전략'을 선택하도록 하는 것이다. 중첩 열거 타입으로 (WEEKEND 혹은 WEEKDAY)로 나누어 '전략'에 계산 방식 선택을 위임하도록 한다.

public enum PayrollDay {
    MONDAY(PayType.WEEKDAY),
    TUESDAY(PayType.WEEKDAY),
    WEDNESDAY(PayType.WEEKDAY),
    THURSDAY(PayType.WEEKDAY),
    FRIDAY(PayType.WEEKDAY),
    SATURDAY(PayType.WEEKEND),
    SUNDAY(PayType.WEEKEND);
    
    private final PayType payType;

    PayrollDay(PayType payType) {
        this.payType = payType;
    }

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

    private enum PayType {
        WEEKDAY {
            int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked * payRate / 2;
            }
        };

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

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

하지만 switch문을 무조건 지양해야 하는 것은 아니다. 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch 문이 좋은 선택이 될 수 있다.

public static Operation inverse(Operation operation) {
        switch (operation) {
            case PLUS:
                return Operation.MINUS;
            case MINUS:
                return Operation.PLUS;
            case TIMES:
                return Operation.DIVDE;
            case DIVDE:
                return Operation.TIMES;
        }
        throw new AssertionError("알 수 없는 연산 : " +operation);
}

💡 결론

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

한 주의 요일은 당연히 포함되고, 메뉴 아이템, 명령줄 플래그 등 허용하는 값 모드를 모두 컴파일 타임에 이미 알고 있다면 쓸 수 있다. 열거 타입은 바이너리 수준에서 호환되도록 설계되어 있어 영원히 개수가 불변일 필요는 없다.

profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️

0개의 댓글