7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

4.1 ラムダ式と関数型インタフェースの仕組み~新Java Advanced編 / AI対応版

7
Last updated at Posted at 2023-02-04

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、約30年にわたって企業システムの開発に携わってきました。現在も現役のアーキテクトとして、重要システムの技術設計やソリューションの選定を担当しています。直近では、システム開発へのAI適用(AI駆動開発)という全社的な施策でリーダーを務めています。
セミナーでの登壇や雑誌への技術記事の寄稿を行ってきたほか、以下の書籍も執筆しています。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『新 Java Advanced編 / AI対応版』の一部の範囲をカバーしたものです。『新 Java Advanced編 / AI対応版』はこちらのリンクから購入できます。

この講座は、以下のような皆様にお薦めします。

  • Javaの基礎を学び終え、設計とレビューができるエンジニアを目指す方
  • AIが書いた並行処理や入出力のコードに潜むリスクを、仕組みから見抜けるようになりたい方
  • フレームワークの裏で使われるリフレクションやアノテーションの仕組みを理解したい方
  • 「Oracle認定Javaプログラマ」Goldの範囲を、仕組みから理解したい方

AIとチャットできるオールインワンの学習環境をリリースしました

Udemy講座の演習を動かすための学習環境として、Windows向けのデスクトップアプリ「Codinable(コーディナブル)」をリリースしました。
エディタ、実行結果、テスト結果、SQL、Webブラウザ、そしてAIチャットを1つの画面にまとめた、オールインワンの学習環境です。

Codinableの画面構成

学習に必要なものは、すべてCodinableの中で完結します。

  • 演習の実行環境(Java 25、Gradle、Bashなど)が一式同梱されており、JDKのインストールや環境変数の設定は不要です
  • インストールして「新規コース開始・再開」からコースを選ぶだけで、講座のすべての演習がそのまま動きます
  • 書き換えて動かなくなっても、「初期化」で配布時の状態に戻せます
  • 開いている演習のファイルと実行結果を踏まえて、AIに質問したり(Askモード)、ファイルの書き換えを指示したり(Agentモード)できます

AIに書き換えを指示し、差分を確かめてから実行する、というAI駆動開発の基本の流れを、Codinableの中だけで安全に体験できます。

AIチャットに書き換えを指示し、差分を確かめて実行する

AIチャットは、受講者ご自身が生成AIサービス(Anthropic、OpenAI、Googleのいずれか)のAPIキーを登録して使うBYOK(Bring Your Own Key)方式です。利用料金は各社から直接請求されます。APIキーを登録しなくても、AIチャット以外の機能はすべて使えます。

Codinableのインストーラは、以下のGitHubのReleasesページからダウンロードできます(64ビット版のWindows 10/11、管理者権限は不要)。

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

4.1 ラムダ式と関数型インタフェースの仕組み

チャプターの概要

本チャプターでは、ラムダ式が「関数型インタフェースを実装したオブジェクト」として成り立つ仕組みを取り上げます。AIエージェントが書くJavaのコードには、ラムダ式やメソッド参照が当たり前のように現れます。短く書けるぶん、いつ実行されるのか、外側のどの値を閉じ込めているのか、どの型として扱われているのかが、コードの見た目からは読み取りにくくなります。

本チャプターでは、ターゲット型による決定、処理を引数で渡す設計、遅延実行とクロージャ(実質的final)、メソッド参照の評価のタイミングを図で整理し、AIが書いたラムダ式をレビューする観点を身に付けます。ここで学ぶ内容は、チャプター4.2のストリームAPIの前提になります。

4.1.1 ラムダ式が成り立つ仕組み

本レッスンでは、ラムダ式がどのインタフェースのどのメソッドを実装したものなのかを、コードから特定できるようになります。

ラムダ式は関数型インタフェースの実装

ラムダ式は、引数や戻り値として受け渡しができるコードブロックです。その実体は、関数型インタフェース(単一の抽象メソッドを持つインタフェース)を実装したオブジェクトです。文字列を長さの順にソートするコードを、匿名クラスとラムダ式で比べます。

  • 04_lambda_stream: pro.kensait.java.advanced.lsn_4_1_2.comparator.Main_3
