
저번 글에서는 람다 표현식에 대해 간단히 살펴봤습니다.
이번에는 함수형 인터페이스(Functional Interface)를 알아보겠습니다.
말 그대로 함수형으로 사용할 수 있는 인터페이스를 의미합니다.
가장 큰 특징은 추상 메서드를 딱 하나만 가진다는 점입니다.
이 덕분에 해당 인터페이스는 보통 람다식이나 메서드 참조로 구현체를 만들 수 있습니다.
예를 들어 Runnable 인터페이스를 보면 void run() 하나만 있습니다.
따라서 아래와 같은 코드가 가능합니다.
new Thread(() -> System.out.println("Hello")).start();
조금만 생각해보면 하나의 추상 메서드만 가져야한다는 특징이 왜 생겼는지 이해가 될거같습니다.
또한, Java 8부터는 @FunctionalInterface 애너테이션을 붙일 수 있습니다.
@Override 처럼 필수는 아니지만, 컴파일 시점에 개발자의 실수를 잡아주는 역할을 합니다.
함수형 인터페이스의 간단한 예를 하나 보겠습니다.
@FunctionalInterface
public interface Calculator {
int apply(int a, int b);
default Calculator andThen(Calculator next) {
return (x, y) -> next.apply(this.apply(x, y), y);
}
static Calculator add() { return (x, y) -> x + y; }
}
apply 메서드 하나만 추상 메서드이기 때문에 함수형 인터페이스 조건을 만족하죠.
그리고 default와 static 메서드는 기본적으로 구현이 되어있기 때문에 하나의 추상 메서드 라는 조건을 해치지 않습니다.
Java 8에서는 자주 쓰이는 패턴들을 모아 java.util.function 패키지로 제공해 줍니다.
입력과 출력에 따라 아래와 같이 구분할 수 있습니다.
| 입력 | 출력 | 이름 | 메서드 |
|---|---|---|---|
| O | X(void) | Consumer | accept |
| X | O | Supplier | get |
| O | O | Function | apply |
| O | boolean | Predicate | test |
| X | X | Runnable | run |
앞서 표에서 본 다섯 가지 표준 함수형 인터페이스를 실제 코드로 확인해보면서 각 인터페이스가 어떤 상황에서 쓰이는지 함께 알아봅시다.
Consumer<T>는 입력을 받아서 무언가를 처리하지만 결과를 반환하지 않습니다.
대표적인 예가 forEach 입니다.
Consumer 예시
List<String> names = List.of("Alice", "Bob", "Charlie");
// Consumer를 람다로 전달
names.forEach(name -> System.out.println("Hello, " + name));
// 메서드 참조도 가능
names.forEach(System.out::println);
Supplier<T>는 입력 없이 값을 생성하는 역할을 합니다.
함수형 프로그래밍의 특징에 의해, 기존 값을 변경하는 것이 아닌, 매번 값을 새로 생성하여 반환합니다.
Supplier 예시
Supplier<LocalDate> todaySupplier = () -> LocalDate.now();
System.out.println("오늘 날짜는? " + todaySupplier.get());
System.out.println("내일 날짜는? " + todaySupplier.get().plusDays(1));
Function<T,R>은 입력을 받아 결과를 반환하는 변환용 인터페이스입니다.
Function 예시
Function<String, Integer> stringLength = String::length;
System.out.println(stringLength.apply("Hello")); // 5
System.out.println(stringLength.apply("Functional Interface")); // 20
Predicate<T>는 입력을 받아 조건을 판단하고 true/false를 반환합니다.
스트림에서 filter와 함께 사용하면 직관적입니다.
또한 동적 쿼리를 생성할 때 자주 사용합니다.
Predicate 예시
Predicate<String> notEmpty = s -> !s.isBlank();
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> condition = notEmpty.and(startsWithA.negate());
List<String> names = List.of("Alice", "Bob", "Andrew", "Charlie");
names.stream()
.filter(condition)
.forEach(System.out::println);
// output : Bob, Charlie
Runnable은 입력도 없고, 반환값도 없는 단순 동작을 표현할 때 씁니다.
스레드를 실행시켜본적 있다면 자주 봤을법한 인터페이스입니다.
Runnable 예시
Runnable task = () -> System.out.println("작업 실행 중...");
new Thread(task).start(); // 별도의 스레드에서 실행
task.run(); // 현재 스레드에서 실행
함수형 프로그래밍 첫 번째 시간에 배웠던 함수 합성의 특징을 이용할 수 있습니다.
즉, 여러 함수를 이어붙여서 새로운 함수를 만들 수 있습니다.
예시 1. 함수 합성
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, Integer> length = String::length;
Function<String, Integer> trimUpperLength = trim.andThen(upper).andThen(length);
int n = trimUpperLength.apply(" hello "); // 결과: 5
예시 2. 조건문 합성
Predicate<String> notBlank = s -> s != null && !s.isBlank();
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> cond = notBlank.and(startsWithA.negate());
boolean ok = cond.test("Beta"); // true
Stream, Optional 등의 함수형 인터페이스를 사용하면서 IDE에 자주 등장했던 클래스들입니다.
자주 쓰면서도 한 번도 제대로 정리하지 않았던 제 자신이 부끄럽지만, 이제라도 정리해두니 앞으로 코드 읽고 쓸 때 훨씬 수월할 것 같습니다.
다음 글에서는 Stream에 대해서 포스팅하겠습니다.