반응형

ITEM 44 "표준 함수형 인터페이스를 활용하라"

 

자바8 부터 람다를 지원하면서 템플릿 메서드 패턴보다는 전략을 함수 객체 인자 형태로 전달하는 템플릿 콜백 패턴(전략 패턴) 이 모범이 되었다. 

 

  • 템플릿 메서드 패턴
abstract class Car {
	// 공통 메서드
    public void drive() {
        System.out.println("운전 시작");
        moving();
        System.out.println("운전 완료");
    }
    
    // 변화하는 부분
    abstract void moving();
}

class Hyundai extends Car {
	@override
    public void moving() {
     	...
    }
}

 

상위 클래스의 변하는 부분들을 메서드로 분리해 하위 클래스에 재정의하는 기법을 템플릿 메서드 패턴이라고 한다. 상속을 사용한다.

 

  • 템플릿 콜백 패턴 (전략 패턴)
// 선언부
class Car {
	// 공통 메서드
    public void drive(MovingStrategy movingStrategy) {
        System.out.println("운전 시작");
        movingStrategy.moving();
        System.out.println("운전 완료");
    }
}

// 사용부
// MovingStrategy 인터페이스에는 moving 이라는 추상 메서드 하나만 존재해야 한다.
Car myCar = new Car();
myCar.drive(() -> System.out.println('100km/h 주행 중'));

 

이와는 반대로 함수 객체를 사용하는 시점(moving 함수 호출)에 전략 함수 객체를 인자로 받아 실행하는 형태를 템플릿 콜백 패턴. 즉 전략패턴이라고 한다. 함수 생성 시점에 전략 객체를 같이 생성하여 강하게 결합하는 것이 아닌, 사용하는 시점에 전략 객체를 인자로 받아 사용하고 제거하는 형태로 결합해 결합도가 낮아 유연하다는 장점이 있다.


 

LinkedHashMap 를 커스터마이징해서 더 자세히 알아보자.

LinkedHashMap 의 removeEldestEntry() 함수는 맵에 새로운 키를 추가할 때 호출되는 put() 메서드에 의해 호출되는데, 해당 메서드가 true 를 반환하면 맵에서 가장 오래된 원소를 제거한다. 이 removeEldestEntry 함수를 오버라이드해서 가장 최신 5 개인 데이터만 유지하는 캐시를 만들어보자.

 

람다 이전에는 아래와 같이 템플릿 메서드 형태로 직접 하위 클래스에서 오버라이딩했었다.

 

protected boolean removeEldestEntry(Map.Entry<K,V> eldest){
	return size() > 5;
}

 

removeEldestEntry 메서드는 인스턴스 메서드라서 바로 자신 Map 객체의 size() 호출을 통해 맵 안의 원소 수를 알아낼 수 있다. 이 부분을 함수형 인터페이스를 사용해서 전략 패턴으로 바꿔보면 다음과 같이 바꿀 수 있다.

 

@FunctionalInterface
interface EldestEntryRemovalFunction<K,V>{
	boolean remove(Map<K,V> map, Map.Entry<K,V> eldest); 
}


일단, removeEldestEntry 메서드는 인스턴스 메서드이므로 자기 자신도 함수 객체로 건네주어야 한다. Map<K, V> map

그리고 해당 인터페이스가 함수형 인터페이스임을 알 수 있도록 @FunctionalInterface 어노테이션을 붙여준다.

FunctionalInterface 어노테이션을 붙여주면 아래와 같은 이점이 있다.

 

  1. 해당 클래스의 코드나 문서를 읽을 이에게 인터페이스가 람다용으로 설계된 것임을 알려준다.
  2. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
  3. 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

 

하지만 위와 같이 두 개의 인자를 받고 boolean 값을 리턴하는 모양의 인터페이스가 이미 자바 표준 라이브러리에 존재한다. BiPredicate 인터페이스다.

 

// java.util.function 패키지에 선언되어 있는 BiPredicate 형태
@FunctionalInterface
public interface BiPredicate<T, U> {
    boolean test(T t, U u);
}
// 별도로 인터페이스를 만들 필요 없다. BiPredicate 사용
BiPredicate<Map<String, String>, Map.Entry<String, String>> removalFunction
     = (map, eldest) -> map.size() > 5;

 

