자바8 부터 람다를 지원하면서, API를 작성하는 모범 사례들도 크게 바뀌었다. 예시로, 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴을 보자.
템플릿 메서드 패턴은 추상 클래스의 상속을 통해 이루어진다.
abstract class Cook {
// 공통 메서드
public void cooking() {
System.out.println("음식 조리 시작");
variant();
System.out.println("음식 조리 완료");
}
// 변화하는 부분
abstract void variant();
}
class Baking extends Cook {
@override
public void variant() {
...
}
}
람다를 통해, 변하는 부분을 함수 객체를 캡슐화하고 이를 매개변수로 받는 정적 팩터리나 생성자를 제공할 수 있다. 전략 패턴(템플릿 콜백 패턴)이라고도 한다.
public class Cook {
public void cooking(Strategy strategy) {
System.out.println("음식 조리 시작");
strategy.variant();
System.out.println("음식 조리 완료");
}
}
Cook.cooking(() -> (System.out.println("라면 끓이는중");));
함수형 매개변수 타입은 직접 구현할 수도 있지만, 뒤에 나오듯이 이미 만들어져 있는 표준 함수형 인터페이스를 사용하는 것이 좋다.
LinkedHashMap
을 예로 들어보자.
removeEldestEntry()
함수는 맵에 새로운 키를 추가할때인 put()
메서드에 의해 호출되는데, 해당 메서드가 true
를 반환하면 맵에서 가장 오래된 원소를 제거한다.
protected boolean removeEldestEntry(Map.Entry<K,V> eldest){
return size() > 100; //size : 호출된 맵 안의 원소 수를 알아냄
}
removeEldestEntry()
는 인스턴스 메서드라서 바로 size()
호출을 통해 맵 안의 원소 수를 알아낼 수 있다.
하지만 만약 함수 객체로 구현한다면, 생성자에 넘기는 함수 객체는 맵의 인스턴스 메서드가 아니기 때문에 자기 자신도 함수 객체에 건네줘야 한다.
이를 반영하여 함수 객체를 받는 정적 팩터리나 생성자로 구현한다면, 다음과 같다.
@FunctionalInterface interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
하지만 이미 자바 표준 라이브러리에 같은 모양의 인터페이스가 존재한다.
🔖 @FunctionalInterface
직접 만든 함수형 인터페이스는 해당 어노테이션이 반드시 존재해야 한다.
- 해당 클래스의 코드나 문서를 읽을 이에게 인터페이스가 람다용으로 설계된 것임을 알려준다.
- 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일 되게 해준다.
- 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
}
BiPredicate<Map<String, String>, Map.Entry<String, String>> predicate
= (k, v) -> k.size() > 100;
필요한 용도에 맞는게 있다면 직접 구현하지 말고 표준 함수형 인터페이스를 활용하자.
아래의 6개 인터페이스들은 모두 참조 타용이다. 참고로 기본 인터페이스는, 기본 타입인 int
, long
, double
용으로 각 3개씩 변형이 생겨난다.
UnaryOperator<T>
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
...
}
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
String::toLowerCase // str -> str.toLowerCase();
BinaryOperator<T>
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
...
}
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
}
BinaryOperator<BigInteger> op = BigInteger::add // (int1, int2) -> int1.add(int2);
op.apply(BigInteger.valueOf(10L), BigInteger.valueOf(20L)); // 함수형 프로그래밍
인수 하나를 받아 boolean
을 반환하는 함수이다. 인자로 주어진 매개변수가 함수의 조건에 맞다면 true
를 반환하고, 아니라면 false
를 반환한다.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
Predicate<String> predicate = Collection::isEmpty // (str) -> str.isEmpty();
predicate.test("string");
Predicate
는 타입을 체크할 때 사용하면 좋다. 사용하지 않는 경우, 아래와 같이 조건문을 통해 클래스의 특정 타입 검사를 진행해야 하기 때문이다.
if (Vehicle.Type.SUV.equals(vehicle.getType())) {
// ...
} else {
// ...
}
Predicate
을 사용한다면, 람다를 이용하여 검사 로직을 독립적인 함수의 형태로 구현할 수 있다. 따라서 여러 검사 로직을 하나 하나 구체 클래스로 구현하지 않아도, 다양한 형태의 구현이 가능해졌다.
@RequiredArgsContructor
public class VehiclePredicator {
private final Vehicle vehicle;
public boolean predicate(Predicate<Vehicle> predicate) { // 표준 함수형 인터페이스
return predicate.test(vehicle);
}
}
public class VehiclePredicateService {
...
public void predicate() {
boolean isSuv = vehiclePredicator.predicate(
(Vehicle vehicle) -> vehicle.getType().equals(Vehicle.Type.SUV)
);
boolean isSedan = vehiclePredicator.predicate(
(Vehicle vehicle) -> vehicle.getType().equals(Vehicle.Type.SEDAN)
);
}
}
인수와 반환 타입이 다른 함수를 뜻한다.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
Function<Integer, List<Integer>> function = Arrays::asList; // x -> Arrays.asList(x);
function.apply(3);
인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Supplier<Instant> supplier = Instant::now // () -> Instant.now();
supplier.get();
인수를 하나 받고 반환값은 없는(특히 인수를 소비하는) 함수를 뜻한다. 다른 대부분의 함수형 인터페이스들과 달리, Consumer 는 부작용(side-effects)을 일으켜 동작하는 것을 기대한다.
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
System.out::println
보통의 경우에는 직접 작성하지 않고 표준 함수형 인터페이스를 사용해야 하지만, 구조적으로 같아도 직접 작성해야 하는 경우가 존재한다.
예를 들어 Comparator<T>
인터페이스는, 구조적으로 ToIntBiFunction<T,U>
와 동일하다. 하지만 Comparator
가 독자적인 인터페이스로 남아야 하는 이유는 다음과 같았다.
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
서로 다른 함수형 인터페이스를, 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다. 아래와 같이 Callable
과 Runnable
함수형 인터페이스를 다중 정의한 것을 볼 수 있다.
하지만 이는 올바른 다중정의 메서드를 선택하기 위해 인자에서 형변환 해야 할 때가 생기기 때문에 좋지 않다. (자세한 내용은 아이템 52: 다중정의는 신중히 사용하라를 참고하자.)
public interface ExecutorService extends Executor {
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
}
📚 핵심 정리
입력값과 반환값에 함수형 인터페이스 타입을 활용하자. 보통은 java.util.function 패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택이지만, 직접 새로운 함수형 인터페이스를 만들어 쓰는 편이 나을 수도 있다.
참고자료
https://7772.github.io/2021-05-29-lambda-functional-interface/