よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

Javaのラムダ式を順序良く理解するための基礎

image.png

初めに

こちらは Java 二年目の自分が実務であまり使用したことのないラムダを理解するためにまとめた記事です。対象の読者は以下の様な方を想定しています。

  • Java 二年目付近の人
  • ラムダが何をしているのかわからない人
  • ラムダを用いることのメリットがわからない人

※本記事では Stream / Optional は扱いません。

ラムダは関数型言語の考え方であること。それを取り入れて匿名クラスの記述を簡略化したもの。そもそも匿名クラスの目的はなんなのか。などを順番に書いていきます。

1. ラムダとは

ラムダを語る上で本当は、マルチコアCPUやスレッドを使用した並列処理などの背景も知っておいた方がより理解が深まるのですが、こちらでは紹介しません。参考記事をご覧ください。登場も15年前ということも踏まえ、今とは異なる部分もありそうです。

参考:詳解 Java SE 8 第2回 ラムダ式

ラムダ式を簡潔に表すと ラムダ式は関数型インタフェースを実装した匿名クラスの簡易的な記述法」 です。

ラムダ式を理解するには、インナークラス・匿名クラスから理解していくのが解りやすいのですが、ここでは一旦大枠的な考え方について説明しています。

匿名クラスで行っていたインスタンス生成がラムダ式でできるようになりました。関数型言語では関数をオブジェクトとして変数に代入したりメソッドの引数や戻り値とすることができ、必要なときに関数の手続きを呼び出すことができます。匿名クラスはクラスの定義とインスタンス化を同時に行うことができるため、関数オブジェクトの代わりとして即席的なコールバックやプラガブルなストラテジを記述するような場合に良く使われていたりまします。

JavaC#のように関数型言語ではない言語も含め、あらゆるプログラミング言語関数型プログラミングをサポートしています。Javaでは匿名クラスの様に従来から関数オブジェクトと同じような機能を実現する手段はありましたが、Java8からはラムダ式を用いた形で関数型プログラミングの枠組みを整えた様です。

ラムダ式関数型言語の考え方や価値観・パラダイムを取り入れた文法です。関数型言語においての関数とJavaでいう関数、いわゆるメソッドでは意味が異なります。

関数型言語にとっての関数とは、自身以外の外部の関数に依存しない処理で、実行をしても他の関数に影響を与えない、副作用のない処理のことを示しています。副作用がない関数を純粋関数と呼びます。関数には副作用がないことが求められていて、そこがメソッドとは大きく異なる点です。この関数の特性を実現する為に導入されたのが「関数型インターフェイス」です。

関数型インターフェイスとは、実装すべきメソッドが1つしかないinterfaceのことです。1つのメソッドしか記述できない、そしてinterfaceなので状態を持てないという制約を課すことで、副作用のない関数を実装できるようにしたのです。

  • 余談
    インターフェイスがデフォルトメソッドを持てる様になったことで抽象クラスとの違いがかなりぼやけた様にみえますし、抽象クラスでしか出来なかったことがインターフェイスでも可能になりました(本来の使用目的は異なりますが)。ただし、決定的に違う点があります。それが「インターフェイスは状態を持たない」という点です。

  • 余談2
    ラムダの語源はギリシア文字の11番目(アルファベットでL)Λ(ラムダ)が由来とされています。   ラムダ計算という計算体系で関数を表現する式に文字ラムダ (λ) を使うという慣習があるとか。その他、電磁波などの波動の波長(波数)を表す文字がラムダと呼ばれていたりします。波長は、周波数と進行距離がわかれば求めることが出来ます。

2. インナークラスと匿名クラス

上記で匿名クラスについて説明した差異に「プラガブルなストラテジを記述する」と表現しました。デザインパターンの中には特定のオブジェクトにデータの操作を委譲し、そのオブジェクトを他のオブジェクトに渡すことで、データと操作を分離するものがあります。それを strategy pattern と呼びます。インターフェイスを用いて実装されています。

このため、作成するオブジェクトは特定のクラスのデータを操作することが必要となります。その操作を情報隠蔽の原則に従い非公開にしたいんでよすね。インターフェイスは public であることが大前提ですが、実際に操作を行うロジックの実装は private にしたいんですね。

作成するオブジェクトだけが操作可能なように、同一パッケージ内からでもアクセスできない様に制御・カプセル化し、オブジェクト生成をデータの存在するクラスで行う必要があります。そのためのテクニックとして、インナークラスや匿名クラスという手法がありましたが、Java8 からはラムダ式を使うことが出きるようになりました。