별도로 인터페이스를 선언할 필요없이 바로 사용하면 된다. "두 개의 인자를 받아 검증한다" 이외에 다른 기능이 존재하지 않는다면 별도로 만들지 않고 표준 라이브러리 기능을 사용하는 것을 권장한다.

 

아래 코드는 위에서 언급한 3가지 경우의 수를 테스트해볼 수 있는 코드이다.

OverrideLinkedHashMap 이 직접 클래스에 전략을 override 를 한 케이스(템플릿 메서드 패턴)이고, FunctionalLinkedHashMap 과 BiPredicateLinkedHashMap 이 전략을 함수 인자 형태로 전달한 케이스(템플릿 전략 패턴) 이다.

 

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiPredicate;

public class MyLinkedHashMap{
    @FunctionalInterface
    public interface EldestEntryRemovalFunction<K, V> {
        boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
    }

    public static void main(String[] args) {
        // OverrideLinkedHashMap
        Map<Integer, Integer> overrideMap = new OverrideLinkedHashMap<>();
        for (int i = 0; i < 10; i++){
            overrideMap.put(i, i);
        }
        System.out.println(overrideMap);

        // FunctionalLinkedHashMap
        Map<Integer, Integer> functionalMap = new FunctionalLinkedHashMap<>((map, eldest) -> map.size() > 5);
        for (int i = 0; i < 10; i++){
            functionalMap.put(i, i);
        }
        System.out.println(functionalMap);

        // BiPredicateLinkedHashMap
        Map<Integer, Integer> functional2Map = new BiPredicateLinkedHashMap<>((map, eldest) -> map.size() > 5);
        for (int i = 0; i < 10; i++){
            functional2Map.put(i, i);
        }
        System.out.println(functional2Map);
    }
    private static class OverrideLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return size() > 5;
        }
    }
    private static class FunctionalLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
        private final EldestEntryRemovalFunction<K, V> eldestEntryRemovalFunction;
        public FunctionalLinkedHashMap(EldestEntryRemovalFunction<K, V> function) {
            this.eldestEntryRemovalFunction = function;
        }
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return eldestEntryRemovalFunction.remove(this, eldest);
        }
    }

    private static class BiPredicateLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
        private final BiPredicate<Map<K, V>, Map.Entry<K, V>> eldestEntryRemovalFunction;
        public BiPredicateLinkedHashMap(BiPredicate<Map<K, V>, Map.Entry<K, V>> function) {
            this.eldestEntryRemovalFunction = function;
        }
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
             return eldestEntryRemovalFunction.test(this, eldest);
        }
    }
}

 

 

java.util.function 패키지에 43개의 인스턴스가 포함되어 있으며 아래 6개의 인터페이스가 기본적인 인터페이스다. 나머지는 충분히 유추해낼 수 있다.

 

인터페이스 함수 시그니처
UnaryOperator<T> T apply(T t) String::toLowerCase
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

 

 

아래의 6개 인터페이스들은 모두 참조 타용이다. 참고로 기본 인터페이스는, 기본 타입인 int, long, double 용으로 각 3개씩 변형이 생겨난다. 나머지는 코드나 문서를 참고하자.

 

보통의 경우에는 직접 작성하지 않고 표준 함수형 인터페이스를 사용해야 하지만, 구조적으로 같아도 직접 작성해야 하는 경우가 존재한다. 예를 들어 Comparator<T> 인터페이스는 구조적으로 ToIntBiFunction<T,U> 와 동일하다. 하지만 Comparator 가 독자적인 인터페이스로 남아야 하는 이유는 다음과 같다.

 

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}

 

  1. API에서 자주 사용되며, 이름 자체가 용도를 명확히 설명해준다.
  2. 구현하는 쪽에서 반드시 따라야 하는 규약이 있다.
  3. 유용한 디폴트 메서드를 여러 개 제공할 필요가 있다. (위 Comparator 예에서는 비교자들을 조합하고 변환하는 메서드를 제공)

