열거 타입은 확실히 정수 상수보다 뛰어나다. 더 읽기 쉽고 안전하고 강력하다. 대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다.
드물게는 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다. 이런 열거 타입에서는 switch 문 대신 상수별 메서드 구현을 사용하자.
열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자.
열거 클래스Enum 클래스 개요장점열거타입에 메서드나 필드 추가열거타입에서 메서드 사용의 일반적 사례 (Planet의 예)상수의 값에 따라 메서드의 동작이 달라져야 하는 사례 ( Operation 의 예) toString 재정의하려면 fromString도 정의하기값에 따라 분기하여 코드를 공유하는 열거 타입 ( 급여 계산 예시 )switch문이 좋은 선택이 되는 경우 — 기존 열거 타입에 상수별 동작을 혼합해 넣을 때열거 클래스 작성 시 주의할 점언제 쓰냐
열거 클래스
정수 열거 패턴의 단점
- 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않음
- 평범한 상수를 사용한 것 뿐이라 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨져, 상수 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 함
Enum 클래스 개요
- 열거 타입 자체는 클래스
- 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개
- 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final 임 → 인스턴스 통제됨
- 싱글턴은 원소가 하나뿐인 열거타입이라 할 수 있고, 거꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있음
장점
- 컴파일타임 타입 안전성을 제공함 — 열거 타입을 매개변수로 받는 메서드 선언하면 건네받은 참조는 무조건 해당 Enum class의 인스턴스임이 확실함(null이 아니라면)
- 각자의 이름공간이 있음
- 새로운 상수를 추가하거나 순서를 다시 바꾸어도 다시 컴파일하지 않아도 됨. 공개되는 것이 오직 필드의 이름 뿐이라, 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문
- toString 메서드는 출력하기에 적합한 문자열 내어줌
- 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있음
- Object 메서드들을 높은 품질로 구현해놨고 Comparable과 Serializable을 구현했음
- 직렬화 형태도 웬만큼 변형을 가해도 문제없이 동작하게 끔 구현해놓음
열거타입에 메서드나 필드 추가
열거타입에서 메서드 사용의 일반적 사례 (Planet의 예)
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 } } // Planet 열거 타입은 단순하지만 놀랍도록 강력함 // 어떤 객체의 지구에서의 무게를 입력받아 여덟 행성에서의 무게를 출력하는 일을 다음 짧은 코드로 작성이가능 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.println("%s에서의 무게는 %f이다. %n", p, p.surfaceWeight(mass)); } }
- 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 됨
- 열거타입은 근본적으로 불변이라 모든 필드는 final이어야 함
- 필드를 public으로 선언해도 되지만, private으로 두고 별도의 public 접근자 메서드를 두는 게 낫다
상수의 값에 따라 메서드의 동작이 달라져야 하는 사례 ( Operation 의 예)
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); private final String symbol; Operation(String symbol) { this.symbol = symbol; } @Override public String toString() { return symbol; } }
- apply 메서드가 상수 선언 바로 옆에 붙어 있으니 새로운 상수를 추가할 때 apply도 재정의해야 한다는 사실을 깜빡하기 어려움
- 그뿐 아니라 apply 가 추상 메서드이므로 재정의하지 않았다면 컴파일 오류로 알려줌
- 상수별 메서드 구현을 상수별 데이터와 결합할 수도 있음
- Operation의 toString을 재정의해 해당 연산을 뜻하는 기호를 반환하도록 한 예
toString 재정의하려면 fromString도 정의하기
- toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 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)); }
- Enum class의 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전임
- 디버그 찍어보면, 상수별로 생성자가 다 호출되고 난 뒤 정적 필드가 초기화됨
- 그래서, 열거 타입 생성자에서 같은 열거 타입의 다른 상수에도 접근할 수 없음(열거 타입 상수 하나씩 생성자가 호출되기에)
값에 따라 분기하여 코드를 공유하는 열거 타입 ( 급여 계산 예시 )
- 상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있음
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 문을 잊지 말고 쌍으로 넣어줘야 하기 때문
- 상수별 메서드 구현으로 급여를 계산하는 방법 — 아래 둘다 장황하고 가독성 떨어짐
- 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣기
- 계산 코드를 평일용과 주말용으로 나눠 도우미 메서드 작성 후 각 상수가 필요한 메서드 호출
- 평일 잔업수당 계산용 메서드인 overtimePay 구현해놓은 후, 주말 상수에서만 재정의해서 쓰면 장황한 부분은 줄지만 switch 문의 단점이 똑같이 생김
- 가장 깔끔한 방법은 새로운 상수를 추가할 때 잔업수당 ‘전략'을 선택하도록 하는 것임
package effectivejava.chapter6.item34; import static effectivejava.chapter6.item34.PayrollDay.PayType.*; // 코드 34-9 전략 열거 타입 패턴 (218-219쪽) 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)); } }
- 잔업수당 계산을 private 중첩 열거 타입(PayType)으로 옮기고 PayrollDay 열거 타입의 생성자에서 이중 적당한 것을 선택
switch문이 좋은 선택이 되는 경우 — 기존 열거 타입에 상수별 동작을 혼합해 넣을 때
package effectivejava.chapter6.item34; // 코드 34-10 switch 문을 이용해 원래 열거 타입에 없는 기능을 수행한다. (219쪽) public class Inverse { 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); } } public static void main(String[] args) { double x = Double.parseDouble(args[0]); double y = Double.parseDouble(args[1]); for (Operation op : Operation.values()) { Operation invOp = inverse(op); System.out.printf("%f %s %f %s %f = %f%n", x, op, y, invOp, y, invOp.apply(op.apply(x, y), y)); } } }
- 추가하려는 메서드가 의미상 열거 타입에 속하지 않는다면 직접 만든 열거 타입이라도 이 방식을 적용하는 게 좋다
열거 클래스 작성 시 주의할 점
- 열거 타입을 선언한 클래스 혹은 그 패키지에서만 유용한 기능은 private이나 package-private 메서드로 구현한다.
- 일반 클래스와 마찬가지로, 그 기능을 클라이언트에 노출해야 할 합당한 이유가 없다면 private으로, 혹은 (필요하다면) package-private으로 선언하라
- 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만든다.
- 예를 들어 소수 자릿수의 반올림 모드를 뜻하는 열거 타입인 java.math.RoundingMode는 BigDecimal이 사용한다. 그런데 반올림 모드는 BigDecimal과 관련 없는 영역에서도 유용한 개념이라 자바 라이브러리 설계자는 RoundingMode를 톱레벨로 올렸다.
언제 쓰냐
- 필요한 원소를 컴파일 타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자
- 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.