////
Search

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

Created
2022/09/04 02:39
Tags

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

열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다.

정수 열거 패턴

public static final int APPLE_FUJI = 0; public static final int APPLE_PIPPIN = 1; public static final int APPLE_SMITH = 2; public static final int ORANGE_NAVEL = 0; public static final int ORANGE_TEMPLE = 1; public static final int ORANGE_BLOOD = 2;
Java
복사
정수 열거 패턴
정수 열거 패턴 기법은 단점이 많다.
타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
오렌지를 건내야할 메서드에 사과를 보내도 컴파일러는 아무런 경고 메세지를 띄우지 않는다.
정수 열거 패턴을 위한 별도 이름공간을 지원하지 않기 때문에 접두어를 써서 충돌을 방지해야한다.
평범함 상수를 사용한것 뿐이라 상수의 값이 변경될 경우 반드시 재컴파일이 필요하다.
정수 상수는 문자열로 출력하기 까다로운데 디버거로 보면 의미가 아닌 숫자로만 보이기 때문이다.

열거 타입

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH } public enum Orange { NAVEL, TEMPLE, BLOOD }
Java
복사
열거 타입
자바의 열거 타입은 완전한 형태의 클래스로 제공된다.
상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 가꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
열거 타입은 타입 안전성을 제공한다.
열거 타입은 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.

열거 타입에 메서드나 필드 추가

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); 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; // 생성자 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 } }
Java
복사
열거 타입 상수 각각의 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

열거 타입의 정수가 하나 제거된다면?

참고하는 클라이언트는 오류가 발생할것이다.
특정 참고 줄에서 매번 동일한 오류를 발생 시킬것이다.
이는 타입 안전성을 중요시 여기는 우리들에게 매우 올바른 동작이라고 볼 수 있다.

상수별 메서드 구현

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; } }; public abstract double apply(double x, double y); private final String 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)); }
Java
복사
열거 타입에 추상 메서드를 통해서 상수별로 메서드를 구현할 수 있다.

문자열을 통해서 열거 타입 얻어내기

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)); }
Java
복사

상수별 메서드는 상수끼리 코드 공유가 어렵다.

상수별 메서드 구현에는 열거 타입 상수 끼리 코드를 공유하기 어렵다는 단점이 있다.

전략 열거 타입 패턴

enum PayrollDay { MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY), SATURDAY(WEEKEND), SUNDAY(WEEKEND); private final PayType payType; PayrollDay(PayType payType) { this.payType = payType; } int pay(int minutesWorked, int payRate) { return payType.pay(minutesWorked, payRate); } // 전략 열거 타입 enum PayType { WEEKDAY { int overtimePay(int minsWorked, int payRate) { return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2; } }, WEEKEND { int overtimePay(int minsWorked, int payRate) { return minsWorked * payRate / 2; } }; abstract int overtimePay(int 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); } }
Java
복사
해당 방식은 switch보다 복잡하지만, 더 안전하고 유연하다.
주말, 주중 로직이 변경되면 PayType만 변경하면 된다.
기존의 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch문이 좋은 선택이 될 수 있다.

열거 타입을 써야할때는 언제인가

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

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

대부분의 열거 타입 상수는 자연스럽게 하나의 정수값에 대응된다.
모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다.

사용하지 말아야하는 이유

상수 선언 순서에 따라서 값이 변경된다.
중간에 값을 비워둘 수도 없다.

해결책

public enum YAxis { ONE(1), TWO(2), ; private final int position; ... }
Java
복사
열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지말고 인스턴스 필드에 저장하자.
ordinal 메서드는 EnumSet과 EnumMap 같이 열거 타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다.

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

비트 열거 패턴

public class Text { public static final int STYLE_BOLD = 1 << 0; // 1 public static final int STYLE_ITALID = 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) { ... } }
Java
복사
비트 열거 상수 - 구닥다리 기법!
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
Java
복사
열거한 값들이 주로 단독이 아닌 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2개의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해왔다.
비트별 OR을 사용해 상수를 하나의 집합으로 모을 수 있으며, 이렇게 만들어진 집합을 비트 필드라고 한다.

단점

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

EnumSet을 사용하자!

public class Text { puiblic enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUGH } public void applyStyles(Set<Style> styles) { ... } }
Java
복사
EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.
Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
EnumSet 내부는 비트 벡터로 구현되어있다.
원소가 총 64개 이하면 EnumSet 전체를 long 변수 하나로 표현한다.

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 name; } }
Java
복사

