본문 바로가기

나(다)/책

이펙티브 자바 - 6장 : 열거 타입과 애너테이션

반응형

6장 열거 타입과 애너테이션

  • 자바에서는 특수한 목정의 참조 타입이 있는데 하나의 클래스의 일종인 열거 타입이고, 인터페이스의 일종인 애너테이션이다.

아이템 34 : int 상수 대신 열거 타입을 사용하라

정수, 문자열 열거 패턴의 단점

public static final int APPLE_FUJ = 0;
public static final int APPLE_PIPPIN = 1;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;

int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN; // 사과 주스를 만들려고 했지만 오렌지를 잘못 첨가했는데 오류가 아니다.
  • 정수 열거 패턴
private final String APPLE = "1"; // 상호 협의 되지 않은 경우 문자열 상수의 이름 대린 문자열 값을 그대로 하드코딩하게 된다.
private final String ORANGEE = "2"; // 문자열에 오타가 있어도 컴파일러가 알길이 없어 런타임 버그가 발생하고 문자열 비교에 따른 성능 저하도 발생한다.
  • 문자열 열거 패턴

열거 타입의 장점

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • java에서의 열거 타입은 일종의 클래스이며 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
  • 클라이언트가 직접 생성하거나 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스는 딱 하나만 존재하여 싱글턴을 일반화한 형태라고 볼 수 있다.
  • 열거 타입에는 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공준할 수 있고 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다.
    • 공개는 필드의 이름뿐이라 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.
  • 열거 타입의 toString 메서드는 출력하기에 적합한 문자열을 내어준다.
  • 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현할 수도 있다.
    • Object 메서드들을 높은 품질로 구현해놓았고 Comparable과 Serializable을 구현했으며 직렬화 형태도 구현해놓았다.
public class Item34 {
    enum Planet {
        ERCURY(3.302e+23, 2.439e6),
        VENUS(4.869e+24, 6.042e6),
        EARTH(5.975e+24, 6.378e6);

        private final double mass; // 질량(단위 : 킬로그램)
        private final double radius; // 반지름(단위 : 미터)
        private final double surfaceGravity; // 표면중력(단위 : m / s^2)

        // 중력 상수(단위 m^2 / kg s^2(
        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; // F = ma
        }

    }
    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));
    }
}
  • 열거 타입을 선언한 클래스 혹은 해당 패키지에서만 유용한 기능은 private이나 package-private으로 구현한다.
  • 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스(아이템24)로 만든다.
enum Operation {
//        PLUS, MINUS, TIMES, DIVIDE;
        // 상수별 메서드 구현을 활용한 열거 타입. 각 상수에서 자신에 맞게 재정의하는 방법
        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;}
        public abstract double apply(double x, double y);

        @Override
        public String toString() {
            return symbol;
        }
        //        // 상수가 뜻하는 연산을 수행한다. // 좋은 코드가 아님
//        public double apply(double x, double y) { // 새로운 상수를 추가하면 해당 case문도 추가해야 한다.
//            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); // 나와서는 안되는 에러
//        }
    }
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) );
    }
  • 상수별 메서드 구현을 활용한 열거 타입
// 열거 타입용 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 Operation.ofNullable(stringToEnum.get(symbol));
}
  • toString 메서드를 재정의 하려거든 해당 열거 타입 상수로 변환해주는 fromString 메서드도 함께 제공하는 걸 고려해보자.
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);
            }

        }
    }
  • 전략 열거 타입 패턴 : 새로운 상수를 추가할 때 '전략'을 선택하도록 한다

성능

  • 정수 상수와 다르지 않으며 열거 타입을 메모리에 올리는 공간과 초기화 시간이 있지만 체감될 정도는 아니다.

