글 검색

공개된 글의 제목과 본문을 검색합니다.

    닫기 Esc

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

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

    정수 열거 패턴#

    java
    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 }
    열거 타입
    • 자바의 열거 타입은 완전한 형태의 클래스로 제공된다.
    • 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
    • 싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 가꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
    • 열거 타입은 타입 안전성을 제공한다.
    • 열거 타입은 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.

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

    java
    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);
            }
        }
    • 해당 방식은 switch보다 복잡하지만, 더 안전하고 유연하다.
    • 주말, 주중 로직이 변경되면 PayType만 변경하면 된다.
    • 기존의 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch문이 좋은 선택이 될 수 있다.

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

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

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

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

    사용하지 말아야하는 이유#

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

    해결책#

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

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

    비트 열거 패턴#

    java
    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);
    • 열거한 값들이 주로 단독이 아닌 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2개의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해왔다.
    • 비트별 OR을 사용해 상수를 하나의 집합으로 모을 수 있으며, 이렇게 만들어진 집합을 비트 필드라고 한다.

    단점#

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

    EnumSet을 사용하자!#

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

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

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

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

    java
    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]);
    }
    • 동작하지만 문제가 한가득하다.
    • 배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.
    • 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
    • 가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 프로그래머가 직접 보증해야 한다.

    좋은 예시 - EnumMap 사용#

    java
    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())));
    • 스트림을 사용하면 EnumMap만 사용했을 때와는 달리 살짝 다르게 동작한다.
    • EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

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

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

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

    java
    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;
        }
    }
    • 열거 타입인 BasicOperation은 확장할 수 없지만 인터페이스인 Operation은 확장할 수 있다.
    • 이 인터페이스를 연산의 타입으로 사용하면 된다.

    인터페이스를 통해서 추가 기능을 확장할 수 있다.#

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

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

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

    애너테이션#

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

    애너테이션 예시#

    java
    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 클래스의 의미에 직접적인 영향을 주지는 않는다.
    • 그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.

    애너테이션 처리 로직#

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

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

    java
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTest {
        Class<? extends Throwable> value();
    }
    • Throwable을 확장한 클래스의 Class객체를 매개변수로 받는 애너테이션

    매개변수를 받는 애너테이션 사용예제#

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

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

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

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

    전체 글 둘러보기