Backend/Java

[Effective Java : Item 44] 표준 함수형 인터페이스를 사용하라

김세진 2024. 3. 6. 00:28
반응형

 

 

 

 

개요

 

상위 클래스의 메서드를 재정의해 원하는 동작을 직접 구현하는 것보다, 같은 효과의 함수 객체를 매개변수로 받는 메서드나 생성자를 제공하는 것은 보다 큰 유연함을 제공한다. LinkedHashMap 클래스의 protected 메서드인 removeEldestEntry를 예시로 살펴보도록 하자.

 

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

 

새로운 원소를 추가하는 put 메서드는 이 메서드를 호출하여 true가 반환될 경우 가장 오래된 원소 하나를 제거한다. 이를 활용하여 원소의 최대 개수를 제한해 맵을 캐시처럼 사용할 수 있다.

 

public class CustomLinkedHashMap<K, V> extends LinkedHashMap<K, V> {

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

	public static void main(String[] args) {
		CustomLinkedHashMap<Integer, String> map = new CustomLinkedHashMap<>();
		map.put(1, "One");
		map.put(2, "Two");
		map.put(3, "Three");
		map.put(4, "Four");
		map.put(5, "Five");

		System.out.println("map = " + map); // map = {3=Three, 4=Four, 5=Five}
	}
}

 

위는 Map의 총 원소 개수가 3을 넘어갈 때 가장 오래된 원소를 제거하도록 재정의된 LinkedHashMap이다.

 

이는 잘 동작하는 코드이지만, 만약 removeEldestEntry의 동작을 변경하고 싶다면 어떻게 해야 할까? 아마도 클래스 자체를 재정의해야 할 것이다. 하지만 함수형 인터페이스를 사용한다면 함수의 동작만 재정의하면 클래스의 변경 없이도 원하는 동작을 구현할 수 있게 된다.

 

 


함수형 인터페이스 사용 예시

 

1. 직접 정의한 함수형 인터페이스 사용

위에서 본 예시 코드에 함수형 인터페이스를 정의하여 함수 객체를 사용해보도록 하자.

 

// 사용자 정의 함수형 인터페이스
@FunctionalInterface
interface RemoveEldestEntryFunction<K, V> {
	boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
}

public class CustomFunctionalLinkedHashMap<K, V> extends LinkedHashMap<K, V> {

	// 생성자 주입을 통해 함수 객체를 초기화한다.
	private final RemoveEldestEntryFunction<K, V> removeEldestEntryFunction;

	public CustomFunctionalLinkedHashMap(RemoveEldestEntryFunction<K, V> eldestEntryRemovalFunction) {
		this.removeEldestEntryFunction = eldestEntryRemovalFunction;
	}

	// 함수 객체에서 removeEldestEntry 의 동작을 정의할 때, size 함수는 Map 의 인스턴스가 필요하므로 자기 자신을 this 로 넘겨준다.
	@Override
	protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
		return removeEldestEntryFunction.remove(this, eldest);
	}

	public static void main(String[] args) {
		// removeEldestEntry 의 내부 동작을 removalFunction 에서 람다로 정의한다.
		RemoveEldestEntryFunction<Integer, String> removalFunction = (m, eld) -> m.size() > 3;

		CustomFunctionalLinkedHashMap<Integer, String> map = new CustomFunctionalLinkedHashMap<>(removalFunction);
		map.put(1, "One");
		map.put(2, "Two");
		map.put(3, "Three");
		map.put(4, "Four");
		map.put(5, "Five");

		System.out.println("map = " + map); // map = {3=Three, 4=Four, 5=Five}
	}
}

 

기존 removeEldestEntry에서 재정의했던 내부 동작을 클래스 외부의 removalFunction에서 정의하고 있다. 해당 함수를 어떻게 정의하느냐에 따라 유연하게 사용 가능하도록 바뀌었다.

 

하지만 위 코드는 문제없이 작동하지만, 굳이 함수형 인터페이스를 선언할 필요는 없다. 왜냐하면 자바 표준 라이브러리에 이미 같은 모양의 인터페이스들이 존재하기 때문이다. 따라서 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하도록 하자. 아래는 그 예시 코드이다.

 

 

