検索
連載

AIに聞きながらJavaのモダンな関数型プログラミングを理解する――ラムダ式とStream APIAIアシスト時代のJavaプログラミング入門(13)

対話型AI(人工知能)にアドバイスを受けながら進めるJavaプログラミングの入門連載。今回は、コレクションに対して抽出や変換などの処理を直感的に行えるStream APIを、その前提となるラムダ式とともに学習します。ラムダ式とともにStream APIを使えるようになり、Javaにおける関数型指向のプログラミングを理解しましょう。

Share
Tweet
LINE
Hatena
「AIアシスト時代のJavaプログラミング入門」のインデックス

連載:AIアシスト時代のJavaプログラミング入門

本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


関数型(関数的)とは

 対話型AI(人工知能)にアドバイスを受けながら進めるJavaプログラミングの入門連載「AIアシスト時代のJavaプログラミング入門。最終回となる今回は、Javaにおけるモダンな関数型プログラミングです。具体的に言うと、第3回でキーワードだけ登場した「ラムダ式」と、第4回で同じくキーワードだけ登場した「Stream API」です。これらがどのように関数型に関わるのかを掘り下げていきますが、まずは言葉の意味としての「関数型」をAIに聞いておきましょう。

 Visual Studio Code(以降、VS Code)からGitHub Copilot(以降、Copilot)に、「関数型、関数的というのを最初に簡単に教えて。」と投げてみました(図1)。なお、モデルには今回もGPT-5 miniを使っていきます。

図1 関数型、関数的についての質問への回答
図1 関数型、関数的についての質問への回答

 これを読むと、「関数型」というのはパラダイムのことで、処理を関数として扱い、値としてやりとりできること、という意味のようです。処理(関数)を、整数値やクラスのインスタンスのように、変数に入れたりメソッドに渡したりできるということですね。そして「関数的」とはスタイルのことで、これをコーディングに持ち込んだもののようです。関数を組み合わせて処理を記述するとありますね。

 「Javaでの関係」に、ラムダやStreamについて書かれているので、これらについて掘り下げていく中で、「主な特徴」にある用語についてもひも付けていきましょう。

【補足】関数

 関数とは、プログラミングの世界ではメソッドのように引数を受け取って一定の処理を施し、結果として返すものをいいます(メソッドは関数の一種)。しかしながら、「関数型」「関数的」といった場合には、数学的な意味合いが強く、概念としての関数を表すことが多いようです。ここでは、プログラミング言語における関数ではなく、概念としてのものなのだと理解しておきましょう。

ラムダ式(lambda)とは

 まずは、ラムダ式を掘り下げていきます。第3回では、以下のように登場していました(図2)。

図2 Javaの条件分岐構文についての質問への回答(再掲)
図2 Javaの条件分岐構文についての質問への回答(再掲)

 この説明を読むと、「簡潔に」「関数的に」「モダンな」という字句が読み取れます。この時点で、「関数的」とか「モダン」が登場していたのですね。条件分岐についての質問で登場したので、「ラムダ式」は条件分岐で役立ちそうな機能なのは間違いのないところです。また、「式」とあるので、式のように使えるものであるもののようです。そこで、図2にあるコードの意味を説明してもらうところから始めましょう。

 コードは図2に既にあるので、この画像をコンテキストに指定し、「画像の一番上にあるコードの意味を簡単に説明して。」と投げてみました(図3)。

図3 コードの意味についての質問への回答
図3 コードの意味についての質問への回答

 箇条書きで4項目が示されました。1番目は学習済みの内容ですね。第3回で学習した条件式(?:)の内容です。注目は2番目で、「左右にある() -> System.out.println("...")」がラムダ式と示されています。ここでいう「左右」とは、条件式の「:」の左右のことと解釈できるので、2個ある「() -> System.out.println("...")」がラムダ式というわけです。今まで見たことのない書き方なので、これを掘り下げていけばラムダ式というものを理解できそうですね。

 ラムダ式はひとまず置いておいて、続く説明を読んでみます。同じく2番目で、「Runnableインタフェース(引数なしで実行する処理)を実装している」とあります。インタフェースの実装については、第7回で学習しましたね。ということは、ラムダ式は何らかのクラスということです。ラムダ式のクラスは、引数なしで実行する処理としてのRunnableインタフェースを実装しているということですね。

 さらに3番目と4番目の説明を読むと、ラムダ式は変数taskに入り、runメソッドで実行できるということです。これはつまり、

ラムダ式とは実行できる処理を表したクラスのインスタンス

