[이펙티브 자바] 05. 열거 타입과 애너테이션
자바에는 특수한 목적의 참조 타입이 두 가지가 있다. 하나는 클래스의 일종인 열거 타입(enum)이고, 다른 하나는 인터페이스의 일종인 애너테이션(annotation)이다.
아이템 34. int 상수 대신 열거 타입을 사용하라
자바에서 열거 타입을 지원하기 전에는 아래 코드처럼 정수 상수를 한 묶음 선언해서 사용하곤 했다.
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int ORANGE_NAVEL = 0
public static final int ORANGE_TEMPLE = 1;
정수 열거 패턴 기법에는 단점이 많다.
1. 타입 안전을 보장할 방법이 없으며 표현력이 좋지 않다. 예를들어 APPLE_FUJI를 사용해야할 곳에 ORANGE_NAVEL을 사용해도 둘다 정수 0이기 때문에 컴파일 때 문제가 없다. 또한 이름공간을 지원하지 않기 때문에 접두어를 사용해서 이름 충돌을 방지한다.
ELEMENT_MERCURY //수은
PLANET_MERCURY //수성
2. 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다. 평범한 상수를 나열한 것 뿐이라 컴파일하면 해당 값이 클라이언트 파일에 그대로 새겨진다. 만약 상수의 값이 바뀌면 반드시 다시 컴파일 해야 한다.
3. 정수 상수는 문자열로 출력하기 어렵다. 값을 출력하면 단지 숫자로만 보일 뿐이다. 같은 그룹에 속한 모든 상수를 한바퀴 순회하기도 어렵고, 그 안에 상수가 몇개인지도 알 수없다.
public static final int APPLE_FUJI = 0;
System.out.println(APPLIE_FUJI); // APPLIE_FUJI 가 아닌 0 출력
34.1 열거 타입
정수 열거 패턴의 단점을 보완하고 여러가지 장점을 주는 대안이 바로 자바의 열거 타입(Enum Type)이다.
//가장 단순한 열거 타입
public enum Apple {FUJI, PIPPIN, GRANNY_SMITH}
public enum Orange {NAVEL, TEMPLE, BLOOD}
- 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
- 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재한다. 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
- 열거 타입은 컴파일타임에 타입 안전성을 제공한다. 값이 같은 0이더라도 Apple 열거 타입에 Orange 열거 타입이 들어올 수 없다.
- 열거 타입에는 각자의 이름 공간이 있어서 이름이 같은 상수도 공존한다.
- 열거 타입의 toString 메서드는 출력하기에 적합한 문자열로 반환한다.
- 열거 타입에는 임의의 메소드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.
- Object 메서드들을 높은 품질로 구현해두었고, Comparable과 Serializable을 구현해두었다.
열거 타입은 고차원의 추상 개념 하나를 완벽하게 표현할 수 있다.
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),
URAUS(8.683e+25,2.556e7),
NEPTUNE(1.024e+26,2.477e7);
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;
this.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으로 두고 별도의 접근자 메서드를 두자
- 열거 타입은 자신안에 정의된 상수들의 값을 선언된 순서대로 배열에 담아 반환하는 정적 메서드인 values를 제공한다.
34.2 상수마다 동작이 달라져야 하는 상황
열거 타입을 구현하면서 열거 타입의 메소드가 상수에 따라 다르게 동작해야 하는 경우가 생길 수도 있다. 다음과 같이 switch 문으로 해결할 수도 있지만, 새로운 상수가 추가된다면 case 문도 추가해야 한다.
public enum Operation {
PLUS,MINUS,TIMES,DIVDE;
// 상수가 뜻하는 연산을 수행한다.
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 DIVDE:
return x / y;
}
throw new AssertionError("알 수 없는 연산:" + this);
}
}
여기서 상수별 메소드 구현을 사용하면 조금 더 나은 방식으로 개선할 수 있다. 상수별 메소드 구현은 열거 타입에 추상 메소드를 선언하고 각 상수별로 클래스 몸체를 상수가 자신에 맞게 재정의하는 방법이다.
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;
}
},
DIVDE {
public double apply(double x, double y) {
return x / y;
}
};
public abstract double apply(double x, double y);
}
34.3 전략 열거 타입 패턴
상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.
public 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문을 잊지 말고 추가해줘야하는 단점이 있다.
상수별 메소드 구현으로 급여를 계산하는 방법은 두가지이다.
- 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣는 방법
- 계산 코드를 평일용과 주말용으로 나눠 각각 도우메 메소드로 작성한 다음 각 상수가 자신에게 필요한 메소드를 적절히 호출하는 방법
하지만 위의 방식은 코드가 장황해져 가독성이 크게 떨어지고 오류가 발생한 가능성이 높아진다.
이러한 경우에 전략 열거 타입 패턴을 사용해보도록 하자.
잔업수당 계산을 private 중첩 열거 타입으로 위임하고 PayrollDay 열거 타입 생성자에서 적절한것을 선택하면 된다.
public 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);
}
private enum PayType {
WEEKDAY {
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked <= MINS_PER_SHIFT ?
0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
int overtimePay(int minutesWorked, int payRate) {
return minutesWorked * payRate / 2;
}
};
abstract int overtimePay(int minutesWorked, int payRate);
private static final int MINS_PER_SHIFT = 8 * 60;
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
return basePay + overtimePay(minutesWorked,payRate);
}
}
}
이 패턴은 swtich문 보다 복잡하지만 더 안전하고 유연하다.
그런데 switch 문이 좋은 선택이 될 수 있는 경우가 있는데, 바로 기존 열거 타입에 상수별 동작을 혼합해 넣을 때 이다.
아래와 같이 Operation 열거 타입에서 각 연산의 반대 연산을 반환하는 메소드가 필요할 때이다.
public static Operation inverse(Operation operation) {
switch (operation) {
case PLUS:
return Operation.MINUS;
case MINUS:
return Operation.PLUS;
case TIMES:
return Operation.DIVDE;
case DIVDE:
return Operation.TIMES;
}
throw new AssertionError("알 수 없는 연산 : " +operation);
}
또는, 추가하려는 메서드가 의미상 열거타입에 속하지 않거나, 종종 쓰이지만 열거 타입 안에 포함할 만큼 유용하지 않은 경우 직접 만든 열거 타입이라도 위의 방식을 적용하는게 좋다.
34.4 열거 타입을 언제 쓰면 좋을까?
필요한 원소를 컴파일 타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자. 추가로 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.
아이템 35. ordinal 메서드 대신 인스턴스 필드를 사용하라
열거 타입 상수는 하나의 정숫값에 대응되고, 모든 열거 타입은 해당 상수가 몇번째 위치인지 반환하는 ordinal이라는 메서드를 제공한다.
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,
SEXTET, SEPTET, OCTET, NONET, DECTET;
public int numberOfMusicians(){
return ordinal() + 1;
}
}
- 동작은 하지만 유지보수하기 어렵다.
- 상수 선언 순서를 바꾸는 순간 numberOfMusicians는 오동작한다.
- 값을 중간에 비워둘 수도 없어 더미 상수를 사용해야 한다.
열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지 말고, 인스턴스 필드에 저장하자.
public enum Ensembel{
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8), DECTET(10);
;
private final int numberOfMusicians;
Ensemble(int size) { this.numberOfMusicians = size;}
public int numberOfMusicians() { return numberOfMusicians; }
}
아이템 36. 비트 필드 대신 EnumSet을 사용하라
열거한 값들이 집합으로 사용될 경우, 예전에는 상수에 서로 다른 2의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해왔다. 아래 코드와 같이 OR 연산자를 사용하여 상수를 하나의 집합으로 모을 수 있으며 이 집합을 비트 필드라 한다.
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) { ... }
}
비트 필드 또한 정수 열거 상수이므로 정수 열거 상수의 단점을 그대로 지닌다. 또한 추가로 다른 문제들도 있다.
- 비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석하기가 훨씬 어렵다.
- 비트 필드 하나에 녹아 있는 모든 원소를 순회하기도 까다롭다.
- 최대 몇 비트가 필요한지를 처음부터 예상하고 적절한 타입을 선택해야 한다.
다행히도 이젠 더 나은 방안인 java.util 패키지의 EnumSet 클래스가 있다. EnumSet은 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.
36.1 EnumSet
1. Set인터페이스를 완벽히 구현하며, 타입 안전하고 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
2. EnumSet 내부는 비트 벡터로 구현되어있으며, 원소가 총 64개 이하라면 EnumSet 전체를 long 변수 하나로 표현하여 비트필드에 비견되는 성능을 보여준다.
3. removeAll과 retainAll 과 같은 대량 작업은 비트를 효율적으로 처리할 수 있는 산술 연산을 써서 구현하였다.
위의 코드를 열거 타입과 EnumSet을 이용하여 수정해보자.
public class Text {
private enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
}
// 어떤 Set을 넘겨도 되나, enumSet이 가장 좋다.
public void applyStyles(Set<Style> styles) { ... }
}
- applyStyles 메서드가 EnumSet<Style>이 아닌 Set<Style>로 받는 이유는 모든 클라이언트가 EnumSet을 건네리라 짐작되는 상황이라도 이왕이면 인터페이스로 받는게 좋은 습관이다.
아이템 37. ordinal 인덱싱 대신 EnumMap을 사용하라
이따금 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 코드가 있다. 식물을 간단히 나타낸 다음 클래스를 예로 살펴보자.
class Plant {
enum LifeCycle {
ANNUAL, PERNNIAL, BIENNIAL
}
final String name;
final LifeCycle lifeCycle;
public Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override
public String toString() {
return name;
}
}
이제 정원에 심은 식물들을 배열 하나로 관리하고, 이들을 생애주기별로 묶어보자
public static void usingOrdinalArray(List<Plant> garden) {
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[LifeCycle.values().length];
for (int i = 0 ; i < plantsByLifeCycle.length ; i++) {
plantsByLifeCycle[i] = new HashSet<>();
}
for (Plant plant : garden) {
plantsByLifeCycle[plant.lifeCycle.ordinal()].add(plant);
}
for (int i = 0 ; i < plantsByLifeCycle.length ; i++) {
System.out.printf("%s : %s%n", LifeCycle.values()[i], plantsByLifeCycle[i]);
}
}
동작은 하지만 문제가 있다.
- 배열은 제네릭과 호환되지 않는다. 따라서 비검사 형변환을 수행해야한다.
- 사실상 배열은 각 인덱스가 의미하는 바를 알지못하기 때문에 출력 결과에 직접 레이블을 달아야 한다.
- 정수는 열거 타입과 달리 타입 안전하지 않기 때문에 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다.
이러한 단점들을 java.util 패키지의 EnumMap 을 사용하여 해결해보자. EnumMap은 열거 타입을 키로 사용하는 Map 구현체이다.
다음은 EnumMap을 사용한 코드와 그 결과이다.
public static void exmapleEnumMap(List<Plant> garden) {
Map<LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(LifeCycle.class);
for (LifeCycle lifeCycle : LifeCycle.values()) {
plantsByLifeCycle.put(lifeCycle,new HashSet<>());
}
for (Plant plant : garden) {
plantsByLifeCycle.get(plant.lifeCycle).add(plant);
}
System.out.println(plantsByLifeCycle);
}
1. 안전하지 않은 형변환을 사용하지 않는다.
2. 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력결과에 직접 레이블을 달 필요가 없다.
3. 인덱스를 계산하는 과정에서 오류가 날 가능성이 존재하지 않는다.
4. EnumMap은 그 내부에서 배열을 사용하기 때문에 내부 구현 방식을 안으로 숨겨서 Map의 타입 안정성과 배열의 성능을 모두 얻었다.
여기서 EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다. 스트림을 사용하면 코드를 더 줄일 수 있다.
public static void streamV1(List<Plant> garden) {
Map plantsByLifeCycle = garden.stream()
.collect(Collectors.groupingBy(plant -> plant.lifeCycle));
System.out.println(plantsByLifeCycle);
}
public static void streamV2(List<Plant> garden) {
Map plantsByLifeCycle = garden.stream()
.collect(Collectors.groupingBy(plant -> plant.lifeCycle,
() -> new EnumMap<>(LifeCycle.class),Collectors.toSet()));
System.out.println(plantsByLifeCycle);
}
- Collectors의 groupingBy 메소드를 이용하여 맵을 구성하였는데, streamV1() 와 streamV2()의 차이는 groupingBy 메소드에 원하는 맵 구현체를 명시하였는가의 차이다.
- V1 메소드의 반환 맵은 HashMap을 사용하고 Key에 대응되는 Value는 ArrayList로 구성된다.
- V2 메소드는 맵 구현체를 명시하였기 때문에 EnumMap을 사용하고 Value는 HashSet으로 구성된다.
- Stream 버전과 EnumMap 버전은 살짝 다르게 동작한다. EnumMap 버전은 열거 타입 상수 별로 하나씩 Key를 전부다 만들지만 Stream 버전에선 존재하는 열거 타입 상수만 Key를 만든다.
아이템 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라
- 열거 타입은 확장할 수 없다. 그러나 연산 코드(operation code 혹은 opcode)에는 확장할 수 있는 열거 타입이 어울린다.
- 연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻한다.
- 이때 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하여 확장과 비슷한 효과를 낼 수 있다.
- 연산 코드용 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 된다.
- 열거 타입이 해당 인터페이스의 표준 구현체 역할을 한다.
인터페이스를 이용해 확장 가능 열거 타입을 흉내 낸다.
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements Operation {
PLUS("+") {
@Override
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
@Override
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
@Override
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
@Override
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을 구현한 또 다른 열거 타입을 정의해 기본 타입인 BasicOperation을 대체할 수 있다.
- 위의 연산 타입을 확장해 지수 연산(EXP)과 나머지 연산(REMAINDER) 추가해보자.
public enum ExtendedOperation implements Operation {
EXP("^") {
@Override
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
@Override
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 인터페이스를 사용하도록 작성되어 있기만 하면 된다.
- apply가 인터페이스(Operation)에 선언되어 있으니 열거 타입에 따로 추상 메서드로 선언하지 않아도 된다. 이 부분이 상수별 메서드 구현과 다른 점이다.
38.1 인터페이스를 이용한 확장된 열거 타입의 문제점
- 열거 타입끼리는 구현을 상속할 수 없다는 문제가 있다.
- 만약 아무 상태에도 의존하지 않는다면, 디폴트 구현을 이용해 인터페이스에 추가하는 방법이 있다.
- 반면 여기서의 Operation 예는 연산 기호를 저장하고 찾는 로직이 BasicOperation과 ExtendedOperation 모두에 들어가야만 한다.
- 이 경우에는 중복량이 적으니 문제되지 않지만, 공유하는 기능이 많다면 그 부분을 별도의 도우미 클래스나 정적 도우미 메서드로 분리하는 방식으로 코드 중복을 없앨 수 있을 것이다.
아이템 39. 명명 패턴 보다 애너테이션을 사용하라
전통적으로 도구나 프레임워크나 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
명명 패턴의 단점
- 오타가 나면 안 된다.
- 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
- 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
컴파일러는 문자열이 무엇을 가리키는지 알 방법이 없다. 애너테이션은 이 모든 문제를 해결해준다. JUnit도 버전 4부터 전면 도입하였다.
마커(marker) 애너테이션 타입 선언
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
- @Retention과 @Target과 같이 애너테이션 선언에 다는 애너테이션을 메타애너테이션(meta-annotation)이라 한다.
- @Retention(RetentionPolicy.RUNTIME) 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다.
- 만약 이 메타애너테이션을 생략하면 테스트 도구는 @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 클래스의 의미에 직접적인 영향을 주지는 않는다.
- 단지 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.
- 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다.
마커 애너테이션을 처리하는 프로그램
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);
}
}
- 이 테스트 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test 애너테이션이 달린 메서드를 차례로 호출한다.
- 여기서 isAnnotationPresent가 실행할 메서드를 찾아주는 메서드다.
- 테스트 메서드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다.
- 따라서 이 프로그램은 InvocationTargetException을 잡아 원래 예외에 담긴 실패 정보를 추출해(getCause) 출력한다.
- 만약 InvocationTargetException 외의 예외가 발생한다면 @Test 애너테이션을 잘못 사용했다는 뜻이다.
매개변수 하나를 받는 애너테이션 타입
/**
* 명시한 예외를 던저야만 성공하는 테스트 메서드 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
- 이 애너테이션의 매개변수 타입은 Class<? extend Throwable>이다.
- 여기서 와일드카드 타입은 "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() { } // 실패해야 한다. (예외가 발생하지 않음)
}
Exception 애너테이션을 처리하는 프로그램
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);
}
}
이 예외 테스트 예에서 한 걸음 더 들어가, 예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다.
배열 매개변수를 받는 애너테이션 타입
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable>[] value();
}
- 배열 매개변수를 받는 애너테이션용 문법은 아주 유연하다.
- 단일 원소 배열에 최적화했지만, 앞서의 @ExceptionTest들도 모두 수정 없이 수용한다.
- 원소가 여럿인 배열을 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다.
배열 매개변수를 받는 애너테이션을 사용하는 코드
public class RunTests {
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나 NullPointerException을 던질 수 있다.
list.addAll(5, null);
}
}
아이템 40. @Override 애너테이션을 일관되게 사용하라
@Override 애너테이션을 일관되게 사용하면 여러가지 악명 높은 버그들을 예방해준다.
버그가 있는 영어 알파벳 2개로 구성된 문자열을 표현하는 클래스
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은 중복을 허용하지 않으니 26이 출력될 거 같지만, 실제로는 260이 출력된다!
- equals를 '재정의(overriding)'한 게 아니라 '다중정의(overloading)'해버렸다.
- Object를 재정의하려면 매개변수 타입을 Object로 해야만 한다.
- 따라서 Object에서 상속한 equals와는 별개인 equals를 새로 정의한 꼴이 되었다.
- Object의 equals는 == 연산자와 똑같이 객체 식별성(identity)만을 확인한다.
- 위 오류는 Object.equals를 재정의한다는 의도를 명시해서 컴파일러가 찾아낼 수 있게 하면 된다.
- @Override 애너테이션을 통해 컴파일 오류로 사고를 방지할 수 있다.
Bigram.java:10: method does not override or implement a method
from a supertype
@Override public boolean equals(Bigram b) {
^
그러니 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달도록 해야한다.
- 예외는 한 가지뿐이다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이 @Override를 달지 않아도 된다.
- 구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아 있다면 컴파일러가 그 사실을 바로 알려주기 때문이다.
- 물론 재정의 메서드 모두에 @Override를 일괄로 붙여두어도 상관없다.
- @Override를 일관되게 사용한다면 실수로 재정의했을 때 경고해줄 것이다. 이는 재정의할 의도였으나 실수로 새로운 메서드를 추가했을 때 알려주는 컴파일 오류의 보완재 역할로 보면 된다.
- @Override는 클래스뿐 아니라 인터페이스의 메서드를 재정의할 때도 사용할 수 있다.
- 디폴트 메서드를 지원하기 시작하면서, 인터페이스 메서드를 구현한 메서드에도 @Override를 다는 습관을 들이면 시그니처가 올바른지 재차 확신할 수 있다.
- 구현하려는 인터페이스에 디폴트 메서드가 없음을 안다면 이를 구현한 메서드에서는 @Override를 생략해 코드를 조금 더 깔끔히 유지해도 좋다.
- 다만, 추상 클래스나 인터페이스에서는 상위 클래스나 상위 인터페이스의 메서드를 재정의하는 모든 메서드에 @Override를 다는 것이 좋다. 상위 클래스가 구체 클래스든 추상 클래스든 마찬가지다.
- ex. Set 인터페이스는 Collection 인터페이스를 확장했지만 새로 추가한 메서드는 없다. 따라서 모든 메서드 선언에 @Override를 달아 실수로 추가한 메서드가 없음을 보장했다.
아이템 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라
- 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스를 마커 인터페이스(marker interface)라 한다.
- ex. Serializable 인터페이스는 자신을 구현한 클래스의 인스턴스는 ObjectOutputStream을 통해 쓸(write) 수 있다고, 즉 직렬화(serialization)할 수 있다고 알려준다.
package java.io;
public interface Serializable {
}
41.1 마커 인터페이스
마커 인터페이스는 두 가지 면에서 마커 애너테이션보다 낫다.
- 첫 번째, 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
- 마커 인터페이스는 타입이기 때문에, 마커 애너테이션을 사용했다면 런타임에 발견할 오류를 컴파일타임에 잡을 수 있다.
- 두 번째, 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다.
- 적용 대상(@Target)을 ElementType.TYPE으로 선언한 애너테이션은 모든 타입(클래스, 인터페이스, 열거 타입, 애너테이션)에 달 수 있다.
- 부착할 수 있는 타입을 더 세밀하게 제한하지 못한다는 뜻이다.
- 마커 인터페이스는 그냥 마킹하고 싶은 클래스에만 그 인터페이스를 구현(인터페이스라면 확장)하면 된다. 그러면 마킹된 타입은 자동으로 그 인터페이스의 하위 타입임이 보장되는 것이다.
마커 애너테이션이 마커 인터페이스보다 나은 점은 거대한 애너테이션 시스템의 지원을 받는다는 것이 있다. 따라서 애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리할 것이다.
41.2 마커 애너테이션 vs 마커 인터페이스, 언제 사용해야 할까?
- 클래스와 인터페이스 외의 프로그램 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야 할 때 애너테이션을 쓸 수밖에 없다.
- 클래스와 인터페이스만이 인터페이스를 구현하거나 확장할 수 있기 때문이다.
- 만약 마킹된 객체를 매개변수로 받는 메서드를 작성할 일이 있다면 마커 인터페이스를 써야 한다.
- 이렇게 하면 그 마커 인터페이스를 해당 메서드의 매개변수 타입으로 사용하여 컴파일타임에 오류를 잡아낼 수 있다.
- 마킹된 객체를 매개변수로 받는 메서드를 작성할 일은 절대 없다고 확신한다면 마커 애너테이션이 더 나은 선택이 될 것이다.
- 애너테이션을 활발하게 활용하는 프레임워크에서 사용하려는 마커라면 마커 애너테이션을 사용하는 편이 더 좋을 것이다.
'스터디 > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 07. 메서드 (1) | 2022.09.21 |
---|---|
[이펙티브 자바] 06. 람다와 스트림 (0) | 2022.09.14 |
[이펙티브 자바] 04. 제네릭 (1) | 2022.08.24 |
[이펙티브 자바] 03. 클래스와 인터페이스 (0) | 2022.08.20 |
[이펙티브 자바] 02. 모든 객체의 공통 메서드 (0) | 2022.08.10 |
댓글