BottleH Blog

이펙티브자바 3판 6장 정리

    Tags

  • Java
이펙티브자바 3판 6장 정리 thumbnail

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

자바에는 특수한 목적의 참조타입이 2가지가 있다.

  • 클래스의 일종인 열거타입
  • 인터페이스의 일종인 애너테이션

목차


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


34-1. 정수 열거 패턴

열거 타입이 등장하기 전에는 아래와 같은 정수 열거 패턴을 사용했다.

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. 타입 안전을 보장할 수 없다.

    • 오렌지를 건네야 하는 메서드에 사과를 보내고, 동등 연산자(==)로 비교해도 아무런 경고없이 동작함.
  2. 표현력이 좋지 않다.

    • 사과용 상수와 오렌지용 상수의 이름 충돌을 방지하기 위해 어쩔 수 없이 접두사(prefix)를 사용함.
  3. 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다.

    • 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일 해야한다.
  4. 정수 상수는 문자열로 출력하기가 다소 까다롭다.

❗ 문자열 열거 패턴: 정수 열거 패턴보다 더욱 좋지 않음. 프로그래머가 하드코딩하게 만들기 때문

34-2. 열거 타입

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH } public enum Orange { NAVEL, TEMPLE, BLOOD }

열거 타입의 장점

  1. 자바의 열거 타입은 완전한 형태의 클래스라고 볼 수 있다.
  2. 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final 이다.
    • 따라서 인스턴스들은 오직 하나만 존재함이 보장됨.
  3. 열거 타입은 컴파일 타임에서의 타입 안전성을 제공함.
    • Apple 열거 타입을 매개변수로 받는 메서드를 선언했다면, 건네받은 참조는 Apple의 세 가지 값 중 하나임이 확실함. 다른 타입의 값을 넘기려 하면 컴파일 오류가 발생한다.
  4. 열거 타입의 toString 메서드는 출력하기에 적합한 문자열을 제공한다.
    • 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수 있다.

열거 타입의 좋은 예시

enum Planet { MERCURY(3.302e+23, 2.439e6), VENUS(4.869e+24, 6.052e6), EARTH(5.975e+24, 6.378e6); // ... 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; // F = ma } }

열거 타입은 근본적으로 불변이므로 모든 필드는 final 이어야 한다.

34-3. 상수별 메소드 구현(constant_specific method implementation)

상수가 더 다양한 기능을 제공하길 원해서 아래와 같이 코딩을 해보았다고 가정하자.

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

위 예시의 가장 안 좋은 점은 깨지기 쉬운 코드라는 점이다.

  • ex) 새로운 상수를 추가하려면 case 문을 추가해야 함.

상수별 메소드 구현의 예시

enum Operation { PLUS { public double apply(double x, double y) { return x + y; } }, MINUS { public double apply(double x, double y) { return x - y; } }; public abstract double apply(double x, double y); }

위와 같은 상수별 메서드 구현(constant-specific method implementation)은 상수에서 자신에 맞게 재정의하는 것을 말한다.

책에서는 apply 메서드가 상수 선언 바로 밑에 있으니 새로운 상수를 추가할 때도 apply 메서드를 항상 재정의해야 한다는 사실을 까먹기 어렵다고 한다.. 나는 썩 공감가지는 않았다.

apply 메서드가 추상 메서드이므로 재정의하지 않았다면 컴파일 오류도 알려준다. 첫번째 예시는 런타임 오류로 알려줌!

상수별 메서드 구현의 단점

  • 열거 타입 상수끼리 코드를 공유하기가 어려운 점

34-4. 전략 열거 타입 패턴

switch 문이나 상수별 메서드 구현이 필요 없음. 새로운 상수를 추가할 때마다 전략을 선택하도록 하는 것

enum PayrollDay { MONDAY(), TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND); private final PayType payType; PayrollDay() { this(PayType.WEEKDAY); } PayrollDay(PayType payType) { this.payType = payType; } enum PayType { WEEKDAY { int overtimePay(int minsWorked, int payRate) { int overtimePay; if (minsWorked <= MINS_PER_SHIFT) { overtimePay = 0; } else { overtimePay = (minsWorked - MINS_PER_SHIFT) * payRate / 2; } return overtimePay; } }, 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; // 하루 8시간 int pay(int minutesWorked, int payRate) { int basePay = minutesWorked * payRate; return basePay + overtimePay(minutesWorked, payRate); } } }

결론: 필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자. 또한, 열거 타입에 정의된 상수 개수가 영원히 고정불변일 필요는 없다.

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


모든 열거 타입은 해당 상수가 열거 타입에서 몇 번째인지 반환하는 ordinal 메서드를 제공한다.

35-1. ordinal 메소드 사용 시 주의사항

ordinal을 잘못 사용한 예시

public enum Ensemble { SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET; public int numberOfMusicians() { return ordinal() + 1; } }

동작은 하지만 유지보수가 매우 힘들다.

