java.util.function 패키지의 함수 인터페이스에 기본 메서드가 있다는 것을 눈치채셨을 것입니다. 이러한 메서드는 람다 표현식의 조합과 연쇄를 허용하기 위해 추가되었습니다.

왜 그런 일을 할까요? 단순히 더 간단하고 읽기 쉬운 코드를 작성할 수 있도록 돕기 위해서입니다.  

기본 메서드를 사용한 조건문 연결하기

문자열 목록을 처리하여 null이 아니고 비어 있지 않으며 5자보다 짧은 문자열만 유지해야 한다고 가정해 보겠습니다. 이 문제를 설명하는 방식은 다음과 같습니다. 주어진 문자열에 대해 세 가지 테스트가 있습니다:

  • non-null;
  • non-empty;
  • 5자보다 짧음.

이러한 각 테스트는 매우 간단한 한 줄 predicate로 쉽게 작성할 수 있습니다. 이 세 가지 테스트를 하나의 predicate로 결합하는 것도 가능합니다. 이 코드는 다음과 같습니다:

Predicate<String> p = s -> (s != null) && !s.isEmpty() && s.length() < 5;

하지만 JDK를 사용하면 이러한 방식으로 코드를 작성할 수 있습니다:

Predicate<String> nonNull = s -> s != null;
Predicate<String> nonEmpty = s -> !s.isEmpty();
Predicate<String> shorterThan5 = s -> s.length() < 5;
 
Predicate<String> p = nonNull.and(nonEmpty).and(shorterThan5);

기술적 복잡성을 숨기고 코드의 의도를 드러내는 것이 바로 람다 표현식을 결합하는 것입니다.

이 코드는 API 수준에서 어떻게 구현될까요? 자세한 내용을 자세히 살펴보지 않고도 다음과 같은 내용을 확인할 수 있습니다:

  • and()는 메서드입니다.
  • 이는 Predicate<T>의 인스턴스에서 호출됩니다: 따라서 인스턴스 메서드입니다.
  • 다른 Predicate<T>를 인자로 받습니다.
  • 그것은 Predicate<T>를 반환합니다.

함수형 인터페이스에는 하나의 추상 메서드만 허용되므로 이 and() 메서드가 기본 메서드가 되어야 합니다. 따라서 API 설계 관점에서 보면 이 메서드를 만드는 데 필요한 모든 요소가 있습니다. 좋은 소식은 Predicate<T> 인터페이스에는 and() 기본 메서드가 있으므로 직접 작성할 필요가 없다는 것입니다.

참고로, 다른 predicate를 인수로 받는 or() 메서드와 아무것도 받지 않는 negate() 메서드도 있습니다.

이를 사용하여 이전 예제를 이런 식으로 작성할 수 있습니다:

Predicate<String> isNull = Objects::isNull;
Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNullOrEmpty = isNull.or(isEmpty);
Predicate<String> isNotNullNorEmpty = isNullOrEmpty.negate();
Predicate<String> shorterThan5 = s -> s.length() < 5;
 
Predicate<String> p = isNotNullNorEmpty.and(shorterThan5);

이 예제가 한계를 조금 넘어선 것일지라도 메서드 참조와 기본 메서드를 활용하면 코드의 표현력을 크게 향상시킬 수 있습니다.

 

팩토리 메서드를 사용하여 Predicates 만들기

함수형 인터페이스에 정의된 팩토리 메서드를 사용하면 표현력을 한 단계 더 높일 수 있습니다. Predicate<T> 인터페이스에는 두 가지가 있습니다.

다음 예제에서 predicate isEqualToDuke는 문자열을 테스트합니다. 테스트된 문자열이 “Duke”와 같으면 이 테스트는 참입니다. 이 팩토리 메서드는 모든 유형의 객체에 대한 predicates를 생성할 수 있습니다.

Predicate<String> isEqualToDuke = Predicate.isEqual("Duke");

두 번째 팩토리 메서드는 인자로 주어진 predicate 를 부정합니다.

Predicate<Collection<String>> isEmpty = Collection::isEmpty;
Predicate<Collection<String>> isNotEmpty = Predicate.not(isEmpty);

 

기본 메소드로 Consumers 연결하기

Consumer<T> 인터페이스에는 consumers를 체인화하는 방법도 있습니다. 다음과 같은 패턴으로 consumers를 연결할 수 있습니다:

Logger logger = Logger.getLogger("MyApplicationLogger");
Consumer<String> log = message -> logger.info(message);
Consumer<String> print = message -> System.out.println(message);
 
Consumer<String> longAndPrint = log.andThen(print);

이 예제에서 printAndLog는 먼저 log 소비자에게 메시지를 전달한 다음 이를 print 소비자에게 전달하는 소비자입니다.

 

기본 메서드로 Functions 연결 및 결합하기

체인 연결과 결합의 차이는 약간 미묘합니다. 두 작업의 결과는 사실 동일합니다. 다른 점은 작성하는 방식입니다.

함수 f1f2가 두 개 있다고 가정해 봅시다. 이 두 함수는 f1.andThen(f2)을 호출하여 연결할 수 있습니다. 결과 함수를 객체에 적용하면 먼저 이 객체를 f1에 전달하고 그 결과를 f2에 전달합니다.

Function 인터페이스에는 두 번째 기본 메서드가 있습니다: f2.compose(f1). 이런 식으로 작성된 함수는 먼저 객체를 f1 함수에 전달하여 처리한 다음 그 결과를 f2에 전달합니다.

여기서 알아두어야 할 것은 동일한 결과 함수를 얻으려면 f1에서 andThen() 또는 f2에서 compose()를 호출해야 한다는 점입니다.

서로 다른 유형의 함수를 연결하거나 결합할 수 있습니다. 하지만 f1이 생성하는 결과의 유형은 f2가 소비하는 유형과 호환되어야 한다는 명백한 제한이 있습니다.

 

Identity Function 만들기

Function<T, R> 인터페이스에는 identity()라는 identity function를 생성하는 팩토리 메서드도 있습니다.

따라서 다음과 같은 간단한 패턴을 사용하여 ID 함수를 생성할 수 있습니다:

Function<String, String> id = Function.identity();

이 패턴은 모든 유효한 유형에 적용할 수 있습니다.