2-1. インナークラス

public なクラスの中に private なクラスが定義されています。private なのでインナークラスが定義されたクラスからしか呼び出せませんから、同一パッケージの他のクラスからもアクセスが出来ません。なぜ、この様に隠蔽してアクセスを制御するのでしょうか?Mainクラス(その他、クライアントとなりうるもの。外部API・システム内のメソッド)から隠すことで、むやみやたらに使用されない様にし、密結合を防ぎ保守性を保つ狙いがあります。

package com.jp.sss.Sample;

public interface Flyable {
    public void fly();
}
package com.jp.sss.Sample;

public class Factory {

    // 自身のインスタンスを生成し、インナークラスインスタンスが作られる。
    // その参照がFlyable 型として返される
    public static Flyable create() {
        return new Factory().new InnerSample();
    }

    // インナークラス 外部からのアクセスはできない
    private class InnerSample implemnts Flyable {
        @Override
        public void fly() {
            System.out.println("I Can Fly!!");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        // static だからインスタンス生成不要
        Factory.create().fly(); 
    }
}

// I Can Fly!!

2-2. 匿名クラスとラムダ式

匿名クラスはインナークラスより簡略化した様な形です。匿名クラスはクラスの定義とインスタンス化を同時に行うことが可能で、クラスの定義が省略された形になります。クラスの定義がなくてもインスタンスの動作や持つべきが決まってさえいれば、クラスとしてあらかじめ定義しておく必要がないという発想です。インナークラスは隠蔽や保守性を保つ発想でしたが、匿名クラスはより簡潔に記述する為の発想です。

匿名クラスとして生成できるのは

  • 『定義済みのクラスのサブクラス』
  • 『定義済みのインタフェースを実装したクラス』

のいずれかとなります。

あるインタフェースを実装した匿名クラスを生成する場合は抽象メソッドを全て実装します。インターフェイスによって型の情報がすでにあり、必要なのは実装の内容のみなので抽象メソッドを具象化する際に必要な、実装の詳細だけあればいいです(オーバーライド必須)。この辺りはインナークラスの実装方法と同じです。

その場でしか使わないコードの場合はコードの可読性が上がるというメリットがあります。ただし、あまり複雑な処理を持つクラスを匿名クラスにしてしまうと返って可読性を落としかねないため比較的短いコードの処理を行う場合に向きます。

上記のコードを少し改良した例を記述します。

public interface Flyable {
    public String fly(int V, String U);
}

匿名クラス

/*
 * インターフェイスを実装した匿名クラスを定義
 */
public class Factory {
    public static Flyable create() {
        // インターフェイスなのに new が書ける。インターフェイスを new するのではなく
        // インターフェイスを実装した 匿名クラスのインスタンス生成し、その参照を扱う
        // クラスの名前がないので匿名(無名)クラスと呼ぶ
        return new Flyable() {
            @Override
            public String fly(int velocityNum, String unitOfVelocity) {
                return "飛行速度は時速" + velocityNum +  unitOfVelocity + "です!";
            }
        };
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println(Factory.create().fly(200, "km")); 
    }
}

// 飛行速度は時速200kmです!

匿名クラスを使用することで、クラスの定義なしにインターフェイスの実装クラスのインスタンスを生成し、その参照を扱うことが実現できます。

3. ラムダ式

ラムダ式を用いると引数とメソッド本体のみ記述するだけで、関数型インターフェイスを実装しインスタンスを生成できます。

また、ラムダは縦の記述が長くなるという問題に対して、今までの記述量を省略するというアプローチを行っています。 ラムダを取り入れることで、今までの記述をかなり省略できるようになっています。それで縦を短くすることが出来ましたが、それと同時に横が長くなりやすくなってしまいました。なのでラムダには横を短くするための工夫がなされています。以下に紹介していきますが、IDE が賢いので結構その辺りは教えてくれたりします。

スクリーンショット 2023-02-16 14.23.26.png

3-1. 関数型インターフェイス

/*
 * 抽象メソッドを一つしか持たない
 */
@FunctionalInterface
public interface Flyable {
    public void fly();
}

もし独自の関数型インターフェイスであることを強制したいならば