마지막으로, 위와 같은 함수형 인터페이스도 주의사항이 있다. 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다. 클라이언트에게 모호함을 안겨줄 뿐만 아니라, 둘 중에 어떤 타입인지 알기 위해 사용하는 쪽에서 타입 형변환을 해야할 수도 있다. "다중정의는 주의해서 사용하라" 라는 아이템 52 조언을 한 번 더 강조한다.

 

public interface ExecutorService extends Executor {
    <T> Future<T> submit(Callable<T> task);
    Future<?> submit(Runnable task);
}

 

Callable 과 Runnable 을 객체로 받는 두 가지 오버라이딩 형태로 함수 이름으로 한 번에 파악이 불가능하다.

 

"입력값과 반환값에 함수형 인터페이스 타입을 활용하라"

반응형
반응형

ITEM 43 "람다보다는 메서드 참조를 사용하라"

 

람다보다도 더 간결하게 작성할 수 있는 방법이 있다. 바로 메서드 참조이다.

 

map.merge(key, 1, (count, incr) -> count + incr);

 

위 코드는 자바 8 때, Map 에 추가된 merge 메서드이다. 키, 값, 함수를 인수로 받으며 주어진 키가 맵에 없다면 주어진 [키, 값] 쌍을 그대로 저장하고, 반대로 키가 있으면 [키, 함수의 결과] 쌍을 저장한다. 깔끔해 보이지만, count 와 incr 가 크게 하는 일 없이 공간만 차지한다. 자바 8이 되면서 Integer 클래스는 이 람다와 같은 기능을 가진 정적 메서드 sum 을 제공했다.

 

map.merge(key, 1, Integer::sum);

 

더 간결해진 것을 볼 수 있다. 하지만 메서드 참조는 함수의 이름만 명시하기 때문에 단번에 이해가 되지 않을 수도 있다.

매개변수의 이름 자체가 프로그래머에게 힌트를 준다면 람다가 더 좋은 선택지가 될 수도 있다. (매개변수가 여러 개이고 함수 이름으로 단 번에 파악이 안 된다면)

 

단 번에 파악할 목적이 아니고 해당 함수의 선언 부분으로 이동하는 수고로움을 감수할 수 있다면, 똑같은 인자 구성으로 함수를 만든 다음, 메서드 참조를 사용하는 것이 더 좋다. 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고, 친절한 설명을 문서에도 남길 수 있으니 말이다. 하지만 같은 클래스 안에 있는 기능을 호출하는 것이라면 람다가 더 간결하다.

 

// 1 번째 방법
service.execute(GoshThisClassNameIsHumongous::action);

// 2 번째 방법
service.execute(() -> action());

 

메서드 참조에는 아래와 같이 5가지 유형이 있다.

메서드 참조 유형 같은 기능을 하는 람다
정적 Integer::parseInt str -> Integer.parseInt(str)
한정적
(인스턴스)
Instant.now()::isAfter Instant then = Instant.now();
t -> then.isAfter(t)
비한정적
(인스턴스)
String::toLowerCase str -> str.toLowerCase()
클래스 생성자 TreeMap<K, V>::new () -> new TreeMap<K, V>()
배열 생성자 int[]::new len -> new Int[len]

 

"메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 사용하고, 그렇지 않을 때만 람다를 사용한다"

반응형
반응형

ITEM 42 "익명 클래스보다는 람다를 사용하라"

 

Java 8 이전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용했었다. 이런 인터페이스의 인스턴스를 함수 객체라고 하는데 다른 곳에서 절대 사용하지 않는 가벼운 함수 객체라면 함수 객체를 인자로 받는 공간에 바로 new 연산자를 통해 생성할 수 있었다.

 

Collections.sort(words, new Comparator<String>() {
	public int compare(String s1, String s2) {
		return Integer.compare(s1.length(), s2.length());
    }
});

 

이렇게 인터페이스 (위 예제에서는 Comparator 인터페이스) 를 바로 new 연산자를 통해 사용하고, 구체적인 전략은 익명 클래스 내부에 작성함으로써 전략 패턴의 장점도 사용할 수 있다. 하지만 매우 간단한 코드임에도 불구하고 매우 길다.

 

