Effective Java - Chapter 6

43 분 소요

ITEM 34. int 상수 대신 열거 타입을 사용하라

열거(이하 Enum) 타입이 도입되기 전에는 상수를 정의할 때에는 정수 열거 패턴(int enum pattern)을 사용했다.

//DO NOT
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;

쓰지 말아야 할 이유

  1. 이 정수 열거 패턴은 type safe를 보장할 수 없다.(APPLE_FUJI==ORANGE_NAVEL의 결과는?)
  2. 개발자의 의도는 APPLE과 ORANGE를 분리하여 사용하고자 했을 것이다. namespace를 컴파일러가 아닌 개발자에게 맡겨버렸다.
  3. 컴파일하면 클라이언트 파일에 그대로 새겨진다.
  4. 상수의 값을 바꾸려면 다시 컴파일해야 했다.
  5. 디버깅할 때 의미나 문자열이 아닌 숫자로 보이기 때문에 디버그하기 힘들다.
  6. 순회를 하기 힘들다. 해당 가상의 namespace 안에 몇 개의 상수가 있을까?

쓰지 말아야할 이유는 차고 넘친다.

Enum 타입이란?

일정 갯수의 상수 값을 정의한 다음 그 외의 값은 허용하지 않는 타입이다. 상수의 namespace 역할을 같이 한다. 카드의 종류, 사계절 등이 좋은 예가 된다.

Enum 타입의 특징

  1. 열거 타입은 완전한 형태의 클래스다.
  2. 상수 하나당 인스턴스가 하나씩 만들어 진다. (public static final)
  3. 생성자를 제공하지 않는다.

    열거타입 선언으로 생성된 인스턴스는 유일함이 보장된다.

  4. 컴파일 타임 safety check가 가능하다.

    위에서 살펴본 int enum pattern에서 발생하는 type safe 문제가 발생하지 않는다. 오렌지 타입과 사과 타입의 변수는 다른 타입 취급을 받는다.

  5. 열거 타입의 상수의 순서를 바꾸거나 추가해도 재컴파일이 필요하지 않다.

    참고 : https://stackoverflow.com/questions/17592584/enum-requires-no-recompilation-of-its-clients-how

  6. 열거 타입에는 임의의 메서드나 필드를 추가할 수 있다

    클래스이기에 가능한 일이다. 앞의 과일의 예를 든다면, 과일의 색을 알려주거나 과일 이미지를 반환하는 메서드도 구현할 수 있다. 이를 통해 고차원의 추상 개념 하나를 완벽하게 표현할 수 있다.

    만약 특정 상수를 제거하고, 그 상수를 참조하는 프로그램은? 컴파일 단계에서 문제를 잡아낼 수 있다. int enum pattern에서는 기대도 할 수 없던 장점이다.

  7. 인터페이스도 구현할 수 있다.
  8. 잘 만들어졌다.

    Comparable과 Serializable을 높은 품질로 구현해냈다.

  9. 열거 타입은 근본적으로 불변이다.
  10. 순회가 쉽다.

    아래 예시에서 보여지는 Planet enum type을 기준으로, 각 행성에서의 무게를 출력하는 코드도 가능하다.

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

예제1 : 태양계의 행성

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); // 안의 value는 각각 mass, radius로 대응

    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;

    // 생성자. 특징2번 참조. MERCURY, VENUS...가 각각의 인스턴스가 되고
		// 생성자를 호출할 때 괄호안의 value들이 순서대로 args로 들어간다.
		// 설명 1번.
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
				//표면중력을 계산해서 필드로 따로 가지고 있다.
				//표면중력을 매번 필요할 때 마다 계산하는 대신
				//mass와 radius가 변하지 않아 surfaceGravity도 변하지 않기 때문에
				//미리 캐싱해 가지고 있는다.
    }

    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
    }
}
  1. enum 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 나열도니 순서대로 받아 인스턴스 필드에 저장한다.
  2. 불변이기 때문에 모든 필드는 final이어야 한다.
  3. private으로 각 멤버 필드를 선언하고 public getter를 선언하는게 낫다. (ITEM 16 참조)
  4. 위 예제에는 없지만, private와 public을 잘 구분해서 구현해야 한다.(ITEM 15)
  5. 널리 쓰이는 enum 타입은 top-level 클래스로 만든다.(1파일 1클래스). 특정 클래스에서만 쓰이는 enum 타입은 해당 클래스의 멤버 클래스로 만든다.