  • @FunctionalInterface

を付加すればOKです。コンパイラが関数型インタフェースの条件を満たしているかどうかチェックをしてくれます。

この状態で抽象メソッドを二つ以上定義すると以下の警告文が出る

Invalid @FunctionalInterface annotation;
Flyable is not a functional interface

「無効 '@FunctionalInterface' アノテーションが無効です。Flyable は関数型 インターフェイスではありません」

3-2. 匿名クラスを簡潔に記述するラムダ式

上記の匿名クラスをラムダ式に変換してみましょう。

関数型インターフェイス

@FunctionalInterface
public interface Flyable {
    public String fly(int V, String U);
}

匿名クラス

public class Factory {
    public static Flyable create() {
        return new Flyable() {
            @Override
            public String fly(int velocityNum, String unitOfVelocity) {
                return "飛行速度は時速" + velocityNum +  unitOfVelocity + "です!";
            }
        };
    }
}

ラムダ

public class Factory {
    public static Flyable create() {
        return (velocityNum, unitOfVelocity) -> "飛行速度は時速" + velocityNum +  unitOfVelocity + "です!";
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println(Factory.create().fly(200, "km")); 
    }
}
// 飛行速度は時速200kmです!

ラムダ式は Factory クラスの create メソッド内で使用されていますが、戻り値の型が指定されている為、どの関数型インターフェイスを使おうとしているのかが推論されます。また、メソッド名の指定がありません。これは関数型インタフェースには一つの抽象メソッドしか定義されておらず、ラムダ式で実装するメソッドが明確で指定が不要なためです。更に、引数の型指定もありませんが、ラムダ式で実装するメソッドが文脈から明確であるため、関数型インターフェイスの抽象メソッドのシグネチャ定義から型推論されるからです。ただ、入れようと思えば入れれます。

この型推論機能は、ラムダ式・Stream などを理解し使いこなす為に必要な考え方になります。

匿名クラスから省略された記述

