WonderPlanet DEVELOPER BLOG

ワンダープラネットの開発者ブログです。モバイルゲーム開発情報を発信。

Java8 のラムダ式と Stream API を試してみる

こんにちは。今回ブログを担当します 藤澤です。

先日ついに Java 8 がリリースされ、Java でもラムダ式やコレクション処理のための Stream API が使えるようになりました。C# 3.0 で LINQ に触れて以来すっかりハマってしまった自分としては嬉しい限りです。というわけで、今回は C# と見比べながら Java のラムダ式と Stream API を見ていきたいと思います。

導入

現時点で Java 8 はこちらからダウンロードできます。
http://www.oracle.com/technetwork/java/javase/downloads/index-jsp-138363.html#javasejdk
インストールしたら早速ラムダ式を使って hello world でも書いてみたくなりますが、web で調べたとおりに eclipse に打ち込んでみてもエラーになります。残念ながら eclipse はまだラムダ式の文法に対応していないようです。そこで、eclipse でラムダ式が使えるようにプラグインをインストールしましょう。
Help → Install New Software → Add で Location に以下の URL を指定します。(eclipse 4.3 の場合)
http://download.eclipse.org/eclipse/updates/4.3-P-builds/
プラグインがインストールできたら Project → Properties → Java Compiler より Compiler compliance level を 1.8 にします。これで準備は完了です。

ラムダ式の基本文法

それではラムダ式の文法を C# と比較してみましょう。

// C#  

// 基本  
Func<int, int> f1 = (int i) => { return i * i; };  
// 型の省略  
Func<int, int> f2 = (i) => { return i * i; };  
// () の省略(引数がひとつの場合)  
Func<int, int> f3 = i => { return i * i; };  
// {}, return の省略  
Func<int, int> f4 = i => i * i;  
// 引数名の省略  
Func<int, int> f5 = _ => 1;  
// 型名の省略  
var f6 = new Func<int, int>(i => i * i);  
// 呼び出し  
f1(1);  
// Java  

// 基本  
Function<Integer, Integer> f1 = (Integer i) -> { return i * i; };  
// 型の省略  
Function<Integer, Integer> f2 = (i) -> { return i * i; };  
// () の省略(引数がひとつの場合)  
Function<Integer, Integer> f3 = i -> { return i * i; };  
// {}, return の省略  
Function<Integer, Integer> f4 = i -> i * i;  
// 引数名に _ は使えない  
//Function<Integer, Integer> f5 = _ -> 1;  
// 型名の省略はできない  

// 呼び出し  
f1.apply(1);  

見てのとおり基本文法は C# とほとんど変わりませんが、変数にとったラムダの呼び出し方が異なります。C# のラムダはデリゲートなので 変数名() で呼び出しますが、Java のラムダは関数型インタフェースなのでメソッド名を指定します。地味に引っかかりそうです。また、Java のジェネリクスの仕様上、型引数に基本型を指定できないのもつまづきそうな点ですね。
C# の Action や Func, Predicate デリゲートに対応するものとしては Consumer や Function, Predicate インタフェースが用意されていますが、型引数は 1 個のものと 2 個のものしかありません。(2 個のものは BiConsumer, BiFunction, BiPredicate という別名のインタフェースになります。また、Supplier が引数 0 個の Func として使えそうです)

ラムダ式のスコープ

// C#  

int i = 0;  
Action<int> action = j =>  
{  
    // 変数名が衝突する  
    //int i = 0;  
    // ローカル変数の書き換えも OK  
    i += 1;  
};
// Java  

int i = 0;  
Consumer<Integer> action = j -> {  
      // 変数名が衝突する  
      //int i = 0;  
      // final でないローカル変数のアクセスは NG  
      //System.out.println(i);  
};  
i = 1;  // この行がなければ実質的に final となるためラムダの中からアクセスできる(ただし書き換えは NG)  

ラムダ式のスコープについても基本的に C# と変わりありませんが、ひとつ大きく異なる点として final でないローカル変数にアクセスできないため、以下のような使い方はできません。通常の使用ではそれでも問題ないかと思いますが、なんとも残念です……。

// イベントが発生したことをテストする  
var mre = new ManualResetEvent(false);  
var called = false;  
foo.PropertyChanged += (_, __) =>  
{  
    called = true;  
    mre.Set();  
};  
// (略)  
mre.WaitOne(1000, false);  
Assert.True(called);  
// 要素を先頭から2つずつグルーピングする  
int i = 0;  
int j = 0;  
Enumerable.Range(0, 10).GroupBy(_ => i = i + ++j % 2);  

Stream API の基本文法

次は待望の Stream API です。

// C#  

Enumerable.Range(0, 10)  
    .Where(i => i % 2 == 0)  
    .Select(i => i * i)  
    .ToList()  
    .ForEach(i => Console.WriteLine(i));  
// Java  

IntStream.range(0, 10)  
    .filter(i -> i % 2 == 0)  
    .map(i -> i * i)  
    .forEach(i -> System.out.println(i));  