List<String> strList = Arrays.asList("Japan", "USA", "France");
Collections.sort(strList, (s1, s2) -> { //【1】
    return s1.length() - s2.length();
});
  • 【1】sort()メソッドの第2引数にラムダ式を渡しています。匿名クラスで書いたMain_2と同じ動きをし、[USA, Japan, France]の順に並びます。
  • 構文は(引数) -> { 本体 }です。引数の型は推論されるため省略でき、本体が1つの式なら{ }とreturnも省略できます((s1, s2) -> s1.length() - s2.length())。

匿名クラスとラムダ式で、どの部分が省略されたのかを示します。

匿名クラスとラムダ式の対応

  • 匿名クラスのnew Comparator<String>()は、Collections.sort()のシグネチャから自動的に決定される
  • public int compare(String s1, String s2)は、Comparatorが関数型インタフェースであることから自動的に決定される
  • 処理本体のreturn s1.length() - s2.length();は、匿名クラスとラムダ式で共通である
  • ラムダ式のコードは、引数(s1, s2)と処理本体だけになる

ターゲット型による決定

ラムダ式そのものには、型の名前が書かれていません。コンパイラは、ラムダ式を受け取る側(変数の型、メソッドの引数の型、戻り値の型)を見て、どの関数型インタフェースとして実装するかを決めます。この受け取る側の型をターゲット型と呼びます。

呼び出し元が渡したラムダ式が、呼び出し先の引数の型に当てはめられる様子を示します。

ラムダ式を引数として渡すときの呼び出し元と呼び出し先

  • 呼び出し先は、Collectionsクラスのsort()メソッドの宣言である
  • sort()メソッドは、第2引数Comparator<? super T> cを関数型インタフェースで受け取る
  • 呼び出し元は、Collections.sort()の第2引数にラムダ式を渡す
  • 渡したラムダ式が、引数cとして受け取られる

ターゲット型になれるのは、抽象メソッドが1つだけのインタフェースです。どのメソッドが数に入るのかを整理します。

関数型インタフェースの条件と数に入るメソッド

  • 中央はComparator<T>インタフェースで、中に4種類のメソッドが並ぶ
  • 抽象メソッドint compare(T o1, T o2)は「数に入る(1つ)」である
  • Objectのpublicメソッドと同じboolean equals(Object)は「数に入らない」である
  • デフォルトメソッドreversed()とスタティックメソッドcomparingInt()は「数に入らない」である
  • 数に入る抽象メソッドが1つなので、ラムダ式はそのcompare()の実装になる

インタフェースに@FunctionalInterfaceを付けると、抽象メソッドが2つ以上になったときにコンパイルエラーで検出できます。関数型インタフェースを自作する場合は、このアノテーションを付けるようにしてください。

なお、ラムダ式の中のthisは、匿名クラスと違って、ラムダ式を書いたクラスのインスタンスを指します。ラムダ式は新しいスコープを作らないため、外側と同じ名前のローカル変数を引数名に使うこともできません。

レビューの観点

  • ラムダ式がどの関数型インタフェースとして扱われているかを、受け取る側の宣言(ターゲット型)までたどって確かめる
  • 自作の関数型インタフェースに@FunctionalInterfaceが付いているかを確かめる
  • 匿名クラスをラムダ式に置き換えたとき、本体のthisの意味が変わっていないかを確かめる
  • コンパレータがs1.length() - s2.length()のような引き算になっていないか。大きな値では桁あふれするため、Integer.compare()やComparator.comparingInt(String::length)を使うのが安全である

4.1.2 処理を引数で渡す設計

本レッスンでは、関数型インタフェースを引数に取るAPIの設計を読み、標準の関数型インタフェースの選び方が適切かを判断できるようになります。

処理の差し替えによる共通化

足し算と引き算で、前後のチェックが同じ2つのメソッドがあるとします。違うのは「計算する部分」だけです。この部分を関数型インタフェースの引数にすると、1つのメソッドにまとめられます。

  • 04_lambda_stream: pro.kensait.java.advanced.lsn_4_1_3.reuse.Main_Function