        new Flyable() { // 関数型インターフェイスは create メソッドの戻り値の型から推論
            @Override   // オーバライドするメソッドの指定は不要。抽象メソッドが一つのみ
            public String fly(int velocityNum, String unitOfVelocity)
                        // 引数の型も同様に関数型インターフェイスのため推論可能

ということで、ラムダ式を用いることで関数型インターフェースを実装したインスタンスを持つことができるようになります。ちなみに抽象メソッドが1つのみという制約はありますが、default・static メソッドはカウントされません。default メソッドや static メソッドとして実装されているものは、オーバーライドして再実装する必要はありません。

3-3. ラムダのメリット

匿名クラスには以下の欠点があります。 @Override しなければいけないメソッドを宣言したりするため、不要な行が増えたり、何層もの深いインデントができたりすることになり、ソースが読みづらくなってしまいます。ラムダ式を取り入れることで、関数型インターフェースの実装のために書いていた冗長なソースを簡潔に書けるようになります。

その他

  • マルチスレッドによる並列処理を簡潔に記述できる
  • 関数型というパラダイムを表現し副作用がない為、テスタビリティが高い(後述)
  • これまでの記述では縦に長くなる記述が短く記述できる様になった
    • 反面、横に長くなりがちだが型推論や構文省略などで発生を抑止している
  • Stream を使用できる様になる
      • for / foreach などのボイラーテンプレートを書かなくても良くなる
    • ここにバグが潜む。
      • 使用するオブジェクトの内部構造・実装の詳細は知らない方がいい
      • 評価式に書く条件(.lngth() / .size() など)によってはパフォーマンスに影響したりする
  • Optional を使用できる様になる
    • null セーフとして半端で、関数型のパラダイムも半端に混じるとコードが読みづらくなる、といった理由で最近は書かないとの声も。ただ、Stream の中で Optional が扱われてるため知っておく必要はある。

3-4. ラムダ式の基本文法

// 基本
(引数) -> { 
        処理; 
    }

// (1) 引数と戻り値がある場合
(number) -> {
    return Math.abs(number) >= 5;
}

// (2) 戻り値がない場合
(number) -> {
    System.out.println(number);
}

// (3) 引数も戻り値もない場合
() -> {
    System.out.println("Hello!");
} 

// (4) (1) は引数が1つなので ( ) を省略できる
number -> {
    return Math.abs(number) >= 5;
}

// (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!")

3-4-1. メソッド参照、コンストラクタ参照

ラムダ式と同様に関数型インターフェースを実装し、インスタンスを生成する構文として、メソッド参照とコンストラクタ参照が追加されています。 メソッド参照やコンストラクタ参照を用いると、ラムダ式よりも簡潔に記述でき、多くの場合、より一層読みやすく記述できます。

3-4-2. メソッド参照

メソッド参照は、クラス名::メソッド名インスタンス::メソッド名 などと記述し、そのメソッ ドを呼び出すように実装された関数型インターフェースのインスタンスを生成します。ラムダ式が1つのメソッド呼び出しで完結する場合、クラス(またはインスタンス)とメソッド名の指定だけで置き換えることができます。

  • インスタンス::メソッド名→バウンド参照
    • インスタンスメソッドのシグネチャが関数型インタフェースの抽象メソッドと一致すればメソッド参照を渡すことができます。
  • クラス名::メソッド名→アンバウンド参照
    • 呼び出し元 (レシーバ)となるインスタンスを第1引数に受け取るメソッドを持つ関数型インターフェースが予期されなければいけません。

バウンド参照を適用できるのは(2)だけとなります。(2)をメソッド参照を用いて記載すると次のようになります。

System.out::println

System.out.printlnメソッドは引数を1つだけ取るメソッドであり、引数である Integer 型の number が渡されることが明らかであるため、メソッド参照が利用できるのです。一方、(1)はメソッド呼び出しの後に>= 5という大小判定があるため、メソッド参照が使えません。また、(3)は引数に"Hello!"という値を指定しているため引数が一意には決まるとは言えず、これもメソッド参照は使えません。

P -> p.getName()          Person::getName
s -> Integer.parseInt(s)  Integer::perseInt 
o -> list.add(o)          list::add  
  • アンバウンド参照

アンバウンド参照は該当するメソッドがインスタンスメソッドの場合です。

関数型インタフェースの抽象メソッドが呼び出されると先頭の引数がメソッドを呼び出すレシーバ(呼び出し元)インスタンスとなり、残りの引数がメソッドに渡されます。

String message = "This is a pen.";
// indexOf()はインスタンスメソッド
BiFunction<String, String, Integer> f2 = String::indexOf;    
// 第一引数はレシーバインスタンスを指定。
// 第二引数 "is" が message.indexOf() に渡されます
System.out.println(f2.apply(message, "is"));   

ぶっちゃけアンバウンド参照はよくわかりません

3-4-3. コンストラクタ参照

コンストラクタ参照は、 クラス名:new という書式になります。引数が存在する場合、 関数型インターフェースのメソッドが受け取った引数が順に適用されます。

init -> new MyClass(init)  MyClass::new
n ->new int [n]            int[]::new

3-5. ローカル変数の参照は final のみが可能

「実質的に final」と表現します。値の変更は不可です。ラムダ式内での再代入やインクリメントなどの増減処理はできません。

従来の final な例

void method(final int n) {
    String str = "HelloFinal" 
    Runnable r = new Runnable() { 
        public void run() {
            System.out.println(str + n) ;
        }
    }; 
} 

void method(int n) {
    final String str = "HelloFinal" 
    Runnable r = new Runnable() { 
        public void run() {
            System.out.println(str + n) ;
        }
    }; 
} 

// 上記をラムダで記述した関数
void method(int n) {
    String str = "HelloFinal" 
    Runnable r = () -> System.out.println(str + n);
} 

実質的に final な例

void method(int n) {
    String str = "HelloFinal" 
    Runnable r = () -> System.out.println(str + n);
    n = 5; // NG ローカル変数の再代入は不可
} 

void method(int n) {
    String str = "HelloFinal" 
    Runnable r = () -> n++; // コンパイルエラー
} 

4. ラムダの注意点

Effective Java ではラムダ式について以下の様な感じで語られています。

匿名内部クラスよりラムダ式を選ぶ   行の少ないコードであれば、ラムダ式は匿名内部クラスより読み易いことがほとんど。逆にラムダには名前とドキュメンテーションがありませんから、自明なロジックでなかったり、数行を超えたロジックなら、ラムダで実装すべきではありません。

その他、匿名クラスよりラムダを選択する基準 - 数行のコードで済む - 自分への参照が不要(this の参照先の違い) - 関数型インターフェースの型しか使用しない

また、ラムダと匿名クラスの違いとして「thisが指し示す物」があります。 ラムダ式は関数型インタフェースの匿名クラスと説明していましたが、若干違う部分もあります。

その違いが this の扱いです。

4-1. this の参照先の違い

通常の匿名クラスではthisは自分自身となりますが、ラムダ式の場合 this はラムダ式を定義したメソッドを持つクラス(エンクロージングクラス:内部クラスを囲い込むクラスという意味。内部クラスとはインナークラスやと匿名クラスを指します)のインスタンスを参照します。つまり、スコープが異なります。匿名クラスのみ新しいスコープが導入され、ラムダにはその様なことはありません。ラムダ式内では一層上のスコープを引き継ぎます。

通常、ラムダ式の中で直接thisを扱うことはそれほどないそうですが、インタフェースのデフォルトメソッドで定義されたメソッドと同じシグネチャのメソッドが、エンクロージングクラスのメソッドであるような場合は注意が必要です。

関数型インターフェイスのデフォルトメソッド

@FunctionalInterface
interface Function {
    void func();

    // デフォルトメソッドによる実装
    default void printClass() {
        System.out.println("関数型インターフェイスのデフォルトメソッドです");
    }
}

ラムダを含むエンクロージングクラスでの同名メソッド

    // 関数型インターフェイスにある printClass と同じシグネチャのメソッド
    private void printClass() {
        System.out.println("エンクロージングクラスのメソッドです");
    }

匿名とラムダで呼び出し

 public LambdaTest() {
    Function anonym = new Function() {
        @Override
        public void func() {
            printClass();
        }
    };
    
    Function lambda = () -> printClass();
    
    anonym.func();
    functionalInterface.func();
}

public static void main(String... args) throws Exception {
        new LambdaTest();
    }
}

// anonym::関数型インターフェイスのデフォルトメソッドです
// lambda::エンクロージングクラスのメソッドです

もし、エンクロージングクラスに printClass() が記述されていない場合は ラムダで printClass() を呼び出すことはできません。

