2024/02/02

なぜあなたはラムダ式が苦手と感じるのか

このエントリーをはてなブックマークに追加

今年もブリ会議で講演してきました。例年、Javaの新しい機能などについて話すことが多かったのですが、今年はProject Lambdaから10年ということもあり、あらためてラムダ式についての話です。

2部構成で前半が初心者向けの「なぜあなたはラムダ式を苦手と感じるのか」というラムダ式を使う立場での話。後半はぐっと難易度があがって、「ラムダ式はどうやって動くのか」という内容です。

このエントリーはブリ会議で話した内容の前半部分の解説です。資料はこちら。


ラムダ式が導入されたのは2014年、Java 8の時です。今年でちょうど10年ですね。

10年もたったのですから、ラムダ式を当たり前のように使っている開発者も多いとは思いますが、いまだに苦手と感じている方がいらっしゃるのも事実だと思います。

ラムダ式が導入後にJavaを使い始めた方でも苦手という方がいるんですよね。

ラムダ式は、Javaで関数型プログラミングの考え方が導入された端緒です。ラムダ式と一緒に導入されたStream APIをはじめ、今では関数型プログラミングに関する機能がJavaではどんどん増えています。

たとえば、言語仕様では

  • switch式
  • Record
  • Sealed Class
  • パターンマッチング

など。RecordとSealed Classは、両方を組み合わせて代数的データ型 (Algebraic Data Type, ADT) を構成するのに使われます。

標準のAPIだと、次のようなAPIがあります。

  • Stream API
  • Flow (Reactive Stream)
  • HTTP Client

一番使われているのはもちろんStream APIですね。HTTP Clientは、Java 11で導入されましたが、Flowを使用して宣言的にHTTPのクライアントを記述します。

標準API以外でも、Spring WebFluxや、Oracle Helidon、Red HataのRed Hat Quarkusなど関数型プログラミングの考えを使うライブラリやフレームワークも増えています。

つまり、これからはJavaを使っていても関数型プログラミングからは逃れられなくなっているのです。

関数型プログラミングの考えを取り入れたプログラミングスタイル、つまり今までの手続き的な記述から宣言的な記述に変えていかなくてはなりません。

さらにいうと、今までのJavaはどうしても文で考えがちなのですが、そうではなく式を使って記述することを意識していきたいのです。


ラムダ式とは

さて、そのラムダ式ですが、端的にいえば関数です。名前はないので、無名関数ということができます。

無名関数といっても、メソッドと同じようなものです。Java Language Specificationのラムダ式の説明の一番はじめには、次のような記述があります。

A lambda expression is like a method: it provides a list of formal parameters and a body - an expression or block - expressed in terms of those parameters. (JLS 15.27)

つまり、メソッドが書ければラムダ式は書けるはずなのです。

もちろん、ラムダ式は通常のメソッドとは異なり、クラスに属していなかったり、ラムダ式の外側のクラスの状態にアクセスすることも制限されていたりするので、メソッドと同じというわけではありません。

しかし、それは今までの手続き的な記述にとらわれてしまっているからではないでしょうか。


手続き的な記述の欠点

手続き的な記述には読みにくいポイントや、バグを発生しやすくしてしまうポイントがあります。

代表的なポイントを以下に示しました。


1点目の複数の処理を一緒に書けてしまうというのは、処理を十分に分解せずにまとめて書きがちだということです。

残りの2点は変数に関することです。手続き的な記述だと、どうしてもy処理の途中経過を保持するなどの変数が使われます。途中経過を保持させるので、ミュータブルになってしまうわけです。

また、途中経過を保持させて後に、最終的な結果を得るためには別のスコープに入ることがあり、スコープが広くなってしまいがちです。

言葉で書いても分かりにくいと思うので、具体的なコードで見てみましょう。

たとえば、A組の生徒の平均値を算出することを考えてみます。

生徒の成績は次のRecordで保持しているとします(通常のクラスでもいいのですが、こういうデータを保持させるにはイミュータブルなRecordの方が適しています)。

    record StudentScore(
            String name,
            String className,
            int score) {}

StudentScoreでは生徒の名前と、クラス、成績を保持させています。

平均値を求めるメソッドを次に示します。

    double calcAverage(List<StudentScore> scores) {
        double sum = 0.0;
        int count = 0;
 
        for (int i = 0; i < scores.size(); i++) {
            var ss = scores.get(i);
            if (ss.className().equals("A")) {
                sum += ss.score();
                count++;
            }
        }

        sum /= count;

        return sum;
    }

一見、よさげに見えますが、何が問題なのでしょう。