2. 자바 표준 함수형 인터페이스 사용

public class BiPredicateLinkedHashMap<K, V> extends LinkedHashMap<K, V> {

	// 생성자 주입을 통해 함수 객체를 초기화한다.
	private final BiPredicate<Map<K, V>, Map.Entry<K, V>> removeEldestEntryFunction;

	public BiPredicateLinkedHashMap(BiPredicate<Map<K, V>, Map.Entry<K, V>> removeEldestEntryFunction) {
		this.removeEldestEntryFunction = removeEldestEntryFunction;
	}

	// 함수 객체에서 removeEldestEntry 의 동작을 정의할 때, size 함수는 Map 의 인스턴스가 필요하므로 자기 자신을 this 로 넘겨준다.
	@Override
	protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
		return removeEldestEntryFunction.test(this, eldest);
	}

	public static void main(String[] args) {
		BiPredicate<Map<Integer, String>, Map.Entry<Integer, String>> removalFunction = (m, eld) -> m.size() > 3;

		BiPredicateLinkedHashMap<Integer, String> map = new BiPredicateLinkedHashMap<>(removalFunction);
		map.put(1, "One");
		map.put(2, "Two");
		map.put(3, "Three");
		map.put(4, "Four");
		map.put(5, "Five");

		System.out.println("map = " + map); // map = {3=Three, 4=Four, 5=Five}
	}
}

 

예시 1에서 직접 선언했던 RemoveEldestEntryFunction 대신 BiPredicate<Map<K, V>, Map.Entry<K, V>>를 사용할 수 있다.

 

 


표준 함수형 인터페이스

 

java.util.function 패키지에는 기본 함수형 인터페이스 6개를 포함해 총 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

 

 

표준 함수형 인터페이스 대부분은 기본(primitive) 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 Wrapper 타입을 넣어 사용할 경우 성능이 굉장히 느려질 수 있으므로 유의해야 한다.

 

 

사용자 정의 함수형 인터페이스를 작성해야 하는 경우

1. 필요한 용도에 맞는 것이 없을 경우

표준 함수형 인터페이스를 사용하는 것으로 대부분의 상황을 커버할 수 있지만, 만약 표준 인터페이스 중 필요한 용도에 맞는 것이 없다면 직접 작성해야 할 수 밖에 없을 것이다.

 

- 매개변수를 3개 받아야 하는 Predicate

- 검사 예외를 던지는 경우

 

2. 필요한 용도에 맞는 것이 있는데도 불구하고 작성해야 하는 경우

예를 들어 Comparator<T> 인터페이스가 있다. 구조적으로는 ToIntBiFunction<T, U> 와 동일하나, Comparator가 독자적인 인터페이스가 되어야 할 특성이 3가지 있는데, 아래의 특성을 만족할 경우 함수형 인터페이스를 직접 작성할 것을 고려해보아야 한다.

 

- 자주 쓰이고, 이름 자체가 용도를 명확히 설명한다.

- 반드시 따라야 하는 규약이 존재한다.

- 유용한 default 메서드를 제공할 수 있다.

 

 


@FunctionalInterface

 

@Override를 사용하는 이유와 비슷한 맥락에서 해당 애너테이션을 사용한다. 프로그래머의 의도를 명시하는 것으로, 크게 세 가지 목적이 존재한다.

 

1. 해당 인터페이스가 람다용으로 설계된 것임을 명시한다.

2. 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.

3. 유지보수 과정에서 실수로 메서드가 추가되지 않도록 막는다.

 

위와 같은 이유로, 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하자.

 

 


정리

 

1. API를 설계할 때 람다도 염두하여 함수형 인터페이스 도입을 검토하자.

2. 함수형 인터페이스를 사용하고자 한다면, java.util.function 에서 제공하는 표준 함수형 인터페이스를 사용하는 것이 대부분 좋다.

3. 경우에 따라 직접 함수형 인터페이스를 만들어 쓰는 편이 나을 수도 있음은 잊지 말도록 하자. 이 경우, @FunctionalInterface를 사용해야 함도 잊지 말자.

 

 

 

 

 

 

반응형