Java SE 8 (2) - ラムダ式、メソッド参照、ストリーム
このエントリーでは、Java8の新機能のうち、最も大きな変更であるラムダ式と、それに関連するメソッド参照、およびストリームについてまとめています。
ラムダ式とメソッド参照
Java8では、新しいAPIとしてjava.util.function
パッケージが追加されています。ここには、関数型インターフェイス(functional interface)と呼ばれる、さまざまな種類のインターフェイスが含まれています。これらのインターフェイスを使うことで、ラムダ式やメソッド参照を変数に格納したり、後で評価する(関数を呼び出す)ことができます。
関数型インターフェイス
関数型インターフェイスには、注釈@FunctionalInterface (java.lang.FunctionalInterface)
が付いています。java.util.function
パッケージに含まれるもの以外でも、@FunctionalInterface
が付いたインターフェイスは、関数型インターフェイスとしてラムダ式やメソッド参照を代入できます。
(2014-03-21追記)@FunctionalInterface
が付いていなくても、メソッドが1つのインターフェイスであれば、ラムダ式やメソッド参照を代入できます。@FunctionalInterface
を付けることで、将来このインターフェイスにメソッドが追加されないようにするための制約ですね。関数型インターフェイスになり得るコアAPIのほとんどにつけられています。
例えば、Runnable
の匿名実装は、
Runnable task = () -> { // 何か処理 };
のように、匿名クラスの代わりにラムダ式で書くことができます。
- サンプル: ラムダ式とメソッド参照
import java.util.function.BiFunction; class Foo { public static void main(String[] args) { Foo o = new Foo(); BiFunction<Integer, Integer, Integer> ff1 = Foo::f1; BiFunction<Integer, Integer, Integer> ff2 = o::f2; BiFunction<Integer, Integer, Integer> ff3 = (x, y) -> x * y; System.out.println("f1: " + ff1.apply(3, 5)); // => f1: 15 System.out.println("f2: " + ff2.apply(4, 6)); // => f2: 24 System.out.println("f3: " + ff3.apply(5, 9)); // => f3: 45 } private static Integer f1(Integer x, Integer y) { return x * y; } private Integer f2(Integer x, Integer y) { return x * y; } }
BiFunction
は、2つの引数と1つの戻り値を持つ関数型インターフェイスです。この場合は、2つのInteger
を渡すとInteger
が返される関数になります。
ff1
の右辺は、クラスメソッドのメソッド参照の書式です。
ff2
の右辺は、インスタンスメソッドのメソッド参照の書式です。
ff3
の右辺は、ラムダ式です。
ところで、ここではオブジェクトラッパー型を使っていますが、プリミティブ型を使用することもできます。ラムダ式には型の情報が無いので、使いたい型のインターフェイスを使います。上記の例をint
に置き換えるならば、IntBinaryOperator
インターフェイスを使います。メソッド参照の場合も同様です。
あと、さっき気づいたのですが、メソッド参照はObject::new
のように、コンストラクターも指定できるのでした。この場合は「コンストラクター参照」となります。
コンストラクター参照は、メソッド名としてnew
を使います。
- サンプル: コンストラクター参照
Supplier<Object> objectCtor = Object::new; Object x = objectCtor.get(); // new Object() System.out.println(x); // => java.lang.Object@17c264 IntFunction<String[]> stringArrayCtor = String[]::new; String[] xa = stringArrayCtor.apply(3); // new String[3] System.out.println(Arrays.toString(xa)); // => [null, null, null]
(2014-01-30) 関数型インターフェイスの宣言について書き忘れていました。
関数型インターフェイスを宣言するには、抽象メソッドが1つだけのインターフェイスの型宣言に注釈@FunctionalInterface
を付けます。
逆に、@FunctionalInterface
を付けたときは、型の種類はインターフェイスで、そのメンバーには抽象メソッドが1つだけであるかどうかがチェックされます。
- サンプル: 正しくない関数型インターフェイスの宣言
// 抽象メソッド無し => NG @FunctionalInterface interface HasNoAbstractMethod { default void op() { } } // 1つの抽象メソッド => OK @FunctionalInterface interface HasAnAbstractMethod { void op(); } // 2つの抽象メソッド => NG @FunctionalInterface interface HasTwoAbstractMethods { default void op1() { } void op2(); void op3(); } // インターフェイスでない => NG @FunctionalInterface abstract class IsNotAnInterface { abstract void op(); }
- コンパイル結果
$ javac incorrect-functional-interface.java incorrect-functional-interface.java:22: エラー: 予期しない@FunctionalInterface注釈 @FunctionalInterface ^ IsNotAnInterfaceは機能インタフェースではありません incorrect-functional-interface.java:2: エラー: 予期しない@FunctionalInterface注釈 @FunctionalInterface ^ HasNoAbstractMethodは機能インタフェースではありません インタフェース HasNoAbstractMethodで抽象メソッドが見つかりません incorrect-functional-interface.java:14: エラー: 予期しない@FunctionalInterface注釈 @FunctionalInterface ^ HasTwoAbstractMethodsは機能インタフェースではありません インタフェース HasTwoAbstractMethodsで複数のオーバーライドしない抽象メソッドが見つかりました エラー3個 $
エラーの箇所を取り除いてコンパイルし、javap
で見てみましょう。
javap -p -v HasAnAbstractMethod
の結果
Classfile /tmp/HasAnAbstractMethod.class Last modified 2014/01/30; size 200 bytes MD5 checksum e1d25b558acb71f5d27361431454338c Compiled from "incorrect-functional-interface.java" interface HasAnAbstractMethod SourceFile: "incorrect-functional-interface.java" RuntimeVisibleAnnotations: 0: #8() minor version: 0 major version: 52 flags: ACC_INTERFACE, ACC_ABSTRACT Constant pool: #1 = Class #9 // HasAnAbstractMethod #2 = Class #10 // java/lang/Object #3 = Utf8 op #4 = Utf8 ()V #5 = Utf8 SourceFile #6 = Utf8 incorrect-functional-interface.java #7 = Utf8 RuntimeVisibleAnnotations #8 = Utf8 Ljava/lang/FunctionalInterface; #9 = Utf8 HasAnAbstractMethod #10 = Utf8 java/lang/Object { public abstract void op(); descriptor: ()V flags: ACC_PUBLIC, ACC_ABSTRACT }
RuntimeVisibleAnnotations
としてFunctionalInterface
が付いている以外は、普通のインターフェイスと変わらないようです。
ラムダ式の内部表現
次に、コンパイルしたFoo.class
をjavap -p -v
で見てみます。
javap -p -v Foo.class
の結果(一部)
Classfile /***/Foo.class Last modified 2014/01/22; size 1603 bytes MD5 checksum 79b51e1db200ecc888a111f97c169832 Compiled from "Foo.java" class Foo SourceFile: "Foo.java" InnerClasses: public static final #78= #77 of #81; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles BootstrapMethods: 0: #29 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #30 (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; #31 invokestatic Foo.f1:(Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; #32 (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; 1: #29 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #30 (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; #35 invokespecial Foo.f2:(Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; #32 (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; 2: #29 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #30 (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; #37 invokestatic Foo.lambda$main$0:(Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; #32 (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; minor version: 0 major version: 52 (中略) private static java.lang.Integer f1(java.lang.Integer, java.lang.Integer); descriptor: (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; flags: ACC_PRIVATE, ACC_STATIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: invokevirtual #12 // Method java/lang/Integer.intValue:()I 4: aload_1 5: invokevirtual #12 // Method java/lang/Integer.intValue:()I 8: imul 9: invokestatic #9 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 12: areturn LineNumberTable: line 13: 0 private java.lang.Integer f2(java.lang.Integer, java.lang.Integer); descriptor: (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; flags: ACC_PRIVATE Code: stack=2, locals=3, args_size=3 0: aload_1 1: invokevirtual #12 // Method java/lang/Integer.intValue:()I 4: aload_2 5: invokevirtual #12 // Method java/lang/Integer.intValue:()I 8: imul 9: invokestatic #9 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 12: areturn LineNumberTable: line 16: 0 private static java.lang.Integer lambda$main$0(java.lang.Integer, java.lang.Integer); descriptor: (Ljava/lang/Integer;Ljava/lang/Integer;)Ljava/lang/Integer; flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: invokevirtual #12 // Method java/lang/Integer.intValue:()I 4: aload_1 5: invokevirtual #12 // Method java/lang/Integer.intValue:()I 8: imul 9: invokestatic #9 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 12: areturn LineNumberTable: line 7: 0 }
このクラスファイルを見る限りでは、次のようになっているようです。
- ラムダ式は、ソースコードからは見えない(合成メンバーの=
ACC_SYNTHETIC
付きの)private static
なメソッドが作られる - ラムダ式やメソッド参照の動的呼出しが可能になるようにブートストラップメソッド宣言(
BootstrapMethods
の箇所)が自動生成される - 上に同じく、
java.lang.invoke.MethodHandles.Lookup
インスタンスのpublic static final
フィールドが自動生成される
これらは、既にJava7で導入されている動的言語サポート(パッケージ java.lang.invoke
の説明参照)の機能を使って実現されています。
ラムダ式のキャプチャー
また、ラムダ式の導入に伴って、コンパイル時に「事実上のfinal」(effectively final)を検出する機能が追加されました。
ラムダ式が導入されるまでは、Comparator
ように、ローカル匿名クラスを使ってラムダ式のようなことを実現していました。このとき、匿名クラスからエンクロージングメソッドのローカル変数を「キャプチャー」するためには、必ずその変数をfinal
にしなければなりませんでした。
Java8では、ラムダ式からエンクロージングメソッドのローカル変数をキャプチャーする場合に、その変数が「事実上のfinal」であっても、キャプチャーが可能になりました。ローカル匿名クラスも同様に、事実上のfinalが適用されます。
- サンプル: キャプチャーの例
// import java.util.function.IntUnaryOperator; int a = 3; IntUnaryOperator f = x -> x * a; int r = f.applyAsInt(5); System.out.println("f(5) = " + r); // => f(5) = 15 // a += 2;
事実上finalは、final
を付けてもコンパイルができる状態でなければなりません。ラムダ式の前後にかかわらず、その変数への再代入が行われている(例えば、上記のコメントアウトしているa += 2
のコメントを外す)場合は、事実上finalとはみなされません。
- 事実上finalにならない場合
Capture.java:6: エラー: ラムダ式から参照されるローカル変数は、finalまたは事実上のfinalである必要があります IntUnaryOperator f = x -> x * a; ^ エラー1個
- キャプチャーの
javap -p
$ javap -p Capture.class Compiled from "Capture.java" class Capture { Capture(); public static void main(java.lang.String[]); private static int lambda$main$1(int, int); } $
ラムダ式では、引数が1つですが、合成メソッドの引数は2つになっています。
ストリーム
Java8では、「ストリーム」という概念が導入されました。大雑把に言うと、既存のコレクションをより抽象的にしたデータ列を表すことができるようにしたものです。ストリームは、コレクションや配列だけでなく、入出力(I/O)データも同様に扱うことができます。
具体的には、主に次のことを実現します。
- 関数型スタイルのコレクション操作(
filter/map/reduce
など) - 遅延評価
- 並列計算
ストリームは、APIとして、新しいjava.util.stream
パッケージの追加と、主に既存のコレクションクラスの拡張がされています。
関数型スタイルのコレクション操作
ストリームを使うと、関数型言語やスクリプト言語では定番の、filter/map/reduce
などの操作ができます。
特に、filter
とmap
。リストのちょっと加工したコピーを作ったりするのは良くあることで*1、今まで何度for文で書いたことか。map
なら1行で書けますね!
- サンプル:
filter/map/reduce
// import java.util.*; // Arrays, List // import java.util.stream.*; // Collectors, Stream // filter, map, and reduce String s = Stream.of("one", "two", "three", "four") .filter(x -> x.length() < 5) .map(String::toUpperCase) .reduce("%", (x, y) -> y + x); System.out.println(s); // => FOURTWOONE% // リストA -> f(x) -> リストA' List<String> a = Arrays.asList("one", "two", "three"); List<String> aa = a.stream().map(x -> x.concat("!")).collect(Collectors.toList()); System.out.println(aa); // => [one!, two!, three!]
コレクションは、一旦ストリームに変換してから(Collection#stream
)、filter/map/reduce
などを行った後、再びコレクションに戻すのにStream#collect
を使います。また、API仕様の例にもありますが、collect
は必ずしもコレクションに変換するものではありません。
遅延評価
実際に計算が必要になるまでデータ処理が開始しません。
- サンプル:標準入力をストリームにする
import java.io.*; // BufferedReader, InputStreamReader import java.util.List; import java.util.stream.*; // Collectors, Stream class StdinAsStream { public static void main(String[] args) { BufferedReader r = new BufferedReader(new InputStreamReader(System.in)); Stream<String> st = r.lines().skip(1).limit(3); System.out.println(">>> 1"); List<String> a = st.collect(Collectors.toList()); System.out.println(">>> 2"); System.out.println(a); } }
標準入力からデータを取得する前に、r.lines().skip(1).limit(3)
という処理を実行しているように見えます。しかし、このコードは、計算をしているというよりも、パイプラインを構築していることを表しています。r.lines()
でストリームが取得できるので、その後ろに遅延評価のskip(1)
とlimit(3)
をつなげたパイプラインになっています。
- 実行結果
$ java8 StdinAsStream >>> 1 one[ENTER] two[ENTER] three[ENTER] four[ENTER] >>> 2 [two, three, four] $
ENTERの箇所は、標準入力へデータを送っていることを表しています。
遅延評価により、データを入力していない状態でも、パイプラインの構築の箇所では評価自体はされずに通過し、skip
とlimit
が呼ばれている箇所を通過しています。終端処理であるcollect
が呼ばれて初めて、入力待ちになります。
4行まで入力が完了した時点で、skip(1)
とlimit(3)
により、先頭の4行のうち2~4行目が選択され、それ以上のデータを必要としないので、結果を表示して処理が終了しているのが分かります。
並列計算
ストリームを使うと、並列計算が手軽に利用できます。
- サンプル: 時間のかかる計算を並列処理
// import java.util.function.*; // Consumer, IntPredicate // import java.util.stream.*; // IntStream IntPredicate sleep2AndPassThru = x -> { try { Thread.sleep(2000L); } catch (InterruptedException ex) { throw new RuntimeException(ex); } return true; }; Consumer<IntStream> action = st -> { long t = System.currentTimeMillis(); int r = st .filter( sleep2AndPassThru ) // 2秒スリープしてそのまま通過 .map( x -> x * 2 ) // 関数f (IntUnaryOperator) .sum(); t = System.currentTimeMillis() - t; System.out.printf("result=%d, elapsed %.3f secs%n", r, t / 1000f); }; IntStream ns1 = IntStream.iterate(1, x -> x + 1).limit(5); IntStream ns2 = IntStream.iterate(1, x -> x + 1).limit(5).parallel(); action.accept(ns1); action.accept(ns2);
sleep2AndPassThru
は、2秒たったら無条件で通過するIntPredicate
です。
関数f
は、単純に2倍する関数です。
action
は、計算の実行と、所要時間を計算する、ベンチマーク的なサブルーチンです。
ストリームのソースは、自然数の数列を表す数列ジェネレーターになっています。ns1
は直列のストリーム、ns2
は(BaseStream#parallel
)で並列のストリームに変換したものです。
計算自体は、
になります。
これを実行してみると...
- 実行結果
result=30, elapsed 10.003 secs result=30, elapsed 4.182 secs
直列のストリームの結果は、5回の計算がそれぞれ2秒かかり、約10秒かかっています。直列なので、どんなに性能が良いコンピューターを使っても、正しく動作する限りは、10秒を切ることはありません。
並列のストリームの結果は、いくつかに分割して計算されるため*2、10秒を大幅に下回っています。おそらく、3グループに分割され、直列でfilter
を通過するのは最大で2回になり、4秒で済むようになったと思われます。残りの約0.2秒は、分割処理にかかった時間でしょうか。
処理のイメージ ※実際の動作はおそらくこの通りではありません f(1) + f(2) + f(3) + f(4) + f(5) ↓ split ( f(1) + f(2) ) + ( f(3) + f(4) ) + ( f(5) ) ↓ filter(1つにつき2秒)、map ( 2 + 4 ) + ( 6 + 8 ) + ( 10 ) ↓ reduce (sum) 6 + 14 + 10 ↓ reduce (sum) 30
このように、並列計算のための特別な仕掛けを自前で用意することなく、並列計算を使うことができます。
もちろん、より複雑な計算ではparallel
を使うだけで上手くいかない場合もあるかも知れませんが、その場合はストリームAPIを使って拡張することもできるでしょう。
ストリームの応用
Java8で、(私のお気に入りの)java.nio.file.Files
クラスにFiles.find
メソッドが追加されました。このメソッドは、findコマンドと同じようなことができるメソッドで、ストリームを使って実装されています。
これを使えば、findコマンドが簡単に実装できます。
- サンプル: 簡易findコマンド
import java.nio.file.*; // Files, Paths import java.util.stream.Stream; class FindCommand { public static void main(String[] args) throws Exception { String root = Stream.of(args).findFirst().orElse("./"); // 処理効率はあまり良くないかも Files.find(Paths.get(root), Integer.MAX_VALUE, (x, y) -> true) .filter(path -> Stream.of(args).skip(1).allMatch(arg -> path.toString().contains(arg))) .limit(10) .forEach(System.out::println); } }
コマンドライン引数の1つ目はルートディレクトリで、指定しない場合はカレントディレクトリになります。2つ目以降はパスの部分一致フィルターで、複数のフィルターはAND結合です。また、マッチした件数が10件に達したら、処理は打ち切られます。
こういった処理を書くときに、ストリームはとても重宝します。
- シリーズ目次
- Java SE 8 (1) - 概要と一覧
- Java SE 8 (2) - ラムダ式、メソッド参照、ストリーム (このエントリー)
- Java SE 8 (3) - 新しい言語機能
- Java SE 8 (4) - 新しいAPIと改良されたAPI
- Java SE 8 (5) - プラットフォーム、セキュリティー、他