argius note

プログラミング関連

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.classjavap -p -vで見てみます。

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
}

このクラスファイルを見る限りでは、次のようになっているようです。

これらは、既に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)データも同様に扱うことができます。
具体的には、主に次のことを実現します。

  1. 関数型スタイルのコレクション操作(filter/map/reduceなど)
  2. 遅延評価
  3. 並列計算


ストリームは、APIとして、新しいjava.util.streamパッケージの追加と、主に既存のコレクションクラスの拡張がされています。


関数型スタイルのコレクション操作

ストリームを使うと、関数型言語スクリプト言語では定番の、filter/map/reduceなどの操作ができます。

特に、filtermap。リストのちょっと加工したコピーを作ったりするのは良くあることで*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の箇所は、標準入力へデータを送っていることを表しています。
遅延評価により、データを入力していない状態でも、パイプラインの構築の箇所では評価自体はされずに通過し、skiplimitが呼ばれている箇所を通過しています。終端処理である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)で並列のストリームに変換したものです。
計算自体は、
\sum_{k=1}^{5} 2k = 30
になります。


これを実行してみると...

  • 実行結果
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件に達したら、処理は打ち切られます。
こういった処理を書くときに、ストリームはとても重宝します。



*1:かなり昔、Lispとか関数型言語を全く知らない頃に、Perlでmapの使い方をおぼえてからは特に使うようになりました。

*2:具体的な動作については、調べていません。