public static void main(String[] args) {
    int answer1 = compute(30, 10, (x, y) -> x + y); //【3】足し算
    int answer2 = compute(30, 10, (x, y) -> x - y); //【4】引き算
}
static int compute(int x, int y, CalcFunction cf) { //【1】
    if (50 < x) {
        throw new RuntimeException("引数不正");
    }
    int value = cf.calc(x, y); //【2】
    if (100 < value) {
        throw new RuntimeException("計算結果不正");
    }
    return value;
}
  • 【1】compute()メソッドは、自作の関数型インタフェースCalcFunction(int calc(int x, int y)だけを持つ)を受け取ります。
  • 【2】計算の部分はcf.calc()の呼び出しに変わります。compute()は、どのような計算が行われるかを知りません。
  • 【3】足し算のラムダ式を渡すと40が返されます。
  • 【4】引き算のラムダ式を渡すと20が返されます。

共通の処理と、呼び出し元が差し替える処理の関係を示します。

関数型インタフェースによる処理の共通化

  • 呼び出し元は、足し算の(x, y) -> x + yか、引き算の(x, y) -> x - yをcompute()メソッドに渡す
  • compute()メソッドは、まず引数をチェックし、50 < xなら例外を送出する
  • 次にcf.calc(x, y)で、呼び出し元が差し替える処理を実行する
  • 最後に計算結果をチェックし、100 < valueなら例外を送出する
  • 戻り値は、計算結果の40や20である

標準の関数型インタフェースを選ぶ

CalcFunctionのような単純な形は、java.util.functionパッケージの標準の関数型インタフェースで表せます。たとえばCalcFunctionはBiFunction<Integer, Integer, Integer>(またはIntBinaryOperator)に置き換えられます。代表的なものを表にまとめます。

【表】Java SEが提供する主要な関数型インタフェース

関数型インタフェース 抽象メソッド 引数と戻り値 代表的な使われ方
Runnable void run() なし → なし スレッドのタスク
Supplier<T> T get() なし → T 値の遅延生成
Consumer<T> void accept(T) T → なし forEach()の処理
Function<T, R> R apply(T) T → R 変換(map())
Predicate<T> boolean test(T) T → boolean 条件(filter()、removeIf())
BiFunction<T, U, R> R apply(T, U) T, U → R 2つの値から結果を作る
UnaryOperator<T> T apply(T) T → T 同じ型への置き換え(replaceAll())
BinaryOperator<T> T apply(T, T) T, T → T 2つの値の合成(reduce())

Runnableはjava.langパッケージ、それ以外はjava.util.functionパッケージに属します。IntPredicateやToIntFunction<T>のように、プリミティブ型に特化したものもあり、ラッパークラスへの変換(ボクシング)を避けたい場合に使います。

どのインタフェースを選ぶかは、引数と戻り値の形で決まります。判断の流れを示します。

引数と戻り値の形による関数型インタフェースの選び方

  • 最初の分岐は「引数があるか」で、無ければ次に「戻り値があるか」を見て、無ければRunnable、あればSupplier<T>になる
  • 引数がある場合は「戻り値があるか」を見て、無ければConsumer<T>になる
  • 戻り値がbooleanならPredicate<T>になる
  • 戻り値が引数と同じ型ならUnaryOperator<T>、別の型ならFunction<T, R>になる
  • どれにも当てはまらない形(引数が3つ、検査例外を投げる 等)だけを自作の関数型インタフェースにする

検査例外とラムダ式

標準の関数型インタフェースの抽象メソッドは、throws句で検査例外を宣言していません。そのため、IOExceptionを投げる処理をそのままFunctionのラムダ式に書くと、コンパイルエラーになります。AIは、これを回避するためにラムダ式の中で例外を捕まえてRuntimeExceptionに包むコードをよく書きます。

検査例外がラムダ式の境界でどう扱われるかを示します。