Java 8 부터는 추상 메서드 하나짜리 인터페이스는 람다식으로 간결하게 작성할 수 있다.

 

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

 

 

매개변수와 반환값에 대한 타입은 각각 String 과 Int 이지만 생략할 수 있다. 컴파일 타임 때 컴파일러가 맥락을 파악하고 자동으로 타입을 추론해주기 때문에 가능하다. 간혹 컴파일러가 타입을 추론하지 못해 컴파일 오류가 발생할 수도 있지만, 그럴 때 직접 프로그래머가 명시하면 된다. 타입을 명시해야 코드가 명확한 경우를 제외하고 람다의 모든 매개변수 타입은 생략하자.

 

더보기

제네릭에서 타입에 대한 정보를 얻을 수 있기 때문에 람다를 사용하는 함수들에서 제네릭을 적극적으로 사용하는 것을 권장한다. 로타입으로 구성했다면 컴파일 오류가 나서 람다 사용하는 부분에서 타입 변환을 해야한다.

 

메서드 참조를 사용하면 더 간결하게 할 수도 있다.

 

Collections.sort(words, comparingInt(String::length));

 

더 나아가 List 인터페이스에 추가된 sort 메서드를 이용하면 더 간결해진다.

 

words.sort(comparingInt(String::length));

 

아래와 같이, 열거 타입에서 상수 별 추상 메서드를 직접 구현하기 보다는 인스턴스 필드를 인자로 전달받아 간결하게 구현할 수도 있다. (람다로 구현하니 더 깔끔해진다)

 

public enum Operation {
	// 추상 함수를 내부에 구현하지 않고 인자로 전달 받음.
	PLUS("+", (x, y) -> x + y),
    MINUS("-", (x, y) -> x - y),
    TIMES("*", (x, y) -> x * y),
    DIVIDE("/", (x, y) -> x / y);
    
    private final String symbol;
    private final DoubleBinaryOperator op;
    
    Operation(String symbol, DoubleBinaryOperator op) {
    	this.symbol = symbol;
        this.op = op;
    }
    
    @Override
    public String toString() { return symbol; }
    
    public abstract double apply(double x, double y) {
    	return op.applyAsDouble(x, y);
   	};
}

 

위 코드에서 사용한 DoubleBinaryOperator 는 java.util.function 패키지가 제공하는 인터페이스 중 하나로 double 타입 인수 2개를 받아 double 타입 결과를 돌려주는 인터페이스이다.

 

간결하게 작성할 수 있다는 가장 큰 장점을 가지고 있는 람다도 사용 시 유의할 사항이 존재한다.

첫 번째, 람다는 이름이 없기 때문에 문서화하기가 곤란하다. 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아진다면 람다를 쓰지 말아야 한다.

두 번째, 인스턴스로 만들어서 재사용할 때 람다를 쓸 수 없다. 익명 클래스를 사용해야 한다. 람다는 태생적으로 한 개의 추상 메서드를 가진 추상 클래스이므로 추상 메서드가 여러 개인 인터페이스의 인스턴스로도 활용이 불가능하다.

세 번째, 익명 클래스에서 this 지시자를 통해 자기 자신의 인스턴스를 참조하는 것과 다르게 람다에서 this 지시자는 람다 바깥 인스턴스를 참조한다.

네 번째, 람다도 익명 클래스처럼 역직렬화/직렬화 형태가 가상머신 VM 마다 구현이 다를 수 있어 유의해야 한다. private 정적 중첩 클래스의 인스턴스를 사용하는 것을 권장한다.

 

"꼭 익명 클래스를 사용해야 하는 자리가 아니라면 익명 클래스보다 간결하게 작성할 수 있는 람다를 사용해보자"

반응형
반응형

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

 

마커 인터페이스란, "아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스"이다. 예를 들어 아래와 같은 Cloneable 인터페이스나 Serializable 인터페이스 등이 있다.

 

public interface Cloneable {
}

 