まず、ローカル変数のsumとcountです。この2つの変数はいずれもループでの中間値を保持させるためにミュータブルになっています。

また、ループが終わった後の最終的な結果を求めるために、ループの外側でも変数を使用します。このため、変数のスコープがメソッド全体になってしまっています。

ミュータブルだと意図しない値の変更がされる可能性があります。このメソッドは行数が少ないからよいですが、行数が多いメソッドを複数人で編集していたりすると意図しない変更が起こりがちです。

スコープが広い変数だとなおさらです。

たとえば、このメソッドでは最後にsumをcountで割って平均値を求めていますが、変数sumはループの中では合計値を保持させています。合計値を保持させる変数であるのに、最後に合計値ではない値を代入しているわけです。

平均値を代入した後に、他の人が合計値だと思って変数sumを使ってしまうとバグが発生してしまいます。


そして、ループです。

ループというか、繰り返し処理ってやっぱり分かりにくいと思うんですよ。

慣れてしまえばパターンとして覚えてしまうので、パッと書けるとは思いますが、初心者には難しい。

ここでのたった7行のループで、ループの制御、値の取り出し、比較、合計処理、カウンターのインクリメントまでやっています。特にループカウンターを使ったループの制御は本来の平均を求める処理とは別個のものなので、一緒にしてしまうと分かりにくくなってしまいます。

ここでは普通のfor文で書きましたが、for-each (拡張for文)で書いたとしても本質的な難しさは変わりません。

ループの制御はコンテナ側に任せ、ループで扱うデータに対し何を行っていくのかを分解して考えることで分かりやすさ、読みやすさは格段に向上します。

これが宣言的に記述するということにつながります。

for文という文ではなく、式で処理を連ねていくわけです。


宣言的な記述

先ほどの平均値を求めるメソッドを宣言的に記述したのが以下のコードです。

    double calcAverage(List<StudentScore> scores) {
        final var ave = scores.stream()
                              .filter(ss -> ss.className().equals("A"))
                              .collect(Collectors.averagingDouble(ss -> ss.score()));
        
        return ave;
    }

この記述には、return以外は文が使われておらず、式で処理を記述しています。

そして、ローカル変数のaveはイミュータブルな変数になります。

Stream APIを使ったループはいわゆる内部イテレータになり、ループの制御はStream APIが行います。Stream APIを使う側は、データをどのように処理するかだけに集中し、それをラムダ式で記述します。

行っている処理自体は手続き的に記述しても、宣言的に記述しても同じです。

しかし、Stream APIを使うことで、条件、値の取り出しなどの処理をそれぞれ1つのラムダ式で記述することで、処理の流れが分かりやすくなります。

とはいっても、今まで手続き的な記述しかしてこなかった方には、宣言的な記述はとっつきにくい感じを受けてしまうのはしかたないと思います。

だからといって、今までの手続き的な記述に固執していたら、どんどん増えている宣言的スタイルのライブラリやフレームワークを使えなくなってしまいます。

いつかは宣言的な考え方をしなくてはいけないのであれば、今がそのチャンスです。


重要なのはシグネチャー

前述したように、ラムダ式は関数として扱うのが自然です。

しかし、関数型インタフェースがとかを考え始めてしまうと、なかなかとっつきにくくなります。

Javaにも関数型があればこんなことに悩む必要はないのですが、残念ながらJavaでは関数型はありません。しかたないので、関数型インタフェースなんてものを持ち出したわけです。

しかし、ラムダ式を関数と考えるのであれば、型はそれほど重要ではありません。

重要なのはシグネチャーです。つまり

  • 引数の個数と、その型
  • 戻り値の有無と、その型

が重要になります。引数や戻り値の型はジェネリクスの型パラメータで定義されるので、引数の個数や戻り値の有無から考えましょうということです。

たとえば、数値を保持しているリストをソートするには以下のように記述します(もっと簡単に書けますけど、説明のためこう書いてます)。

    List<Integer> nums = ...;

    var sortedNums = nums.stream()
                         .sorted((x1, x2) -> x1 - x2)
                         .toList();

sortedメソッドの引数のラムダ式で要素同士を比較して、ソートの並び順を決めています。

このラムダ式はComparator<S>インタフェースなのですが、実際にはBiFunction<S, S,  Integer>インタフェースだとしても、処理はまったく同じです。

つまり、sortedメソッドの引数にするラムダ式では2つの引数が渡されて、戻り値としてintで戻すということが重要になるわけです。


java.util.functionパッケージのインタフェース

ラムダ式は関数で、重要なのはシグネチャということですが、ではFunctionインタフェースなどのjava.util.functionパッケージで提供されている関数型インタフェースはどのように考えればよいのでしょう。

