BOOK 6 - 이펙티브 자바(6)
6장 열거 타입과 애너테이션
- 자바에는 특수한 목적의 참조 타입이 두가지 있다.
- 하나는 클래스의 일종인 열거 타입이고, 다른 하나는 인터페이스의 일종인 애너테이션이다.
int 상수 대신 열거 타입을 사용하라
- 열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다.
정수 열거 패턴
- 열거 타입을 지원하기 전에는 다음과 같은 정수 열거 패턴을 많이 사용하였다.
public static final int APPLE_FUJI = 0;
public static final int AAPLE_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;
- 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
정수 열거 패턴의 단점
- 자바가 정수 열거 패턴을 위한 별도의 namespace를 지원하지 않기 때문에
APPLE,ORANGE같은 접두어를 사용해서 이름 충돌을 방지해야 한다. - 평범한 상수를 나열한 것이기 때문에, 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다.
- 상수의 값이 바뀐다면 반드시 클라이언트도 다시 컴파일해야 한다.
- 문자열로 출력하기가 까다롭다.
- 값을 출력하거나 디버거로 확인했을 때 단순히 숫자로만 보여서 크게 도움이 되지 않는다.
열거 타입 (enum type)
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum ORANGE { NAVEL, TEMPLE, BLOOD }
- 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어
public static final필드로 공개한다. - 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다.
- 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없기 때문에 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장된다.
- 즉, 열거 타입은 인스턴스 통제된다.
열거 타입의 장점
- 컴파일타임 타입 안정성을 제공한다.
Apple열거 타입을 매개변수로 받는 메서드에 다른 타입의 값을 넘기려 하면 컴파일 오류가 발생한다.
- 각자의 namespace가 있어서 이름이 같은 상수도 공존할 수 있다.
- 공개되는 것이 오직 필드의 이름 뿐이기 때문에 상수 값이 클라이언트로 컴파일되어 각인되지 않는다.
toString메서드가 출력하기에 적합한 문자열을 내어준다.
열거 타입에 메서드, 필드 추가
- 열거 타입에는 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다.
-
Object메서드들을 높은 품질로 구현해놨고,Comparable과Serializable을 구현했으며, 그 직렬화 형태도 웬만큼 변형을 가해도 문제없이 동작하게끔 구현해놨다. - 즉, 열거 타입으로 고차원의 추상 개념 하나를 완벽히 표현해낼 수 있다.
행성 열거 타입 예제
public enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS(..., ...),
EARTH(..., ...),
MARS(..., ...),
JUPITER(..., ...),
SATURN(..., ...),
URANUS(..., ...),
NEPTUNE(..., ...);
private final double mass;
private final double radius;
private final double surfaceGravity;
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;
}
}
- 각 행성에는 질량과 반지름이 있고, 이 두 속성을 이용해 표면중력을 계산할 수 있다.
-
어떤 객체의 질량이 주어지면 그 객체가 행성 표면에 있을 때의 무게도 계산할 수 있다.
- 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
- 열거 타입은 근본적으로 불변이라 모든 필드는 final 이어야 한다.
- 필드를
public으로 선언해도 되지만,private으로 두고 별도의 접근자 메서드를 두는게 낫다.
- 필드를
행성 열거 타입 클라이언트 예제
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를 제공한다. toString메서드는 상수 이름을 문자열로 반환한다.
기타 유의 사항
- 열거 타입에서 상수 하나를 제거하면 어떻게 될까?
- 제거된 상수를 참조하하는 클라이언트 코드에서 컴파일 오류가 발생할 것이다.
- 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만든다.
- 예를 들어 소수 자릿수의 반올림 모드를 뜻하는 열거 타입인
java.math.RoundingMode는BigDecimal이 사용한다. - 하지만 반올림 모드는 다른 영역에서도 유용한 개념이라 자바 라이브러리 설계자는
RoundingMode를 톱레벨로 올렸다.
- 예를 들어 소수 자릿수의 반올림 모드를 뜻하는 열거 타입인
상수별 메서드 구현
- 상수마다 동작이 달라져야 하는 경우, 첫번째로 다음과 같이
switch문을 이용해 분기하는 방법을 쓸 수 있다.
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문도 추가해야 한다. - 열거 타입에
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 appy(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);
}
전략 열거 타입 패턴
-
상수별 메서드 구현은 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.
-
급여명세서에 쓸 요일을 표현하는 열거 타입을 예로 들어보자.
- 주중에 오버타임이 발생하면 잔업 수당이 주어지고, 주말에는 무조건 잔업 수당이 주어진다.
switch문 활용
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문을 잊지말고 넣어줘야 한다.
상수별 메서드 구현
- 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣는다.
- 계산 코드를 평일용, 주말용으로 나눠 가각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 호출하도록 한다.
- 위 방법들은 코드가 장황해져 가독성이 떨어지고 오류 발생 가능성도 높다.
- 또 다른 방법으로, 평일 잔업수당 계산용 메서드인
overtimePay를 구현해놓고, 주말 상수에서만 재정의해 쓰면 코드는 간단해지지만,switch문을 사용했을 때와 같은 문제가 발생한다.- 새로운 상수 추가시
overtimePay메서드를 재정의하지 않으면 의도대로 동작하지 않을수도 있다.
- 새로운 상수 추가시
전력 열거 타입 패턴 활용
- 잔업 수당 계산을 private 중첩 열거 타입으로 옮기고,
PayrollDay열거 타입의 생성자에서 이 중 적당한 것을 선택한다.
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 minsWorked, 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);
}
}
}
- 새로운 상수를 추가할 때 잔업수당 ‘전략’을 선택하도록 한다.
PayrollDay열거 타입은 잔업수당 계산을 전략 열거 타입에 위임하여switch문이나 상수별 메서드 구현이 필요없게 된다.
valueOf / fromString
- 열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는
valueOf메서드가 자동 생성된다. - 열거 타입의
toString메서드를 재정의할 때는toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는fromString메서드도 함께 제공하는 것이 좋다.
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(toMap(Object::toString, e -> e));
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
Operation상수가stringToEnum맵에 추가되는 시점은 열거 타입 상수 생성 후 정적 필드가 초기화될 때이다.- 열거 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다.
- 열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐이다.
- 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 필요하다.
- 입력받은
symbol문자열이 가리키는 연산이 존재하지 않을 수 있음을 클라이언트에 알리기 위해 반환 타입은Optional<Operation>으로 설정하였다.
ordinal 메서드 대신 인스턴스 필드를 사용하라
- 모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는
ordinal메서드를 제공한다. - 하지만 다음과 같이
ordinal메서드를 잘못 사용해서는 안된다.
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET;
public int numberOfMusicians() { return ordinal() + 1; }
}
- 동작은 하지만 유지보수하기 안좋다.
- 상수 선언 순서를 바꾸는 순간
numberOfMusicians메서드가 오동작 한다. - 이미 사용중인 정수와 값이 같은 상수는 추가할 수 없다.
- 예를 들어, 8중주(octet) 상수가 이미 있으니 똑같이 8명이 연주하는 복4중주(double quartet)는 추가할 수 없다.
- 값을 중간에 비워둘 수 없다.
- 예를 들어, 12명이 연주하는 3중 4중주(triple quartet)를 추가하고 싶으면 중간에 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; }
}
비트 필드 대신 EnumSet을 사용하라
- 열거한 값들이 주로 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2의 거듭제곱 값을 할당한 정수 열거 패턴을 사용했다.
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public static final int STYLE_STRIKETHROUGH = 1 << 3;
public void applyStyles(int styles) { ... }
}
text.applyStyles(STYLE_BOLD | STYLE_ITALIC)
- 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있다.
- 비트 필드를 사용하면 비트 연산을 사용해 합집합과 교집합 같은 집합 연산을 효율적으로 수행할 수 있지만, 정수 열거 상수의 단점을 그대로 가지고 있다.
- 또한, 최대 몇 비트가 필요한지 미리 예측하여 적절한 타입을 선택해야 한다.
- API를 수정하지 않고는 비트 수를 더 늘릴 수 없기 때문이다.
EnumSet
EnumSet클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.Set인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set<Style> styles) { ... }
}
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
EnumSet의 내부는 비트 벡터로 구현되었다.- 원소가 64개 이하(즉 대부분의 경우)일 때 EnumSet 전체를
long변수 하나로 표현하여 비트 필드에 견주는 성능을 보여준다.- 그러면서도 비트를 직접 다룰 때 흔히 겪는 오류들에서 해방된다.
ordinal 인덱싱 대신 EnumMap을 사용하라
- 이따금 배열이나 리스트에서 원소를 꺼낼 때
ordinal메서드로 인덱스를 얻는 코드가 있다.
Plant 예제 - ordinal을 사용하는 배열
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;
}
}
garden배열에 심은 식물들을 관리하고, 이들을LifeCycle별로 묶어 집합에 넣는 코드를 살펴보자.
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 < plantByLifeCycle.length; i++) {
System.out.printf("%s : %s%n", Plant.LifeCycle.values()[i], plantByLifeCycle[i]);
}
-
집합들을 배열
plantsByLifeCycle에 넣고,LifeCycle의ordinal값을 그 배열의 인덱스로 사용하고 있다. - 위 코드는 동작은 하지만 문제가 많다.
- 배열은 제네릭과 호환되지 않기 때문에 비검사 형변환을 수행해야 하고, 이는 컴파일 경고를 발생시킨다.
- 가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다는 점이다.
- 정수는 열거 타입과 달리 타입 안전하지 않기 때문이다.
- 잘못된 값을 사용하면 잘못된 동작을 수행하거나
ArrayIndexOutOfBoundsException을 던질 것이다.
해결책 - EnumMap 사용
- 열거 타입을 키로 사용하도록 설계한 아주 빠른
EnumMap구현체가 있다.
Map<Plant.LifeCycle, Set<Plant>> plantByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
plantByLifeCycle.put(lc, new HashSet<>());
}
for (Plant p : garden) {
plantByLifeCycle.get(p.lifeCycle).add(p);
}
System.out.println(plantByLifeCycle);
- 안전하지 않은 형변환은 쓰지 않고, 짧고 명료하다.
- 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 없어진다.
- 여기서
EnumMap의 생성자가 받는 키 타입의Class객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다.
스트림 기반으로 변형
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle)));
- 이 코드는
EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에EnumMap을 썼을 때 얻는 공간과 성능 이점이 사라진다는 단점이 있다. - 다음과 같이 매개변수 3개짜리
Collectors.groupingBy메서드의mapFactory매개변수에 원하는 맵 구현체를 명시해EnumMap을 사용하도록 할 수 있다.
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));
차이점
- 스트림을 사용하면 EnumMap을 사용했을 때와 살짝 다르게 동작한다.
- EnumMap 버전은 언제나 식물의
LifeCycle당 하나씩의 중첩 맵을 만든다. - 스트림 버전은 해당
LifeCycle에 속하는 식물이 있을 때만 중첩 맵을 만든다.
Phase 예제 - 이차원 배열 사용
- 두 열거 타입 값들을 매핑하기 위해서
ordinal을 두번씩 쓴 이차원 배열 예제를 살펴보자.
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
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()];
}
}
}
- 앞의
Plant예제와 마찬가지로, 컴파일러는ordinal과 배열 인덱스의 관계를 알지 못한다.- 즉,
Phase나Transition열거 타입을 수정하면서TRANSITIONS배열을 함께 수정하지 않으면 런타임 오류가 발생할 것이다. - 이상하게 동작하거나
ArrayIndexOutOfBoundsException이나NullPointerException을 던질 것이다.
- 즉,
Phase의 가짓수가 늘어나면TRANSITIONS배열의 크기도 제곱해서 커지며null이 채워지는 칸도 늘어날 것이다.
해결책 - 중첩 EnumMap 사용
public enum Phase {
SOLID, LIQUID, GAS;
public enum Transition {
MELT(SOLID, LIQUID), FREEZE(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);
}
}
}
Map<Phase, Map<Phase, Transition>> m은 “이전 상태에서 ‘이후 상태에서 전이로의 맵’에 대응시키는 맵” 이다.- 중첩 맵을 초기화 시키기 위해
Collector2개를 차례로 사용했다.- 첫번째
groupingBy에서는from기준으로 묶고, 두번째groupingBy에서는to를Transition에 대응시켰다.
- 첫번째
- 여기서
Phase에 새롭게PLASMA를 추가할 경우,- 위의 배열로 만든 코드에 추가하려면
TRANSITIONS배열을 원소 16개짜리로 교체해야 한다. - 중첩 EnumMap 버전에서는
Phase와Transition에 원소만 추가하면 끝이다.
- 위의 배열로 만든 코드에 추가하려면
public enum Phase {
SOLID, LIQUID, GAS, PLASMA;
public enum Trasition {
MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS),
CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);
// 나머지 코드는 그대로다
...
}
}
- 실제 내부에서는 맵들의 맵이 배열들의 배열로 구현되기 때문에 낭비되는 공간과 시간이 거의 없이 명확하고 안전하게 유지보수하기 쉽다.
확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라
- 열거 타입은 확장할 수 없다.
- 즉, 열거한 값들을 그대로 가져온 다음 값을 더 추가하여 다른 목적으로 쓸 수 없다.
- 대부분 상황에서 열거 타입을 확장하는 것은 좋지 않지만, 어울리는 쓰임이 최소한 하나는 있다.
- 인터페이스를 구현하는 열거 타입을 사용한다면 클라이언트가 이 인터페이스를 구현해 자신만의 열거 타입(또는 다른 타입)을 만들 수 있다.
- API가 열거 타입이 아닌 인터페이스 기반으로 작성된다면 기본 열거 타입 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거 타입 인스턴스로 대체해 사용할 수 있다.
OperationCode 예제 - 인터페이스 이용
- 가끔 API가 제공하는 기본 연산 외에 사용자 확장 연산을 추가할 수 있도록 열어줘야 할 때가 있다.
- 기본 아이디어는 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하는 것이다.
- 연산 코드용 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 된다.
- 이 때 열거 타입이 인터페이스의 표준 구현체 역할을 한다.
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;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
- 열거 타입인
BasicOperation은 확장할 수 없지만 인터페이스인Operation은 확장할 수 있다.- 이 인터페이스를 연산의 타입으로 사용하면 된다.
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;
ExtendedOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
- 새로 작성한 연산 타입은 기존
Operation인터페이스를 사용하던 곳이면 어디든 쓸 수 있다.
타입 수준 활용
- 개별 인스턴스 수준에서뿐 아니라 타입 수준에서도 기본 열거 타입 대신 확장된 열거 타입을 넘겨 확장된 열거 타입의 원소 모두를 사용하게 할 수도 있다.
첫번째 방법 - class 리터럴
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));
}
}
test메서드에ExtendedOperation의class리터럴을 넘겨 확장된 연산들이 무엇인지 알려준다.- 여기서
class리터럴은 한정적 타입 토큰 역할을 한다.
- 여기서
opEnumType매개변수 선언<T extends Enum<T> & Operation>은Class객체가 열거 타입인 동시에Operation의 하위 타입이어야 한다는 의미이다.
두번째 방법 - 한정적 와일드카드 타입
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(Arrays.asList(ExtendedOperation.values()), x, y);
}
private 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));
}
}
- 첫번째보다 좀 더 유연해졌다.
- 즉, 여러 구현 타입의 연산을 조합해 호출할 수 있게 되었다.
- 하지만, 특정 연산에서는
EnumSet과EnumMap을 사용하지 못한다.
인터페이스를 이용한 열거 타입 확장의 문제점
- 열거 타입끼리 구현을 상속할 수 없다.
- 아무 상태에도 의존하지 않으면 디폴트 구현을 이용해 인터페이스에 추가할 수 있다.
- 하지만 상태 값에 따라 로직이 달라지는 Operation 예제 같은 경우, 로직이
BasicOperation과ExtendedOperation모두에 들어가야 한다.
- 하지만 상태 값에 따라 로직이 달라지는 Operation 예제 같은 경우, 로직이
- 공유하는 기능이 많다면 그 부분을 별도의 도우미 클래스나 정적 도우미 메서드로 분리하는 방식으로 코드 중복을 없앨 수 있을 것이다.
명명 패턴보다 애너테이션을 사용하라
- 전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
- 예를 들어 JUnit은 버전3까지 테스트 메서드 이름을
test로 시작하게끔 했다. - 이 방법은 효과적이지만 단점도 크다.
명명 패턴의 단점
- 오타가 나면 안된다.
- 실수로 명명 패턴의 이름을 틀려도 컴파일러는 이를 잡아내지 못한다.
- 즉, 메서드 이름을
tsetSafetyOverride로 지으면 JUnit3은 이 메서드를 무시하고 지나치기 때문에 테스트를 적절히 수행하지 못한다.
- 올바른 프로그램 요소에만 사용되리라 보증할 방법이 없다.
- 즉, 클래스 이름을
TestSafetyMechanisms라고 짓고 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit은 클래스 이름에는 관심이 없기 때문에 무시한다.
- 즉, 클래스 이름을
- 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
애너테이션 - @Test 예제
// 매개변수 없는 정적 메서드 전용
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface Test {
}
@Retention이나@Target같이 애너테이션 선언에 다는 애너테이션을 meta-annotation 이라 한다.@Retention(RetentionPolicy.RUNTIME):@Test가 런타임에도 유지되어야 한다는 표시@Target(ElementType.METHOD):@Test가 반드시 메서드 선언에서만 사용돼야 한다는 표시
-
주석에 쓰여있는
매개변수 없는 정적 메서드 전용이라는 제약을 컴파일러가 강제하도록 하려면javax.annotation.processing을 사용하여 애너테이션 처리기를 직접 구현해야 한다. -
적절한 애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 애너테이션을 달면 컴파일은 되겠지만 테스트 도구 실행시에 문제가 발생할 것이다.
-
위와 같은 애너테이션을 마커 애너테이션이라 한다.
- 아무 매개변수 없이 단순히 대상에 마킹한다는 의미이다.
- 이 애너테이션 사용시에 이름에 오타가 있거나 메서드 외의 프로그램 요소에 달면 컴파일 오류가 발생한다.
마커 애너테이션 적용
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() { }
}
m5메서드는 인스턴스 메서드이므로@Test를 잘못 사용한 경우이다.m3과m7메서드는 예외를 던지기 때문에 테스트에 실패할 것이다.m1메서드는 테스트에 성공할 것이다.@Test를 붙이지 않는 나머지 메서드는 테스트 도구가 무시할 것이다.
테스트 도구
@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 exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
- 위 테스트 도구는 명령줄로부터 정규화된 클래스 이름을 받아 해당 클래스에서
@Test애너테이션이 달린 메서드를 차례로 호출한다. - 테스트 메서드가 실행 중 예외를 던진다면 리플렉션 매커니즘이
InvocationTargetException으로 예외를 감싸서 던진다.- 따라서
InvocationTargetException을 캐치해 원래 예외를 추출해 출력하도록 했다.
- 따라서
InvocationTargetException외의 예외가 발생했다면@Test애너테이션을 잘못 사용했다는 뜻이다.- 인스턴스 메서드나 매개변수가 있는 메서드 등에 애너테이션을 달았을 것이다.
매개변수를 포함한 애너테이션
- 특정 예외를 던져야만 성공하는 테스트를 지원하도록 해보자.
- 매개변수 하나를 받는 애너테이션 타입을 만들어야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTest {
Class<? extends Throwable> value();
}
- 이 애너테이션의 매개변수 타입은
Class<? extends Throwable>이다.- 즉,
Throwable을 확장한 클래스의Class객체이다.
- 즉,
@ExceptionTest 적용
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 () { } // 실패 (예외 발생하지 않음)
}
테스트 도구 수정
@ExceptionTest애너테이션을 다룰 수 있도록 테스트 도구를 수정해보자.
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedExc.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);
}
}
- 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인한다.
배열 매개변수를 받는 애너테이션
- 예외를 여러 개 명시하고 그 중 하나가 발생하면 성공하게 만들 수 있다.
- 이를 위해
@ExceptionTest애너테이션의 매개변수 타입을Class객체의 배열로 수정한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTest {
Class<? extends Throwable>[] value();
}
- 배열 매개변수를 받는 애너테이션 문법은 아주 유연하기 때문에, 앞의
@ExceptionTest적용 코드들도 모두 수정없이 수용한다. - 여러 매개변수를 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주면 된다.
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
List<string> list = new ArrayList<>();
...
}
테스트 도구 수정
- 배열 매개변수를 받는
@ExceptionTest애너테이션을 지원하도록 아래와 같이 수정한다.
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Throwable> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) {
System.out.printf("테스트 %s 실패: %s%n", m, exc);
}
}
}
@Repeatable 반복 가능 애너테이션
- 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.
- 배열 매개변수를 사용하는 대신 애너테이션에
@Repeatable메타 애너테이션을 다는 방식이다. @Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 반복해서 달 수 있다.
반복 가능 애너테이션 정의
- 먼저, 컨테이너 애너테이션을 정의해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTestContainer {
ExceptionTest[] value();
}
- 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는
value메서드를 정의해야 한다. -
또한 적절한 보존 정책(
@Retention) 과 적용 대상(@Target)을 명시해야 한다. - 그리고, 애너테이션 선언에서
@Repeatable에 컨테이너 애너테이션의class객체를 매개변수로 전달해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @Interface ExceptionTest {
Class<? extends Throwable> value();
}
반복 가능 애너테이션 적용
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
...
}
테스트 도구 수정
- 반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 컨테이너 애너테이션 타입이 적용된다.
isAnnotationPresent메서드는 이 둘을 명확히 구분한다.- 즉, 반복 가능 애너테이션을 여러개 단 후
isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 확인하면false를 반환할 것이다.- 컨테이너 애너테이션이 달렸기 때문이다.
- 같은 이유로, 반복 가능 애너테이션을 한번만 단 후 컨테이너 애너테이션이 달렸는지 확인하면 역시
false를 반환할 것이다.- 이 때는 반복 가능 애너테이션이 달렸기 때문이다.
- 따라서 애너테이션이 달려있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.
- 즉, 반복 가능 애너테이션을 여러개 단 후
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);
}
}
}
getAnnotationsByType은 반복 가능 애너테이션과 그 컨테이너 애너테이션을 구분하지 않아서 모두 가져온다.
정리
- 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자.
- 도구 제작자를 제외하고는 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다.
- 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.
- IDE나 정적 분석 도구가 제공하는 애너테이션을 사용하면 해당 도구가 제공하는 진단 정보의 품질을 높여줄 것이다.
@Override 애너테이션을 일관되게 사용하라
@Override애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의했음을 뜻한다.- 이 애너테이션을 일관되게 사용하면 여러가지 악명 높은 버그들을 예방해준다.
Bigram 예제
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());
}
}
- Set은 중복을 허용하지 않기 때문에 위 코드를 실행했을 때 출력값이 10이 나올 것 같지만, 실제로는 260이 출력된다.
- 위 코드에서
equals메서드를 재정의하려고 했으나 사실은 overloading 해버렸다.Object의equals메서드를 재정의하려면 매개변수 타입이Object여야 하는데,Bigram으로 선언했기 때문이다.
Object의equals메서드는==연산자와 똑같이 객체 식별성만 확인하기 때문에, 같은 소문자를 소유한 바이그램 10개 각각을 서로 다른 객체로 인식하여 각각 Set에 저장한 것이다.@Override애너테이션을 달면 컴파일러가 이 오류를 찾아낼 수 있다.
@Override 추가
@Override
public boolean equals(Object o) {
if (!o instanceof Bigram) return false;
Bigram b = (Bigram) o;
return b.first == first && b.second == second;
}
- 상위 클래스의 메서드를 재정의하려는 모든 메서드에서 @Override 애너테이션을 다는 것이 좋다.
예외
- 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이
@Override를 달지 않아도 된다. - 구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아있다면 컴파일러가 그 사실을 알려주기 때문이다.
- 물론 재정의 메서드 모두에
@Override애너테이션을 일괄로 붙여두는게 좋아보인다면 그래도 상관없다.
정의하려는 것이 타입이라면 마커 인터페이스를 사용하라
-
아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스를 마커 인터페이스 라고 한다.
-
Serializable인터페이스가 좋은 예이다. -
Serializable은 자신을 구현한 클래스의 인스턴스는ObjectOutputStream을 통해 write 할 수 있다고, 즉 직렬화할 수 있다고 알려준다.
-
마커 인터페이스의 장점
- 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있다.
- 적용 대상을 더 정밀하게 지정할 수 있다.
- 마커 애너테이션의 경우,
@Target을ElementType.TYPE으로 선언하면 모든 타입에 애너테이션을 달 수 있다. - 타입을 더 세밀하게 제한하지는 못한다는 뜻이다.
- 그러나 마커 인터페이스의 경우, 그냥 마킹하고 싶은 클래스에서만 그 인터페이스를 구현하면 된다.
- 마커 애너테이션의 경우,
마커 애너테이션의 장점
- 거대한 애너테이션 시스템의 지원을 받는다.
- 애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리할 것이다.
마커 인터페이스 VS 마커 애너테이션
- 클래스와 인터페이스 이외의 프로그램 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야할 때는 애너테이션을 쓸 수 밖에 없다.
- 마커를 클래스나 인터페이스에 적용해야 한다면,
- “이 마킹이 된 객체를 매개변수로 받는 메서드를 작성할 일이 있을까?” 라고 자문해보자.
- 그렇다면, 마커 인터페이스를 써야 한다.
- 마커 인터페이스를 해당 메서드의 매개변수 타입으로 사용하여 컴파일 타임에 오류를 잡아낼 수 있기 때문이다.
- 아니라면, 마커 애너테이션을 사용하는 것이 나은 선택일 것이다.