자바가 람다를 지원하면서 API 작성 모범 사례가 크게 바뀌었다.
기존 템플릿 메서드 패턴에서 함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만드는 것이 좋다.
📌 As-is. Templete method pattern
LinkedHashMap을 이용해서 Cache를 구현해보자.
public class Cache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public Cache(int maxSize) {
super(16, 0.75f, true);
this.maxSize = maxSize;
}
@Override protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
return size() > this.maxSize;
}
public static void main(String[] args) {
Cache<String, String> cache = new Cache<>(3);
cache.put("1", "1");
cache.put("2", "2");
cache.put("3", "3");
System.out.println(cache);
cache.put("4", "4");
System.out.println(cache);
cache.put("5", "5");
System.out.println(cache);
}
}
{1=1, 2=2, 3=3}
{2=2, 3=3, 4=4}
{3=3, 4=4, 5=5}
- put 메서드가 removeEldestEntry를 호출하여 true가 반환되면 맵에서 가장 오래된 원소를 제거한다.
- 이 방식도 잘 동작하지만, 함수 객체를 받는 정적 팩터리나 생성자 방식이 현대에는 더 유용하다.
📌 To-be. Functional Interface
1️⃣ 함수형 인터페이스 사용
@FunctionalInterface interface EldestEntryRemovalFunction<K, V> {
boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}
public class Cache<K, V> extends LinkedHashMap<K, V> {
private final EldestEntryRemovalFunction<K, V> eldestEntryRemovalFunction;
public Cache(EldestEntryRemovalFunction<K, V> el) {
super(16, 0.75f, true);
this.eldestEntryRemovalFunction = el;
}
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return eldestEntryRemovalFunction.remove(this, eldest);
}
public static void main(String[] args) {
Cache<String, String> cache = new Cache<>((map, eldest) -> map.size() > 3);
cache.put("1", "1");
cache.put("2", "2");
cache.put("3", "3");
System.out.println(cache);
cache.put("4", "4");
System.out.println(cache);
cache.put("5", "5");
System.out.println(cache);
}
}
- removeEldestEntry에서 size()을 호출하여 맵의 원소 수를 알아내는 것은, removeEledestEntry가 인스턴스 메서드기 때문이다. → 생성자에 넘기는 함수 객체는 인스턴스 메서드가 아니므로 다른 방식 필요
- Map이 자기 자신(this)을 함수 객체에 건네주면 된다.
2️⃣ 표준 함수형 인터페이스 사용
💡 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라
public class Cache<K, V> extends LinkedHashMap<K, V> {
private final BiPredicate<Map<K, V>, Map.Entry<K, V>> biPredicate;
public Cache(BiPredicate<Map<K, V>, Map.Entry<K,V>> biPredicate) {
super(16, 0.75f, true);
this.biPredicate = biPredicate;
}
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return biPredicate.test(this, eldest);
}
...
}
- 같은 기능을 하면서, 다루는 API 개념 수가 줄어들어 익히기 쉬워진다.
- 표준 함수형 인터페이스는 유용한 디폴트 메서드를 많이 제공하므로, 다른 코드와 상호운용성도 크게 좋아진다.
📌 표준 함수형 인터페이스
💡 표준 함수형은 기본타입만 지원하므로 박싱된 기본 타입을 넣어 사용하지는 말자.
총 43개의 인터페이스가 있으나, 기본 인터페이스 6개에 파생된 것들이다.
기본 인터페이스는 기본 타입인 int, long, double용으로 각 3개씩 변형이 생겨난다.
기본 인터페이스 이름 앞에 해당 기본 타입 이름을 붙여 짓는다.
1️⃣ UnaryOperator<T>
@FunctionalInterface public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity() {
return t -> t;
}
}
- 인수 1개, 인수의 타입 == 반환 타입
- 함수 시그니처: T apply(T t)
- 예: String::toLowerCase
- 변형
- 기본 타입용 : DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator
2️⃣ BinaryOperator<T>
@FunctionalInterface public interface BinaryOperator<T> extends BiFunction<T,T,T> {
public static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) <= 0 ? a : b;
}
public static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) >= 0 ? a : b;
}
}
- 인수 2개, 인수의 타입 == 반환 타입
- 함수 시그니처: T apply(T t1, T t2)
- 예: BigInteger::add
- 변형
- 기본 타입용: DoubleBinaryOperator, IntBinaryOperator, LongBinaryOperator
3️⃣ Predicate<T>
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}
default Predicate<T> negate() {
return (t) -> !test(t);
}
default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}
static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
}
- 인수 1개, 반환 타입 == boolean
- 함수 시그니처: boolean test(T t)
- 예: Collection::isEmpty
- 변형
- 기본 타입용: DoublePredicate, IntPredicate, LongPredicate
- 인수 2개 받고 boolean 반환: BiPredicate<T, U>
4️⃣ Function<T, R>
@FunctionalInterface public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
- 인수의 타입 != 반환 타입
- 함수 시그니처: R apply(T t)
- 예: Arrays::asList
- 변형
- 입력 기본타입, 출력 R 타입: DoubleFunction<R>, IntFunction<R>, LongFunction<R>
- 입력과 출력 모두 기본 타입: LongToIntFunction, DoubleToLongFunction, ... etc
- 출력이 기본타입: ToDoubleFunction<T>, ToIntFunction<T>, ToLongFunction<T>
- 인수 2개 받고, 출력 R 타입: BiFunction<T,U,R>
- 인수 2개 받고, 출력 기본 타입: ToDoubleBiFunction<T, U>, ToIntBiFunction<T, U>, ToLongBiFunction<T, U>
5️⃣ Supplier<T>
@FunctionalInterface public interface Supplier<T> {
T get();
}
- 인수 X, 반환 O
- 함수 시그니처: T get()
- 예: Instant::now
- 변형
- 기본 타입용: DoubleSupplier, IntSupplier, LongSupplier, BooleanSupplier
6️⃣ Consumer<T>
@FunctionalInterface public interface Consumer<T> {
void accept(T t);
default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}
}
- 함수 시그니처: void accept(T t)
- 예: System.out::println
- 변형
- 기본 타입용: DoubleConsumer, IntConsumer, LongConsumer
- 인수 2개 받는 경우: BiConsumer<T, U>
- T 타입, 기본 타입 받는 경우: ObjDoubleConsumer<T>, ObjIntConsumer<T>, ObjLongConsumer<T>
📌 직접 함수형 인터페이스를 구현해야 하는 경우
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
//이하 생략
}
Comparator의 특징을 살펴보면 쉽게 파악할 수 있다.
- 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다.
- ex. 구조적으로는 ToIntBiFunction<T, U>와 같지만 Comparator<T>가 훨신 명료한 이름을 갖는다.
- 반드시 따라야 하는 규약이 있다.
- ex. compare()는 반드시 따라야 하는 규약이 많다.
- 유용한 디폴트 메서드를 제공할 수 있다.
- ex. Comparator<T>는 reversed(), thenComparing() 등의 이로운 메서드를 다수 제공한다.
이 중 하나 이상을 만족한다면, 전용 인터페이스 구현을 고민해봐야 한다.
📌 함수형 인터페이스 구현할 때의 주의 사항
- @FunctionalInterface를 붙여라
- 해당 인터페이스가 람다용으로 설계된 것임을 명확하게 알려준다. (@Overriding과 비슷)
- 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되도록 한다.
- 누군가 실수로 메서드를 추가할 수 없게 막는다.
- 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드를 오버라이딩해선 안 된다.
- 올바른 메서드를 알려주기 위해 형변환을 해야하는 경우가 많이 생긴다 (Item 52)
- 함수형 인터페이스의 위치를 다르게 해서 오버로딩하라.
public interface ExecutorService extends Executor {
// ...
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
// ...
}