34. int 상수 대신 열거 타입을 사용하라
•
열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다.
정수 열거 패턴
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
Java
복사
정수 열거 패턴
•
정수 열거 패턴 기법은 단점이 많다.
•
타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
•
오렌지를 건내야할 메서드에 사과를 보내도 컴파일러는 아무런 경고 메세지를 띄우지 않는다.
•
정수 열거 패턴을 위한 별도 이름공간을 지원하지 않기 때문에 접두어를 써서 충돌을 방지해야한다.
•
평범함 상수를 사용한것 뿐이라 상수의 값이 변경될 경우 반드시 재컴파일이 필요하다.
•
정수 상수는 문자열로 출력하기 까다로운데 디버거로 보면 의미가 아닌 숫자로만 보이기 때문이다.
열거 타입
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
Java
복사
열거 타입
•
자바의 열거 타입은 완전한 형태의 클래스로 제공된다.
•
상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
•
싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 가꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
•
열거 타입은 타입 안전성을 제공한다.
•
열거 타입은 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.
열거 타입에 메서드나 필드 추가
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
}
}
Java
복사
•
열거 타입 상수 각각의 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
열거 타입의 정수가 하나 제거된다면?
•
참고하는 클라이언트는 오류가 발생할것이다.
◦
특정 참고 줄에서 매번 동일한 오류를 발생 시킬것이다.
•
이는 타입 안전성을 중요시 여기는 우리들에게 매우 올바른 동작이라고 볼 수 있다.
상수별 메서드 구현
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;
}
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));
}
Java
복사
•
열거 타입에 추상 메서드를 통해서 상수별로 메서드를 구현할 수 있다.
문자열을 통해서 열거 타입 얻어내기
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));
}
Java
복사
상수별 메서드는 상수끼리 코드 공유가 어렵다.
•
상수별 메서드 구현에는 열거 타입 상수 끼리 코드를 공유하기 어렵다는 단점이 있다.
전략 열거 타입 패턴
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);
}
}
Java
복사
•
해당 방식은 switch보다 복잡하지만, 더 안전하고 유연하다.
•
주말, 주중 로직이 변경되면 PayType만 변경하면 된다.
•
기존의 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch문이 좋은 선택이 될 수 있다.
열거 타입을 써야할때는 언제인가
•
필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.
•
열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.
35. ordinal 메서드 대신 인스턴스 필드를 사용하라
•
대부분의 열거 타입 상수는 자연스럽게 하나의 정수값에 대응된다.
•
모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다.
사용하지 말아야하는 이유
•
상수 선언 순서에 따라서 값이 변경된다.
•
중간에 값을 비워둘 수도 없다.
해결책
public enum YAxis {
ONE(1),
TWO(2),
;
private final int position;
...
}
Java
복사
•
열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지말고 인스턴스 필드에 저장하자.
•
ordinal 메서드는 EnumSet과 EnumMap 같이 열거 타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다.
36. 비트 필드 대신 EnumSet을 사용하라
비트 열거 패턴
public class Text {
public static final int STYLE_BOLD = 1 << 0; // 1
public static final int STYLE_ITALID = 1 << 1; // 2
public static final int STYLE_UNDERLINE = 1 << 2; // 4
public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8
public void applyStyles(int styles) { ... }
}
Java
복사
비트 열거 상수 - 구닥다리 기법!
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
Java
복사
•
열거한 값들이 주로 단독이 아닌 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2개의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해왔다.
•
비트별 OR을 사용해 상수를 하나의 집합으로 모을 수 있으며, 이렇게 만들어진 집합을 비트 필드라고 한다.
단점
•
비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석하기 훨씬 어렵다.
•
비트 필트 하나에 녹아있는 모든 원소를 순회하기도 까다롭다.
•
최대 몇 비트가 필요한지를 API 작성 시 미리 예측하여 적절한 타입을 선택해야 한다.
EnumSet을 사용하자!
public class Text {
puiblic enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set<Style> styles) { ... }
}
Java
복사
•
EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.
•
Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
•
EnumSet 내부는 비트 벡터로 구현되어있다.
•
원소가 총 64개 이하면 EnumSet 전체를 long 변수 하나로 표현한다.
37. ordinal 인덱싱 대신 EnumMap을 사용하라
class Plant {
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
final String name;
final LifeCycle lifeCycle;
Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override
public String toString() {
return name;
}
}
Java
복사
안좋은 예시 - ordinal을 사용하는 코드
Set<Plant>[] plantsByLifeCycle = (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]);
}
Java
복사
•
동작하지만 문제가 한가득하다.
•
배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.
•
배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
•
가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 프로그래머가 직접 보증해야 한다.
좋은 예시 - EnumMap 사용
Map<Plat.LifeCyfcle, Set<Plat>> platsByLifeCycle = new EnumMap<>(Play.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
playsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle);
Java
복사
•
더 짧고 명료하고 안전하고 성능도 원래 버전과 비등하다.
•
안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다.
•
배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.
스트림을 사용하는 방법
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));
Java
복사
•
스트림을 사용하면 EnumMap만 사용했을 때와는 달리 살짝 다르게 동작한다.
•
EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.
38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라
•
열거 타입은 확장하기 어렵다.
•
대부분의 상황에서 열거 타입을 확장하는건 좋지 않은 생각이다.
•
확장성을 높이려면 고려할 요소가 늘어나 설계와 구현이 복잡해진다.
•
확장할 수 있는 열거 타입이 어울리는 쓰임새로는 연산코드가 있다.
인터페이스를 이용한 기능확장
public interface Operation {
double apply(double x, double y);
}
Java
복사
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;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
Java
복사
•
열거 타입인 BasicOperation은 확장할 수 없지만 인터페이스인 Operation은 확장할 수 있다.
•
이 인터페이스를 연산의 타입으로 사용하면 된다.
인터페이스를 통해서 추가 기능을 확장할 수 있다.
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;
...
}
Java
복사
public static void test(Collection<? extends Operation> opSet, double x, double y) {
for (Operation op : opSet) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
Java
복사
•
인터페이스를 이용해 확장 가능한 열거 타입을 흉내 내는 방식에도 한 가지 사소한 문제가 있다.
◦
열거 타입끼리 구현을 상속할 수 없다는 점
◦
아무 상태에도 의존하지 않는 경우 디폭트 구현을 이용해 인터페이스에 추가하는 방법이 있다.
39. 명명 패턴보다 애너테이션을 사용하라
•
명명패턴은 효과적인 방법이지만 단점도 크다.
◦
오타가 나면 안된다.
◦
올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
◦
프로그램 요소를 매개변수로 전달할 마당한 방법이 없다.
애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
Java
복사
•
명명패턴의 모든 단점을 해결해주는 개념이다.
•
@Retention은 메타애너테이션으로 해당 애너테이션이 언제까지 유지되어야 하는지 명시한다.
•
@Target은 어떤 위치에서 사용되어야 하는지 제한한다.
◦
컴파일러에서 강제되는건 아니라서 애너테이션 처리기를 통해서 구현되어야 한다.
•
위 같이 아무 매개변수 없이 단순히 대상에 마킹한다는 뜻에서 마커 애너테이션이라 한다.
애너테이션 예시
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() {}
}
Java
복사
•
@Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다.
•
그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.
애너테이션 처리 로직
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 exception) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
Java
복사
•
이 테스트 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에 마커 애너테이션이 달린 메서드를 차례로 호출한다.
•
isAnnotationPresent가 실행할 메서드를 찾아주는 메서드다.
•
테스트 메서드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다.
매개변수를 받는 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
Java
복사
•
Throwable을 확장한 클래스의 Class객체를 매개변수로 받는 애너테이션
매개변수를 받는 애너테이션 사용예제
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() { } // 실패해야 한다. (예외가 발생하지 않음)
}
Java
복사
매개변수를 받는 애너테이션 처리 로직
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(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);
}
}
Java
복사
•
애너테이션에서 매개변수를 가져와 처리한다.
40. @Override 애너테이션을 일관되게 사용하라
•
자바가 기본적으로 제공하는 애너테이션 중 보통의 프로그래머에게 가장 중요한것은 @Override일 것이다.
•
@Override는 메서드 선언에만 달 수 있으며, 이 애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의 했음을 뜻한다.
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 bigram) {
return bigram.first == first && bigram.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());
}
}
Java
복사
•
이 클래스는 equal와 hashCode를 재정의가 아닌 다중정의를 한것으로 취급된다.
•
이 오류는 컴파일러가 찾아낼 수 있지만, Object의 equal와 hashCode를 재정의한다는 의도를 명시해야 한다.
•
상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자.
◦
예외로는 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이 @Override를 달지 않아도 된다.
◦
일괄성을 위해 붙여도 상관없다.