と理解できますね。インスタンスなので、値として渡したり、返したりすることができるという、関数型の特徴を持つわけです。

ラムダ式の構文

 ここで、気になるラムダ式の構文を説明してもらいましょう。実行できる処理という説明を受けると、「() -> System.out.println("...")」の意味も何となく分かりそうです。そうです、第5回で学習したメソッド定義の構文に似ています。これも踏まえて、ラムダ式の構文を説明してもらいましょう。「ラムダ式の部分の構文について簡単に説明して。」と投げてみました(図4)。

図4 ラムダ式の構文についての質問への回答
図4 ラムダ式の構文についての質問への回答

 最初から細かく説明されている感じなので、まずは5番目の「例の意味」に着目しましょう。

・Runnableのrun()を実装する無名関数(引数なし・戻り値なし)で、task.run()で実行される

 これは、既に説明されている内容ですが、「無名関数」というものが登場します。どうやら、ラムダ式とは無名関数というもののようで、この例の場合は引数もない、戻り値もない関数ということです。

 ここで、図4の説明の最初の「構文の基本」に戻りましょう。これによると、ラムダ式は以下のような構文の式ということです。

(引数リスト) -> 式
(引数リスト) -> { 文; ... }

 例では、前者が該当します。ラムダ式では、(...)で引数を指定し、「->」の次に式や文を記述することで、名前のない関数を表すものということです。名前がないので、メソッドのように呼び出すことはできず、Runnableインタフェースのインスタンスとして、間接的に呼び出すということですね。

図5 ラムダ式
図5 ラムダ式

ラムダ式の用途

 ところで、このラムダ式はどのように役に立つのでしょうか。図2のコードでは、特にラムダ式にしなければならない理由はなさそうに見えます。なぜなら、以下のように記述すればいいからです。

if (10 > 5) {
  System.out.println("条件が真");
} else {
  System.out.println("条件が偽");
}

 それでも、あえてRunnableインタフェースやrunメソッドといったものを使うからには、ラムダ式には大きなメリットがあると想像できます。そこで、「ラムダ式はどのようなときに役立つの?」と投げてみました(図6)。

図6 ラムダ式の意義についての質問への回答
図6 ラムダ式の意義についての質問への回答

 ラムダ式を使う場面が幾つか提示され、例も2つ提示されました。場面の説明も、「匿名クラスの簡略化」「コールバック/イベント処理」「コレクション操作(Streams)」などと、この時点では意味が分からないものがほとんどですね。例を見てみると、「Runnable」の方は既に見たものそのままです。これが、「匿名クラスの簡略化」に相当しそうです。ということで、そもそも「匿名クラス」とは何者かを理解する必要がありそうです。「匿名クラスとは何でしょうか。ラムダ式との関係も教えてください。」と投げてみました(図7)。

図7 匿名クラスとラムダ式の関係についての質問への回答
図7 匿名クラスとラムダ式の関係についての質問への回答

 匿名クラスとは、

名前を持たないクラス定義をその場で作り、インスタンス化する構文。主にインタフェース実装や抽象クラスの実装で使う

とあります。通常、クラスには名前を付けるものですが、名前を持たないクラス定義を作ってインスタンス化することが匿名クラスというわけですね。そして、クラス定義の基がインタフェースや抽象クラスというわけです。インタフェースからその場でクラスを作って、しかもインスタンス化までしてしまうのが匿名クラスということです。

 図7では、匿名クラスとしてRunnableインタフェースの例が示されています。1行で書かれていて分かりにくいので、成形したものが以下のリストです。

Runnable r = new Runnable() {
  public void run() {
    System.out.println("run");
  }
};

 匿名クラスでは、インスタンス化においてnewを使うのは通常のクラスと変わりませんが、クラス名の代わりにインタフェース名を指定します。そして、インタフェースのメソッドをブロック「{ ... }」内に記述します。Runnableインタフェースでは、runメソッドを定義するということですね。

 これを使って、図2にあるコードを書き直してみると、以下のようになりました。

Runnable r1 = new Runnable() {
  public void run() {
    System.out.println("条件が真");
  }
};
Runnable r2 = new Runnable() {
  public void run() {
    System.out.println("条件が偽");
  }
};
Runnable r = (10 > 5)? r1:r2;
r.run();

 基のコードと比較すると、コード量が明らかに多く読みづらいですし、ミスの混入も増えそうです。そこで、図7の「ラムダ式との関係」にあるように、