検査例外を投げる処理をラムダ式に渡す3つの方法

  • 左は「そのまま書く」で、Function.apply()がIOExceptionを宣言していないためコンパイルエラーになる
  • 中央は「RuntimeExceptionに包む」で、コンパイルは通るが、呼び出し元は例外の種類で処理を分けにくくなる
  • 右は「UncheckedIOExceptionに包む」で、原因のIOExceptionをgetCause()で取り出せる
  • 3つに共通して、例外を握りつぶしてnullを返す書き方は避ける

レビューの観点

  • 標準の関数型インタフェースで表せる形なのに、同じ形のインタフェースを自作していないかを確かめる
  • 変換にConsumer、条件にFunction<T, Boolean>を使うなど、引数と戻り値の形に合わないインタフェースを選んでいないかを確かめる
  • ラムダ式の中で検査例外を握りつぶしたり、原因を失う形でRuntimeExceptionに包んだりしていないかを確かめる
  • List.of()やArrays.asList()で作ったリストにremoveIf()を呼んでいないか(どちらも要素数を変えられない。List.of()のリストは取り除く要素が無くても、Arrays.asList()のリストは取り除く要素があるとUnsupportedOperationExceptionになる。要素の置き換えのreplaceAll()は、Arrays.asList()のリストなら使える)

4.1.3 遅延実行とクロージャ

本レッスンでは、ラムダ式の本体が「いつ」実行され、そのとき外側の「どの値」を使うのかを判断できるようになります。

遅延実行

ラムダ式を渡した時点では、本体は実行されません。受け取った側が抽象メソッドを呼び出したときに初めて実行されます。この性質を遅延実行と呼びます。ログの出力で、メッセージの組み立てを遅らせる例を以下に示します。

  • 04_lambda_stream: pro.kensait.java.advanced.lsn_4_1_5.supplier.Main
public static void main(String[] args) {
    info(() -> "THIS IS INFO LOG."); //【1】
}
static void info(Supplier<String> s) {
    if (infoLevel) {
        System.out.println("[ INFO ] " + s.get()); //【2】
    }
}
  • 【1】文字列ではなく、「文字列を返すラムダ式」を渡します。
  • 【2】ログを出力する場合に限ってget()を呼び出すため、出力しないときはメッセージが組み立てられません。

文字列を渡す場合と、ラムダ式を渡す場合の違いを示します。

ログの出力における即時の生成と遅延実行

  • 文字列を渡す場合、呼び出し元はメッセージの文字列を生成してからinfo()メソッドに渡す
  • info()メソッドがログレベルを判定して出力しない場合、生成した文字列が無駄になる
  • ラムダ式を渡す場合、呼び出し元はメッセージを組み立てるラムダ式をinfo()メソッドに渡す
  • 出力する場合に限り、s.get()が呼び出されて文字列が生成され、ログが出力される
  • 出力しない場合、s.get()は呼ばれず、文字列は生成されない

遅延実行は、本体が「別の時点」、場合によっては「別のスレッド」で動くことを意味します。ThreadやExecutorService(チャプター3.1、3.2)に渡したラムダ式は、渡したメソッドが終わった後に別のスレッドで実行されます。

クロージャと実質的final

ラムダ式の本体からは、外側のローカル変数を参照できます。ラムダ式が作られた時点でその値がキャプチャされ、ラムダ式のオブジェクトの中に閉じ込められます。この仕組みをクロージャと呼びます。

  • 04_lambda_stream: pro.kensait.java.advanced.lsn_4_1_4.closure.Main
int base = 100; //【1】
CalcFunction cf = (x, y) -> {
    return base + x + y; //【2】
};
Processor p = new Processor();
p.process(cf); //【3】
  • 【1】ローカル変数baseを宣言します。
  • 【2】ラムダ式の中でbaseを参照します。この時点で値100がキャプチャされます。
  • 【3】Processorのprocess()メソッドの中でcf.calc(30, 10)が呼ばれ、main()の変数が見えない場所でも140が表示されます。

キャプチャされた値が、ラムダ式のオブジェクトとともに運ばれる様子を示します。

