아이템 34: int 상수 대신 열거 타입을 사용하라
Java 1.5 Enum 등장 전 사용되던 int enum 패턴
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;
- 타입 안전을 보장할 방법이 없다.
- 표현 방식이 까다롭다.
- toString(), 디버거 등에서는 그냥 아무 의미 없는 상수로 보인다.
- 그렇다고 문자열 상수를 사용하자니, 성능 저하 + 문자열 하드코딩의 위험이 있다.
→ Enum 타입을 사용하자.
public enum Apple {
FUJI, PIPPIN, GRANNY_SMITH
}
public enum Orange {
NAVEL, TEMPLE, BLOOD
}
Enum 타입의 장점
- Java의 Enum은 완전한 클래스이다.
- 밖에서 접근 가능한 생성자가 없기 때문에, 사실상 final이다.
- 따라서 오직 하나만 존재함이 보장된다.
- 컴파일타임 타입 안전성을 제공한다.
- 출력하기에 적합한 문자열을 제공한다.
상수별 메서드 구현
// 코드 34-6 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입 (215-216쪽)
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);
// 코드 34-7 열거 타입용 fromString 메서드 구현하기 (216쪽)
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 인스턴스별로 ‘전략’을 선택하도록 하는 것
- 열거 타입 상수 일부가 같은 동작을 공유할 때 유용하다.
// 코드 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));
}
}
- 필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 Enum을 사용하자.
- Enum의 상수 개수가 고정불변일 필요는 없다.
- 나중에 상수가 추가되어도 바이너리 수준에서 호환되도록 설계되었기 때문
아이템 35: ordinal 메서드 대신 인스턴스 필드를 사용하라
- Enum 상수에 연결된 값은 Enum.ordinal() 메서드를 사용해서 얻으면 안 된다.
- 상수 순서가 바뀔 경우 기존 코드가 깨지고, 중간에 값을 비워둘 수도 없다.
- (DB에 저장할 때도 순서를 그대로 저장하면 안 된다.)
public enum Ensemble{
SOLO, DUET, TRIO, QUARTET, ..., OCTET;
public int numberOfMusicians(){
return ordinal() + 1;
}
}
public enum Ensemble{
SOLO(1), DUET(2), TRIO(3), QUARTET(4);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians(){
return numberOfMusicians;
}
}
아이템 36: 비트 필드 대신 EnumSet을 사용하라
열거 상수가 집합으로 사용할 경우, 예전에는 비트 필드 enum 패턴을 사용했다.
public class Text {
public static final int STYLE_BOLD = 1 << 0; // 1
public static final int STYLE_ITALIC = 1 << 1; // 2
public static final int STYLE_UNDERLINE = 1 << 2; // 4
public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8
// 매개변수 styles는 0개 이상의 STYLE_ 상수를 비트별 OR한 값이다.
public void applyStyles(int styles) {...}
}
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
- String으로 출력하거나 디버거에서 의미를 직관적으로 인식하기 어렵다.
- 최대 몇 비트가 필요한지 예측해야 한다.
→ EnumSet을 사용하자.
- 내부 구현은 비트 필드와 유사한 형태로 구현되어 있어 효율성과 안전성 모두 만족한다.
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set<Style> styles) {...}
}
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
아이템 37: ordinal 인덱싱 대신 EnumMap을 사용하라
Enum.ordinal() 메서드를 배열 인덱스로 사용하면 위험하다.
Set<Plant>[] plantByLifeCycle =
(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++) {
plantsByLifeCycle[i] = new HashSet<>();
}
for (plant p : garden) {
plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}
// 결과 출력
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}
- 비검사 형변환을 수행한다.
- 배열을 직접 사용했기 때문에 각 인덱스의 의미를 설명하는 레이블을 직접 달아야 한다.
- 정확한 정수값을 사용한다는 것을 직접 보증해야 한다.
- ordinal()은 상수 선언 순서에 따라 반환값이 바뀐다.
→ EnumMap을 사용하자.
EnumMap
열거 타입을 키로 사용하도록 설계된 아주 빠른 Map 구현체
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
plantsByLifeCycle.put(lc, new HashSet<>());
}
for (Plant p : garden) {
plantsByLifeCycle.get(p.lifeCycle).add(p);
}
System.out.println(plantsByLifeCycle);
- 내부 구현 방식을 숨겨 배열 사용 시의 성능과 Map의 타입 안정성을 모두 만족한다.
- 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공한다.
스트림을 사용해 맵을 관리하면 코드를 더 줄일 수 있다.
//HashMap을 이용한 데이터와 열거타입 매핑
Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle))
//EnumMap을 이용해 데이터와 열거타입 매핑
Arrays.stream(garden)
.collect(groupingBy(
p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())
);
다차원 관계는 중첩된 EnumMap으로 표현할 수 있다.
// 코드 37-5 배열들의 배열의 인덱스에 ordinal()을 사용 - 따라 하지 말 것!
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
// 행은 from의 ordinal을, 열은 to의 ordinal을 인덱스로 쓴다.
private static final Transition[][] TRANSITIONS = {
{ null, MELT, SUBLIME },
{ FREEZE, null, BOIL },
{ DEPOSIT, CONDENSE, null }
};
// 한 상태에서 다른 상태로의 전이를 반환한다.
public static Transition from(Phase from, Phase to) {
return TRANSITIONS[from.ordinal()][to.ordinal()];
}
}
}
// 코드 37-6 중첩 EnumMap으로 데이터와 열거 타입 쌍을 연결했다.
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT(SOLID, LIQUID), FREEBZ(LIQUID, SOLID),
BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);
private final Phase from;
private final Phase to;
Transition(Phase from, Phase to) {
this.from = from;
this.to = to;
}
// 상전이 맵을 초기화한다.
private static final Map<Phase, Map<Phase, Transition>>
m = Stream.of(values()).collect(groupingBy(t -> t.from,
() -> new EnumMap<>(Phase.class),
toMap(t -> t.to, t -> t,
(x, y) -> y, () -> new EnumMap<>(Phase.class))));
public static Transition from(Phase from, Phase to) {
return m.get(from).get(to);
}
}
}
아이템 38: 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라
Enum을 확장하는 것은 일반적으로 좋지 않은 생각이지만, 필요할 경우 인터페이스를 이용할 수 있다.
- 기존 Enum을 인터페이스를 구현하도록 만든 뒤, 확장할 Enum은 해당 인터페이스를 구현한 새로운 Enum으로 구성
- 사용 시에는 인터페이스를 참조한다.
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements 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;
}
public enum ExtendedOperation implements Operation {
EXP("^") {
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
public double apply(double x, double y) {
return x % y;
}
};
private final String symbol;
}
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(ExtendedOperation.class, x, y);
}
private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
for (Operation op : opEnumType.getEnumConstants()) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
Enum은 final이므로 구현을 상속할 수 없기 때문에, 코드 중복이 많아지는 경우 헬퍼 클래스로 분리하는 것이 좋다.
아이템 39: 명명패턴보다 애너테이션을 사용하라
도구나 프레임워크가 특별히 다루어야 할 프로그램 요소에는 전통적으로 구분되는 명명 패턴을 사용
e.g. JUnit 3: 모든 테스트 메서드는 test로 시작해야 한다.
- 오타에 취약하다.
- 올바른 프로그램 요소에서만 사용될 보장이 없다.
- 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
→ 애너테이션을 사용하자.
// 코드 39-1 마커(marker) 애너테이션 타입 선언 (238쪽)
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
// 코드 39-2 마커 애너테이션을 사용한 프로그램 예 (239쪽)
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() { }
}
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)) {
tests++;
try {
m.invoke(null);
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);
}
}
애너테이션의 의미
- 애너테이션은 클래스의 의미에 직접 영향을 주지는 않는다.
- 애너테이션에 관심 있는 프로그램에 추가 정보를 제공할 뿐이다.
메타 애너테이션
애너테이션 선언에 다는 애너테이션
- @Retention: 애너테이션의 범위를 지정한다.
- RUNTIME, CLASS, SOURCE
- @Target: 애너테이션이 적용될 수 있는 위치를 제한한다.
- PACKAGE, TYPE, CONSTRUCTOR, FIELD, METHOD, ANNOTATION_TYPE, LOCAL_VARIABLE, PARAMETER, TYPE_PARAMETER, TYPE_USE
- @Inherited: 자식 클래스가 부모 클래스에 달린 애너테이션을 가지도록 한다.
- @Repeatable: 반복해서 동일한 애너테이션을 달 수 있다. (후술)
매개변수를 받는 애너테이션
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
// 코드 39-5 매개변수 하나짜리 애너테이션을 사용한 프로그램 (241쪽)
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // 성공해야 한다.
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 실패해야 한다. (다른 예외 발생)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // 실패해야 한다. (예외가 발생하지 않음)
}
// 마커 애너테이션과 매개변수 하나짜리 애너태이션을 처리하는 프로그램 (241-242쪽)
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)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Throwable> excType =
m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf(
"테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n",
m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("잘못 사용한 @ExceptionTest: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
배열 매개변수 애너테이션
- 매개변수를 배열로 받거나, @Repeatable 메타 애너테이션을 사용해 여러 개의 파라미터를 받을 수 있다.
- 배열로 선언된 매개변수에 하나만 전달하는 경우에는 중괄호로 묶어줄 필요가 없다.
반복 가능 애너테이션
자바 8부터 도입된 @Repeatable 메타 애너테이션을 적용하면 같은 애너테이션을 여러 번 적용할 수 있다.
- @Repeatable을 단 애너테이션을 반환하는 컨테이너 애너테이션을 하나 더 정의하고, 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
ExceptionTest[] value();
}
컨테이너 애너테이션 타입에는 적절한 보존 정책과 적용 대상 (@Retention, @Target)을 명시해야 한다.
반복 가능 애너테이션을 하나만 달았을 때와 여러 번 달았을 때는 다른 애너테이션 타입이 적용된다.
- 메서드별로 동작이 달라지기 때문에 주의해야 한다.
- getAnnotationByType은 구분하지 않지만, isAnnotationPresent 메서드는 구분한다.
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)) { tests++; try { m.invoke(null); passed++; } catch (InvocationTargetException wrappedExc) { Throwable exc = wrappedExc.getCause(); System.out.println(m + " 실패: " + exc); } catch (Exception exc) { System.out.println("잘못 사용한 @Test: " + m); } } // 코드 39-10 반복 가능 애너테이션 다루기 (244-245쪽) if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) { tests++; try { m.invoke(null); System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m); } catch (Throwable wrappedExc) { Throwable exc = wrappedExc.getCause(); int oldPassed = passed; ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class); for (ExceptionTest excTest : excTests) { if (excTest.value().isInstance(exc)) { passed++; break; } } if (passed == oldPassed) System.out.printf("테스트 %s 실패: %s %n", m, exc); } } } System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed); }
애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
아이템 40: @Override 애너테이션을 일관되게 사용하라
상위 타입의 메서드를 재정의했음을 뜻하는 @Override 메서드를 일관되게 사용하면 많은 버그를 예방할 수 있다.
// 코드 40-1 영어 알파벳 2개로 구성된 문자열(바이그램)을 표현하는 클래스 - 버그를 찾아보자. (246쪽)
public class Bigram {
private final char first;
private final char second;
public Bigram(char first, char second) {
this.first = first;
this.second = second;
}
public boolean equals(Bigram b) {
return b.first == first && b.second == second;
}
public int hashCode() {
return 31 * first + second;
}
public static void main(String[] args) {
Set<Bigram> s = new HashSet<>();
for (int i = 0; i < 10; i++)
for (char ch = 'a'; ch <= 'z'; ch++)
s.add(new Bigram(ch, ch));
System.out.println(s.size());
}
}
- equals() 메서드의 파라미터 타입을 Object로 선언하지 않아 메서드 재정의가 아니라 다중정의가 되어 예상하지 않은 방식으로 작동한다.
→ 상위 클래스의 메서드를 재정의하고자 하는 모든 곳에 @Override 애너테이션을 붙이면 컴파일 타임에 실수를 예방할 수 있다.
아이템 41: 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라
마커 인터페이스와 마커 애너테이션은 각자의 쓰임이 있다.
마커 인터페이스의 장점
- 마커 인터페이스는 구현한 클래스의 인스턴스를 구분하는 타입으로 쓸 수 있으나, 애너테이션은 그렇지 않다.
- 적용 대상을 좀 더 정밀하게 지정할 수 있다.
마커 애너테이션의 장점
- 애너테이션 프레임워크의 지원을 받을 수 있다.
→ 마킹이 된 객체를 매개변수로 받는 메서드를 작성할 일이 있다면 인터페이스로, 그렇지 않다면 애너테이션으로 작성하자.