예제2 : 계산기

//DO NOT

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

사칙연산을 하는 계산기를 enum 타입으로 구현한다면 어떻게 해야할까? 위의 예제대로 하는 것은 좋은 방법이 아니다. 그 이유는

  1. throw문을 생략할 수 없다.

    실제 실행에선 throw문이 호출될 일은 없지만, 기술적으로는 도달할 수 있다.

    참조 : https://docs.oracle.com/javase/specs/jls/se8/html/jls-14.html#jls-14.21

  2. 깨지기 쉬운 코드다.

    새로운 연산자를 추가하면 case문도 하나 추가하여야 한다. 만약 개발자가 상수를 추가하고 case문 추가를 까먹었다면, 런타임 오류를 띄우게 된다.

대신에 enum에서는 상수별로 다르게 동작하는 코드를 구현하는 apply라는 키워드를 제공한다. apply라는 추상 메서드를 선언하고 각 상수별 클래스 몸체(constant-specific class body), 즉 상수별 메서드 구현(constant-specific method implementation)을 하는 것이다.

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

    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));
    }
}
  1. apply 메서드를 abstract로 선언함으로써 새로운 타입을 추가하더라도 overriding을 컴파일러가 강제함으로써 빠뜨릴 가능성이 없다.
  2. toString 메서드는 symbol을 리턴한다
  3. toString을 구현했으니 그 역함수 역할을 하는 fromString도 구현할 수 있다.

    Operation 상수는 static 필드가 초기화될 때 stringToEnum의 map으로 추가된다. return type이 Optional이다.

    참고 : http://homoefficio.github.io/2019/10/03/Java-Optional-바르게-쓰기/

  4. 해당 방법은 코드를 공유할 수 없는 단점이 있다.

    중복되는 부분이 많으면 중복되는 모든 코드를 상수에 작성하거나 혹은 helper 메서드를 작성하는 방법이 있다. 아래 전략 열거 타입패턴을 참조.

예제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;
    }
}

주말과 주중의 수당 계산을 하는 코드다. 평일용 수당인 overtimePay 메서드를 정의하고 주말 상수에서만 재정의 하여 고치면 코드의 양은 줄겠지만, 만약 새로운 상수를 추가하면서 overtimePay를 재정의하는 것을 잊는다면 평일 수당 계산을 받을 것이다.

enum PayrollDay {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
    THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);
    // (역자 노트) 원서 1~3쇄와 한국어판 1쇄에는 위의 3줄이 아래처럼 인쇄돼 있습니다.
    //
    // MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    // SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
    //
    // 저자가 코드를 간결하게 하기 위해 매개변수 없는 기본 생성자를 추가했기 때문인데,
    // 열거 타입에 새로운 값을 추가할 때마다 적절한 전략 열거 타입을 선택하도록 프로그래머에게 강제하겠다는
    // 이 패턴의 의도를 잘못 전달할 수 있어서 원서 4쇄부터 코드를 수정할 계획입니다.

    private final PayType payType;

    PayrollDay(PayType payType) { this.payType = payType; }
    // PayrollDay() { this(PayType.WEEKDAY); } // (역자 노트) 원서 4쇄부터 삭제

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

상수에 잔업수당을 계산할 전략을 포함시키고, 중첩한 enum 클래스에 전략에 따른 계산을 위임하는 식으로 작성한다. 코드가 좀 복잡하지만 안전하고 유연하다.

예제 4: 기존 enum 타입에 원래 열거타입에 없는 상수별 동작을 구현할 때는 switch도 좋은 대안이 될 수 있다.

public class Inverse {
    public static Operation inverse(Operation op) {
        switch(op) {
            case PLUS:   return Operation.MINUS;
            case MINUS:  return Operation.PLUS;
            case TIMES:  return Operation.DIVIDE;
            case DIVIDE: return Operation.TIMES;

            default:  throw new AssertionError("Unknown op: " + op);
        }
    }
}