언제 사용해야 하는가?

  • 필요한 원소를 컴파일타임에 다 알 수 있는 상수 집함이라면 항상 열거 타입을 사용하자.
    • 태양계 행성, 한 주의 요일, 체스 말, 메뉴 아이템, 연산 코드, 명령줄 플래그 등 허용하는 값 모두를 컴파일타임에 이미 알고 있을 때도 쓸 수 있다.
  • 열거 타입에 정의 된 상수 개수가 영원히 고정 불변일 필요는 없다.
    • 열거 타입은 나중에 상수가 추가돼도 바이너리 수준에서 호환되도록 설계되었다.

싱글턴(아이템3), Comparable(아이템14), 열거 타입은 근본적으로 불변이라 모든 필드는 final이어야 한다.(아이템17), 필드는 private으로 두고 별도의 public 접근자 메서드를 두는게 낫다.(아이템16), 클라이언트에 노출해야 할 합당한 이유가 없다면 private으로, 혹은 (필요하다면) package-private으로 선언하라(아이템15), 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스(아이템24)로 만든다., 열거 타입의 정적 필드 중 열거 타입의 생성자에 접근할 수 있는 것은 상수 변수뿐이다.(아이템24)

대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다. 드물게 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다. 이런 열거 타입에서는 switch문 대신 상수별 메서드 구현을 사용하자. 열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자.


아이템 35 : ordinal 메서드 대신 인스턴스 필드를 사용하라.

ordinal은 해당 상수가 열거타입에서 몇 번째 위치인지를 반환한다.

public class Item35 {
    enum Ensemble {
        SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET;

        public int numberOfNumsicians() { return ordinal() + 1;}// 절대 하지 말 것. 동작은 하지만 유지보수하기 끔찍하다.
    }
}
  • 상수 선언 순서를 바꾸면 오작동하며 이미 사용중인 정수와 값이 같은 상수는 추가할 방법이 없고 값을 중간에 비워둘 수 없다.

열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지말고, 인스턴스 필드에 저장하자.

public class Item35 {
    enum Ensemble {
        SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5), SEXTET(6), SEPTET(7), OCTET(8), NONET(9), DECTET(10), TRIPLE_QUARTET(12);
        private final int numberOfMusicians;
        Ensemble(int size) { this.numberOfMusicians = size; }
        public int numberOfMusicians() { return this.numberOfMusicians; }

//        public int numberOfNumsicians() { return ordinal() + 1;}// 절대 하지 말 것. 동작은 하지만 유지보수하기 끔찍하다.
    }
}
  • 인스턴스 필드에 저장하여 값 자체를 넘기자.

Enum의 API 문서에 ordinal에 대해서 "대부분 프로그래머는 이 메서드를 쓸 일이 없고 EnumSet과 EnumMap 같이 열거 타입 기반의 범용 자료 구조에 쓸 목적으로 설계되었다." 따라서 이런 용도가 아니라면 ordinal 메서드는 절대 사용하지 말자.


아이템 36 : 비트 필드 대신 EnumSet을 사용하라.

열거한 값들이 주로(단독이 아닌) 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해왔다.

비트 필드의 단점

public class Item36 {
    // 비트 열거 상수 - 구닥다리 기법
    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

    public void applyStyles(int styles) { }

    public static void main(String[] args) {
        Item36 i = new Item36();
        i.applyStyles(STYLE_BOLD | STYLE_ITALIC); // 비트 OR을 이용해 여러 상수를 하나의 집합으로 모을수 있으며 이런 집합을 비트 필드라 한다.
    }
}
  • 비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석하기가 훨씬 어렵다.
  • 비트 필드 하나에 녹아 있는 모든 원소를 순회하기도 까다롭다.
  • 최대 몇 비트가 필요한지를 API 작성 시 미리 예측하여 적절한 타입(보통은 int나 long)을 선택해야 하며 API를 수정하지 않고는 비트 수(32비트 or 64비트)를 더 늘릴 수 없다.