Serializable 인터페이스를 구현한 클래스의 인스턴스들은 ObjectOutputStream을 통해 write 할 수 있다고, 즉 직렬화(serialization)할 수 있다고 "Serializable" 단어만으로도 바로 알 수 있다. 마커 애너테이션도 같은 역할을 한다. 그렇다면 마커 인터페이스와 마커 애너테이션은 어느 특징을 가지고 있고 언제 사용해야 하는지 확인해보자.

 

마커 인터페이스가 마커 애너테이션보다 나은 점은 다음과 같다.

1 . 타입으로 사용가능하다.

마커 인터페이스를 구현한 클래스의 인스턴스들은 타입으로 구분할 수 있으나, 마커 애너테이션은 그렇지 않다. 마커 인터페이스는 어엿한 타입이기 때문에, 마커 애너테이션을 사용했다면 런타임에야 발견될 오류를 컴파일타임에 잡을 수 있다.

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

애너테이션은 @Target 이라는 메타 애너테이션을 통해 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE 에만 적용할 수 있다. 만약 특정 인터페이스를 구현한 클레스에만 적용하고 싶은 마커가 있을 경우, 애너테이션만으로는 더 세밀하게 제한할 수 없다. 마커 인터페이스를 통해 구현하면 그 인터페이스의 하위 타입임을 보장할 수 있다.

마커 인터페이스가 마커 애너테이션이 마커 인터페이스보다 나은 점은 다음과 같다.

스프링같이 거대한 애너테이션 시스템의 지원을 받아 손쉽게 사용할 수 있다.
애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리하다.

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

마킹이 된 객체를 매개변수로 받는 메서드를 작성할 필요가 있다면 반드시 인터페이스를 사용해야 한다. 컴파일타임에 오류를 잡을 수 있는 강점이 있다.

 

마커 애너테이션을 사용해야 하는 경우
클래스와 인터페이스 외 프로그램 요소 (모듈, 패키지, 필드, 지역변수 등)에 마킹해야 할 때는 애너테이션을 쓸 수 밖에 없다. 클래스와 인터페이스만이 인터페이스를 구현하거나 확장할 수 있기 때문이다. 애너테이션을 활발히 활용하는 프레임워크를 사용하는 경우에도 사용할 수 있다.

 

"적재적소에 마커 인터페이스와 마커 애너테이션을 활용하자"

반응형
반응형

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

 

@Override 애너테이션은 해당 메서드가 상위 타입의 메서드를 재정의한다는 애너테이션이다.

메서드 선언에만 사용할 수 있고, 꼭 붙이지 않아도 되지만 재정의를 하려고 한다면 붙이는 것을 권장한다. 컴파일 타임 단계에서 자칫 오버로딩(Overloading) 으로 메서드를 선언하는 실수를 방지해준다.

 

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

 

위와 같이 equals 메서드를 작성했을 때 HashSet 은 중복을 허용하지 않는 자료구조이기에 26 이라는 값이 출력될 것이라고 예상하지만, 260 이 출력된다. HashSet 에서 사용하는 실제 Object 클래스의 equals 메서드의 원형은 다음과 같다.

  • public boolean equals(Object o)

하지만 예제에서는 Bigram 을 인자로 받는 equals 메서드를 오버로딩 하게 되었고, Object 의 equals 메서드의 기본 형태인 == 를 통해 객체의 식별성만 확인하다 보니 260 이라는 잘못된 값이 나오게 된 것이다. 이처럼 @Override 애너테이션을 붙이지 않고 직접 작성하면 타입을 잘못 작성하는 실수를 하게 된다.

만약 @Override 라는 애너테이션을 붙이고 위와 같이 타입을 잘못 작성하게 되면 아래와 같이 컴파일 오류가 발생하기 때문에 올바르게 바로 수정할 수 있다.

 

method does not override or implement a method from a supertype

 

↓  (올바르게 수정되었을 때 모습)

 

@Override
public boolean equals(Object o) {
	if (!(o instanceof Biagram)) {
    	return false;
    }
    Biagram b = (Biagram) o;
	return b.first == first && b.second == second;
}

 

위 예제처럼 살펴보았듯이 가급적 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너네이션을 다는 것을 권장한다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우에는 애너테이션을 달지 않아도 되지만 달아도 해로울 것은 없다. (구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아 있다면 컴파일러가 바로 그 사실을 알려주긴 한다.)

 

