[이펙티브자바 3판] ITEM34. int 상수 대신 열거 타입을 사용하라
이번장의 핵심은...
열거 타입은 확실히 정수 상수보다 뛰어나다
더 읽기 쉽고 안전하고 강력하다
대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만,
각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다
드물게는 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다
이런 열거 타입에서는 switch 문 대신 상수별 메서드 구현을 사용하자
열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자
정수 열거 패턴
자바에서 열거 타입을 지원하기 전에는 정수 상수를 한 묶음 선언해서 사용
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
이런 정수 열거 패턴 기법에는 단점이 많다
-
타입 안전을 보장할 방법이 없으며 표현력도 좋지 않음
오렌지를 건네야 할 메서드에 사과를 보내도 값이 같으면 컴파일러는 아무런 경고 메시지를 출력하지 않음
-
이름공간을 지원하지 않아 접두어를 써서 충돌을 방지해야함
ex) MERCURY → ELEMENT_MERCURY, PLANET_MERCURY
-
프로그램이 깨지기 쉽다
상수 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다
-
정수 상수는 문자열로 출력하기가 까다롭다
그 값을 출력하거나 디버거로 살펴보면 의미가 아닌 숫자로만 보임
열거 타입 (Enum type)
위와 같은 열거 패턴의 단점을 모두 없애며 여러 장점이 있음
// 가장 단순한 열거 타입
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
-
자바의 열거 타입은 완전한 형태의 클래스이다
-
열거 타입 자체는 클래스이고, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개
-
밖에서 접근할 수 있는 생성자는 제공하지 않음 (사실상 final)
→ 따라서 클라이언트가 인스턴스를 직접 생성, 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장 (싱글턴)
-
컴파일타임 타입 안정성을 제공 (Apple, Orange..)
-
각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다
-
새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다
-
toString 메서드는 출력하기에 적합한 문자열을 내어준다
-
임의의 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다
열거 타입에 메서드나 필드를 추가?
예) 태양계의 여덟 행성
각 행성에는 질량과 반지름이 있고, 이 속성을 이용해 표면중력을 계산할 수 있다
열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아서 인스턴스 필드에 저장하면 된다
- 열거 타입은 정적 메서드인 values를 제공 (정의된 상수들의 값을 배열로 담아 반환)
- 열거 타입에서 상수를 하나 제거해도 클라이언트에는 아무 영향 없다 (제거한 상수를 참조하지 않는다면)
- 만약 참조하고 있더라고 다시 컴파일을 하거나 런타임에 유용한 예외가 발생할 것이다
(정수 열거 패턴에서는 기대할 수 없는 예외)
- 열거 타입 상수는 자신을 선언한 클래스 혹은 패키지에서만 사용할 수 있는 기능을 담게 된다
일반 클래스처럼 그 기능을 클라이언트에 노출해야 할 합당한 이유가 없다면 private or package-private로 선언
- 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들자
- 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만들자
- 상수마다 동작이 달라져야 하는 상황
예) 사칙연산 계산기
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;}};
public abstract double apply(double x, double y);
}
- apply 메소드가 상수 선언 옆에 붙어 있으니 새로운 상수 추가 시, 같이 재정의하기 쉽다
- 또한, apply가 추상 메서드이니 재정의하지 않으면 컴파일 오류로 알려줌
- 상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다
// 상수별 클래스 몸체(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 메서드
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));
}
Operation 상수가 stringToEnum 맵에 추가되는 시점은 열거 타입 상수 생성 후, 정적 필드가 초기화될 때다
열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐
-
열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 꼭 필요하다
이 제약의 특수한 예) 열거 타입 생성자에서 같은 열거 타입의 다른 상수에도 접근할 수 없다
-
fromString이 그냥 Operation이 아닌 Optional<Operation>을 반환하는 점도 주의
주어진 문자열이 가르키는 연산이 존재하지 않을 수 있음을 클라이언트에 알리고, 그 상황을 클라이언트에게 대처하도록 한 것
상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어려움
예) 급여명세서에서 쓸 요일을 표현하는 열거 타입
// 값에 따라 분기하여 코드를 공유하는 열거 타입 - 좋은 방법인가?
enum PayrollDay {
MONDAY, TUSEDAY, 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 문을 같이 추가해야함
- 상수별 메서드 구현으로 급여를 정확히 계산하는 방법
1. 잔업 수당을 계산하는 코드를 모든 상수에 중복해서 넣으면 된다
2. 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 적절히 호출
-> 두 방식 모두 코드가 장황해져 가독성이 크게 떨어지고 오류 발생 가능성이 높음
- 가장 깔끔한 방법
새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것
잔업 수당 계산을 private 중첩 열거 타입(PayType)으로 옮기고 PayrollDay 열거 타입의 생성자에서 이 중 적당한 것을 선택한다
→ PayrollDay 열거 타입은 잔업수당 계산을 그 전략 열거 타입에 위임하여, switch 문이나 상수별 메서드 구현이 필요 없게 된다
이 패턴은 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(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);
}
}
}
switch 문은 열거 타입의 상수별 동작을 구현하는 데 적합하지 않다
하지만, 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch 문이 좋은 선택이 될 수 있다
서드 파티에서 가져온 Operation 열거 타입이 있는데, 원래 열거 타입에 없는 기능을 수행
// 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("Unknown op: " + op);
}
}
- 추가하려는 메서드가 의미상 열거 타입에 속하지 않으면 이 방식을 적용하는 게 좋음 (직접 만든게 아니라도)
- 열거 타입 안에 포함할 만큼 유용하지 않은 경우도 괜찮음
- 열거 타입을 메모리에 올리는 공간과 초기화하는 시간이 들긴 하지만 체감될 정도는 아님
열거 타입은 언제 쓰지?
필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자
열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다
나중에 상수가 추가돼도 바이너리 수준에서 호환되도록 설계되었다
이 글은 “이펙티브 자바 3판” 책 내용을 정리한 글입니다.
만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.