자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다.
예컨대 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴[Gamma95]의 매력이 크게 줄었다.
이를 대체하는 현대적인 해법은 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것이다.
LinkedHashMap을 생각해보자.
이 클래스의 protected 메서드인 removeEldestEntry를 재정의하면 캐시로 사용할 수 있다.
맵에 새로운 키를 추가하는 put 메서드는 이 메서드를 호출하여 true가 반환되면 맵에서 가장 오래된 원소를 제거한다.
예컨대 removeEldestEntry를 다음 처럼 재정의하면 맵에 원소가 100개가 될 때까지 커지다가, 그 이상이 되면 새로운 키가 더해질 때마다 가장 오래된 원소를 하나씩 제거한다. 즉, 가장 최근 원소 100개를 유지한다.
protected boolean removeEldestEntry (Map.Entry<K, V> eldest){
return size() > 100;
}
람다를 사용하면 훨씬 잘 해낼 수 있다.
LinkedHashMap을 오늘날 다시 구현한다면 함수 객체를 받는 정적 팩터리나 생성자를 제공했을 것이다.
removeEldestEntry는 size()를 호출해 맵 안의 원소 수를 알아내는데, removeEldestEntry가 인스턴스 메서드라서 가능한 방식이다.
팩터리나 생성자를 호출할 때는 맵의 인스턴스가 존재하지 않는다. 따라서 맵은 자기 자신도 함수 객체에 건네줘야 한다.
불필요한 함수형 인터페이스 - 대신 표준 함수형 인터페이스를 사용하라.
@FunctionalInterface interface EldestEntryRemovalFunction<K, V> {
boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}
java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다. 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라.
예컨대 Predicate 인터페이스는 프레디키트(predicate)들을 조합하는 메서드를 제공한다.
앞의 LinkedHashMap 예에서는 직접 만든 EldestEntryRemovalFunction 대신 표준 인터페이스인 BiPredicate<Map<K, V>, Map.Entry<K, V>>를 사용할 수 있다.
java.util.function 패키지에는 총 43개의 표준 함수형 인터페이스가 담겨 있다.
https://docs.oracle.com/javase/8/docs/api/java/util/function/package-summary.html
java.util.function (Java Platform SE 8 )
Interface Summary Interface Description BiConsumer Represents an operation that accepts two input arguments and returns no result. BiFunction Represents a function that accepts two arguments and produces a result. BinaryOperator Represents an operation u
docs.oracle.com
전부 기억하긴 어렵겠지만, 기본 인터페이스 6개만 기억하면 나머지를 충분히 유추해 낼 수 있다.
이 기본 인터페이스들은 모두 참조 타입용이다.
Operator 인터페이스:
- 반환값과 인수의 타입이 같은 함수를 뜻한다.
- 인수가 1개인 UnaryOperator
- 인수가 2개인 BinaryOperator
Predicate 인터페이스:
- 인수 하나를 받아 boolean을 반환하는 함수를 뜻한다.
Function 인터페이스:
- 인수와 반환 타입이 다른 함수를 뜻한다.
Supplier 인터페이스:
- 인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
Consumer 인터페이스:
- 인수를 하나 받고 반환값은 없는(특히 인수를 소비하는) 함수를 뜻한다.
다음은 이 기본 함수형 인터페이스들을 정리한 표다.
인터페이스
|
함수 시그니처
|
예
|
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
|
기본 인터페이스는 기본 타입인 int, long, double용으로 각 3개씩 변형이 생겨난다.
그 이름도 기본 인터페이스의 이름 앞에 해당 기본 타입 이름을 붙여 지었다.
예를 들어,
- int를 받는 Predicate는 IntPredicate
- long을 받아 long을 반환하는 BinaryOperator는 LongBinaryOperator
이 변형들 중 유일하게 Function의 변형만 매개변수화됐다.
정확히는 반환 타입만 매개변수화됐는데, 예를 들어 LongFunction<int[]>은 long 인수를 받아 int[]을 반환한다.
이에 더해, Function 인터페이스에서 기본 타입을 반환하는 변형 9개:
- 입력과 결과 타입이 모두 기본 타입인 경우,
- 접두어로 SrcToReult를 사용한다.
- 예컨대 long을 받아 int를 반환하면 LongToIntFunction이 되는 식이다(총 6개).
- 입력이 객체 참조이고 결과가 int, long, double인 경우,
- 입력을 매개변수화하고 접두어로 ToReult를 사용한다.
- 즉, ToLongFunction<int[]>은 int[] 인수를 받아 long을 반환한다(총 3개).
기본 인터페이스의 인수 2개짜리 변형 총 9개:
- 기본 함수형 인터페이스 중 인수를 2개씩 받는 변형 3개:
- BiPredicate<T, U>
- BiFunction<T, U, R>
- BiConsumer<T, U>
- BiFunction에서 기본 타입을 반환하는 변형 3개:
- ToIntBiFunction<T, U>
- ToLongBiFunction<T, U>
- ToDoubleBiFunction<T, U>
- Consumer에서 객체 참조와 기본 타입 하나, 즉 인수를 2개 받는 변형 3개:
- ObjDoubleConsumer<T>
- ObjIntConsumer<T>
- ObjLongConsumer<T>
BooleanSupplier 인터페이스는 boolean을 반환하도록 한 Supplier의 변형이다.
표준 함수형 인터페이스 중 boolean을 이름에 명시한 유일한 인터페이스지만, Predicate와 그 변형 4개도 boolean 값을 반환할 수 있다.
표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.
그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
동작은 하지만 "박싱된 기본 타입 대신 기본 타입을 사용하라"라는 아이템 61의 조언을 위배 한다.
특히 계산량이 많을 때는 성능이 처참히 느려질 수 있다.
대부분 상황에서는 직접 작성하는 것보다 표준 함수형 인터페이스를 사용하는 편이 낫다.
코드를 직접 작성해야 할 경우는?
표준 인터페이스 중 필요한 용도에 맞는 게 없다면, 예를 들어 매개변수 3개를 받는 Predicate라든가 검사 예외를 던지는 경우가 그렇다.
구조적으로 똑같은 표준 함수형 인터페이스가 있더라도 직접 작성해야만 할 때가 있다.
Comparator<T> 인터페이스를 떠올려보자.
구조적으로는 ToIntBiFunction<T, U>와 동일하다.
심지어 자바 라이브러리에 Comparator<T>를 추가 할 당시 ToIntBiFunction<T, U>가 이미 존재했더라도 ToIntBiFunction<T, U>를 사용하면 안 됐다.
Comparator의 특성, 독자적인 인터페이스로 살아남아야 하는 이유:
- API에서 굉장히 자주 사용되는데, 지금의 이름이 그 용도를 아주 훌륭히 설명해준다.
- 구현하는 쪽에서 반드시 지켜야 할 규약이 있다.
- 비교자들을 변환하고 조합해주는 유용한 디폴트 메서드들을 제공한다.
이 중 하나 이상을 만족한다면 전용 함수형 인터페이스를 구현해야 하는 건 아닌지 진중히 고민해야 한다.
@FunctionalInterface 애너테이션을 사용하는 이유는 @Override를 사용하는 이유와 비슷하다.
프로그래머의 의도를 명시하는 것으로, 크게 세 가지 목적이 있다.
- 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
- 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다
- 그 결과 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.
그러니 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라.
함수형 인터페이스를 API에서 사용할 때의 주의점,
서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중정의해서는 안 된다.
클라이언트에게 불필요한 모호함만 안겨줄 뿐이며, 이 모호함으로 인해 실제로 문제가 일어나기도 한다.
ExecutorService의 submit 메서드는 Callable<T>를 받는 것과 Runnable을 받는 것을 다중정의했다.
https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html
ExecutorService (Java Platform SE 8 )
An Executor that provides methods to manage termination and methods that can produce a Future for tracking progress of one or more asynchronous tasks. An ExecutorService can be shut down, which will cause it to reject new tasks. Two different methods are p
docs.oracle.com
그래서 올바른 메서드를 알려주기 위해 형변환해야 할 때가 많이 생긴다(아이템 52).
이런 문제를 피하는 가장 쉬운 방법은 서로 다른 함수형 인터페이스를 같은 위치의 인수로 사용하는 다중정의를 피하는 것이다.
핵심 정리
이제 자바도 람다를 지원한다.
여러분도 지금부터는 API를 설계할 때 람다도 염두에 두어야 한다는 뜻이다.
입력값과 반환값에 함수형 인터페이스 타입을 활용하라.
보통은 java.util.function 패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택이다.
단, 흔치는 않지만 직접 새로운 함수형 인터페이스를 만들어 쓰는 편이 나을 수 도 있음을 잊지 말자.
'Technology > Effective Java 3E' 카테고리의 다른 글
Item 46: 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2022.01.25 |
---|---|
Item 45: 스트림은 주의해서 사용하라 (0) | 2022.01.24 |
Item 43: 람다보다는 메서드 참조를 사용하라 (0) | 2022.01.21 |
Item 42: 익명 클래스보다는 람다를 사용하라 (0) | 2022.01.21 |
7장 람다와 스트림 (0) | 2022.01.21 |