ラムダ式・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以前では、無名クラスをこのように書く必要がありました。
しかし、上記のコードは冗長に感じるでしょう。
右辺のnewの後の「Calculator」の部分は、左辺から推測(=型推論)できるはずです。
また、「calc」というメソッド名や、メソッド引数の型なども、Calculatorインターフェイスがメソッドを1つしか持たない(=関数型インターフェイス)ならば、推測可能なはずです。
よって、Java SE 8からは、これらの部分を省略することができます。
そして、残った「(引数名リスト)」と「{処理}」を、アロー記号(->)でつなぎます。これがラムダ式です。
さらに、処理の部分が1行のみの場合は、{ }とreturnも省略可能です。
(引数が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);
型変換と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
つまり、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メソッドを学ぶ — 裏紙
まとめ
少しでも参考になれば幸いです。
ツッコミ大歓迎ですので、何かありましたら是非コメントをいただきたいと思います。
*1:ただし「シンタックスシュガー」ではありません。参考URL→徹底解説!Project Lambdaのすべて リターンズ[祝Java8Launch #jjug]
*2:なぜ「? super」が付いているのかは「実践本」に書いてあります→Amazon.co.jp: Javaプログラマーなら習得しておきたい Java SE 8 実践プログラミング: Cay S. Horstmann, 柴田 芳樹: 本