こんにちは。今回ブログを担当します 藤澤です。
先日ついに 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
それではそれぞれの構成要素について見ていきましょう。
生成
C# では 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
// 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# の 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,000 | 100,000 | 1,000,000 | 10,000,000 |
---|---|---|---|---|
C# for | 0 | 1.01 | 13.14 | - |
C# foreach | 0 | 1.06 | 22.15 | - |
C# LINQ | 0.09 | 5.17 | 61.45 | - |
Java for | 0.11 | 0.32 | 1.42 | 10.73 |
Java 拡張 for | 0.28 | 0.98 | 7.17 | 61.21 |
Java Stream API | 1.05 | 2.78 | 15.98 | 134.48 |
計測したマシンが違うので C# と Java の比較に意味はありません。Stream API が for, 拡張 for に比べて どの程度オーバーヘッドがあるかという観点で見ていただければと思いますが、LINQ の場合と同程度か、若干 Stream API のほうが効率がよいように見受けられます。
まとめ
以上、Java のラムダ式と Stream API についてざっと試してみました。まだ使い込んでいないので結論を出すには早いですが、今回触った印象としては Stream API は LINQ に比べてもの足りない感じがしました。とはいえ通常のループを置き換えるには十分だと思います。LINQ や Stream API をうまく使えばコードが格段に見やすくなると思いますので早く普及してくれるのを期待しています。今後さらに改善されて使いやすくなるといいですね。