결론

  • 컴파일 타입에 필요한 원소를 다 알 수 있는 상수의 집합이라면 열거 타입을 사용하자.

    카드 종류같은 집합은 물론 메뉴의 종류, 명령줄 플래그 등 허용되는 값이 정해져 있는 경우에도 마찬가지다.

ITEM 35. ordinal 메서드 대신 인스턴스 필드를 사용하라

Enum 클래스는 해당 상수가 Enum 타입에서 몇 번째 상수인지 리턴하는 ordinal 메서드를 제공한다.

그러나 사용하지 않아야 한다.

예제1 : 합주단의 종류

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

        public int numberOfMusicians() { return ordinal() + 1; }
}

쓰지 말아야 하는 이유

  1. 상수 선언 순서를 바꾸면 ordinal()은 오동작한다.

    선언 순서에 기반해 동작하는 ordinal() 입장에선 당연하다.

  2. 이미 사용중인 정수와 똑같은 값을 가지는 상수는 만들 수 없다.

    8중주는 8명이서 연주한다(octet). 그러나 복4중주(double quartet) 또한 8명이서 연주하는데 이를 표현할 방법이 없다.

  3. 값을 중간에 비워둘 수도 없다.

    dummy 상수를 넣어 순서를 맞추는 방법도 있지만 굉장히 비효율적이다.

  4. API User를 위해 구현된 메서드가 아니다.

    Enum API 문서에 명시된 바로는 EnumSet과 EnumMap과 같이 열거타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다고 한다.

해결법

열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지말고, 인스턴스 필드에 선언한다.

예제2: 합주단의 종류 개선

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

ITEM 36. 비트 필드 대신 EnumSet을 사용하라

작성자 주: 저는 C/C++ 개발을 할 때 비트 필드 구현을 많이 사용했습니다. 하나의 int/long 타입 정수에 여러 개의 flag를 넣을 수 있어 많이 사용됩니다. 보통 or 연산으로 합치고, and로 원하는 flag를 가져오는 식으로 사용합니다.

아래 예제는 리눅스 커널의 mman.h 파일에 있는 flag의 일부입니다.

#ifdef __USE_MISC
# define MAP_GROWSDOWN        0x00100                /* Stack-like segment.  */
# define MAP_DENYWRITE        0x00800                /* ETXTBSY */
# define MAP_EXECUTABLE        0x01000                /* Mark it as an executable.  */
# define MAP_LOCKED        0x02000                /* Lock the mapping.  */
# define MAP_NORESERVE        0x04000                /* Don't check for reservations.  */
# define MAP_POPULATE        0x08000                /* Populate (prefault) pagetables.  */
# define MAP_NONBLOCK        0x10000                /* Do not block on IO.  */
# define MAP_STACK        0x20000                /* Allocation is for a stack.  */
#endif

비트 필드의 단점은 정수 열거 패턴의 단점을 그대로 지닙니다. (ITEM 34)

참고하기 :

  1. 이 정수 열거 패턴은 type safe를 보장할 수 없다.(APPLE_FUJI==ORANGE_NAVEL의 결과는?)
  2. 개발자의 의도는 APPLE과 ORANGE를 분리하여 사용하고자 했을 것이다. namespace를 컴파일러가 아닌 개발자에게 맡겨버렸다.
  3. 컴파일하면 클라이언트 파일에 그대로 새겨진다.
  4. 상수의 값을 바꾸려면 다시 컴파일해야 했다.
  5. 디버깅할 때 의미나 문자열이 아닌 숫자로 보이기 때문에 디버그하기 힘들다.
  6. 순회를 하기 힘들다. 해당 가상의 namespace 안에 몇 개의 상수가 있을까?

또한

  • 최대 몇 비트를 사용할 것인지 int나 long으로 적절히 선택해야 하며, 이미 확정되어 구현된 이후로는 의존성 때문에 수정이 매우매우 힘들다. 64bit가 최대인 한계도 존재한다.

대안 : EnumSet 클래스 사용하기

EnumSet의 특징은

  1. Set 인터페이스를 구현했다.
  2. 타입 안전하다.
  3. 원소가 64개 이하인 경우 비트 벡터 구현을 이용하여 비트 필드 수준의 성능을 보여준다.
  4. 직접 비트를 다룰 때의 오류를 걱정하지 않아도 된다.