  • ラムダ式内のthis = エンクロージングクラス

4-2. ラムダは匿名クラスのシンタックスシュガーではない

ラムダ ≒ 匿名クラスですが、上記の様にスコープの違いなどによって、以下の様な使い分けが存在します。 
ラムダ式は関数型インタフェースのインスタンスを生成する場合にしか使えません。また、ラムダ式ではthisで関数オブジェクト(インターフェイス)自身を参照することができません。したがって、次のような場合はラムダ式で書くことはできず匿名クラスを使う必要があります。

※おまけ:副作用とは

関数において、 「想定外の作用」とは、 関数外部の変数・状態の影響のことです。言うなれば、関数が自身以外に依存しており独立性が阻害されていることを表し、変数の再代入や入出力などによってプログラムの状態が変化することを指します。密結合とも表現できますね。
ただし、画面入力やファイル入力などプログラミングには状態を持たせることが不可欠なので副作用は必要であり、重要なのは制御することです。「副作用を持たない部分」(純粋関数)と「副作用を持つ部分」を分離し、管理します。

副作用を持つ関数から副作用を持たない関数を呼び出して良いが、逆に副作用を持たない関数から副作用を持つ関数を呼び出してはいけません。

  • 副作用を持たない関数 引数に日付を受け取って処理する関数

  • 副作用を持つ関数 関数の内部で日付を生成して処理する関数

  • 副作用があるソースのデメリット

    • テストコードを書きづらくなる。実行するたびに結果が変わるのでテストコードは書けないと。
    • 単純に認知負荷がでかい。どこで状態が変わったのかソースコードを順に読んで、時にはデバックしながら把握していく必要がある。
    • バグの温床。副作用に関係する変数のスコープ管理が必要です。

副作用を防ぐ方法

  1. メソッド中で意図した処理で終わらせたい場合はreturnなどの終了処理を明示的に記述する
  2. 外部の状態に依存せず、関数の結果に影響を与えない
  3. 変数の値は不変(変数のスコープを出来る限り小さくする必要がある)
  4. 演算内容が引数にのみ依存し、演算結果は[戻り値]のみ影響する
  5. 関数の引数として変数を指定する時、その値のみを渡すこと値渡しが理想的
    1. 引数の参照(ポインタ)を渡した場合(参照渡し、もしくは参照の値渡し)、関数は引数の内容を変更するこができてしまい、計算の過程で引数の値が変わってしまう可能性あり。この場合、関数の処理前後で引数となる変数の値が変化してしまう→副作用。
  6. グローバル変数を使用しない

5 については、以下の記事で紹介しています。

終わりに

正直、考慮不足な点やまだまだ勉強不足な点もあり網羅的な内容にできなかったなーと思います。現時点での自分の理解度はここまでです。次は、Stream や Optional についても記事作成したいと思います。