ラムダは「関数型インタフェース(一つの抽象メソッド)」を簡潔に実装するための構文糖。…

であるところのラムダ式が有用になってくるというわけです。なお、「構文糖」とは第12回で学習した「記法的糖衣」と同じ意味で、長い複雑な構文を短く楽に書けるようにするための仕組みです。関数型インタフェースを使うときにいちいち匿名クラスの定義とインスタンス化を記述するのではなく、ラムダ式で簡単にしてしまうことで、次で学習するStreamでもシンプルな記述でコレクションを操作できるようになります。

こんな質問をしてみたい!

 最初、「ラムダ式」と聞いたときに、このように思うのではないでしょうか。「なぜラムダ? アルファやガンマじゃないのはなぜ?」ということで、そもそもラムダ式がどこから発祥したのか聞いてみると、関数的という意味がさらに深く理解できるかもしれませんよ。

Stream(ストリーム)とは

 次はStreamです。図6で「コレクション操作」とあるので、Streamがコレクションに関係したものであるということは分かります。そしてコレクションについては第8回で学習しました。ジェネリクスを利用したユーティリティーで、リストやセット、マップというものがありましたね。また、第4回では「関数型でコレクションを処理」するものとして登場しました。

 では、Streamによるコレクション操作とは、どのようなものでしょうか? 「Streamを使うと、コレクションでどのような操作が可能になりますか?」と投げてみました(図8)。

図8 Streamによるコレクション操作についての質問への回答
図8 Streamによるコレクション操作についての質問への回答

 たくさんの操作が列挙されました。微に入り細に入りさまざまな「変換・絞り込み・集約」操作を、「パイプライン・並列処理」可能ということですね。具体的な操作の例については、この後でサンプルを通じて見ていきましょう。

Streamとラムダ式

 Streamでラムダ式がどう関わってくるのか知るために、「例にあるStreamsのコードでラムダ式がどう関わっているのか説明して。」と投げてみました(図9)。

図9 Streamのサンプルコードについての質問への回答
図9 Streamのサンプルコードについての質問への回答

 コードに登場したfilterやforEachの説明がありますが、4番目の項目にまず注目しましょう。

・ラムダは「処理をその場で定義して引数として渡す」ために使われる:Streamの各ステップは処理のルール(ラムダ)を受け取り、流れに沿って適用する

 Streamの英単語としての意味は「流れ」であるので、この流れに沿って処理を適用するというのがStreamということです。そして、その処理というのがfilterやforEachで、図1にあった「高階関数」とはまさしくこれらを指すわけですね。

 それぞれ説明を見てみましょう。まずはfilterです。

・filterに渡しているs -> s.startsWith("A")はラムダ式で、型はPredicate<String>(引数1つを受け真偽を返す関数)。各要素が条件を満たすかどうかを判定する振る舞いを定義している

 s -> s.startsWith("A")というラムダ式が登場しています。この形は、図4の2番目に相当しますね。「引数:引数がなければ()(例:())。引数が1つで型を省略する場合はカッコも省略可」とありました。このラムダ式の引数にはカッコが付いてないことから分かるように、引数が1つだけなら省略できるようです。また、型も省略できるようです。これはまさしく、第2回で学習した「型の推論」ですね。そして、ラムダ式の処理内容は、startWithメソッドの呼び出しです。このメソッドは、文字列が"A"で始まるかを判定するというものです。

 ラムダ式の説明を振り返ると、Runnableなどのインタフェースを実装するとありました。ここでは「ラムダはPredicate<String>というインタフェース型で、引数が1つで真偽を返す」とはっきり書かれています(Predicateとは「断言する」といった意味です)。ラムダ式の姿がだいぶはっきりしてきましたが、続く説明も読んでみましょう。

・forEachに渡しているSystem.out::printlnはメソッド参照(ラムダの短縮形)で、型はConsumer<String>(引数1つを受け処理し戻り値はない)。各要素を出力する振る舞いを定義している

 「メソッド参照」という言葉が出てきました。これは、「ラムダの短縮形」とあるので、引数も書かない、メソッド呼び出しでもない、特殊な書き方のようです。初めて目にするダブルコロン「::」もありますしね! ラムダ式も十分シンプルなのですが、メソッド参照はそれをさらに突き進めたものです。ラムダ式のように関数型のインタフェースを扱う場面において、定義済みのメソッド名を記述するだけで引数も呼び出しも省略できます。例えば「s -> System.out.println(s)」では、ラムダ式の引数をメソッドの引数として渡しますが、この一連の処理をメソッド名のみの記述で済ますのがメソッド参照です。

 このラムダ式(メソッド参照)の処理内容としては、各要素を出力するためにprintlnメソッドを呼び出す、ということになります。ここではラムダ式のインタフェース型はConsumer<String>(引数1つを受け処理し戻り値はない)ということが分かりますね(Consumeとは「消費する」といった意味です)。