"재정의한 모든 메서드에 @Override 애너테이션을 달자"

반응형
반응형

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

 

예전 프레임워크들을 살펴보면 이름을 통해 제약을 하는 경우가 있다. 예를 들어 Junit 버전 3까지는 테스트 메서드 이름을 무조건 test 로 시작했어야 했다. 이런 경우를 명명 패턴이라고 하는데 이번 절에서는 이런 명명 패턴을 사용하는 것보다 애너테이션을 사용하도록 권장하고 있다.

 

명명 패턴을 사용하면 오타가 나면 절대 안 된다. test 로 시작해야 하는데 tset 으로 오타를 치면 Junit3 에서는 그냥 무시하고 지나가기 때문에 통과했다고 오해할 수 있다.

또한, 올바른 프로그램 요소에 사용되라라 보장할 방법이 없다. Junit3 의 테스트 단위는 메서드인데 클래스만 test 로 이름을 짓고 넘겼다고 가정해보자. 개발자는 테스트가 수행되었을 것이라고 기대했겠지만, Junit 의 테스트 대상이 아니어서 통과된다.

프로그램 요소를 매개변수로 전달할 방법이 없다는 것도 문제이다. 특정 예외를 전달해야만 성공하는 테스트가 있다고 가정해보자. 기대하는 예외 타입을 매개변수로 전달해야 하는데 명명패턴이다보니 제약할 방법이 없다. 예외 이름을 테스트 메서드 이름에 덧붙이는 방식으로 할 수도 있지만 이 방법은 보기도 나쁘고 깨지기도 쉽다.

 

애너테이션은 이런 모든 문제점을 해결해준다. 애너테이션을 활용해서 테스트 프로그램(예외가 발생하면 테스트 실패)을 작성해보자.

 

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

 

Test 애너테이션 자체에도 메타 애너테이션이 붙어있다. 각각 아래 의미를 가진다.

  • Retentaion : 해당 애너테이션이 언제까지 유지되어야 하는지 알려주는 애너테이션이다. (예제에서는 런타임까지)
    • (해당 태그가 존재하지 않으면 테스트 도구는 해당 애너테이션을 인식할 수 없다.)
  • Target : 어떤 프로그램 요소에 사용되어야 하는지 알려주는 애너테이션이다. (예제에서는 메서드에만 적용 가능)

아쉽게도 매개변수가 없다는 제약을 줄 수는 없는데 이를 컴파일러가 강제하게 하려면 javax.annotation.processing API 문서처럼 직접 구현해야 한다. 구현하지 않는다면 컴파일은 잘 되겠지만, 테스트 할 때 문제가 생긴다.

 

이렇게 구현해두면 마킹을 하듯이 애너테이션을 붙일 수 있어 원래 클래스에 직접적인 영향을 주지 않으면서 테스트할 범위를 정할 수 있다는 장점이 있다. 해당 애너테이션을 사용하는 구현부를 작성해보자.

 

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

 

 

리플렉션과 클래스 이름을 활용해서, @Test 애너테이션이 달린 메서드를 차례로 호출하는 것을 볼 수 있다. 테스트 메서드가 예외를 던지면, 리플렉션 메커니즘이 InvocationTargetException 으로 감싸서 예외를 다시 던진다. 이 프로그램은 해당 예외를 잡아 실패 정보를 추출해 출력한다. InvocationTargetException 외 다른 예외가 발생했다면 @Test 애너테이션을 잘못 사용한 것이다.

 

1) 특정 예외를 인자로 전달

다음은 특정 예외를 인자로 전달하여 처리하는 애너테이션 처리기를 만들어보자.

 

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

 

 

Class<? extends Throwable> 타입으로 인자를 받기 때문에 모든 예외와 오류 타입을 수용할 수 있다. 사용부는 다음과 같다.

 

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

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

 

2) 여러 개의 예외를 인자로 전달

더 나아가 여러 개의 예외를 인자로 전달해서 그 중 하나가 발생하면 성공하게 만들 수도 있다.

 