public class Text {
    public enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}

    // 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다.
    public void applyStyles(Set<Style> styles) {
        System.out.printf("Applying styles %s to text%n",
                Objects.requireNonNull(styles));
    }

    // 사용 예
    public static void main(String[] args) {
        Text text = new Text();
        text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
    }
}

불변 EnumSet을 만들 수 없다는 단점이 있지만 비트 필드를 사용할 이유가 없어졌다. EnumSet을 활용하자.

ITEM 37. ordinal 인덱싱 대신 EnumMap을 사용하라

배열이나 리스트에서 원소를 꺼낼 때, ordinal메서드로 인덱스를 얻는 코드가 가끔 있는데, 바람직하지 못하다.

예제 1-1 : Plant 클래스

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

Plant 클래스의 inner class로 enum 타입을 선언해주었다. Enum타입은 생애주기를 나타낸다.

예제 1-2 : ordinal()을 배열의 인덱스로 사용(따라하지 말 것)

Set<Plant>[] plantsByLifeCycleArr =
        (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycleArr.length; i++)
    plantsByLifeCycleArr[i] = new HashSet<>();
for (Plant p : garden)
    plantsByLifeCycleArr[p.lifeCycle.ordinal()].add(p);
// 결과 출력
for (int i = 0; i < plantsByLifeCycleArr.length; i++) {
    System.out.printf("%s: %s%n",
            Plant.LifeCycle.values()[i], plantsByLifeCycleArr[i]);
}

위 코드는 각 식물의 생애주기별로 집합을 통해 관리하려는 코드다. 배열은 Enum 타입의 상수를 매핑하려는 목적으로 사용한 것이다.

그러나 굉장히 문제가 많은 코드이다 배열과 제네릭의 호환성 때문에 type safe 보장 불가(ITEM 28)하다.

대신에 EnumMap을 사용하자.

Enum 타입의 상수를 Key로 사용하는 빠른 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);
  1. 짧게 작성 가능하다.
  2. type safe가 보장된다.

    EnumMap의 Key는 한정적 타입 토큰을 이용한 Class 객체이다.(ITEM 33)

  3. 성능도 뒤떨어지지 않는다.

    EnumMap이 배열에 뒤쳐지지 않는 이유는, 내부 구현에 배열을 사용했다. 별 차이가 없이 느껴질 수도 있겠지만 배열을 직접 다루는 것과 잘 만들어진 API를 이용하는건 생산성 측면에서나 안전성 측면에서 큰 차이를 보인다.

참고 : Stream을 이용해서 맵 관리하기

// 코드 37-3 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다! (228쪽)
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle)));

// 코드 37-4 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다. (228쪽)
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle,
                () -> new EnumMap<>(LifeCycle.class), toSet())));

위의 코드는 EnumMap 대신 고유의 맵 구현체를 사용했기에 EnumMap의 공간과 성능 이점을 활용할 수 없다.

매개변수 3개짜리 Collectors.groupingBy 메서드를 이용해 원하는 맵 구현체를 명시할 수 있으며, 37-4 코드를 참조하면 된다.

EnumMap만 사용했을 때와 Stream을 사용했을 때의 차이는, 전자는 각 생애주기에 해당하는 식물이 존재하든 그렇지 않든 각 생애주기별 중첩 맵을 만들지만, Stream을 이용할 땐 해당하는 식물 주기가 존재할 때만 중첩 맵을 만든다.

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

Enum type은 확장을 할 수 없다. OOP의 상속의 시점으로 본다면, 확장한 타입들의 원소는 상위 타입의 원소가 되지만, 그 반대는 성립하지 않는다. 순회할 방법도 마땅치 않다. 설계와 구현의 난이도도 올라갈 것이다.

그러나 Enum 타입은 인터페이스를 구현할 수 있다.

예제 1 : 연산 코드(Operation Code)

// 인터페이스 정의
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;
    }
}

Enum 타입인 BasicOperation은 확장할 수 없지만, Operation 인터페이스는 확장이 가능하다. 사칙연산 뿐 아니라 mod 연산과 exp 지수연산을 추가하고 싶다고 가정하자. Operation 인터페이스를 확장해 구현하기만 하면 된다.

