ラムダ式とStream APIで学ぶモダンJava ― 関数型を取り入れて変化するJava言語の現在
20年以上の歴史を持つJava言語ですが、近年は関数型を取り入れるなど大きく変化し、リリースサイクルも格段に短くなってますます進化しています。モダンなJavaプログラミングで必要となるラムダ式とStream APIについて、谷本心(cero_t)さんによる詳細な解説です。
1996年にJava 1.0が登場して、もう20年以上がたちました。この間、Javaにはさまざまな言語機能やAPIが追加され、変化し続けています。
これだけ長い歴史を持つプログラミング言語ですから、利用者が多かったり、フレームワークやライブラリが充実していたりする一方で、書籍やWebに掲載されている情報が少し古かったり、研修で学ぶJavaが最新の動向を踏まえていなかったりするなど、長い歴史を持つが故の問題もあります。
特に、近年のJava8で登場したラムダ式やStream APIという関数型を踏まえた新機能は、これまでのJavaの書き方を一変させる大きな変化となりました。また、Java9以降のリリースモデルの変更は、JDKのオープンソース化以来となる大きな変化をもたらしました。
このような動向を踏まえて、モダンなJavaの文法やスタイル、そしてそのメリットを紹介したいと思います。
- なぜ、私たちはJavaの新しい機能を学ぶのか?
- ラムダ式の基本
- Stream APIの基本
- ラムダ式とStream APIを用いたモダンなJavaプログラミング
- Stream APIとラムダ式と、これからのJava
- ますます進化が早まるJava
- 回答
なぜ、私たちはJavaの新しい機能を学ぶのか?
「なぜ、新しい機能を学ぶ必要があるのでしょうか?」
Javaの勉強会やイベントを主催していると、たまにそのような質問をされることがあります。それに対する私の回答は決まって「コンピューティングが進歩し続けているから」です。
Javaが登場した頃にはまだシングルコアが中心だったCPUは、その後デュアルコア、メニーコアが当たり前となりました。そのような背景の中で、Javaは単一スレッドで処理を行うだけでなく、マルチスレッドで処理するための機能が追加されていきました。
また、メモリのサイズが大きくなることに合わせて、新しいガベージコレクション(GC)のアルゴリズムも追加され続けています。そして最近ではクラウドコンピューティングや仮想化コンテナを利用することが一般的になってきているため、そのための機能もJavaには追加されています。
つまり、Javaの新しい機能を習得することは、進歩し続けるコンピューティングや変化する技術トレンドに追従することと同じだと言えます。現実として目前にある業務や研究は必ずしも最新の技術を要するものではないかもしれませんが、将来的に必要となる技術トレンドを把握するためにも、ぜひこの記事でモダンなJavaを学んでください。
ひと目で分かるJavaの変化
前置きが長くなりましたが、ここ数年のJavaの変化を踏まえて、Java1.4時代のコードと、Java9以降のコードを比べてみます。ここでは例として、数値の配列やリストを大きい順に並べるソート処理を行います。
次のコードは、Java1.4時代の文法を用いて書いたものです。
int[] array = {3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5}; List numbers = new ArrayList(array.length); for (int i = 0; i < array.length; i++) { numbers.add(Integer.valueOf(array[i])); } Comparator comparator = new Comparator() { public int compare(Object o1, Object o2) { return ((Integer) o2).intValue() - ((Integer) o1).intValue(); } }; List sorted = new ArrayList(numbers); Collections.sort(sorted, comparator); System.out.println(numbers); System.out.println(sorted);
出力結果は次のようになります。
[3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5] [9, 6, 5, 5, 3, 3, 1, 1, -2, -4, -5]
配列をもとにしてリストを作り、そのリストの複製を作ってからソートを行い、最後に標準出力に出力しています。
実行内容がつかみやすいJava9のコード
続いて、同じ処理をJava9以降の文法やAPIを用いて書いてみます。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); List<Integer> sorted = numbers.stream() .sorted((n1, n2) -> n2 - n1) .collect(Collectors.toList()); System.out.println(numbers); System.out.println(sorted);
出力結果は次のようになります。
[3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5] [9, 6, 5, 5, 3, 3, 1, 1, -2, -4, -5]
上と同じ処理を、Java5で追加されたジェネリクスや、Java9で追加されたList.ofメソッド、そしてJava8で追加されたStream APIとラムダ式を使って記述しました。
いずれのコードも同じように動作しますし、性能もほとんど変わりません。しかし、コードの量と内容がまるで違っていることが分かります。
Java1.4時代のコードは全体を読まなければ何をしたいのかをつかめませんし、途中で匿名クラスの宣言(new Comparatorの箇所)なども入っていて読みづらくなっています。
一方、Java9以降のコードは、少し慣れが必要かもしれませんが、コードを上から順番に読み進めながら、何をしようとしているかをつかみやすくなっています。
このコードの例で分かるように、Javaは1.4から9までの十数年だけでもかなり大きく変わりました。今回は、特に変化の大きかった「ラムダ式」と「Stream API」について詳しく説明します。
ラムダ式の基本
ラムダ式は、Java8で導入された新しい文法です。詳しい説明の前に、簡単なラムダ式の例を見てみましょう。
次のコードは、Stream APIをラムダ式を用いて記述したものです。Stream APIやラムダ式については後ほど詳しく説明するため、まずはコードだけを眺めてください。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(number -> Math.abs(number) >= 5) .forEach(System.out::println);
Java7までのJavaだけを学習してきた方には見慣れないコードになっていますね。出力結果は次のようになります。
-5 9 6 5 5
ラムダ式の効果を確認するため、上のコードをラムダ式を使わずに記述してみます。次のようになります。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(new Predicate<Integer>() { @Override public boolean test(Integer number) { return Math.abs(number) >= 5; } }) .forEach(new Consumer<Integer>() { @Override public void accept(Integer number) { System.out.println(number); } });
明らかにコードの記述量が多くなり、処理の見通しがとても悪くなりました。この例で分かるように、ラムダ式はStream APIをはじめとしたJavaの新しいAPIを利用する際に、簡潔に記述できるよう導入された文法なのです。
ラムダ式は関数型インタフェースを記述する
ラムダ式はもともと、関数型言語の考え方を取り入れた文法です。「関数」という言葉は、プログラミング言語では「メソッド」と同じような意味で用いられる言葉でしたが、関数型を説明する上では、もう少しだけ慎重に意味を捉える必要があります。
ここで言う関数とは、他の関数から独立した処理であり、実行をしても他の関数に影響を与えない、副作用のない処理のことを示しています。
Javaでは、メソッドの実行中にそのインスタンスが持つ変数(インスタンスフィールド)の値を変えることが普通にあります。例えば、StringBuilderクラスでは、appendメソッドを呼び出すたびに保持している文字列が増えていき、toStringメソッドが返す文字列が変わっていきます。それに対して、関数はそのように状態が変わらないことが求められます。
関数を実現するため、Javaに「関数型インタフェース」が導入されました。関数型インタフェースとは、実装すべきメソッドが1つしかないinterfaceのことです。1つのメソッドしか記述できない、そしてinterfaceなので状態を持てないという制約を課すことで、副作用のない関数を実装できるようにしたのです。
概念の説明はこれぐらいにして、実際の関数型インタフェースのコードを見てみましょう。上のラムダ式のサンプルコードにも登場したjava.util.function.Predicateインタフェースは、次のようなコードになっています。
@FunctionalInterface public interface Predicate<T> { boolean test(T t); default Predicate<T> and(Predicate<? super T> other) { Objects.requireNonNull(other); return (t) -> test(t) && other.test(t); } default Predicate<T> negate() { return (t) -> !test(t); } // 以降、defaultメソッドとstaticメソッド }
このインタフェースは、@FunctionalInterfaceアノテーションが示している通り、関数型インタフェースです。メソッドが複数ありますが、andメソッドやnegateメソッド、またそれ以降のメソッドはいずれもdefaultメソッドやstaticメソッドとして実装されているため、オーバーライドして再実装する必要はありません。defaultメソッドはJava8から導入された新しい構文で、interfaceに実装を持たせることができるものです。
つまり、このPredicateインタフェースを実装する際には、testメソッドのみオーバーライドして実装すればよいのです。次のコードは、Predicateインタフェースを匿名クラスとして実装した例です。
new Predicate<Integer>() { @Override public boolean test(Integer number) { return Math.abs(number) >= 5; } }
そして、この処理はラムダ式で置き換えることができ、次のコードのように記述することができます。
number -> Math.abs(number) >= 5
少し乱暴な言い方をすれば、ラムダ式とは、関数型インタフェースを実装する際に、匿名クラスの代わりに簡潔に処理を記述するための文法だと言えます。
後に説明するStream APIでは、ほとんど全てのメソッドの引数の型がこの関数型インタフェースとなっており、ラムダ式を用いることで、少ないコードで記述できるようになるわけです。
ラムダ式の文法
それでは、ラムダ式の文法について詳しく説明します。ラムダ式の基本文法は、次のようになります。
(引数) -> { 処理; }
この文法に従った例をいくつか記載します。
// (1) 引数と戻り値がある場合 (Integer number) -> { return Math.abs(number) >= 5; } // (2) 戻り値がない場合 (Integer number) -> { System.out.println(number); } // (3) 引数も戻り値もない場合 () -> { System.out.println("Hello!"); }
(1)は、Predicateのように引数と戻り値がある例です。引数で指定されたnumberを用いて処理を行い、戻り値をreturnしています。
(2)は、Consumerのように戻り値がない例です。その場合はreturn文を書く必要はありません。
(3)のように、引数がない処理は引数部分を( )
で記載します。java.lang.Runnableなどがこれに該当します。
また、ラムダ式では引数の型を省略することができます。(1)について引数の型を省略すると、次のようになります。
(number) -> { return Math.abs(number) >= 5; }
ラムダ式が利用されるほとんどの場面では引数の型は明確であるため、基本的にラムダ式では引数の型は記述しません。
そして、引数が1つしかない場合に限り、引数を囲む小括弧( )
を省略することができます。引数がない場合や、2つ以上ある場合は省略できません。このルールを(1)と(3)に当てはめると、次のようになります。
// (1) 引数が1つなので ( ) を省略できる number -> { return Math.abs(number) >= 5; } // (3) 引数がないため ( ) を省略できない () -> { System.out.println("Hello!"); }
さらに、処理が1行しかない場合は、中括弧{ }
と、returnと、文末のセミコロン;
を省略することもできます。(1)~(3)について省略した形で記述すると次のようになります。
// (1) 引数と戻り値がある場合 number -> Math.abs(number) >= 5 // (2) 戻り値がない場合 number -> System.out.println(number) // (3) 引数も戻り値もない場合 () -> System.out.println("Hello!")
最後に、処理内容がメソッド呼び出し1つの場合、かつ、引数が一意に決まる場合に限り、メソッド参照を利用して、引数そのものを省略することができます。メソッド参照は次のような文法になります。
クラス名::メソッド名
このメソッド参照を適用できるのは(2)だけとなります。(2)をメソッド参照を用いて記載すると次のようになります。
System.out::println
System.out.printlnメソッドは引数を1つだけ取るメソッドであり、引数であるIntegerの値が渡されることが明らかであるため、メソッド参照が利用できるのです。一方、(1)はメソッド呼び出しの後に>= 5
という大小判定があるため、メソッド参照が使えません。また、(3)は引数に"Hello!"
という値を指定しているため引数が一意には決まるとは言えず、これもメソッド参照は使えません。
さて、ここまで学んだことを振り返れば、冒頭で紹介したラムダ式が読み解けるようになっているでしょう。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(number -> Math.abs(number) >= 5) .forEach(System.out::println);
引数の値の絶対値が5以上であるかを判定する処理と、引数の値を標準出力に出力する処理を記述している、ということが分かります。
しかしながら、ラムダ式を利用しているfilter、forEachといったメソッドは見慣れないかもしれません。これらはStream APIのメソッドであり、次の章で詳しく紹介します。
復習テスト
ここまでの復習のため、課題に挑戦してみましょう。次のコードは、java.util.Comparatorを利用して、数値を絶対値の小さい順に並べるものです。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.sort(new Comparator<Integer>() { @Override public int compare(Integer v1, Integer v2) { return Math.abs(v1) - Math.abs(v2); } }); System.out.println(numbers);
出力結果は次のようになります。
[1, 1, -2, 3, 3, -4, -5, 5, 5, 6, 9]
この処理を、ラムダ式に置き換えてください。回答は、記事末に掲載します。
Stream APIの基本
Stream APIは名前の通り、ストリーム(Stream)という流れてくるデータやイベントを処理するためのAPI群です。
ただ、データやイベントの流れを処理すると言ってもなかなかイメージしにくいため、いったんはjava.util.Listやjava.util.Mapなどのデータ構造に対して、効率的な処理をするAPI群であると捉えてもらった方がイメージしやすいでしょう。
前の章の冒頭で紹介したコードは、Stream APIを用いたものです。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(number -> Math.abs(number) >= 5) .forEach(System.out::println);
出力結果は次のようになります。
-5 9 6 5 5
numbersという数値のリスト対して、streamメソッドでストリームを生成し、filterメソッドで絶対値が5以上の値のみに絞り込み、forEachメソッドでそれぞれの値を標準出力に出力しています。
この処理をStream APIを使わずにfor文のみで記述した場合は、次のようなコードになります。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); for (Integer number : numbers) { if (Math.abs(number) >= 5) { System.out.println(number); } }
Java7以前のJavaに慣れ親しんだ方には、こちらの方が見慣れたコードと感じるかもしれません。しかし、for文を使った処理では、コード全体を読んで頭の中で処理内容を想像しなくては、処理の目的が分かりません。
Stream APIを利用した処理では、「絞り込む」「出力する」といった目的に分けて記述されています。
つまりStream APIは「何をしているか(what)」を記述するものであり、既存のfor文を使った処理は「どのように処理するか(how)」を記述するものだと言えます。どちらが読みやすいかは経験によるところもありますが、多くの場合、コードを読む際にはどのように処理するかよりも、何をしているかの方を知りたいでしょうから、目的が明確になりやすいStream APIの方が読みやすく感じると思います。
Stream APIは「生成する」「操作する」「まとめる」の3段階
Stream APIは、データ構造に対してStreamを「生成する」「操作する」「まとめる」の処理を連続して行うことで、目的の結果を得られるよう記述できるAPIです。
「生成する」API群
まずはStreamを生成するAPIから紹介します。Streamは、大きく分けてListやSetなどのCollectionインタフェースから作る場合と、配列から作る場合の2つに分けられます。
ListやSetからStreamを作る場合には、stream()メソッドを使います。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); Stream<Integer> stream = numbers.stream();
このStream
配列からStreamを作る場合には、Arrays.stream()メソッドを使います。
int[] array = {3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5}; IntStream stream = Arrays.stream(array);
生成されるStreamの型は、元の配列の型によって決まります。Stream<String>などの通常のStreamの型に加えて、int、long、doubleの配列に限り、IntStream、LongStream、DoubleStreamという3つのStreamの型になります。これらのStreamには合計値を計算するsumメソッドや、平均値を計算するaverageメソッドなど、数値計算を便利に行うメソッドが用意されています。
また、MapからStreamを作りたい場合は、Mapから直接Streamを作るAPIが用意されていないため、MapのentrySetメソッドを用いて得られるSet<Entry>からStreamを生成します。
Map<String, Integer> map = Map.of("key1", 3, "ke2", 1, "key3", -4, "key4", 1); Stream<Entry<String, Integer>> stream = map.entrySet().stream();
Mapに対するStream処理は少しだけ複雑になってしまいますが、この辺りは今後のバージョンアップで改善されるとうれしいところです。
また、テキストファイルを1行ずつ読んで文字列のStreamとする場合に、Files.lineメソッドが利用できます。
Stream<String> lines = Files.lines(Path.of("/tmp/test.txt"));
このように、Java8以降のAPIではこれまでListや配列で処理していたところから、Streamで処理できるようになったAPIがいくつかあります。これらのAPIを利用することで、より簡潔に処理を記述できるようになります。
「操作する」中間操作のAPI群
続いて、Streamに対して操作するAPIです。このAPI群は中間操作と呼ばれており、Stream APIの一番の肝と言えるものです。この中間操作のうち、特に利用頻度の高い、mapメソッド、filterメソッド、sortedメソッドを紹介します。
mapメソッドは元の値に対して、値や型を別のものに置き換えるメソッドです。次のコードは、全ての値を2倍して表示する処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .map(number -> number * 2) .forEach(System.out::println); System.out.println(numbers);
出力結果は次のようになります。
6 2 -8 2 -10 18 -4 12 10 6 10 [3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5]
forEachメソッドについては後で説明しますが、全ての値を表示する処理を行っていると理解してください。出力結果を見ると、いずれも2倍された値に置き換わったことが分かります。また、最後のSystem.out.printlnでnumbersを表示していますが、numbersの内容は何も変わっていない、つまり副作用を与えていないことが分かります。
ラムダ式のところでも少し述べましたが、関数型の考え方では副作用を与えないことは重要です。Stream APIは、このように副作用を与えずに処理できるという点が大きな特徴だと言えます。
続いてfilterメソッドです。次のコードは、0より大きな値のみを抽出する処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(number -> number > 0) .forEach(System.out::println); System.out.println(numbers);
出力結果は次のようになります。
3 1 1 9 6 5 3 5 [3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5]
filterメソッドで合致した条件のものだけ抽出されていることが分かり、また、元のnumbersの内容が何も変わっていないことも分かりました。
なお、filterメソッドは条件に一致するものを取り出すことができますが、条件に一致しなかったものも同時に取り出して、処理を分岐させるようなことはできません。その場合にはStreamを作り直して処理をするか、後述する終端処理をうまく利用する必要があります。
Stream APIに慣れないうちは、処理を分岐したい場合には無理にStream APIを使わず、for文とif文を使うのも方法の一つとなるでしょう。
中間操作を複数呼び出して目的の結果を得る
mapやfilterなどの中間操作は、複数呼び出すことができます。次のコードは、mapとfilterの両方を利用したものです。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .map(number -> number * 2) .filter(number -> number > 0) .forEach(System.out::println);
出力結果は次のようになります。
6 2 2 18 12 10 6 10
2倍された値のうち、0より大きいものが出力されていることが分かります。Stream APIでは、このようにして複数の処理を連続して行って、目的の結果を得られるよう記述します。
中間操作の最後として、sortedメソッドを紹介します。次のコードは、2倍した値を大きい順に並べる処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .map(number -> number * 2) .sorted((number1, number2) -> number2 - number1) .forEach(System.out::println);
出力結果は次のようになります。
18 12 10 10 6 6 2 2 -4 -8 -10
このように、期待通りに整列した結果が得られました。
中間操作には、今回紹介したメソッド以外にも、指定した件数だけ値を取得するlimitメソッド、重複した値を削除してユニークな値のみにするdistinctメソッドなど、便利な処理がたくさんあります。少しずつ習得するとよいでしょう。
「まとめる」終端操作のAPI群
最後に、Streamから別の型に変換したり、副作用ある操作を行って終了する、終端操作について紹介します。終端操作は戻り値がなかったり、Stream以外の型となるため、1つのStreamに対して一種類の終端操作しか利用できません。ここでは終端操作の中でも利用頻度の高い、forEachメソッド、collectメソッドを紹介します。
まずは、Streamの全ての要素について指定した処理を行うためのforEachメソッドです。ここまでも何度か説明なく使ってきたので、どのように使えるか推測できるかもしれません。forEachメソッドは、値に対して行う処理を記述した関数を引数に取り、Streamの各要素に対してその処理を行うものです。
次のコードは、数値のListのうち、正の値を全て出力するものです。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.stream() .filter(number -> number > 0) .forEach(System.out::println);
出力結果は次のようになります。
3 1 1 9 6 5 3 5
forEachメソッドの引数に渡したSystem.out.printlnメソッドが、全ての要素に対して実行されて、1行1文字ずつ表示がされています。forEachメソッドは戻り値がなく、ここで処理が終了となるという点を見ても、終端処理と呼ばれる理由が分かります。
他の値に変換する終端処理
forEachメソッドには戻り値がありませんが、他の値に変換したい場合は、どのようにすればよいでしょうか。例えばforEachメソッドを用いて別のListに格納する場合、次のようなコードになります。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); List<Integer> result = new ArrayList<>(); numbers.stream() .filter(number -> number > 0) .forEach(number -> result.add(number)); System.out.println(result);
出力結果は次のようになります。
[3, 1, 1, 9, 6, 5, 3, 5]
これで確かに目的通りの動作となりました。しかし、このようなコードは、Stream APIの利用方法としてはあまり推奨されません。副作用を伴うようなforEachメソッドを使うのではなく、もっと他の方法を利用した方がよいでしょう。
では、forEachメソッドを使わずにStreamから他の型に変換するには、どうすればよいのでしょうか。そのような時に使えるのが、StreamをListやMapなど別の方に変換できるcollectメソッドという終端処理です。Streamの処理結果をListやMapとして利用したい場面はよくあるため、利用頻度が高いメソッドだと言えます。
collectメソッドは、引数にjava.util.stream.Collectorという変換用のインタフェースを受けて変換処理を行います。次のコードは、数値のListをStream処理で正の値のみに絞り込み、別のListとして取り出す処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); List<Integer> result = numbers.stream() .filter(number -> number > 0) .collect(Collectors.toList()); System.out.println(result);
出力結果は次のようになります。
[3, 1, 1, 9, 6, 5, 3, 5]
Collectors.toListは、StreamをListに変換するCollectorを返すメソッドです。それを用いてcollect処理を行うことで、Listに変換されたことが分かります。
collectメソッドは、Listのようなコレクションの型に変換するだけでなく、単一の値に変換することもできます。次のコードは、正の数のみの平均値を計算する処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); Double result = numbers.stream() .filter(number -> number > 0) .collect(Collectors.averagingInt(Integer::intValue)); System.out.println(result);
出力結果は次のようになります。
4.125
Collectors.averagingIntは、int型の数値から平均値を求めるCollectorを返すメソッドです。averagingIntの引数には、Streamの型(ここではInteger)からintに変換するためのFunction(ここではInteger::intValue)を指定します。
また、collectによる処理を使うことで、StreamからMapを作ることもできます。次のコードは、数値のListを正の数と、負の数もしくは0に分類する処理です。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); Map<String, List<Integer>> result = numbers.stream() .collect(Collectors.groupingBy(number -> number > 0 ? "positive" : "zero or negative")); System.out.println(result);
出力結果は次のようになります。
{zero or negative=[-4, -5, -2], positive=[3, 1, 1, 9, 6, 5, 3, 5]}
Collectors.groupingByは、指定された条件に従ってStreamの値を分類して、Mapに格納するCollectorを返すメソッドです。値を元にしてMapに格納する際のキーを返すFunctionを引数として受け取ります。ここでは、数値によって"positive"と"zero or negative"いずれかのキーを指定しており、元の数値Listがその通りに分類されていたことが分かります。
groupingByメソッドは少し見慣れない処理かもしれませんので、この処理をJava7以前の文法で記述したものと比べてみます。Java7以前の文法では次のコードのようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); Map<String, List<Integer>> result = new HashMap<>(); for (Integer number : numbers) { String key; if (number > 0) { key = "positive"; } else { key = "zero or negative"; } if (result.containsKey(key) == false) { result.put(key, new ArrayList<>()); } List<Integer> list = result.get(key); list.add(number); } System.out.println(result);
Java7以前の書き方では、確かに見慣れた記述になりますが、コードの量が長くなりがちですし、キーを分ける処理や初期化する処理がばらけてしまい、コード全体を読むまで何がしたいかという意図を理解することも難しくなります。
それに対して、collect(Collectors.groupingBy)メソッドを用いた場合は、少なくとも何らかのルールに従って分類したいことはすぐに分かります。
このような分かりやすさが、Stream APIを利用するメリットだということが感じられたでしょうか。
ラムダ式とStream APIを用いたモダンなJavaプログラミング
ここまでラムダ式とStream APIについて説明してきましたが、これらを用いるとプログラミングはどのように変わるのでしょうか。それを体感するために、デザインパターンの一つをラムダ式とStream APIで記述するとどのように変わるかを紹介します。
デザインパターンと言えば、GoF(Gang of Four)のものが有名です。Java 1.0のリリースよりも前に考案された歴史の古いパターンですが、現在の開発においても適用できることの多い汎用性の高いものです。
Java7でのStrategyパターンの実装
今回はデザインパターンのうち、Strategyパターンについて実装します。Strategyパターンとは、APIの呼び出し方法を変えないまま、処理の内容を変えたい場合に利用するパターンです。
まずは、Java7時代のコードとして、StrategyパターンをStream APIやラムダ式を利用せずに実装してみましょう。目的の処理は、数値のリストから条件に合致した数値のみを取り出して、標準出力に出力するというものです。ただし条件の判定処理は差し替えられるものとします。
なお、このコードの完全なサンプルは、githubの次のURLにあります。
GitHub - cero-t/modernjava201904: ラムダ式とStream APIで学ぶモダンJava ― 関数型を取り入れて変化するJava言語の現在 サンプルコード
まずは、Selectorという、条件を抽出するためのinterfaceを定義します。次のコードのようになります。
interface Selector {
List<Integer> select(List<Integer> values);
}
続いて、Selectorを使った実装です。次のコードは、条件に合致した数値を標準出力に出力するものです。
public class StrategyJava7 { Selector selector; StrategyJava7(Selector selector) { this.selector = selector; } void execute(List<Integer> values) { System.out.println(selector.select(values)); } // 以降、いったん省略 }
コンストラクタでSelectorインタフェースを受け取り、executeメソッドではそれを利用して値を絞り込んだ上で、標準出力に出力するという処理の流れになります。
続いて、Selector部分の実装です。奇数のみを抽出するOddSelector、偶数のみを抽出するEvenSelector、正の数のみを抽出するPositiveSelector、負の数のみを抽出するNegativeSelectorの4つのSelectorを実装しています。
static class OddSelector implements Selector { @Override public List<Integer> select(List<Integer> values) { List<Integer> result = new ArrayList<>(); for (Integer value : values) { if (value % 2 == 1) { result.add(value); } } return result; } } static class EvenSelector implements Selector { @Override public List<Integer> select(List<Integer> values) { List<Integer> result = new ArrayList<>(); for (Integer value : values) { if (value % 2 == 0) { result.add(value); } } return result; } } static class PositiveSelector implements Selector { @Override public List<Integer> select(List<Integer> values) { List<Integer> result = new ArrayList<>(); for (Integer value : values) { if (value > 0) { result.add(value); } } return result; } } static class NegativeSelector implements Selector { @Override public List<Integer> select(List<Integer> values) { List<Integer> result = new ArrayList<>(); for (Integer value : values) { if (value < 0) { result.add(value); } } return result; } }
とても似た処理が並んでいます。今回はあえてこのまま使いますが、AbstractSelectorのようなクラスを作って、処理が似た部分をまとめられるようにするのもよいでしょう。
4つのSelectorを作成しましたが、このままでは利用する側がどのようなSelectorがあるのか分かりづらいため、Selectorsというクラスを作って、Selectorの生成処理をまとめるようにします。こうすることで、IntelliJやEclipseなどのIDEの自動保管機能を使って目的のSelectorを探しやすくなります。ちょうどStream APIのCollectorsクラスと同じような役割です。次のようなコードになります。
static class Selectors { static Selector oddSelector() { return new OddSelector(); } static Selector evenSelector() { return new EvenSelector(); } static Selector positiveSelector() { return new PositiveSelector(); } static Selector negativeSelector() { return new NegativeSelector(); } }
いずれもstaticメソッドで実装しており、Selectors.positiveSelector()というように呼び出すことで、Selectorのインスタンスを取得できます。
長くなってきましたが、これらのSelectorを使うための処理は次のコードのようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); StrategyJava7 sample = new StrategyJava7(StrategyJava7.Selectors.positiveSelector()); sample.execute(numbers);
出力結果は次のようになります。
[3, 1, 1, 9, 6, 5, 3, 5]
期待通りに、正の数のみが出力されました。
もちろんSelectorsで別のメソッドを呼び出すことで、抽出する条件を変えることができます。次のコードは、偶数のみを抽出する処理です。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); StrategyJava7 sample = new StrategyJava7(StrategyJava7.Selectors.evenSelector()); sample.execute(numbers);
出力結果は次のようになります。
[-4, -2, 6]
期待通り、偶数のみが出力されました。
このようにStrategyパターンは、処理の流れを変えることなく、その処理の内容(戦略)のみを差し替えることができるパターンであることが分かりました。
Stream APIとラムダ式を使ったStrategyパターンの実装
続いて、StrategyパターンをStream APIとラムダ式を使って実装してみます。次のコードは、Selectorを利用する箇所やSelectorsをまとめて実装したものです。
public class StrategyLambda { Selector selector; StrategyLambda(Selector selector) { this.selector = selector; } void execute(List<Integer> values) { System.out.println(selector.select(values)); } static class Selectors { static Selector oddSelector() { return values -> values.stream() .filter(i -> i % 2 == 1) .collect(Collectors.toList()); } static Selector evenSelector() { return values -> values.stream() .filter(i -> i % 2 == 0) .collect(Collectors.toList()); } static Selector positiveSelector() { return values -> values.stream() .filter(i -> i > 0) .collect(Collectors.toList()); } static Selector negativeSelector() { return values -> values.stream() .filter(i -> i < 0) .collect(Collectors.toList()); } } }
Selectorが関数型インタフェースであることを利用して、各Selectorの実装ではクラスを作らずにラムダ式のみで処理を記述しています。また処理の内容もStream APIを使って簡潔に記述しているため、随分とコードの見た目がすっきりしたことが分かります。
このクラスを利用する側の処理は、次のコードのようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); StrategyLambda sample = new StrategyLambda(StrategyLambda.Selectors.positiveSelector()); sample.execute(numbers);
出力結果は次のようになります。
[3, 1, 1, 9, 6, 5, 3, 5]
コードの中身は、Java7の時と何も変わってないことが分かります。Strategyを外から利用するときは何も変わらず、ただ内部の実装だけがStream APIとラムダ式のおかげで簡潔になったことが分かります。
ところで、Selectorを自前で実装する場合にはどのようになるのでしょうか。次のコードは、数値のListのうち3で割り切れる値だけを抽出するSelectorを実装し、利用したものです。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); StrategyLambda sample = new StrategyLambda(values -> values.stream() .filter(i -> i % 3 == 0) .collect(Collectors.toList())); sample.execute(numbers);
出力結果は次のようになります。
[3, 9, 6, 3]
3で割り切れる値のみが出力されました。このように、新しくSelectorを実装する際にもラムダ式を利用して簡潔に記述することができます。
もっと簡潔にStrategyパターンを実装する
ここまででStream APIとラムダ式を利用することでStrategyパターンの戦略を簡潔に記述する様子を説明しましたが、それでもコード中にはfilterメソッドやcollectメソッドを呼び出す部分が何度も記述されており、冗長になっています。
重複したコードを削除して、次のように修正しました。
public class StrategyMoreLambda { // Selectors以外は同じ内容なので割愛 static class Selectors { static Selector of(Predicate<Integer> predicate) { return values -> values.stream() .filter(predicate) .collect(Collectors.toList()); } static Selector oddSelector() { return of(i -> i % 2 == 1); } static Selector evenSelector() { return of(i -> i % 2 == 0); } static Selector positiveSelector() { return of(i -> i > 0); } static Selector negativeSelector() { return of(i -> i < 0); } } }
Selectorsにofメソッドを追加して、filterメソッドやcollectメソッドの呼び出しを行うようにしました。そして、それぞれのSelectorの実装では判定部分のみを記述することで、簡潔に記述できるようになりました。
これを利用する側のコードは次のようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); StrategyMoreLambda sample = new StrategyMoreLambda(StrategyMoreLambda.Selectors.positiveSelector()); sample.execute(numbers); StrategyMoreLambda sample2 = new StrategyMoreLambda(StrategyMoreLambda.Selectors.of(i -> i % 3 == 0)); sample2.execute(numbers); }
出力結果は次のようになります。
[3, 1, 1, 9, 6, 5, 3, 5] [3, 9, 6, 3]
Selectorを利用する部分に関しては何も変わらず、さらに、独自のSelectorを実装する箇所が簡潔になりました。
Stream APIやラムダ式をうまく利用することで、Strategyパターンのような実装もより簡潔に記述できることが分かりました。また、その実装もうまく工夫することで、コードがどんどん簡潔になっていく様子が分かっていただけたと思います。
Stream APIとラムダ式と、これからのJava
この記事では、Stream APIとラムダ式に焦点を当てて、基本的な文法や使い方、またそれらを使うことでコードが大きく変わっていく様子を紹介してきました。今回は記述が簡潔になることを主なメリットとして紹介をしてきましたが、Stream APIやラムダ式のメリットはそれだけではありません。
この章では、Stream APIやラムダ式といった関数型の考え方がなぜ重要なのか、また今後のJavaを取り巻く環境にどう影響するのかについて説明したいと思います。コードはほとんど出てきませんので、読み物として楽しんでください。
Stream APIと並列処理
Stream APIやラムダ式などの関数型の考え方を踏まえる大きなメリットとして、並列処理を行いやすくなることが挙げられます。
Stream APIでは、Streamに対してparallelメソッドを挟むことで、Streamの処理が複数スレッドで処理されるようになり、マルチコアのCPUを効率よく使って処理されるようになります。例えば、次のようなコードです。
List<Integer> numbers = List.of(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); Double result = numbers.stream() .parallel() .filter(number -> number > 0) .collect(Collectors.averagingInt(Integer::intValue)); System.out.println(result);
出力結果は次のようになります。
4.125
処理結果は何も変わりませんが、処理そのものがマルチスレッドとなります。このコードのようにデータ量が少ないとメリットはないというか、むしろオーバーヘッドにより処理が遅くなりかねませんが、データの件数が数十万、数百万となってくれば、マルチスレッドで処理することで処理時間を短縮することができます。
もちろん、Stream APIなどを使わないJavaの標準的な書き方でもマルチスレッドの処理は行えます。しかし、Stream APIやラムダ式を使うことで安全性が大きく変わります。
本文中でも少し触れたことですが、Stream APIやラムダ式のような関数型のコードでは、基本的に副作用が起きないようにコードを記述します。もしも副作用がある場合、副作用する先の外部オブジェクトがマルチスレッドで処理されることを想定していなければ(スレッドセーフでなければ)、並列実行した際に問題が起きかねません。しかし関数型の作法に従って副作用のないコードにしていれば、並列実行しても目的の結果を得ることができます。
つまり、これまでのJavaのコードの書き方でマルチスレッド処理を記述する際には、どの部分がマルチスレッドになり、どのオブジェクトに作用するか、またそのオブジェクトがスレッドセーフかどうかを慎重に確認する必要がありました。しかし、関数型の作法であれば、ほとんどの状況でスレッドセーフかどうかを気にしなくてよくなります。
この記事の冒頭でも少し触れたとおり、コンピューティングの進化に伴ってCPUのコア数が増え、並列処理を行う重要度も高まりました。そのような背景の中で、並列処理と相性のよい関数型言語の注目度が高まったのです。それこそがJavaにStream APIやラムダ式が導入された理由であり、これらを学ぶべき理由なのです。
(※ただし単純に関数型はカッコイイからJavaに取り入れられた、という説があるのは確かです!)
Stream APIとリアクティブプログラミング
今後のトレンドのひとつとして、リアクティブプログラミングが挙げられます。リアクティブプログラミングとは、非同期に変化し続けるデータに対して処理を記述するようなプログラミングの手法です。IoTやビッグデータなどに代表されるような大量のリクエストや大量のデータを、より少ないコンピュータリソースで処理するための手法として注目されています。
誤解を恐れずに言えば、Stream APIは局所的に関数型を用いて記述するようなプログラミング手法でしたが、リアクティブプログラミングになると処理全体を関数型で記述する、あるいは複数のサーバ間をまたがったような処理でも関数型で記述するようなプログラミング手法となります。
例として、Javaでリアクティブプログラミングを行うためのライブラリとしてよく使われる、ReactorのAPIを見てみましょう。次のURLは、ReactorのFluxという、ちょうどStreamのリアクティブプログラミング版のようなクラスのAPIです。
Flux (Reactor Core 3.2.8.RELEASE)
膨大な数のAPIがありますが、mapやcollectなど、Stream APIと似たようなメソッドもいくつかあります。
実際に、ReactorではStream APIを使う時と似たようなコードになります。次のコードは、Spring WebFluxというフレームワークとReactorを用いて、Webのリクエストを行う処理です。処理の詳細は気にせず、コードだけ眺めてください。
Flux<Student> students = getStudents(); Flux<StudentScore> studentScore = students.flatMap(student -> webClient.get() .uri("localhost:8081/scores/" + student.id) .retrieve() .bodyToFlux(Score.class) .collectList() .map(scores -> new StudentScore(student, scores)));
なんとなくStream APIと似た処理を、ラムダ式を用いて記述していることが分かります。つまり、リアクティブプログラミングのライブラリであるReactorを習得する際に、前もってStream APIやラムダ式を習得しておくと有利だと言えます。
今はまだ、リアクティブプログラミングを用いるような機会は多くないのかもしれませんが、このままコンピューティングが順調に進歩し、扱うデータやリクエストが増加し続けると、間違いなく必要となる技術の一つだと筆者は予想しています。その時代に向けて、いまから関数型の考え方を学び、Stream APIやラムダ式を習得することは大事だと考えています。
なお、上記のコードは次の発表資料からの抜粋です。Spring WebFluxとReactorを使ったリアクティブプログラミングについてもう少し知りたい方は、ぜひ読んでください。
業務で使いたいWebFluxによるReactiveプログラミング / Introduction to Reactive Programming using Spring WebFlux - Speaker Deck
ますます進化が早まるJava
ところで、Javaのリリースはこれまで数年おきでしたが、Java9以降は半年ごとにバージョンアップすることになりました。この記事の執筆時点では、2019年3月にリリースされたJava12が最新です。
リリースのサイクルが早まることで、これまでのように数年ごとにたくさんの新機能がまとめて提供されていた状況から、半年ごとに細かな新機能が追加されることとなります。そのおかげで、新しい文法やAPIが積極的に開発されるようになり、今後も期待できる言語仕様などが検討されています。
このようにJavaの進化が早まっているため、追加される新機能を数年おきにまとめて勉強するのではなく、継続的な学習によってキャッチアップする方がよいと筆者は考えています。
世の中を取り巻く状況が変わり、コンピューティングが進歩する中で、Javaもその他のプログラミング言語も、また技術トレンドやアーキテクチャも次々に変わっていきます。その波にしっかり乗り切ることこそが、エンジニアとしての苦労であり、楽しみでもあるのではないでしょうか。
それでは、よいJavaライフを!
回答
「ラムダ式の基本」のセクションで出題した復習テストの正解は、次のようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.sort((v1, v2) -> Math.abs(v1) - Math.abs(v2)); System.out.println(numbers);
Comparatorを匿名クラスとして実装していた部分を、ラムダ式に置き換えることができました。
実は、このコードはさらに簡略化することができます。ComparatorにはcomparingIntというメソッドがあり、引数で渡されたラムダ式を実行した結果を昇順に並べるためのComparatorを返します。このComparator.comparingIntメソッドを用いて、処理を記述してみましょう。
正解は、次のようになります。
List<Integer> numbers = Arrays.asList(3, 1, -4, 1, -5, 9, -2, 6, 5, 3, 5); numbers.sort(Comparator.comparingInt(Math::abs)); System.out.println(numbers);
comparingIntに渡す関数は引数が一つであり、今回はMath.absメソッドを呼び出すのみでしたから、メソッド参照の記法が使えるのです。