본문 바로가기

SW 개발 이야기

Functional interfaces in Java (standard functional interfaces)

Java 8부터 함수형 프로그래밍(functional programming)을 지원한다. Java 함수형 프로그래밍의 가장 기초는 아무래도 함수형 인터페이스(functional interface)이다. JDK 내에 많은 기능들, 특히 collections과 관련된 기능들은 함수형 인터페이스를 기반으로 제공된다(eg: ```Iterable``` 인터페이스의 ```forEach(Consumer)``` 메서드  정의의 ```Consumer``` 파라미터). 

람다 표현식도 이 함수형 인터페이스를 활용해 코드를 간결하고 효율적으로 표현할 수 있게 한다.

1. 함수형 인터페이스 

우선 함수형 인터페이스 정의부터 알아보자.

Java 8의 ```java.util.function``` 패키지에는 43개의 함수형 인터페이스가 있다고 한다.

함수형 인터페이스는 오직 하나의 추상(abstract) 메서드를 갖고 있는 인터페이스를 일컫는다. 이때 ```enum``` 타입을 갖고 있거나 ```default``` 또는 ```static```으로 구현된 다른 메서드를 가지고 있어도 함수형 인터페이스가 된다는 점에 유의하자. 

예를 들면 JDK에서 기본적으로 제공하는 ```Iterable``` 인터페이스는 다음과 같이 정의되어 있고, 구현되지 않은 하나의 메서드를 갖고 있기에 함수형 인터페이스가 된다.

public interface Iterable<T> {
    Iterator<T> iterator();
    
    default void forEach(Consumer<? super T> action) {
        // ...
    }
    // ...
}

하나의 미구현된 메서드를 가지고 있다는 점의 의미에 대해서는 추후 생각해 보자.

아울러, 명시적으로 하나의 추상 메서드만을 갖도록 지정하는 annotation도 제공된다. 

@FunctionalInterface
public interface ExecutorTask {
    void run();
}

이 annotation은 인터페이스의 추상 메서드 개수뿐만 아니라 이를 상속한 자식 인터페이스에 추상 메서드가 추가되는 것을 방지할 수 있다. (컴파일 오류 발생)

참고로 thread 처리를 위한 ```Runnable``` 인터페이스나 ```Callable``` 인터페이스도 이 annotation이 지정되어 있다. 

2. 표준 함수형 인터페이스

별도로 함수형 인터페이스를 정의해 사용할 수 있지만, JDK에는 기본적으로 사용할 수 있는 함수형 인터페이스들을 제공한다. (```java.util.function``` 패키지의 43개 인터페이스)

그 중 ```Function```, ```Consumer```, ```Supplier```, ```Predicate```가 많이 사용되는데, 아마도 JDK에서 제공하는 여러 메서드의 파라미터로 정의된 것을 보았을 것이다.

2.1. Function 인터페이스

Function 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
    // ...
}

수학에서 함수와 같이 하나의 입력과 하나의 출력(리턴)을 갖는 함수형 인터페이스이다. 제네릭으로 T 타입은 입력, R 타입은 출력에 해당되며, ```apply()``` 메서드로 구현된다.

대표적으로 ```Stream```의 ```map()``` 메서드의 파라미터로 사용되는데, 스트림에서 하나의 객체를 다른 객체로 변환하는 역할을 한다. 예를 들면 문자열 리스트에서 모두 대문자로 변경하는 스트림 처리를 생각해 보자.

List<String> list = Arrays.asList("hello", "world!");

Function<String, String> converter = new Function<String, String>() {
    @Override
    public String apply(String s) {
        return s.toUpperCase();
    }
}

List<String> result = list.stream().map(converter).collect(Collectors::toList());

 리스트의 개별 객체를 입력(T 타입)으로 변환한 결과를 출력(R 타입)으로 정의하여 개별로 ```apply()``` 메서드를 호출하여 변환(mapping)을 처리한다.

그리고 하나의 추상 메서드를 갖기에 다음과 같이 람다(lambda) 표현으로 변경할 수 있다. (만약 구현해서 제공해야 할 메서드가 여러 개면 이 방식은 불가능)

List<String> list = Arrays.asList("hello", "world!");

List<String> result = list.stream()
    .map(s -> s.toUpperCase())
    .collect(Collectors::toList());

2.2 Consumer 인터페이스

Consumer 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    // ...
}

이 함수형 인터페이스는 이름에서 알 수 있듯이 소비만을 한다. 즉, 입력 T 타입만 있고 출력을 갖고 있지 않다.

```Iterable``` 인터페이스의 ```forEach()``` 메소드 파라미터에 활용되는 인터페이스가 바로 이 인터페이스이다. 2.1 예시에서 대문자로 변환된 리스트를 출력을 하는 예제를 다음과 같이 구성할 수 있다(여기서는 바로 람다식만 사용)

List<String> list = Arrays.asList("hello", "world!");

list.stream()
    .map(s -> s.toUpperCase())
    .forEach(s -> System.out.println(s));

추가적으로 메서드 레퍼런스(method reference) 형식을 사용하면 다음과 같이 수정할 수 있다.

List<String> list = Arrays.asList("hello", "world!");

list.stream()
    .map(String::toUpperCase)
    .forEach(System.out::println);

이 메서드 레퍼런스는 람다 표현식에서 단순히 기존에 존재하는 메서드를 호출하는 경우에 사용되며, 다음과 같은 유형을 사용할 수 있다.

  • static method : ContainingClass::staticMethodName
  • instance method : containingObject::instanceMethodName
  • constructor : ClassName::new

2.3 Supplier 인터페이스

Supplier 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

이 함수형 인터페이스는 Consumer와 반대로 입력 없이 출력 T 타입만 제공한다. 특성 상 많이 사용되진 않는다. 

2.4 Predicate 인터페이스

Predicate 인터페이스는 다음과 같이 정의되어 있다.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    // ...
}

이 인터페이스는 하나 T 타입을 입력으로 받고 ```boolean```을 리턴함으로써 조건에 대한 처리를 지정할 수 있다. 예를 들면 특정 디렉토리 하위 파일의 개수를 세는 유틸리티 기능을 다음과 같이 생각해 볼 수 있다.

public static int getFileCount(Path dir) {
    try (Stream<Path> paths = Files.walk(dir)) {
        return (int) path.parallel()
            .filter(p -> !p.toFile().isDirectory())
            .count();
    } catch (IOException ex) {
        throw new UncheckedIOException(ex);
    }
}

여기서 추가로 언급이 필요한 2가지 사항이 있다.

우선, ```parallel()```은 스트림을 병렬로 처리할 수 있도록 지원한다. 다만, 실제 구현 스트림에서 병렬처리를 지원해야 효과가 있다.

두번째는 Stream을 try-with-resource로 처리한 점이다. 실제로 ```Stream```은 ```AutoCloseable``` 인터페이스를 갖고 있다. 다만, Collection 객체를 다루는 Stream은 ```close()``` 처리가 필요하지 않지만, 이와 같이 파일과 같은 리소스를 다루는 경우에는 ```close()```를 처리해 주는 것이 좋다. 

이상으로 Java에서의 함수형 인터페이스의 의미와 가장 많이 사용되는 4개의 인터페이스를 살펴 봤다. 이제 라이브러리가 기본적으로 제공하는 함수형 인터페이스 파라미터 유형에 따라 어떤 람다식을 작성해야 할지 쉽게 이해할 수 있게 된 것 같다. ^^

Written with by Vincent Han