예제 1-1 : 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;
    }
}

인터페이스를 확장한 덕에, 처음 고안했던 BasicOperation 뿐 아니라 ExtendedOperation도 인터페이스를 통해 접근할 수 있다.

예제 2 : 확장된 Enum 타입을 인터페이스로 접근하기

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

ExtendedOperation.values()는 상수의 배열을 반환한다. 앞서 다뤘던 아이템에서 보았듯이 가능하면 배열보다는 List를 사용하길 권장하므로, Arrays.asList() 메서드로 배열을 리스트로 변환한다.

한정적 타입을 통해 Operation을 확장한 무언가를 담고있는 Collection을 test 메서드에서 인자로 받는다.

확장한 타입의 코드 중복작성을 줄이는 테크닉

그러나 열거타입끼리 상속을 구현할 수 없는 문제는 여전하다. 그러나 확장한 Enum 타입끼리 많은 로직을 공유해야 한다면?

  1. 별도의 Helper class 작성
  2. Static helper method로 분리
  3. 인터페이스의 default 메서드

ITEM 39. 명명 패턴보다 애너테이션을 사용하라

유틸이나 프레임워크에서 특별히 다뤄야 할 프로그램 요소에 특정한 이름짓는 규칙을 적용하는 명명 패턴이 있다.

테스트 프레임워크인 JUnit은 test로 시작하는 메서드를 ㅌ베스트 메서드로 인식하게끔 구현되어있다. 그러나 문제점이 있따

  1. 오타가 나면 안된다

    tsetBlahBlah와 같이 오타가 난다면 JUnit 3은 이 메서드를 테스트로 취급하지 않는다. 테스트를 무시하지만 별다른 오류를 띄우지 않기 때문에 개발자는 테스트가 통과했다는 착각을 가질 수 있다.

  2. 개발자가 올바른 프로그램 요소에 사용하리라는 보장이 없다.

    클래스 이름을 TestSafetyMechanisms로 지어서 개발자는 안에 있는 메서드를 테스트 취급하여 수행해주길 바라지만 JUnit 3은 여기에 관심이 없다.

  3. 프로그램 요소를 매개변수로 전달할 방법이 없다.

    특정 예외를 던져야 성공하는 테스트를 수행하고자 하지만, 프로그래머가 함수를 호출하는게 아닌 JUnit이 테스트 메서드를 호출하기 때문에 매개변수를 전달할 방도가 없다.

annotation은 이 문제를 해결하기 위해 도입되었다.

예제 1 : Test annotation

import java.lang.annotation.*;

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

annotation 선언에 annotation이 달려있는 것을 meta-annotation이라 한다.

@Retension

이 annotation이 선언된 annotation은 @Test가 런타임에도 유지되어야 한다는 표시다.

@Target (ElementType.METHOD)

이 annotation이 선언된 annotation은 메서드 선언에 사용되어야 함을 표기한다.

이 예제에서는 개발자가 주석으로 매개변수 없는 정적 메서드에만 부착하도록 구현했다는 의도를 달아놓았지만, 인스턴스 메서드나 매개변수가 있는 메서드에 달 수도 있다. 컴파일러는 이러한 제약을 알 길이 없기 때문이다. 만약 컴파일러가 검사하는 이러한 제약을 구현하고자 한다면 javax.annotation.processing API 문서를 참고하면 된다. 그리고 해당 Test annotation은 아무런 역할을 하지 않아 marker annotation이라고 부른다.

예제 2 : Marker Annotation 적용 예시

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("Crash");
    }
    public static void m8() { }
}

위에서 언급한 것 처럼 프로그래머가 정적 메서드에만 부착하도록 annotation을 구현하였더라도 별도의 annotation 처리기를 구현하지 않았기에 m5()와 같은 경우가 발생할 수 있다. 컴파일 타임에 에러가 발생하지 않지만, 테스트 과정에 오류가 발생할 수 있다. → 왜?

@Test annotation은 Sample 클래스에 아무런 영향을 끼치지 않고, 그냥 테스트를 수행하도록 표시해놓는 역할만을 수행한다. 테스트를 수행하는 프로그램 요소가 이 마커를 보고 테스트를 수행할 것이다.

