ラムダ式・Stream APIの理解のポイントは「型」

はじめに

Java SE 8がリリースされて、そろそろ1年が経とうとしています。早いものです。
それはすなわち、Java SE 7のサポート切れが間近に迫っているということでもあります。
そこで今回は、改めてJava SE 8の理解のポイントを解説しようと思います。

ラムダ式・Stream APIの2点が、Java SE 8の目玉となる新機能です。
こんなコードが出てきます。

List<Emp> source = Arrays.asList(
        new Emp(101, "Nishida", Dept.ADMIN, 500000),
        new Emp(102, "Nohira", Dept.SALES, 285000),
        // 以下省略
);
// ラムダ式&Stream API!
// EmpのListから、Deptが「SALES」の要素のみ抽出し、名前のみのListに集約する
List<String> result = source.stream()
        .filter(emp -> emp.getDept() == Dept.SALES)
        .map(emp -> emp.getName())
        .collect(Collectors.toList());

はじめて読んだときは「何コレ??」と感じるかもしれません。

しかし、理解するためのポイントがあります。
かつ、Java SE 8の日本語書籍ではほとんど触れられていない事項です。

それは「型」です。
もう少し具体的に言うと、「型推論」と「型変換」です。
この2点について解説します。

型推論ラムダ式

ラムダ式の基本

型推論とは、変数やインスタンスの型を、コンパイラが推測してくれることです。

例えば、Java SE 7からダイヤモンド演算子が導入されました。これは、左辺の型から右辺の型を推論しています。これも型推論の1つです。

// 右辺の型引数は、左辺から推論されるので省略できる!
List<String> list = new ArrayList<>();

Javaのアップデートに関する公式ドキュメント「Java Programming Language Enhancements」では、Java SE 8での強化ポイントとして、ラムダ式に次ぐ2番目に「Improved Type Inference(改良された型推論)」が挙げられています。
Java Programming Language Enhancements

もっと言うと、ラムダ式自体、型推論から成り立っています。

ラムダ式は、端的に言えば「無名クラスを簡単に書く方法」です。*1
SE 7以前では、無名クラスをこのように書く必要がありました。
f:id:MasatoshiTada:20150206140413p:plain

しかし、上記のコードは冗長に感じるでしょう。
右辺のnewの後の「Calculator」の部分は、左辺から推測(=型推論)できるはずです。
また、「calc」というメソッド名や、メソッド引数の型なども、Calculatorインターフェイスメソッドを1つしか持たない(=関数型インターフェイス)ならば、推測可能なはずです。
よって、Java SE 8からは、これらの部分を省略することができます。
f:id:MasatoshiTada:20150206140850p:plain

そして、残った「(引数名リスト)」と「{処理}」を、アロー記号(->)でつなぎます。これがラムダ式です。
f:id:MasatoshiTada:20150206141011p:plain

さらに、処理の部分が1行のみの場合は、{ }とreturnも省略可能です。
f:id:MasatoshiTada:20150206141252p:plain
(引数が1個の場合は、( )も省略可能です)

Java SE 8で強化された型推論

Java SE 8の型推論のすごいところは、型に関する手がかりが何か1つでもあれば、推論可能だということです。
例を挙げます。

Object obj = (a, b) -> a + b;

このコードはコンパイルエラーになります。なぜなら、右辺のラムダ式の型を推論できる手がかりがないからです。
分かるのは、「右辺はラムダ式だ。そしてそれは、2つ引数(型は不明)があって、それらを+演算子で計算(or 文字列結合?)した結果をreturnするメソッドを1つ持っている、関数型インターフェイスラムダ式らしい」ということだけです。

ラムダ式は「インスタンス」です。なので、何がしかの「型」があるはずです。そして、その型は関数型インターフェイスのはずです。

そこで、推論できるようになるための手がかりを与えます。以下のコードはコンパイルが通ります。

// 左辺の型から右辺の型を推論
Calculator c = (a, b) -> a + b;
// キャストから型を推論
Object obj = (Calculator) (a, b) -> a + b;

また、メソッドの引数の型からも推論できます。ラムダ式は基本的に、メソッドの引数に指定することが多いです。

public void hoge(Calculator c) {
    // do shomething
}

// hogeメソッドの引数の型はCalculatorなので、引数のラムダ式の型はCalculatorと推論される
hoge((a, b) -> a + b);

さらに、ジェネリクスの型引数まで含めて、推論可能です。

// 型引数を持つ関数型インターフェイス
interface Function<T, R> {
    R apply(T t);
}

// Functionを引数とするメソッド。T = String、R = Integer
public void fuga(Function<String, Integer> func) {
    System.out.println(func.apply("123"));
}