안좋은 예시 - ordinal을 사용하는 코드

Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length]; for (int i = 0; i < plantsByLifeCycle.length; i++) plantsByLifeCycle[i] = new HashSet<>(); for (Plant p : garden) plantsByLifeCycle[p.lifeCycle.ordinal()].add(p); // 결과 출력 for (int i = 0; i < plantsByLifeCycle.length; i++) { System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]); }
Java
복사
동작하지만 문제가 한가득하다.
배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.
배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 프로그래머가 직접 보증해야 한다.

좋은 예시 - EnumMap 사용

Map<Plat.LifeCyfcle, Set<Plat>> platsByLifeCycle = new EnumMap<>(Play.LifeCycle.class); for (Plant.LifeCycle lc : Plant.LifeCycle.values()) playsByLifeCycle.put(lc, new HashSet<>()); for (Plant p : garden) plantsByLifeCycle.get(p.lifeCycle).add(p); System.out.println(plantsByLifeCycle);
Java
복사
더 짧고 명료하고 안전하고 성능도 원래 버전과 비등하다.
안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다.
배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.

스트림을 사용하는 방법

System.out.println(Arrays.stream(garden) .collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet())));
Java
복사
스트림을 사용하면 EnumMap만 사용했을 때와는 달리 살짝 다르게 동작한다.
EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

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

열거 타입은 확장하기 어렵다.
대부분의 상황에서 열거 타입을 확장하는건 좋지 않은 생각이다.
확장성을 높이려면 고려할 요소가 늘어나 설계와 구현이 복잡해진다.
확장할 수 있는 열거 타입이 어울리는 쓰임새로는 연산코드가 있다.

인터페이스를 이용한 기능확장

public interface Operation { double apply(double x, double y); }
Java
복사
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; } }
Java
복사
열거 타입인 BasicOperation은 확장할 수 없지만 인터페이스인 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; ... }
Java
복사
public 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)); } }
Java
복사
인터페이스를 이용해 확장 가능한 열거 타입을 흉내 내는 방식에도 한 가지 사소한 문제가 있다.
열거 타입끼리 구현을 상속할 수 없다는 점
아무 상태에도 의존하지 않는 경우 디폭트 구현을 이용해 인터페이스에 추가하는 방법이 있다.

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

명명패턴은 효과적인 방법이지만 단점도 크다.
오타가 나면 안된다.
올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
프로그램 요소를 매개변수로 전달할 마당한 방법이 없다.

애너테이션

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Test { }
Java
복사
명명패턴의 모든 단점을 해결해주는 개념이다.
@Retention은 메타애너테이션으로 해당 애너테이션이 언제까지 유지되어야 하는지 명시한다.
@Target은 어떤 위치에서 사용되어야 하는지 제한한다.
컴파일러에서 강제되는건 아니라서 애너테이션 처리기를 통해서 구현되어야 한다.
위 같이 아무 매개변수 없이 단순히 대상에 마킹한다는 뜻에서 마커 애너테이션이라 한다.

애너테이션 예시

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() {} }
Java
복사
@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); } }
Java
복사
이 테스트 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에 마커 애너테이션이 달린 메서드를 차례로 호출한다.
isAnnotationPresent가 실행할 메서드를 찾아주는 메서드다.
테스트 메서드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다.

매개변수를 받는 애너테이션

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ExceptionTest { Class<? extends Throwable> value(); }
Java
복사
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() { } // 실패해야 한다. (예외가 발생하지 않음) }
Java
복사

매개변수를 받는 애너테이션 처리 로직

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); } }
Java
복사
애너테이션에서 매개변수를 가져와 처리한다.

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

자바가 기본적으로 제공하는 애너테이션 중 보통의 프로그래머에게 가장 중요한것은 @Override일 것이다.
@Override는 메서드 선언에만 달 수 있으며, 이 애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의 했음을 뜻한다.
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 bigram) { return bigram.first == first && bigram.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()); } }
Java
복사
이 클래스는 equalhashCode를 재정의가 아닌 다중정의를 한것으로 취급된다.
이 오류는 컴파일러가 찾아낼 수 있지만, Object의 equalhashCode를 재정의한다는 의도를 명시해야 한다.
상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자.
예외로는 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이 @Override를 달지 않아도 된다.
일괄성을 위해 붙여도 상관없다.

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