EnumSet으로 대체하기

  • EnumSet의 내부는 비트 벡터로 구현되어 있고 원소가 총 64개 이하라면, 대부분의 경우에 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보여준다.
public class Item36 {
    // 비트 필드를 대체하는 현대적 기법
    public enum Style {        BOLD, ITALIC, UNDERLINE, STRIKETHROUGH    }
    public void applyStyles(Set<Style> styles) { }

    public static void main(String[] args) {
        Item36 i = new Item36();
                i.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC)); // EnumSet에서 집합 생성 등 다양한 기능의 정적팩터리를 제공하는데, 그 중 of를 사용했다.
    }
}
  • EnumSet보다 인터페이스인 Set으로 받아 다른 Set 구현체를 넘기더라도 처리할 수 있도록 하는게 좋다.

정수 열거 패턴(아이템34), 이왕이면 인터페이스로 받는게 일반적으로 좋은 습관이다.(아이템64)

열거할 수 있는 타입을 한데 모아 집합 형태로 사용한다고 해도 비트 필드를 사용할 이유는 없다. EnumSet 클래스로 더 좋은 코드를 작성할 수 있고 단점으로는 불변 EnumSet을 만들수 없다는 것이다. Collections.unmodifiableSet으로 EnumSet을 감싸 사용할 수 있지만 명확성과 성능이 저하된다.


아이템 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 this.name;
  }
}

사용하지 말아야 하는 방법 : ordinal()을 배열 인덱스로 사용

//        ordinal()을 배열 인덱스로 사용 - 따라하지 말것
        Set<Plant>[] plantByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
        for (int i = 0; i < plantByLifeCycle.length; i++) {
            plantByLifeCycle[i] = new HashSet<>();
        }
        for (Plant p : garden) { // garden은 없는 구현체, Plant를 담은 객체
            plantByLifeCycle[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]);
        }
  • 배열은 제네릭과 호환되지 않아 비검사 형변환을 수행해야 하고 배열은 각 인덱스의 의미를 알 수 없어 출력 결과에 직접 레이블을 달아야 한다.
  • 정확한 정수값을 사용한다는 것을 직접 보증해야 하고 정수는 열거 타입과 달리 타입 안전하지 않다.

해결방법 : EnumMap을 사용해 데이터와 열거 타입을 매핑

// EnumMap을 사용해 데이터와 열거 타입을 매핑한다.
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);
  • EnumMap의 내부는 배열을 사용했다.

스트림을 사용한 코드

System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.liefCycle))); // EnumMap을 사용하지 않은 경우
System.out.println(Arrays.stream(garden).collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet()))); // EnumMap을 사용한 경우
  • EnumMap을 사용하면 공간과 성능의 이점을 얻을 수 있다.

EnumMap 버전과 스트림의 차이

  • EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들고, 스트림 버전은 해당 생애 주기에 속하는 식물이 있을 경우에만 만든다.

열거 타입 2개를 매핑하기 위해 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()];
        }
    }
}
  • 컴파일러가 ordinal과 배열 인덱스의 관계를 알 수가 없다.
    • Phase나 Phase.Transition 열거 타입을 수정하면서 상전이 표 TRANSITIONS를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 난다.
    • 상전이 표의 크기는 상태의 가짓수가 늘어나면 제곱으로 커지고 null로 채워지는 칸도 늘어나게 된다.
import java.util.EnumMap;
import java.util.Map;
import java.util.stream.Stream;

import static java.util.stream.Collectors.groupingBy;

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>>은 "이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵"이라는 뜻이다.

ordinal 메서드(아이템35), 배열은 제네릭과 호환되지 않으니(아이템28), 런타임 제네릭 타입 정보를 제공한다.(아이템33), 스트림(아이템45)

배열의 인덱스를 얻기 위해 ordinal을 쓰는것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라. Enum.ordinal을 (웬만해서는) 사용하지 말아야 한다.


아이템 38 : 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

열거 타입은 확장할 수 없다.

