ラムダ式とStream APIで学ぶモダンJava ― 関数型を取り入れて変化するJava言語の現在

20年以上の歴史を持つJava言語ですが、近年は関数型を取り入れるなど大きく変化し、リリースサイクルも格段に短くなってますます進化しています。モダンなJavaプログラミングで必要となるラムダ式とStream APIについて、谷本心(cero_t)さんによる詳細な解説です。

ラムダ式とStream APIで学ぶモダンJava ― 関数型を取り入れて変化するJava言語の現在

1996年にJava 1.0が登場して、もう20年以上がたちました。この間、Javaにはさまざまな言語機能やAPIが追加され、変化し続けています。

これだけ長い歴史を持つプログラミング言語ですから、利用者が多かったり、フレームワークやライブラリが充実していたりする一方で、書籍やWebに掲載されている情報が少し古かったり、研修で学ぶJavaが最新の動向を踏まえていなかったりするなど、長い歴史を持つが故の問題もあります。

特に、近年のJava8で登場したラムダ式Stream APIという関数型を踏まえた新機能は、これまでのJavaの書き方を一変させる大きな変化となりました。また、Java9以降のリリースモデルの変更は、JDKのオープンソース化以来となる大きな変化をもたらしました。

このような動向を踏まえて、モダンな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が、Integerの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文を使うのも方法の一つとなるでしょう。

中間操作を複数呼び出して目的の結果を得る

エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く