// fugaメソッドにラムダ式を指定
fuga(s -> Integer.parseInt(s));

ちょっとややこしくなってきましたが、下記と同じです。

Function<String, Integer> func = s -> Integer.parseInt(s);
fuga(func);
// ラムダ式を省略しないで書くとこうなる
Function<String, Integer> func = (String s) -> { return Integer.parseInt(s); };
fuga(func);

つまり、fugaメソッドの引数の型から、ラムダ式のsがString、戻り値がIntegerだと推論してくれます。

このように、そもそもラムダ式型推論によって成り立っています。

型変換とStream API

次に、型変換です。Stream APIでは、型変換が頻繁に発生します。
最初のコードを再掲します。

List<Emp> source = ・・・
List<String> result = source.stream()
        .filter(emp -> emp.getDept() == Dept.SALES)
        .map(emp -> emp.getName())
        .collect(Collectors.toList());

Stream APIのコードは、「1回のStream生成」「0回以上の中間操作」「1回の終端操作」から成り立ちます。
上記のコードでは、「source.stream()」が生成、「filter()」「map()」が中間操作、「collect()」が終端操作です。
これらのそれぞれの処理で、型変換が発生しています。

メソッドチェーンなしで書く

上記のようにメソッドチェーンで書くことが一般的なのですが、これだと型変換が見えにくくなります。
型変換が分かりやすくなるように、メソッドチェーンなしで書き直します。

List<Emp> source = ・・・
// (1) List<Emp>→Stream<Emp>
Stream<Emp> empStream = source.stream();
// (2) Stream<Emp>→Stream<Emp>
Stream<Emp> filteredStream = empStream.filter(emp -> emp.getDept() == Dept.SALES);
// (3) Stream<Emp>→Stream<String>
Stream<String> nameStream = filteredStream.map(emp -> emp.getName());
// (4) Stream<String>→List<String>
List<String> result = nameStream.collect(Collectors.toList());

Streamの生成

まず、List<Emp>からStream<Emp>に変換します。

Stream<Emp> empStream = source.stream();

streamメソッドは、ListのスーパーインターフェイスであるCollectionで定義されており、戻り値のStreamの型引数は、Collectionの型引数と同じ(今回はEmp)と定められています。

filterでの抽出

次に(2)で、DeptがSALESの要素のみを抽出しています。

Stream<Emp> filteredStream = empStream.filter(emp -> emp.getDept() == Dept.SALES);

Stream<Emp>からStream<Emp>に変換されます。
filterメソッドの引数に指定したラムダ式は、java.util.function.Predicateです。

public interface Predicate<T> {
    boolean test(T t);
}

Streamインターフェイスのfilterメソッドの引数は、「Predicate predicate」です。*2
つまり、TはempStreamの要素の型(今回はEmp)です。
そして、このラムダ式がempStreamの全要素に適用され、条件に合致した(emp.getDept() == Dept.SALESがtrueとなる)要素のみが、filteredStreamに含まれます。

mapでの変換

このmapメソッドが、大きな理解ポイントになります。
mapメソッドも、filter()と同じくStreamを返すのですが、型引数が変換される可能性があります。
(3)では、Stream<Emp>からStream<String>に変換しています。

Stream<String> nameStream = filteredStream.map(emp -> emp.getName());

map()の引数は、Functionインターフェイスです。

interface Function<T, R> {
    R apply(T t);
}

Stream#map()では、TはStreamの要素の型と定められています(今回はEmp)。
Rはapply()の戻り値の型ですが、これはプログラマが自分で好きに決めることが出来ます。
ちょっと上記のままだと分かりづらいので、ラムダ式の部分を省略せずに書き直します。

Stream<String> nameStream = filteredStream.map( (Emp emp) -> { return emp.getName(); } );

今回の場合は、名前のみを抽出したいので、emp.getName()をreturnしています。
そうすると、このreturn文から「R=String」だと型推論されます。

collectでの集約

最終的には、終端操作であるcollect()で、List<String>に集約しています。

List<String> result = nameStream.collect(Collectors.toList());

collect()の引数は、Collectorというインターフェイス(関数型ではない)です。
つまり、Collectors.toList()はCollector実装クラスのインスタンスを返すメソッドなのですが、これを説明すると力尽きる長くなりますので、@backpaper0さんの下記のブログをご参照ください。
Streamのcollectメソッドを学ぶ — 裏紙

まとめ

少しでも参考になれば幸いです。
ツッコミ大歓迎ですので、何かありましたら是非コメントをいただきたいと思います。