【補足】ラムダ式のインタフェース型

 このように、filterやforEachなどで受け取れるラムダ式のインタフェース型は決まっていて、それはメソッドの役割に準じたものと理解できます。ただ、ラムダ式のインタフェース型といってもイメージしにくいと思うので、既出のものも含めて主なものを表1にまとめてみました。他にも単項演算や二項演算を行うものなどいろいろあるので、AIに質問してもよいでしょう。

概要
Comparator<T> 2つのTを比較して順序を返す
Consumer<T> 引数を使った処理。Tはメソッドの引数の型。値は返さない
Function<T, R> 値を変換する。Tはメソッドの引数の型、Rは戻り値の型
Predicate<T> 値を判定する。Tはメソッドの引数の型。結果を真偽値で返す
Runnable 引数も戻り値もない処理
Supplier<T> 引数なしで戻り値の型Tを返す
表1 主なラムダ式のインタフェース型

 ここで整理すると、例のコードは以下のような「流れ」になります。

  1. コレクションからstreamメソッドでStreamの処理を開始する
  2. filterメソッドで、"A"で始まる文字列のみ抽出する
  3. forEachメソッドで、それらを出力する

 これが、「コレクション操作」なわけですね。大本のコレクションに、メソッドをラムダ式とともにつなげていくことで、さまざまな操作が可能になるわけです。これが図1で「関数的」とされたスタイルで、関数を組み合わせて処理を記述していく手法です。

Streamのコードの実行

 ここで、Streamの例のコードを実際に動かしてみましょう。Agentモードに切り替えて、「streamという名前のプロジェクトを作り、前の例にあるStreamsのコードを配置して。メソッド参照は使わないで。」と投げて一連の作業を実行してもらいました(図10)。メソッド参照を使わないように指示したのは、ラムダ式の理解を優先するためです。

図10 Streamを使うコードを作成した結果
図10 Streamを使うコードを作成した結果

 コードは既に見てきたので、コンパイルして実行してみます。README.mdファイルにあるコマンド例を参考にしてみました(図11)。

図11 Streamsを使うコードを実行した結果
図11 Streamsを使うコードを実行した結果

 出力として、「Alice」と「Amy」が得られています。このような出力となった流れを改めて確認してみます(図12)。

図12 Streamsのサンプルコードの説明
図12 Streamsのサンプルコードの説明
  1. コレクション(リスト)は、"Alice"、"Bob"、"Amy"、"Charlie"の4要素
  2. リストにstreamメソッドを呼び出してStream処理を開始する
  3. filterメソッドで、条件(この場合は"A"で始まる)に合致する要素のみ抽出
  4. forEachメソッドで、それらの要素を出力

 このように、ラムダ式のおかげでシンプルに記述でき、Streamによって処理をパイプラインのようにつなげていけるわけですね(図13)。

図13 Stream
図13 Stream

こんな質問をしてみたい!

 ここではfilterメソッドによる絞り込み、forEachメソッドによる出力を取り上げましたが、図8で示したように、Streamによるコレクション操作には多くの種類があります。他の操作方法を例とともに聞いてみると、ラムダ式やStreamへの理解が、より深まるかもしれませんよ!

まとめ

 今回は、ラムダ式とStreamを、AIに聞きながら学習しました。

 AIに聞きながらJavaを学習するという連載はいかがでしたでしょうか。質問の仕方一つで求める回答が得られなかったり、LLM(大規模言語モデル)によって回答の方向性に大きな差が出たりと、Javaの学習とは違った面でいろいろ興味深かったと思います。今後は、ソフトウェア開発におけるAIの関与はますます深いものになっていくでしょう。本連載が、ソフトウェアの学習においてもAIが非常に有用であることを感じていただけたなら幸いです。

筆者紹介

WINGSプロジェクト 山内直

WINGSプロジェクト所属のテクニカルライター。出版社秀和システムを経てフリーランスとして独立。ライター、編集者、開発者、講師業に従事。屋号は「たまデジ。」。

たまデジ。 | たまプラーザで生活、仕事する。(https://naosan.jp/

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
X: @WingsPro_info(https://x.com/WingsPro_info
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

ページトップに戻る