クロージャによるローカル変数の値のキャプチャ

  • main()メソッドには、ローカル変数base = 100と、baseを参照するラムダ式(x, y) -> base + x + yがある
  • ラムダ式から生成されたCalcFunctionインスタンスは、キャプチャした値base = 100を保持する
  • CalcFunctionインスタンスは、Processor.process()メソッドに渡される
  • process()メソッドでcf.calc(30, 10)を呼び出すと、100 + 30 + 10 = 140が計算され、140が表示される

ラムダ式から参照するローカル変数は、finalか、実質的にfinal(宣言後に値が変更されない)である必要があります。後からbase = 200;と代入し直すとコンパイルエラーになります。キャプチャしたのは「値のコピー」なので、元の変数が変わると両者が食い違うためです。

ただし、参照型の変数でキャプチャされるのは「参照」です。変数自体は実質的finalでも、参照先のオブジェクトの中身は変えられます。

プリミティブ型の値のキャプチャと参照のキャプチャ

  • 左はint base = 100のキャプチャで、ラムダ式のオブジェクトの中に値100のコピーが入る
  • 右はList<String> namesのキャプチャで、ラムダ式のオブジェクトの中には参照だけが入る
  • 右の参照は、ヒープ上の同じArrayListオブジェクトを、main()の変数namesとラムダ式の両方から指している
  • main()がラムダ式を作った後にnames.add("Carol")を実行すると、ラムダ式の実行時には"Carol"を含む中身が見える

実質的finalのすり抜け

AIは、ラムダ式の中で件数を数えたり、結果を集めたりするために、「要素1つの配列」やAtomicInteger、外側のArrayListを使うコードを書くことがあります。変数自体は実質的finalのままなのでコンパイルは通りますが、ラムダ式が外側の状態を書き換える副作用を持つことになります。

// 問題のあるコード:外側の状態をラムダ式の中で書き換える
int[] count = {0};
names.forEach(name -> {
    if (name.startsWith("A")) count[0]++;
});
// 良いコード(上のコードを置き換えた別の例):結果を戻り値として受け取る
long count = names.stream()
        .filter(name -> name.startsWith("A"))
        .count();
  • 問題のあるコードは、実質的finalの制約を配列ですり抜けています。このラムダ式を並列ストリームや別のスレッドで実行すると、更新が競合して件数が狂います。
  • 良いコードは、ラムダ式が何も書き換えず、結果をストリームの終端操作の戻り値として受け取ります(チャプター4.2参照)。

2つの書き方で、状態がどこにあるかを比べます。

外側の状態を書き換えるラムダ式と結果を返すラムダ式の比較

  • 上段は「問題のある設計」で、ラムダ式の外に配列countがあり、ラムダ式の中からcount[0]++で書き換える
  • 上段で複数のスレッドが同じcount[0]を同時に書き換えると、更新が失われる
  • 下段は「良い設計」で、filter()のラムダ式は判定結果のtrueかfalseを返すだけである
  • 下段では、件数はcount()の戻り値として呼び出し元に返る

レビューの観点

  • ラムダ式の本体がいつ、どのスレッドで実行されるのかを、受け取る側のAPIから確かめる
  • ラムダ式が、要素1つの配列、AtomicInteger、外側のコレクションなどを使って、外側の状態を書き換えていないかを確かめる
  • キャプチャした参照の先のオブジェクトが、ラムダ式の実行までに変更されることを想定しているかを確かめる
  • ログの出力など、出力しない場合が多い処理で、引数の文字列を毎回組み立てていないか(Supplierを受け取るAPIがあればそちらを使う)

4.1.4 メソッド参照

本レッスンでは、メソッド参照をラムダ式に読み替え、置き換えによって意味が変わっていないかを判断できるようになります。

メソッド参照の4つの形

メソッド参照は、ラムダ式の本体が「引数をそのまま渡すだけの単一のメソッド呼び出し」の場合に、それを短く書く記法です。クラス名::メソッド名または参照型変数名::メソッド名と書きます。引数を加工したり、条件分岐を含んだりする場合は、ラムダ式で書く必要があります。

【表】ラムダ式とメソッド参照の対応