  • 상수 선언 순서가 바뀌는 순간 오동작
  • 이미 사용 중인 정수와 값이 같은 상수는 추가할 방법이 없음.
  • 값을 중간에 비워둘 수도 없다.

해결책

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

위와 같이 열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지 말고, 인스턴스 필드에 저장해서 사용하면 된다.

결론: ordinal 메서드는 열거 타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다. 따라서, 이런 용도가 아니라면 ordinal 메서드는 절대 사용하지 말자.

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


36-1. 비트 필드의 단점

열거한 값들이 주로 (단독이 아닌) 집합으로 사용될 경우, 아래와 같이 비트 필드 표현을 사용했다.

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) { // ... } }

다음과 같이 비트별 OR를 이용하여 여러 상수를 하나의 집합으로 모을 수 있었는데 이를 비트 필드(bit field)라고 한다.

text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

비트 필드의 단점

  1. 정수 열거 상수의 단점을 그대로 지닌다..
  2. 정수 열거 상수보다 해석하기 훨씬 어렵다.
  3. 비트 필드 하나에 녹아 있는 모든 원소를 순회하기도 까다롭다.
  4. 최대 몇 비트가 필요한지를 API 작성 시 미리 예측하여 적절한 타입을 선택해야 한다.

36-2. EnumSet

EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.

EnumSet의 장점

  1. Set 인터페이스를 구현하며 타입 안전하고 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
  2. EnumSet의 내부는 비트 벡터로 구현되었기 때문에 원소가 64개 이하라면, EnumSet 전체를 long 변수 하나로 표현한다.
  3. removeAllretainAll과 메서드는 비트 필드를 쓸 때와 동일하게 비트를 효율적으로 처리할 수 있는 산술 연산을 사용하여 구현했다.
  4. 비트를 직접 다룰 때의 발생할 수 있는 오류에서 자유롭다.
public class Text { public enum Style {BOLD, ITALIC, INDERLINE, STRIKETHROUGH} // 깔끔하고 안전하다. 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다. // 보통 인터페이스를 전달 받는 것이 좋은 습관이다. public void applyStyles(Set<Style> styles) { // ... } }

EnumSet의 단점

  1. 불변 EnumSet을 만들 수 없다.
    • 자바 11버전까지 추가가 안되었음.
    • Collections.unmodifiableSet을 사용하면 불변 상태로 만들 수 있다.

결론: 비트 필드를 사용할 이유가 없다. EnumSet을 사용하자.

아이템37. ordinal 인덱싱 대신 EnumMap을 사용하라


37-1. ordinal을 배열 인덱스로 사용하지 말 것

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

위 예시는 동작은 하지만 문제가 많다.

  1. 배열은 제네릭과 호환되지 않으니, 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.
  2. 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
  3. 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다. ❗ 가장 심각한 문제
    • 정수는 열거 타입과 달리 타입 안전하지 않기 때문

37-2. 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. 더 짧고 명료하고 안전하고 성능도 원래 버전과 비등하다.
    • EnumMap 내부에서 배열을 사용하기 때문
  2. 안전하지 않은 형변환은 쓰지 않는다.
  3. 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다.
  4. 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.

37-3. Stream 사용

Arrays.stream(garden) .collect(groupingBy(p -> p.lifeCycle)) Arrays.stream(garden) .collect(groupingBy( p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet()) );

코드를 더 줄일 수 있지만 EnumMap과는 살짝 다르게 동작한다.

  • EnumMap은 언제나 하나씩의 중첩 맵을 만들지만 스트림은 조건을 만족하는 경우만 만든다.

결론: 배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라.

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


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

우선 BasicOperation은 열거 타입이기 때문에 추가 확장이 불가능하지만, 인터페이스인 Operation은 확장할 수 있다.

❗ 해당 방식의 단점: 열거 타입끼리 구현을 상속할 수 없다.

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

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


전통적으로 도구나 프레임워크가 특별하게 다뤄야할 요소에는 딱 구분되는 명명 패턴을 적용해왔다. 예를 들어 JUnit은 버전3 까지 테스트 메서드 이름이 test로 시작해야 했다. 이러한 설정에는 몇 가지 단점이 따랐다.

39-1. 명명 패턴의 단점

  1. 오타가 나면 안 된다.
    • 실수로 tsetSomething과 같이 test라는 단어에 오타를 내면 메서드가 무시되었다. 즉, 테스트가 실패하지 않았으니 통과했다고 오해할 수 있다.
  2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
    • 메서드가 아닌 클래스 이름을 TestSafetyMechanisms로 지어서 JUnit에 던져주었다고 하자. 클래스 내의 테스트 메서드를 수행할 것 같으나 JUnit은 클래스 이름에 관심이 없다.
  3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.

애너테이션은 위의 문제를 해결해주는 개념으로 JUnit 버전4부터 전면 도입하였다.

39-2. 마커 애너테이션

/** * 테스트 메서드임을 선언하는 애너테이션 * 매개변수 없는 정적 메서드 전용이다. */ @Retention(RetentionPolicy.RUNTIME) // @Test가 런타임에도 유지되어야 한다는 표시 @Target(ElementType.METHOD) // @Test가 메서드 선언에서만 사용돼야 한다고 알려줌 public @interface Test { }

✔ 메타애너테이션(meta-annotation): 애너테이션 선언에 다는 애너테이션

public class Sample { @Test public static void m1() { // 성공한다. } public static void m2() { // 무시된다. } @Test public void m3() { // 실패한다 정적 메서드가 아니다. } @Test public static void m4() { // 실패한다. throw new RuntimeException("실패"); } }

✔ 마커애너테이션(marker-annotation): 애너테이션 선언에 다는 애너테이션

  • test 이름에 오타를 내거나 메서드 선언 외의 프로그램 요소에 달면 컴파일 오류를 내준다.

39-2-1. 마커 애너테이션 처리코드

import java.lang.reflect.*; 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 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주진 않는다. 애너테이션에 관심 있는 코드에서 처리하도록 하는 것이다.

InvocationTargetException 외의 예외가 발생: 애너테이션을 잘못 사용했다는 뜻

  • 선언된 곳이 메서드가 아니거나 m4 메서드처럼 정적 메서드가 아닌 인스턴스 메서드 등에 달았을 가능성이 높다.

39-3. 매개변수가 있는 애너테이션

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

매개변수를 추가하여 특정 예외를 던져야만 테스트가 성공하도록 할 수 있다.

처리코드 또한 바뀌어야 한다.

39-4. 배열 매개변수를 받는 애너테이션

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

앞서 살펴본 방식보다 문법적으로 아주 유연하다. 단일 원소 배열에 최적화 했지만, 앞서의 @ExceptionTest들도 모두 수정없이 수용한다.

39-5. 반복 가능 애너테이션

자바 8부터는 앞서 살펴본 배열 매개변수 대신 애너테이션에 @Repeatable 메타애너테이션을 사용할 수 있다.

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

@Repeatable 사용시 주의할 점

  1. @Repeatable을 달고 있는 애너테이션을 반환하는 컨테이너 애너테이션 을 하나 더 정의하고 @Repeatable에 이 컨테이너 애녀테이션의 class 객체를 매개변수로 전달해야 한다.
  2. 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
  3. 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다.
    • 컴파일되지 않음.

결론: 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.

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


40-1. @Override 애너테이션을 선언하라

@Override는 상위 타입의 메서드를 재정의했다는 뜻이며 메서드 선언에만 달 수 있다. 이 애너테이션을 일관되게 사용하면 발생할 수 있는 실수나 버그들을 예방해준다.

public class Bigram { private final char first; private final char second; public boolean equals(Bigram b) { return b.first == first && b.second == second; } public int hashCode() { return 31 * first * second; } public static void main(Sring[] 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()); } }

위의 코드에서 equals 메서드를 재정의 한 것으로 보인다. 그런데 자세히 보면 equals 메서드를 재정의(overriding)한 것이 아니라 다중정의(overloading) 해버렸다. Object의 equals를 재정의 할 때는 매개변수 타입을 Object로 해야 하는데 개발자가 실수한 것이다.

@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 애너테이션을 의식적으로 달면 여러분이 실수했을 때 컴파일러가 바로 알려줄 것이다. 예외는 한 가지뿐이다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우엔 이 에너테이션을 달지 않아도 된다.(달아도 상관 없다.)

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


마커 인터페이스(marker interface): 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 갖는 것을 표현해주는 인터페이스를 말한다.

  • ex) Serializable 인터페이스를 구현한 클래스의 인스턴스는 직렬화(Serialization)할 수 있다고 알려준다.

41-1. 마커 인터페이스의 장점(마커 애너테이션 대비)

  1. 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
    • 마커 애너테이션을 사용한다면 런타임에 발견될 오류를 컴파일타임에 잡을 수 있다.
  2. 적용 대상을 더 정밀하게 지정할 수 있다.
    • 마커 애너테이션의 경우 적용 대상(@Target)을 elementType.TYPE 으로 선언했다면 클래스, 인터페이스, enum 그리고 애너테이션 모두에 설정할 수 있다. 즉, 더 세밀하게 제한하지 못한다는 뜻이다. 마커 인터페이스의 경우는 마킹하고 싶은 클래스 또는 인터페이스에서만 마커 인터페이스를 구현(인터페이스라면 확장)하기만 하면 된다.

41-2. 마커 애너테이션의 장점(마커 인터페이스 대비)

  1. 커대한 애너테이션 시스템의 지원을 받는다.
    • 만일 애너테이션을 적극적으로 사용하는 프레임워크를 사용한다면, 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리하다.

결론: 마커 인터페이스와 마커 애너테이션은 각자의 적재적소가 있다. 새로 추가하는 메서드 없이 단지 타입 정의가 목적인 경우 마커 인터페이스를 선택하자. 클래스나 인터페이스 외의 프로그램 요소에 마킹해야 하거나, 애너테이션을 적극 활용하는 프레임워크의 일부로 그 마커를 편입시키고자 한다면 마커 애너테이션이 올바른 선택이다.

Written by@BottleH
Back-End Developer

GitHub