/**
 * 배열 매개변수를 받는 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable>[] value();
}

 

[] 배열이 추가되었다. Class 타입들을 배열로 인자를 받아 처리하는데 기존 한 개 매개변수도 처리할 수 있다는 장점이 있다. 수정없이 사용 가능하다. 원소가 여럿인 배열들을 지정할 때 아래와 같이 원소들을 중괄호로 감싸고 쉼표로 구분하면 된다.

 

@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {   // 성공해야 한다.
    List<String> list = new ArrayList<>();

    // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
    // NullPointerException을 던질 수 있다.
    list.addAll(5, null);
}

 

(... 생략 동일 ...)
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);
}
(... 생략 동일 ...)

 

3) (자바 8 방식)여러 개의 예외를 인자로 전달

자바 8 부터는 위 방식 대신 @Repeatable 메타애너테이션을 사용하여 하나의 프로그램 요소에 여러 번 달 수 있다.

단 주의할 점이 있다. 아래 3가지 주의사항을 만족하지 않는다면 컴파일되지 않는다. 

첫 째. @Repeatable 을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable 에 이 컨테이너 애너테이션의 Class 객체를 매개변수로 전달해야 한다.

둘 째. 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.

셋 째. @Retentioin 과 @Target 메타 애너테이션을 명시해야 한다.

 

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

// 컨테이너 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
		ExceptionTest[] value();
}

 

처리할 때도 주의가 필요하다. getAnnotaionByType 메서드는 컨테이너 애너테이션과 반복 가능 어노테이션을 구분하지 못 하는데, isAnnotationPresent 메서드는 명확하게 구분한다. 하지만 여러 번 달린 애너테이션은 구분하기가 힘들어 컨터이너 쪽과 반복 가능 어노테이션 쪽 모두를 검증해야 한다.

 

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

 

 

"소스코드에 추가정보를 제공해야 한다면 애너테이션을 고려해보자"

반응형
반응형

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

 

enum 열거 타입은 확장할 수 없다. 확장이 가능하다면 기반이 되는 타입과 확장이 되는 타입들의 원소 모두를 순회하는 방법이 있어야 하는데 방법이 마땅치 않다.

 

그런데도 확장이 가능한 enum 타입이 필요할 때가 있는데 대표적인 예가 연산코드이다. 연산코드 같이 명령어들을 열거타입으로 만들 때 보통 사용자가 확장 연산을 추가할 수 있도록 열어주기 때문에 확장이 용이해야 한다.

 

다행히 enum 타입이 인터페이스를 구현할 수 있어, 행위 자체를 인터페이스에 명명하고 그 인터페이스를 구현하면 된다.

 

public interface Operation {
	double apply(double x, double y);
}

public enum BasicOperation implements Operation {
	PLUS("+") {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        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 Enum 자체는 확장할 수 없지만, 인터페이스인 Operation 은 확장할 수 있기 때문에, 이 인터페이스를 타입으로 사용하면 된다. 사칙연산에 이어서 지수 곱, 나머지 연산자를 추가하고 싶다면 아래와 같이 만들 수 있다.

 

public enum ExtendedOperation implements Operation {
    EXP("^") {
        @Override
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        @Override
        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 Enum 을 ExtendedOperation Enum 으로 교체할 수도 있다. 이미 인터페이스 내부에 메서드들이 명명되어 있어 열거 타입에 따로 추상 메서드를 선언하지 않아도 된다. (Enum 상수별 메서드 구현)

 

사용할 때는 아래 두 가지 방법이 존재한다.

 

1) 클래스 타입을 인자로 전달

 

public enum ExtendedOperation implements Operation {
    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        test(ExtendedOperation.class, x, y);
    }
    private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y) {
        for (Operation operation : opEnumType.getEnumConstants()) {
            System.out.printf("%f %s %f = %f%n", x, operation, y, operation.apply(x, y));
        }
    }
}

 

한정적 타입 토큰 역할을 하는 Class 리터럴을 전달하고, 그 리터럴의 getEnumConstants() 함수를 사용해서 접근한다.

Class 객체가 열거 타입인 동시에 Operation 의 하위 타입어야 하기 때문에 함수의 타입은 <T extends Enum<T> & Operation> 이어야 한다. 열거 타입이어야 원소를 순회할 수 있고, Operation 이어야 원소가 뜻하는 연산을 할 수 있기 때문이다.

 

2) 한정적 와일드카드 타입을 인자로 전달

 

public enum ExtendedOperation implements Operation {
    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> operations, double x, double y) {
        for (Operation operation : operations) {
            System.out.printf("%f %s %f = %f%n", x, operation, y, operation.apply(x, y));
        }
    }
}

 

enum 타입의 값들을 컬렉션 형태로 전달하면 여러 구현 타입의 연산을 조합해 호출할 수 있다는 장점이 있다.

다만 특정 연산(EnumSet, EnumMap) 은 사용하지 못 한다.

 

인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 이 방식에도 아래와 같은 문제점이 존재한다.

  • 열거 타입끼리는 구현을 상속할 수 없다.
  • 아무 상태에도 의존하지 않는 경우라면, 인터페이스를 구현한 enum 타입들에 직접 로직들을 작성해야 한다. (중복되는 로직이 많아진다면 정적 도우미 메서드나 클래스로 분리해야 한다.)

 

"enum 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현한

기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다."

반응형
반응형

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

 

enum 열거 타입을 기준으로 집합을 만들고 싶을 때, enum 을 키 값으로 하는 EnumMap 을 사용하는 것을 권장한다.

enum 의 ordinal 함수의 결과 값을 배열의 인덱스로 사용해서 분류하는 것보다 훨씬 안정적이고 간결하다.

 

static class Plant {
    enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
    final String name;
    final LifeCycle lifeCycle;

    public Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }

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

 

위와 같이 식물에 대한 클래스가 주어질 때, ANNUAL(한해살이), PERENNIAL(여러해살이), BIENNIAL(두해살이) 를 기준으로 식물들을 분류하고 싶다고 가정하자. 두 가지 방식을 떠올릴 수 있다.

 

  • Enum 열거체를 순서대로 표현한 이중 배열
  • Enum 열거체를 키 값으로 하는 Map

 

Enum 열거체를 순서대로 표현한 이중 배열은 문제점이 많다. Enum 의 길이는 고정되어 있으므로 배열로 만들텐데 배열은 일전에도 언급하였듯이 제네릭과 호환이 되지 않는다. (비검사 형변환을 해야될 수도 있다.) 또한, 배열은 각 인덱스의 의미를 모르니 출력할 때 레이블을 달아주어야 한다. 아마도 이렇게 구현할 것이다.

 

Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++) {
    plantsByLifeCycle[i] = new HashSet<>();
}

// ordinal 로 enum 인덱스를 구해서 배열에 hashset 을 삽입
for (Plant p : garden) {
    plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

// 배열의 인덱스가 무엇을 뜻하는지 몰라서 values 를 다시 한 번 호출하는 모습
for (int i = 0; i < plantsByLifeCycle.length; i++) {
    System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

 

Enum 열거체를 키 값으로 하는 EnumMap 을 사용하면 Enum 열거체가 변경이 되어도 유연하게 적용할 수 있고 안전하다.

안전하지 않은 형변환도 사용하지 않으면서 열거타입 자체에서 toString 출력용 문자열을 제공하니 출력 결과에 레이블을 달 필요도 없다. 더 나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 완전봉쇄한다.

 

Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
    plantsByLifeCycle.put(new HashSet<>());
}
for (Plant p : garden) {
    plantsByLifeCycle.get(p.lifeCycle).add(p);
}
System.out.println(plantsByLifeCycle);

 

(Strem 버전)

 

 System.out.println(garden.stream()
 	.collect(Collectors.groupingBy(p -> p.lifeCycle,
    	() -> new EnumMap<>(Plant.LifeCycle.class), Collectors.toSet())));

 

이중으로 Enum 열거체의 값들을 매핑할 때도, EnumMap 으로 관리하면 좋다.

 

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

 

"enum 열거 타입을 기준으로 집합을 만들고 싶을 때,

배열로 만들지 않고(배열의 인덱스를 얻기 위해 ordinal() 함수 사용)

EnumMap 을 사용하자."

반응형

+ Recent posts