形 メソッド参照の例 同じ意味のラムダ式
スタティックメソッド StaticCalculator::add (x, y) -> StaticCalculator.add(x, y)
引数のインスタンスメソッド User::isAdult user -> user.isAdult()
外側の変数のインスタンスメソッド printer::print msg -> printer.print(msg)
コンストラクタ参照 ArrayList::new () -> new ArrayList<>()

2つ目と3つ目は、どちらもインスタンスメソッドですが、メソッドを呼び出す対象が違います。対象がどこから来るのかを示します。

メソッド参照の4つの形と呼び出しの対象

  • 1つ目のStaticCalculator::addでは、ラムダ式の引数xとyが、スタティックメソッドadd(x, y)の引数にそのまま渡る
  • 2つ目のUser::isAdultでは、ラムダ式の第1引数userが、呼び出しの対象(user.isAdult()のuser)になる
  • 3つ目のprinter::printでは、外側の変数printerが呼び出しの対象になり、ラムダ式の引数msgはprint(msg)の引数に渡る
  • 4つ目のArrayList::newでは、呼び出すたびに新しいインスタンスが生成される

呼び出しの対象が決まるタイミング

参照型変数名::メソッド名の形では、呼び出しの対象が「メソッド参照を評価した時点」で決まります。ラムダ式は、本体が実行される時点で変数を参照します。この違いは、対象がnullのときに表れます。

  • 04_lambda_stream: pro.kensait.java.advanced.lsn_4_1_6.Main_5
Printer printer = new Printer();
// snippet_1:ラムダ式
Consumer<String> c1 = msg -> printer.print(msg);
c1.accept("Hello, World!");
// snippet_2:メソッド参照
Consumer<String> c2 = printer::print;
c2.accept("Hello, World!");
  • どちらもmessage => Hello, World!と表示され、ここでは同じ意味です。
  • 仮にprinterがnullだった場合、snippet_1はaccept()を呼び出した時点でNullPointerExceptionになります。snippet_2は、printer::printを評価した代入の行でNullPointerExceptionになります。

ラムダ式とメソッド参照で、対象の評価が起きる時点を比べます。

ラムダ式とメソッド参照で対象を評価する時点の比較

  • 横軸は時間で、「Consumerを作る」「別の場所へ渡す」「accept()を呼ぶ」の順に進む
  • 上段のラムダ式msg -> printer.print(msg)では、printerの参照は「accept()を呼ぶ」時点で使われる
  • 下段のメソッド参照printer::printでは、printerの参照は「Consumerを作る」時点で評価され、オブジェクトの中に保持される
  • 下段でprinterがnullなら、「Consumerを作る」時点でNullPointerExceptionになる

レビューの観点

  • メソッド参照をラムダ式に読み替えたとき、呼び出しの対象と引数の対応が意図どおりかを確かめる
  • 引数を加工する処理や条件分岐を、無理にメソッド参照へ寄せて読みにくくしていないかを確かめる
  • 参照型変数名::メソッド名で、対象の変数がnullになり得るときや、後から別のオブジェクトに差し替わることを想定しているときに、評価の時点の違いを考慮しているかを確かめる
  • 同名のメソッドがオーバーロードされている場合に、どのメソッドが選ばれたのかを関数型インタフェースの引数の型から確かめる

このチャプターで学んだこと

本チャプターでは、以下を取り上げました。

  1. ラムダ式は関数型インタフェースを実装したオブジェクトであり、どのインタフェースとして扱われるかは受け取る側の型(ターゲット型)で決まること
  2. 処理を関数型インタフェースの引数で受け取ると共通の処理を再利用でき、インタフェースは引数と戻り値の形で標準のものから選ぶこと
  3. ラムダ式の本体は抽象メソッドが呼ばれた時点で実行され(遅延実行)、外側のローカル変数は実質的finalの値または参照としてキャプチャされること
  4. 配列や外側のコレクションで実質的finalをすり抜けて状態を書き換えるコードは、並列実行で壊れるため、結果を戻り値で受け取る設計にすること
  5. メソッド参照の4つの形と、参照型変数名::メソッド名では呼び出しの対象が作成時に評価されること
7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?