Javaの関数型インターフェースとラムダ式・Streamの関係

新しいプロジェクトでJavaのコーディングルールが変わり、ラムダ式やStreamを積極的に使っていく方針になった。それなりに書き方はわかっているつもりでいたが、「今回は関数型インターフェースを多用することになるので、見た目が独特かもしれませんが慣れてくださいね」と言われ、関数型インターフェースって何を指してるの?状態になった。
そこで、まずは関数型インターフェースの概念を理解したいと思い調べ直した。なお、手元にあるJavaは9.0.4だが、この記事で取り上げている機能はJava8から使える。

関数型インターフェースとラムダ式

Javaの関数型インターフェースは、抽象メソッドが1つだけ定義されているインターフェース のこと。ただしdefaultやstaticメソッド、Objectクラスにあるpublicメソッドはカウントしない。

例えば、こんなインターフェースを定義したとする。

public interface GreetingInterface {
    public abstract String sayHello(String name);
}

このインターフェースは、普通なら別のクラスにimplementして中身を実装することになるが、ローカルクラスの省略記法を使えば、以下のように呼び出し元メソッドの中で無名クラスを定義し、実装と呼び出しを両方できる。

public class Greeting {
    public static void main(String[] args) {
        String friendName = "John";
        GreetingInterface greet = new GreetingInterface() {
            
            @Override
            public String sayHello(String name) {
                return "Hello, " + name + "!";
            }
        };
      
        System.out.println(greet.sayHello(friendName)); // Hello, John!  
    }
}

しかし、このインターフェースにアノテーションをつけると、関数型インターフェースにすることができる。
(2019/02/09追記: このアノテーションをつけなくても、冒頭で書いた定義を満たしていれば、関数型インターフェースとして扱うことができます。wkwkhautboisさん、ご指摘ありがとうございます)

@FunctionalInterface
public interface GreetingInterface {
    public abstract String sayHello(String name);
}

この場合、ローカルクラスの定義に使う記法を大幅に省略して、以下のようにラムダ式で呼び出せる。

public class Greeting {
    public static void main(String[] args) {
        String friendName = "John";
        GreetingInterface greet = name -> {
            return "Hello, " + name + "!";
        };
        
        System.out.println(greet.sayHello(friendName));  // Hello, John!
    }
}

ラムダ式自体にnew GreetingInterface がついていないのは、Java型推論をするため。また、引数nameStringをつけなくてよいのは、インターフェースの方で定義されているから。

ここまできて、そもそもラムダ式の定義を勘違いしていたことがわかった。ラムダ式は単なる「関数の簡潔な書き方」ではなく、「メソッドが1つだけ定義されたインターフェースを、実装する側でより簡潔に使う方法」 だった。
ドキュメントにもしっかり書いてある。

docs.oracle.com

One issue with anonymous classes is that if the implementation of your anonymous class is very simple, such as an interface that contains only one method, then the syntax of anonymous classes may seem unwieldy and unclear. In these cases, you're usually trying to pass functionality as an argument to another method, such as what action should be taken when someone clicks a button. Lambda expressions enable you to do this, to treat functionality as method argument, or code as data.

メソッドを1つしか持たないようなシンプルなインターフェースだったとしても、それをimplementした無名クラスのコードは冗長になってしまいやすい。ラムダ式はこのような問題意識から、より簡潔な記法として生まれた。

関数型インターフェースとStream

次は、ラムダ式を最もよく使う場面であるStreamの中で、関数型インターフェースがどう使われているかを見てみる。
例えば、このような簡単なStreamを考える。

public class SampleStream {
    public static void main(String[] args) {
        List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5);
        numList.stream()
            .map(n -> n * 2)
            .filter(n -> n >= 5)
            .forEach(n -> System.out.println(n));
    }
}

それぞれのStream処理では、引数としてラムダ式が使われている。その中でmapの定義をドキュメントで確認してみると、引数の型はFunctionというインターフェースになっている。 このFunctionとは、java.util.functionで定義されている汎用の関数型インターフェースの1つであり、 あるデータ型の値1つを入力として受け取り、あるデータ型の値1つを出力する という処理をするためのもの。
Functionインターフェースの中をのぞいてみるとこのようになっている。

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

抽象メソッドは apply の1つしかなく、これを見るとT型の入力を受け取りR型の出力をするようになっている。そのため、入力・出力値が1つずつあるようなラムダ式を書くことで、applyメソッドが実装されその通りにデータが処理される。

java.util.functionには、他にも汎用の関数型インターフェースが定義されている。例えば、filterの引数の型になっているのはPredicateというインターフェース。これはあるデータ型の値1つを入力として受け取り、boolean型を返す処理を実装するために使う。また、forEachの引数の型はConsumerというインターフェースで、あるデータ型の値1つを入力として受け取るが、何も出力しない処理を実現する。
改めてStreamで使われているラムダ式を見直してみると、それぞれ規定されているインターフェースを満たすように実装されているのがわかるだろう。

また、以下のようにインターフェースの実装をStreamとは別に切り出すこともできる。

// GreetingProcessor.java
public class GreetingProcessor {
    public static Function<String, String> sayHello = name -> {
        return "Hello " + name + "!";
    };
}

//Greeting.java
public class Greeting {
    public static void main(String[] args) {
        List<String> friendsList = Arrays.asList("John", "Mary", "Bob");
        friendsList.stream()
            .map(GreetingProcessor.sayHello)
            .forEach(greet -> System.out.println(greet));
    }
}

こうすると可読性は落ちるが、メインの処理をStreamで固めることができる。Stream処理を追加したいときは、GreetingProcessorに関数型インターフェースとその実装をさらに書き足していく。

このように、ラムダ式やStreamは、関数型インターフェースと密接に関係しており、関数型インターフェースを理解することで応用の幅も広がる。