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) 기법에는 단점이 많다. 타입 안전을 보장할 방법이 없고 표현력도 좋지 않다. 오렌지를 보내야할 메서드에 사과를 보내고, 동등 연산자(==) 로 비교하더라도 컴파일러는 아무런 경고 메세지를 출력하지 않는다.
문자열 열거 패턴은 더 나쁘다. 상수의 의미를 출력할 수 있음은 좋지만 문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩하는 방식이다. 하드코딩한 문자열에 오타가 있어도 컴파일러는 알 방도가 없고 문자열 비교에 따른 성능 저하는 덤이다.
enum 자체는 하나의 클래스이고 상수 하나당 자신의 인스턴스를 하나씩 만들어서 public static final 필드로 공개한다. enum은 밖에서 접근할 수 있는 생성자를 제공하지 않기 때문에 사실상 final 이다. 따라서 클라이언트가 인스턴스를 하나씩만 생성할 수 있도록 통제할 수 있음이 보장된다. 즉, enum은 싱글톤이다.
public enum Apple {FUJI, PIPPIN, GRANNY_SMITH}
public enum Orange {NAVEL, TEMPLE, BLOOD}
enum 은 컴파일타임 타입 안전성을 제공한다. 어느 enum 클래스를 매개변수로 받는 메소드를 선언했다면 건네 받은 참조는 null 아니면 enum 클래스 내 인스턴스 뿐이다. 다른 타입의 enum 클래스를 넘기려고 하면 컴파일 오류가 난다. 타입이 다른 enum 변수에 할당하려 하거나 다른 enum 타입의 값 끼리 ==
연산자로 비교하려는 꼴이기 때문이다.
enum 클래스 각각은 고유한 namespace가 있어서 이름이 같은 상수도 공존할 수 있다. 또한 enum에 새로운 상수를 추가하거나 순서를 바꿔도 클라이언트 사이드를 다시 컴파일하지 않아도 된다. 공개되는 것이 오직 필드의 이름뿐이라 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.
enum 의 toString()
은 출력하기 적합한 문자열을 리턴하고 임의의 메소드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.
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.447e7);
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;
this.surfaceGravity = G * mass / (radius * radius);
}
public double surfaceWeight(double mass) {
return mass * surfaceGravity;
}
}
enum 상수 각각을 특정 데이터와 연결 지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면되기 때문에 기능이 들어있는 enum 클래스를 만드는 일도 어렵지 않다.
enum 은 근본적으로 불변이라서 모든 필드는 final 이어야 한다. enum 은 자신 안에 정의된 상수들의 값을 배열에 담아 리턴하는 정적 메서드인 values를 제공한다. 각 enum 값의 toString()
은 상수 이름을 문자열로 반환하기 때문에 출력하기에도 안성맞춤이다.
상황에 따라 종종 상수마다 동작이 달라지는 코드가 필요해질 경우가 있다. 다음 코드를 보자.
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);
}
}
위 코드는 switch
문을 통해서 분기 처리했다. 이 방식은 깨지기 쉬운 코드이다. 새로운 상수를 추가한다면 해당 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; }
public abstract double apply(double x, double y);
@Override
public String toString() {
return symbol;
}
private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(Collectors.toMap(Object::toString, e -> e));
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
}
Operation 상수가 stringToEnum 에 추가되는 시점은 enum 타입 상수 생성 후 정적 필드가 초기화 될때이다. JAVA 8 이전에는 비어있는 해시맵에 values가 반환하는 배열을 돌면서 map에 값을 추가했을 것이다. 하지만, enum 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다. enum 타입의 정적 필드 중 enum 타입의 생성자에서 접근할 수 있는 것은 상수 변수 뿐이다.
enum 타입 생성자가 실행되는 시점엔 정적 필드들이 아직 초기화되기 이전이라서 자기 자신을 추가하지 못하게 하는 제약이 꼭 필요하다. 이 제약의 특수한 예로 enum 타입 생성자에서 같은 enum 타입의 다른 상수에도 접근할 수 없다. fromString이 Optional을 반환하는 점도 주의하자. 이는 주어진 문자열이 가리키는 연산이 존재하지 않을 수 있음을 클라이언트에게 알리고 그 상황을 클라이언트에서 대처하도록 한 것이다.
상수별 메소드를 구현하는 방식에는 enum 타입 상수끼리 코드를 공유할 수 없다는 단점이 있다.
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;
}
}
위 코드는 간결하지만 깨지기 쉽다. 휴가와 같은 새로운 값을 열거 타입에 추가하려면 그 값을 처리하는 case
문을 잊지 말고 수정해야하기 때문이다.
두 가지 방법을 생각해보자. 첫번째는 상수별로 중복해서 잔업수당을 계산하는 방법이고, 두번째는 평일용과 주말용 계산 메서드를 helper 메서드로 작성하고 각 상수가 자신에게 필요한 메서드를 호출하는 방식이다. 두 방식 모두 가독성이 떨엊니다. 또 switch
문과 같은 단점이 발생한다.
가장 깔끔한 방식은 전략 패턴을 사용하는 방법이다.
enum PayrollDay {
MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDSDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
SATURDAY(WEEKEND), SUNDAY(WEEKEND);
private final PayType payType;
PayrollDya(PayType payTyoe) {this.payType = payType;}
int pay(int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}
/* 전략 열거 타입 */
enum PayType {
WEEKDAY {
int overtimePay(int minusWorked, int payRate) {
return minusWorked <= MINS_PER_SHIFT ? 0 :
(minusWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
int overtimePay(int minusWorked, int payRate) {
return minusWorked * 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
문이 반드시 부적합한것은 아니다. 기존의 enum 타입에 상수별 동작을 혼합해서 넣을 때는 switch
문이 좋은 선택이 될 수도 있다. 예를 들어서, 서드파티에서 가져온 Operation enum 타입을 통해 각 연산의 반대 연산을 반환하는 메서드를 작성할 때는 적합할 수도 있다. 혹은, 추가하려는 메서드가 의미상 enum 타입에 속하지 않을때도 적합하다.
enum 타입은 필요한 원소를 컴파일 타임에 다 알 수 있는 상수 집합이라면 항상 enum 타입을 사용하자.
메뉴 아이템, 연산 코드, 플레그, 그리고 태양계 행성, 한 주의 요일, 체스 말 처럼 본질적으로 열거 타입인 경우도 해당된다.
enum 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다. 나중에 추가되도 바이너리 수준에서 호환이 간으하도록 설계되었기 때문이다.