public class Item38 {
// 타입 안전 열거 패턴 예시
    private final String type;
    private Item38(String type) {
        this.type = type;
    }

    @Override
    public String toString() {
        return type;
    }
    public static final Item38 x = new Item38("x");
    public static final Item38 y = new Item38("y");
    public static final Item38 z = new Item38("z");
}
  • 타입 안전 열거 패턴은 열거한 값들을 그대로 가져온 다음 값을 추가하여 다른 목적으로 쓸 수 있지만 열거 타입은 그렇게할 수 없다.
  • 확장한 타입의 원소는 기반 타입의 원소로 취급하지만 반대는 성립하지 않는다.
  • 기반 타입과 확장된 타입들의 원소 모두를 순회할 방법이 없다.
  • 확장성을 높이려면 고려할 요소가 늘어나 설계와 구현이 더 복잡해진다.

열거 타입은 연산 코드에서 사용하자.

  • 연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻하며 Operation 타입도 포함된다.
  • 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;
    }
}

public enum ExtendsedOperation 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;

    ExtendsedOperation(String symbol) {
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }

        public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        test(ExtendedOperation.class, x, y); // 첫 번째 방법
        test(Arrays.asList(ExtendsedOperation.values()), x, y); // 두 번째 방법
    }

    private static void test(Collection<? extends Operation> opSet, double x, double y) {
        for (Operation op : opSet) {
            System.out.println("%f %s %f = %f%n", x, op, y, op.apply(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));
    }
}
  • 열거 타입인 BasicOperation은 확장할 수 없지만 인터페이스인 Operation은 확장할 수 있고, 인터페이스를 연산의 타입으로 사용하면 된다.
  • Operation 인터페이스를 사용하면 어디서든 사용할 수 있다.
  • <T extends Enum & Operation> Class는 Class 객체가 열거 타입인 동시에 Operation의 하위 타입이어야 한다는 의미이다.

인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 방식에서의 사소한 문제

  • 열거 타입끼리 구현을 상속할 수 없다.
  • 아무 상태에도 의존하지 않는 경우에는 디폴트 구현을 이용해 인터페이스를 추가하는 방법이 있다.

class 리터럴은 한정적 타입 토큰(아이템33), 와일드카드 타입(아이템31), EnumSet(아이템36), EnumMap(아이템37), 디폴트 구현(아이템20)

열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다.


아이템 39 : 명명 패턴보다 애너테이션을 사용하라

명명 패턴의 단점

  1. 오타가 나면 안된다.
  2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
    • 클래스명이지만 메서드로 인식할 수도 있다.
  3. 프로그램 요소를 매개변수로 전달할 방법이 없다.
    • 특정 예외를 매개변수로 던져야 성공하는 테스트이지만 테스트 메서드명에 덧붙이는 방법도 있지만, 컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가리키는지 알 방법이 없다.

애너테이션으로 해결하자