インタフェース自体の用途などは気にせずに、シグネチャーを区別するためのものと割り切ると理解しやすいです。

つまり、シグネチャーが

  • 引数なし、戻り値ありならば Supplier<T>
  • 引数が1つ、戻り値ありならば Function<T, R>
  • 引数が1つ、戻り値がbooleanならば Predicate<T>
  • 引数が1つ、戻り値はなしならば Consumer<T>

のように考えるわけです。

たとえば、Streamインタフェースのmapメソッドの引数はFunctionインタフェースですが、その場合は引数が1つで、戻り値ありのラムダ式を書けばいいのだなと分かるわけです。


ちなみに、ラムダ式を関数型インタフェースの匿名クラスの延長として考えてしまうのは危険です。それだと、いつまでたっても手続き的な考え方に執着してしまいます(実際に動作としても匿名クラスとラムダ式はまったく異なるのですが、それはブリ会議の後半のエントリーで説明します)。

そんな変なことを考えずに、ラムダ式は関数として考えましょう。そして、ラムダ式を書く時にはシグネチャーから処理を記述していきましょう。


ラムダ式を書く時のTips

ここまでラムダ式は関数として扱いましょうということを説明してきたわけですが、では実際にラムダ式を書く時にどうすればよいでしょう。

ラムダ式は単独で使うということはほぼなく、ほとんどがライブラリやフレームワークのメソッドの引数として使います。

ということはラムダ式を単体で考えるのではなく、ライブラリやフレームワークと合わせて一緒に考えればよいということです。

ちなみに、メソッドの引数や戻り値に関数を使用することを高階関数と呼びます。名前はどうでもいいのですが、メソッドの引数にラムダ式というのがラムダ式の主流の使い方ということです。

Project LambdaのスペックリードのBrian GoetzはJava Magazineのインタビューで次のように語っています。

Project Lambdaは単なる言語機能ではなく、ライブラリも対象としています。言語機能とライブラリが一体となって、Javaプログラミング・モデルを大幅にアップグレードします。 (Project Lambdaの展望, Java Magazine Oct. 2012)

ラムダ式を策定したProject Lambdaではラムダ式とStream APIを合わせて策定しています。

このようにラムダ式単独ではなく、ライブラリやフレームワークと一緒に使うことを前提にラムダ式を考えていきましょう。


次の処理は1つだけというのは、ラムダ式はなるべくシンプルにしましょうということです。ラムダ式のボディにいろいろ処理を書いてしまうというのは、処理を正しく分解できていないということにつながり、どうしてもラムダ式のボディが手続き的になってしまいます。

処理を分解して、1つのラムダ式には単純な処理を記述し、それを連ねていくというスタイルに変えていきましょう。


3つめはラムダ式で扱うデータをなるべく引数だけにするということです。

ラムダ式は、ラムダ式が定義されている外側のクラスのフィールドなどにもアクセスできますが、それは可読性の低下につながります。もし、外部のデータにアクセスするのであれば、定数などに限定しましょう。


4つめの処理結果を戻り値以外で戻さないというのは、3つめの外部のデータにアクセスしないにも通じます。関数なのですから、結果は戻り値だけです。


最後はラムダ式だけでなく、宣言的な記述全般にいえることですが、変数はイミュータブルにしましょう。


まとめ

さて、前半のまとめです。

なんども書いていますが、ラムダ式は関数としてあつかい、宣言的な記述を行うために役立てるのがお勧めです。

匿名クラスの延長として考えてしまうと、今までの手続き的な考えの枠内にとどまってしまい、宣言的な記述を書くことの妨げになってしまいます。

Javaは今までの手続き的な記述から、宣言的な記述にプログラミングスタイルが変わってきています。今後もこの流れは変わりません。

ですから、手続き的な考えでラムダ式を理解しようとすることはやめましょう。

宣言的な記述、文から式への移行を役立てるためにラムダ式が存在するのです。


最後に参考文献にあげた「なっとく! 関数型プログラミング」を紹介しておきます。

この本はJavaからScalaへの移行を解説した書籍です。

しかし、手続き的なJavaから、宣言的なJavaへ移行するにも役立つはずです。

トピックはだいたい1ページで収まっており、読みやすいのもいいです。

ただ、Kindleだと画像の解像度が低くて、ちょっと読みにくいのが欠点です。翔泳社さん、どうにかしてくれないですかねぇ。

www.amazon.co.jp


さて、ブリ会議で後半に解説したラムダ式がどのように動作するのかについては、次エントリーで紹介する予定です。


0 件のコメント: