[이펙티브 자바, Effeective Java] 6장 열거 타입과 어노테이션
34 int 상수 대신 열거 타입을 사용하라
정수 열거 패턴은 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
비교되는 단점
- 수은(mecury)과 수성(mecury)를 동시에 사용해야할 경우 각각 이름을 달리 설정해야한다.
- 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨지기 때문에 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.
- 문자열로 출력하거나 디버거로 살펴보면 단순히 숫자로만 보이기 때문에 도움이 되지 않는다.
위 문제점을 해소할 수 있는 열거 타입은 아래와 같이 사용한다.
public enum Apple {FUJI, PIPPIN, GRANNY_SMITH}
public enum Orange {NAVEL, TEMPLE, BLOOD}
열거 타입은 클래스 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다. (싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.)
열거 타입은 컴파일타임 타입 안전성을 제공한다. 만약 위에 작성한 enum Apple을 매개변수로 받는 메서드를 선언했다면,
public void eatApple(Apple apple){}
건네받은 참조(Apple apple)는 null이 아니라면 enum Apple의 세 가지 값 중 하나임이 확실하다. 다른 타입의 값을 넘기려하면 컴파일 오류가 난다.
비교되는 장점
- 열거 타입에는 각자의 이름 공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.
- 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다. 공개되는 것이 오직 필드의 이름뿐이라서 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.
- 열거 타입의 toStirng 메서드는 출력하거나 디버깅하기 적합한 문자열을 내어준다.
이처럼 열거 타입은 정수 열거 패턴의 단점들을 해소해준다.
더불어 열거 타입은 임의의 메서드나 필드를 추가할 수 있고 인터페이스도 구현하게 할 수 있다.
예를 들어 각 상수와 연관된 데이터를 해당 상수 자체에 내재시키고싶다고 해보자.
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
}
}
이 열거타입은 태양계의 여덟 행성을 나타내고 있다.
각 열거 타입 상수 오른쪽 괄호 안 숫자는 생성자에 넘겨지는 매개변수로, 이 예에서는 행성의 질량과 반지름을 뜻한다.
열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
열거 타입의 필드를 public으로 선언해도 되지만 private으로 두고 별도의 public 접근자 메서드를 두는 게 낫다.
위의 열거타입을 이용해 지구의 무게가 여덟 행성에서 어떻게 변하는지 출력하는 일은 아래처럼 짧게 작성할 수 있다.
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));
}
}
열거 타입은 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. (배열의 값들은 선언된 순서로 저장된다.)
앞서 WtightTable을 실행한 결과는 아래와 같다.
MERCURY에서의 무게는 69.912739이다.
VENUS에서의 무게는 167.434436이다.
EARTH에서의 무게는 185.000000이다.
MARS에서의 무게는 70.226739이다.
JUPITER에서의 무게는 467.990696이다.
SATURN에서의 무게는 197.120111이다.
URANUS에서의 무게는 167.398264이다.
NEPTUNE에서의 무게는 210.208751이다.
널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만들도록 하자.
열거 타입은 상수별로 다르게 동작하는 코드를 구현하기 위한 방법으로 상수별 메서드 구현을 제공한다. (constant-specific method implementation)
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가 추상 메서드이므로 재정의하지 않았다면 컴파일 오류로 알려준다.
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을 재정의함으로서 계산식 출력을 편하게 작성할 수 있다.
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n",x, op, y, op.apply(x, y));
}
명령줄에 인수 2와 4를 주어 이 프로그램을 실행하면 다음과 같은 결과를 볼 수 있다.
2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000
열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf(String) 메서드가 자동 생성된다.
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));
}
위 코드는 모든 열거 타입에서 사용할 수 있도록 구현한 fromString이다.
- 단 타입 이름을 적절히 바꿔야 하고, 모든 상수의 문자열 표현이 고유해야 한다.
일당계산을 위한 중첩 enum 사용 방식
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, 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);
}
// 전략 열거 타입
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 class enumTest {
@Test
public void doEnum(){
System.out.println(PayrollDay.MONDAY);
System.out.println(PayrollDay.MONDAY.payType);
System.out.println(PayrollDay.MONDAY.pay(9 *60, 100));
System.out.println(PayrollDay.SATURDAY);
System.out.println(PayrollDay.SATURDAY.payType);
System.out.println(PayrollDay.SATURDAY.pay(9 *60, 100));
}
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);
}
// 전략 열거 타입
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);
}
}
}
}
54000 + 540-480*100/2
54000 + 540*100/2
35 ordinal 메서드 대신 인스턴스 필드를 사용하라
모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인가를 반환하는 ordinal이라는 메서드를 제공한다.
다음 코드는 합주단의 종류를 1명인 솔로(solo)부터 10명인 디텍트(dectet)까지 정의한 열거 타입이다.
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,SEXTET, SEPTET, OCTET, NONET, DECTET;
public int numberOfMusicians() { return ordinal() + 1; }
}
상수 선언 순서를 바꾸는 순간 numberOfMusicians가 오작동하며, 이미 사용 중인 정수와 값이 같은 상수는 추가할 방법이 없다.
예를 들어 8중주가 이미 있으니 복4중주는 추가할 수 없다. 또, 12중주를 추가한다고 할 때 11중주를 일컫는 이름이 없다. 게다가 11중주를 더미데이터로 채우면 코드가 깔끔하지 않다.
해결책은 간단하다. 열거타입 상수에 연결된 값은 ordinal 메서드로 얻지 말고, 인스턴스 필드에 저장하자. (아래의 코드처럼)
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
NONET(9), DECTET(10), TRIPLE_QUARTET(12);
private final int numberOfMusicians;
Ensemble(int size) { this.numberOfMusicians = size; }
public int numberOfMusicians() { return numberOfMusicians; }
}
39 명명 패턴보다 어노테이션을 사용하라
Test라는 이름의 어노테이션을 정의한다고 해보자.
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
@Test 애너테이션 타입 선언 자체에도 두 가지의 다른 애너테이션이 달려 있다. 바로 @Retention과 @Target이다.
이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션(meta-annotation)이라 한다.
@Retention(RetentionPolicy.RUNTIME) 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다.
@Target(ElementType.METHOD) 메타애너테이션은 @Test가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다.(따라서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.)
다음 코드는 @Test 애너테이션을 실제 적용한 모습이다. 이와 같은 애너테이션을 “아무 매개변수 없이 단순히 대상에 마킹(marking)한다”는 뜻에서 마커(marker) 애너테이션이라 한다.
public class Sample {
@Test public static void m1() { } // 성공해야 한다.
public static void m2() { }
@Test public static void m3() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m4() { }
@Test public void m5() { } // 잘못 사용한 예: 정적 메서드가 아니다.
public static void m6() { }
@Test public static void m7() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m8() { }
}
@Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다. 그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.
더 넓게 이야기하면, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다.
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) { // 1
tests++;
try {
m.invoke(null); // 2
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패\\: %d%n", passed, tests - passed);
}
}
** https://docs.oracle.com/javase/8/docs/api/
1 m.isAnnotationPresent(Test.class)
→ Interface AnnotatedElement 를 구현한 Class AccessibleObject를 상속받아 생성된 코드
2 m.invoke(null);
→ 매개변수가 없는 메소드를 실행
다음은 이 RunTests로 Sample을 실행했을 때의 출력 메시지다.
public static void Sample.m3() failed: RuntimeException: Boom
Invalid @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash
성공: 1, 실패: 3