基本文法はこんな感じです。Where に相当するのが filter、Select に相当するのが map で、一見 C# と同じように使用できそうです。C# はいったん List にしないと ForEach が書けませんでしたが、Java は普通に forEach が書けるのがうれしいですね。
それではそれぞれの構成要素について見ていきましょう。

生成

C# では IEnumerable を実装するコレクションに対して LINQ の操作を行なうことができ、配列を含め多くのコレクションが IEnumerable を実装しているため実質なにも意識しなくても Select や Where を書くことができました。また、IEnumerable を実装していないコレクションについては、yield return を使って簡単に IEnumerable に変換することができました。

internal static class MatchCollectionExtensions  
{  
    public static IEnumerable<Match> AsEnumerable(this MatchCollection collection)  
    {  
        var @enum = collection.GetEnumerator();  
        while (@enum.MoveNext())  
            yield return @enum.Current as Match;  
    }  
}  

Java の Stream API において IEnumerable に相当するものが Stream になります。ただし、コレクションは Stream を直接実装しているわけではなく、Stream に変換するためのメソッドがいろいろ提供されていますので、これらを使って Stream に変換してから map や filter を書くことになります。

 // Collection を Stream に変換  
 List<String> list = new ArrayList<String>();  
 Stream<String> stream = list.stream();  
// 配列を Stream に変換  
String[] array = { "a", "b", "c" };  
Stream stream = Arrays.stream(array);  
// 値を指定して Stream を作成  
Stream<Integer> stream = Stream.of(1, 2, 3);  
// StringBuilder 同様 Stream.Builder を使うと高速(らしい)  
Stream.Builder<String> builder = Stream.builder();  
builder.add("a");  
builder.add("b");  
builder.add("c");  
Stream<String> stream = builder.build();  
// Iterable を Stream に変換  
Iterable<String> it = new ArrayList<String>();  
Spliterator<String> spliterator = it.spliterator();  
Stream<String> stream = StreamSupport.stream(spliterator, false);  

これらの変換メソッドを使えばたいていのコレクションを Stream に変換できそうな気がしますが、それでもサポートされていないコレクションがあった場合、C# の yield return のようなことは可能でしょうか。

// ラムダ式でローカル変数を書き換えられないため匿名クラスを使用  
String[] array = { "a", "b", "c" };  
Stream<String> stream = Stream.generate(new Supplier<String>() {  
        private int index = 0;  
        @Override  
        public String get() {  
                return index < array.length ? array[index++] : null;  
        }  
}).limit(array.length);  

いちおう generate などを使って任意の方法で Stream に変換することができそうです。注意点として、generate は無限に値を返し続けるため、limit などを使って個数を制限する必要があります。

射影

射影操作を行なうメソッドは map です。引数は Function<T, R> となっており、C# の Select と同じ操作感で使用できます。また、Java ではプリミティブ型用の Stream として IntStream, LongStream, DoubleStream が存在し、それらに変換するための mapToInt, mapToLong, mapToDouble が存在します。配列をソースとして Stream API を使う際は これらの違いを意識する必要がありそうです。

C# では LINQ によるパイプライン処理の途中で一時的に型が必要になった際、匿名型とオブジェクト初期化子によって簡単に型を作成できました。Java では匿名型こそありませんが、オブジェクト初期化子に似た書き方はできます。

// C#  

Enumerable.Range(1, 3)  
    .Select(i => new { val1 = i, val2 = i * 2 })  
    .ToList()  
    .ForEach(i => Console.WriteLine("{0} : {1}", i.val1, i.val2));  
// Java  
  
public class StreamSample {  
    public static void main(String args[]) {  
        Stream.of(1, 2, 3)  
            .map(i -> new Value() {{ val1 = i; val2 = i * 2; }})  
            .forEach(i -> System.out.println(i.val1 + " : " + i.val2));  
    }  
}  
  
class Value {  
    public int val1;  
    public int val2;  
}  

また、C# の SelectMany に相当するものとして flatMap も存在します。

// C#  

Enumerable.Range(1, 3)  
    .SelectMany(i => new int[] { i, i * 2 })  
    .ToList()  
    .ForEach(Console.WriteLine);  
// Java  

Stream.of(1, 2, 3)  
        .flatMap(i -> Stream.of(i, i * 2))  
        .forEach(System.out::println);  

選択

選択操作は filter を使用します。引数は Predicate となっており、これも C# の Where と同様に使用できます。
C# の Skip, Take に相当するものとして skip, limit がありますが、SkipWhile, TakeWhile に相当するものは存在しないようです。
特定のひとつの要素を選択するものとしては findFirst, findAny があります。findFirst は C# の FirstOrDefault のような動きになります。Last や ElementAt に当たるものは無いようです。

集合演算

Stream API では集合演算系(合成系)の操作はあまり用意されていないようです。Join, Union, Intersect, Except, Zip などは無いようでした。(Collectors.joining というのがありますが、これは文字列連結でした)
Concat は存在しますが、static メソッドとなっており操作感が異なります。
Distinct に対しては distinct が、Aggregate に対しては reduce が同じように使えます。
GroupBy は C# と同様の結果こそ返しますが、collect メソッドに Collectors.groupingBy を渡すという使い方になっており、直感的にわかりづらい気がします。