/**
 * 테스트 메서드임을 선언하는 애너테이션이다.
 * 매개변수 없는 정적 메서드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Item39 {

}
  • @Item39 애너테이션 타입 선언과 @Retention, @Target이 있으며 이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다.
  • @Retention 메타애너테이션은 @Item39가 런타임에도 유지되어야 한다는 표시이며 생략하면 테스트 도구가 @Item39를 인식할 수 없다.
  • @Target 메타애너테이션은 반드시 메서드 선언에서만 사용돼야 한다는 표시이므로 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.
public class Sample {
    @Item39 public static void m1() {} // 성공
    public static void m2() {}
    @Item39 public static void m3() { throw new RuntimeException("실패");} // 실패
    public static void m4() {}
    @Item39 public void m5() {} // 실패 : 잘못 사용
    public static void m6() {}
    @Item39 public static void m7() { throw new RuntimeException("실패"); } // 실패
    public static void m8() {}
}
  • @Item39 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않고 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.
public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName("Sample");
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Item39.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
                    System.out.println(m + " 실패 : " + exc);
                } catch (Exception e ){
                    System.out.println("잘못 사용한 @Item39: " + m);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}
  • @Item39 애너테이션이 달린 메서드를 차례로 호출하고 isAnnotationPresent가 실행할 메서드를 찾아 테스트 메서드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetExcetpion으로 감싸서 다시 던진다. 그러면 예외에 담긴 실패 정보를 추출해 출력한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();// 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() { // 실패 : 예외가 발생하지 않음

    }
}

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName("Sample2");
        for (Method m : testClass.getDeclaredMethods()) {
//            if (m.isAnnotationPresent(Item39.class)) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
//                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
//                    System.out.println(m + " 실패 : " + exc);
                    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 e ){
                    System.out.println("잘못 사용한 @Item39: " + m);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}
  • 테스트 프로그램이 문제 없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 의미이다. 단, 해당 예외의 클래스 파일이 컴파일타임에는 존재했으나 런타임에는 존재하지 않을 수는 있다.

배열 매개변수를 받는 애너테이션 타입인 경우

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable>[] value();// Throwable을 확장한 클래스의 Class 객체라는 의미로 모든 예외 타입을 다 수용한다.
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

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({IndexOutOfBoundsException.class, NullPointerException.class})
    @ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(NullPointerException.class)
    public static void doublyBad() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}
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({IndexOutOfBoundsException.class, NullPointerException.class})
    public static void doublyBad() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName("Sample2");
        for (Method m : testClass.getDeclaredMethods()) {
//            if (m.isAnnotationPresent(Item39.class)) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
//                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
//                    System.out.println(m + " 실패 : " + exc);

//                    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);
//                    }

                    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);
                    }

                } catch (Exception e ){
                    System.out.println("잘못 사용한 @Item39: " + m);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}

배열 방식 대신 반복 가능 애너테이션인 경우

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({IndexOutOfBoundsException.class, NullPointerException.class})
    @ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(NullPointerException.class)
    public static void doublyBad() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Class.forName("Sample2");
        for (Method m : testClass.getDeclaredMethods()) {
//            if (m.isAnnotationPresent(Item39.class)) {
//            if (m.isAnnotationPresent(ExceptionTest.class)) {
            if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) {
                tests++;
                try {
                    m.invoke(null);
//                    passed++;
                } catch (InvocationTargetException e) {
                    Throwable exc = e.getCause();
//                    System.out.println(m + " 실패 : " + exc);

//                    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);
//                    }

                    int oldPassed = passed;
//                    Class<? extends Throwable>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
//                    for (Class<? extends Throwable> excType : excTypes) {
//                        if (excType.isInstance(exc)) {
//                            passed++;
//                            break;
//                        }
//                    }
                    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);
                    }

                } catch (Exception e ){
                    System.out.println("잘못 사용한 @Item39: " + m);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}
  • 위 방식들로는 코드의 가독성을 개선할 수 있다면 이 방법을 사용해도 좋지만 애너테이션을 선언하고 처리하는 부분에서는 코드 양이 늘어나며 특히 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심하자.

예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다.(아이템62), 한정적 타입 토큰(아이템33), 애너테이션 타입들은 사용해야한다.(아이템40)

도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.


아이템 40 : @Override 애너테이션을 일관되게 사용하라

@Override는 메서드 선언에만 달 수 있으며 상위 타입의 메서드를 재정의했음을 의미하고 일관되게 사용하면 여러 가지 악명 높은 버그들을 예방해준다.

import java.util.HashSet;
import java.util.Set;

public class Item40 {
    private final char first;
    private final char second;

    public Item40(char first, char second) {
        this.first = first;
        this.second = second;
    }
//    @Override
//    public boolean equals(Item40 i) {
    public boolean equals(Object o) {
        if (!(o instanceof Item40)) {
            return false;
        }
        Item40 i = (Item40) o;
        return i.first == this.first && i.second == this.second;
    }

    public int hashCode() {
        return 31 * first + second;
    }

    public static void main(String[] args) {
        Set<Item40> b = new HashSet<>();
        for (int i = 0; i < 10; i++) {
            for (char ch = 'a'; ch <= 'z'; ch++) {
                b.add(new Item40(ch, ch));
            }
            System.out.println(b.size());
        }
    }
}
  • equals, hashCode도 재정의했지만 실행하면 26개가 아닌 260개 나오는데 Object의 equals를 재정의하려면 매개변수도 Object여야 하는데 그렇지 않아 다중 정의가 되었다.
  • @Override를 달면 컴파일 오류가 발생하여 미연에 방지할 수 있다.
  • 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자

equals 메서드를 재정의하려 한 것으로(아이템10), hashCode도 함께(아이템11), 다중 정의(아이템52)

재정의한 모든 메서드에 @Override 애너테이션을 의식적으로 달면 여러분이 실수했을 때 컴파일러가 바로 알려줄것이다. 예외는 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우엔 달지 않아도 되며 달아도 해로울 것이 없다.


아이템 41 : 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

  • 마커 인터페이스 : 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스
    • Serializable 인터페이스가 가장 좋은 예로 직렬화를 할 수 있다고 알려주며 ObjectOutputStream을 통해 사용할 수 있다.
  • 마커 애너테이션이 등장하면서 마커 인터페이스는 구식이 되었다다고 하지만 두 가지 면에서 마커 애너테이션보다 낫다.
    1. 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
      • 마커 인터페이스는 타입이기에 컴파일 오류 수준에서 잡을 수 있다.
    2. 적용 대상을 더 정밀하게 지정할 수 있다.
      • 적용 대상(@Target)을 ElementType.TYPE으로 선언한 애너테이션은 모든(클래스, 인터페이스, 열거타입, 애너테이션)에 달 수 있어 세밀하게 제어하지 못하지만 특정 인터페이스를 구현한 클래스에만 적용하고 싶은 마커가 있드면 인터페이스를 구현하면 된다.
        • 한 예로 Set을 들 수 있는데 Collection의 하위 타입이지만 Collection이 정의한 메서드 외에는 새로 추가한 것이 없다.
  • 마커 애너테이션이 마커 인터페이스보다 나은 점으로는 거대한 애너테이션 시스템의 지원을 받는다는 점을 들 수 있다.
    • 애너테이션을 적극활용하는 프레임워크는 일관성을 지키는데 유리하다.

언제 마커 애너테이션을, 언제 마커 인터페이스를 사용해여 하는가

  • 클래스와 인터페이스 외의 프로그램 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야 할 경우에는 애너테이션을 쓸 수밖에 없다.
    • 클래스와 인터페이스만이 인터페이스를 구현하거나 확장할 수 있기 때문이다.
  • 마커를 클래스나 인터페이스에 적용해야 한다면 "이 객체를 매개변수로 받는 메서드를 작성할 일이 있을까?" 라고 자문하고 "그렇다"라고 한다면 마커 인터페이스를 써야 한다.
  • 반대로 메서드를 작성할 일은 절대 없다고 확신한다면 아마도 마커 애너테이션이 나은 선택일 것이다.
  • 추가로 애너테이션을 활발히 활용하는 프레임워크에서 사용하는 마커라면 마커 애너테이션을 사용하는 편이 좋을 것이다.

마커 애너테이션(아이템39)

새로 추가하는 메서드 없이 단지 타입 정의가 목적이라면 마커 인터페이스를 선택하자. 적용 대상이 ElementType.TYPE인 마커 애너테이션을 작성하고 있다면, 잠시 여유를 갖고 정말 애너테이션으로 구현하는게 옳은지, 혹은 마커 인터페이스가 낫지는 않을지 곰곰히 생각해보자.


반응형