예제 3 : Marker Annotation을 처리하는 프로그램

import java.lang.reflect.*;

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

m.isAnnotationPresent(Test.class)를 통해 해당 메서드에 @Test annotation이 달려있는 메서드를 m.invoke()로 호출한다.

만약 테스트가 예외를 던지면 Java Reflection이 InvocationTargetException을 catch하여 실패 정보를 getCause()로 추출하여 출력한다. 만약 InvocationTargetException 이외의 예외가 발생한다면 @Test annotation을 잘못 사용하였다는 의미다.

참고 : 자바의 리플렉션(강관우님) https://brunch.co.kr/@kd4/8

위 예제는 허용되는 아무 예외나 던져도 받아들인다. 만약 특정 예외를 던지면 성공하는 테스트는 어떻게 작성할까?

예제 4 : 매개변수를 받는 annotation 타입

import java.lang.annotation.*;

/**
 * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();//annotation의 매개변수
}

annotation의 매개변수는 Throable을 확장한 클래스의 객체를 받는다.

예제 5 : 매개변수 하나짜리 annotation을 사용한 프로그램

import java.util.*;

// 코드 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() { }  // 실패해야 한다. (예외가 발생하지 않음)
}

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

m.getAnnotation(ExceptionTest.class).value();를 통해 annotation의 매개변수 값을 추출하고, 발생한 예외와 비교하여 올바른 예외를 던졌는지 확인한다.

하나의 예외가 아니라 여러개의 예외 중 하나가 발생했는지 알고 싶다면?

예제 6 : 배열 매개변수를 받는 annotation

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value();
}

예제 7 : 배열 매개변수를 받는 annotation을 사용하는 코드

public class Sample3 {
    // 이 변형은 원소 하나짜리 매개변수를 받는 애너테이션도 처리할 수 있다. (241쪽 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() { }  // 실패해야 한다. (예외가 발생하지 않음)

    // 코드 39-7 배열 매개변수를 받는 애너테이션을 사용하는 코드 (242-243쪽)
    **@ExceptionTest({ IndexOutOfBoundsException.class,
                     NullPointerException.class })**
    public static void doublyBad() {   // 성공해야 한다.
        List<String> list = new ArrayList<>();

        // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
        // NullPointerException을 던질 수 있다.
        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(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            // 배열 매개변수를 받는 애너테이션을 처리하는 코드 (243쪽)
            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);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n",
                passed, tests - passed);
    }
}

@ExceptionTest({ IndexOutOfBoundsException.class,NullPointerException.class })와 같이 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다.

여러 값을 받는 annotation을 다른 방식으로도 만들 수 있다.

@Repeatable meta-annotation을 다는게 그 방법이다.

주의할 점도 있다

  1. @Repeatable을 단 annotation을 반환하는 ‘컨테이너 annotation’을 하나 더 정의하고, @Repeatable에 컨테이너 annotation의 class 객체를 매개변수로 전달해야 한다.
  2. 컨테이너 annotation은 내부 annotation의 타입의 배-열을 반환하는 .value() 메서드를 정의해야 한다.
  3. 컨테이너 annotation 타입에는 @Retention과 @Target을 명시해야 한다. 그렇지 않다면 컴파일에 문제가 생긴다.

예제 8 : @Repeatable을 사용한 annotation 타입

import java.lang.annotation.*;

// 반복 가능한 애너테이션의 컨테이너 애너테이션 (244쪽)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

예제 9 : 반복 가능한 annotation을 두 번 단 코드

public class Sample4 {
    @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() { }  // 실패해야 한다. (예외가 발생하지 않음)

    // 코드 39-9 반복 가능 애너테이션을 두 번 단 코드 (244쪽)
    **@ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(NullPointerException.class)**
    public static void doublyBad() {
        List<String> list = new ArrayList<>();

        // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
        // NullPointerException을 던질 수 있다.
        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(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
             // 코드 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);
    }
}

배열로 선언하는 대신 두 번 달아서 같은 효과를 낼 수도 있지만 주의가 필요하다. 여러 개 달 때와 하나만 달았을 때를 구분하기 위해 ‘컨테이너’ 애너테이션 타입이 적용된다.

getAnnotationsByType() 메서드는 @Repeatable을 사용한 Annotation과 컨테이너 Annotation를 모두 가져오지만, isAnnotationPresent() 메서드는 둘을 명확히 구분하기 때문에 @Repeatable을 사용한 Annotation이 달렸는지 isAnnotationPresent()로 검사할 때는 컨테이너 Annotation과 @Repeatable을 사용한 Annotation 모두를 검사해주어야 한다.

            if (m.isAnnotationPresent(ExceptionTest.class)
                    || m.isAnnotationPresent(ExceptionTestContainer.class))

정리하기

Annotation으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다. 처리코드가 방대해져 오류가 날 가능성이 높지만, 가독성이 높아진다.

ITEM 40. @Override Annotation을 일관적으로 사용하기

앞서 ITEM 10이나 ITEM 11에서 살펴본 바와 같이, HashSet은 두 인스턴스의 동일성 판단을 위해 equals()hashSet()을 호출한다. 그러나 메서드 시그니쳐를 바르게 작성하지 않는다면 필요에 의해 Overriding 하려는 목적일지라도 Overloading이 되어바린다.

예제 1 : 영어 알파벳 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 s의 사이즈는 26이 될 것 같지만 260을 출력한다.

문제는 equals를 Overloading한 데에 있다. Object 클래스의 equals는 파라미터에 Object를 가져간다. 컴파일러는 이를 Overloading으로 인식하기 때문에 별도의 컴파일 에러 없이 실행시키게 된다.

그러나 @Override annotation을 작성하면 컴파일러는 아래와 같은 오류를 띄운다.

Bigram.java:10 : method does not override or implement a method from a supertype (이하 생략..)

@Override 를 작성하는 것 만으로 런타임에 잘못 동작하는 프로그램을 디버깅하는 노력을 들이는 것 대신 컴파일 타임에 프로그래머가 문제를 인식하고 고치게 된다.

그러니 상위 클래스의 메서드를 재정의할 때, @Override annotation을 꼭 달도록 하자.

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

마커 인터페이스란 아무런 메서드를 담지 않고, 오로지 해당 클래스가 특정 속성을 가짐을 표현하는 인터페이스다. Serializable이 그 예다. 아무런 추상 메서드를 가지지 않았지만, 해당 클래스의 인스턴스는 ObjectOutputStream을 통해 직렬화가 가능함을 표기한다.

목적을 달성하기 위해 마커 인터페이스와 마커 annotation을 사용할 수 있지만 장단점은 다르다.

마커 인터페이스 vs 마커 annotation

  1. 마커 인터페이스는 이를 구현한 클래스의 인스턴스를 구분하는 타입으로 쓸 수 있지만, 마커 애너테이션은 그렇지 않다.

    마커 인터페이스로는 런타임에 문제를 잡을 수 있지만 마커 annotation으로는 그렇지 못하다.

    마커 인터페이스를 쓸 때 주의할 점은, ObjectOutputStream.writeObject 메서드로 설명할 수 있다. 이 메서드는 Serializable이 아닌 Object 객체를 받도록 설계되었기 때문에 Serializable을 구현했는지 컴파일 타임에 검사할 수 없다.

  2. 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다.

    마커 annotation은 모든 클래스, 인터페이스, enum 타입, annotation에 달 수 있기 때문에 세밀하게 제한할 수 없다. 그러나 마커 인터페이스는 그냥 붙이고 싶은 클래스에 구현하면 끝이다.

  3. 마커 annotation은 거다핸 annotation 시스템의 지원을 받을 수 있다.

    어떤 프레임워크가 annotation을 적극 사용한다면, 일관성 유지를 위해 마커 annotation을 사용하는 것이 유리할 것이다.

정리하기

마커 annotation을 사용하는 경우

  1. 클래스 이외의 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야 할 때
  2. annotation을 적극적으로 활용하는 프레임워크에서

마커 인터페이스를 사용하는 경우

  1. 마킹이 되는 객체를 매개변수로 받는 메서드를 작성할 일이 있는 경우

    컴파일 타임에 오류를 잡을 수 있다.

댓글남기기