var data = new int[] { 1, 2, 2, 3, 3, 3 };  
data.GroupBy(i => i)  
    .ToList()  
    .ForEach(Console.WriteLine);  
Stream.of(1, 2, 2, 3, 3, 3)  
        .collect(Collectors.groupingBy(i -> i))  
        .forEach((i, j) -> System.out.println(i + " : " + j));  

count, max, min は普通に使用できます。average, sum は IntStream, LongStream, DoubleStream に対してのみ提供されています。
要素が条件を満たすか判定する All, Any に対しては、allMatch, anyMatch が存在します。条件を満たす要素が存在しないことを判定する noneMatch というメソッドもあります。
また、集合演算ではありませんが、OrderBy に対しては sorted が同様に使用できました。(任意の Comparator も指定できます)

実行速度

ところで、Stream API の実行速度は通常のループと比べてどうでしょうか。以下のようなコードで要素数を変えながら実行速度を比較してみました。(選択、射影をまじえながらランダムな値の平均を算出。時間は 100 回試行した平均値)

// C#  

long elapsed = 0;  
var count = 100;  
var data = new int[10000];  
for (int i = 0; i < data.Length; i++)  
    data[i] = (new Random()).Next() % 100;  

for (int i = 0; i < count; i++)  
{  
    var sw = new Stopwatch();  
    sw.Start();  

    var sum = 0;  
    var elements = 0;  
    for (int j = 0; j < data.Length; j++)  
    {  
        if (data[j] % 2 != 0)  
            continue;  

        sum += data[j] * 2;  
        elements++;  
    }  
    var avg = sum / elements;  

    sw.Stop();  
    elapsed += sw.ElapsedMilliseconds;  
}  
Console.WriteLine("for {0}", (double)elapsed / (double)count);  

for (int i = 0; i < count; i++)  
{  
    var sw = new Stopwatch();  
    sw.Start();  

    var sum = 0;  
    var elements = 0;  
    foreach (var j in data)  
    {  
        if (j % 2 != 0)  
            continue;  

        sum += j * 2;  
        elements++;  
    }  
    var avg = sum / elements;  

    sw.Stop();  
    elapsed += sw.ElapsedMilliseconds;  
}  
Console.WriteLine("foreach {0}", (double)elapsed / (double)count);  

for (int i = 0; i < count; i++)  
{  
    var sw = new Stopwatch();  
    sw.Start();  

    data.Where(x => x % 2 == 0)  
        .Select(x => x * 2)  
        .Average();  

    sw.Stop();  
    elapsed += sw.ElapsedMilliseconds;  
}  
Console.WriteLine("LINQ {0}", (double)elapsed / (double)count);  
// Java  

long elapsed = 0;  
int count = 100;  
int[] data = new int[10000000];  
for (int i = 0; i < data.length; i++)  
    data[i] = (int)(Math.random() * 100);  

for (int i = 0; i < count; i++) {  
    long now = System.currentTimeMillis();  
      
    int sum = 0;  
    int elements = 0;  
    for (int j = 0; j < data.length; j++) {  
        if (data[j] % 2 != 0)  
            continue;  
          
        sum += data[j] * 2;  
        elements++;  
    }  
    double average = sum / elements;   
      
    elapsed += System.currentTimeMillis() - now;  
}  
System.out.println((double)elapsed / (double)count);  

for (int i = 0; i < count; i++) {  
    long now = System.currentTimeMillis();  
      
    int sum = 0;  
    int elements = 0;  
    for (int j : data) {  
        if (j % 2 != 0)  
            continue;  
          
        sum += j * 2;  
        elements++;  
    }  
    double average = sum / elements;   
      
    elapsed += System.currentTimeMillis() - now;  
}  
System.out.println((double)elapsed / (double)count);  
  
for (int i = 0; i < count; i++) {  
    long now = System.currentTimeMillis();  
      
    Arrays.stream(data)  
        .filter(x -> x % 2 == 0)  
        .map(x -> x * 2)  
        .average();  
      
    elapsed += System.currentTimeMillis() - now;  
}  
System.out.println((double)elapsed / (double)count);
要素数10,000100,0001,000,00010,000,000
C# for01.0113.14-
C# foreach01.0622.15-
C# LINQ0.095.1761.45-
Java for0.110.321.4210.73
Java 拡張 for0.280.987.1761.21
Java Stream API1.052.7815.98134.48

計測したマシンが違うので C# と Java の比較に意味はありません。Stream API が for, 拡張 for に比べて どの程度オーバーヘッドがあるかという観点で見ていただければと思いますが、LINQ の場合と同程度か、若干 Stream API のほうが効率がよいように見受けられます。

まとめ

以上、Java のラムダ式と Stream API についてざっと試してみました。まだ使い込んでいないので結論を出すには早いですが、今回触った印象としては Stream API は LINQ に比べてもの足りない感じがしました。とはいえ通常のループを置き換えるには十分だと思います。LINQ や Stream API をうまく使えばコードが格段に見やすくなると思いますので早く普及してくれるのを期待しています。今後さらに改善されて使いやすくなるといいですね。