たなかのJava日記

どんなことをやったか(学んだか)、どこで詰まったか(わからなかったか)、どこで工夫したかの記録です。

【Java】インスタンスの等価判定(後編)

【今回学ぶこと】
・コレクションとequals()の関係を学び、不具合の原因となる例を実際に体験すること
・配列での等価判定を正しく行うこと

【そもそもコレクションとは何?おさらいしましょう】
Javaにはさまざまなデータ構造に対応した「データをまとめて格納するための入れ物クラスたち」が
API(標準添付されているクラス)として準備されています。
それらはjava.utilパッケージに属し、コレクションフレームワークと総称されています。

詳細のリンク:
Javaコレクション操作の基礎



【equals()のオーバーライドをサボると・・・】
これまで、インスタンスの等価判定・等値判定をみっちり学んできました。
【Java】インスタンスの等価判定(前編) - たなかのJava日記
【Java】インスタンスの等価判定(疑問編) - たなかのJava日記


とはいえ、恥ずかしながらequals()を知るまではオーバライドしてきませんでした。
なので、学んだもののサボっても問題ないと心のどこかで考えています。
しかし、これが原因特定が困難な不具合に繋がることがあるというのです。
例えば、equals()をオーバライドしていないクラスをコレクションに格納する次のコードを確認してください。

package jp.co.mtanaka;

public class Comedian {
    // equals()をオーバライドしていない
    public String name;
}
package jp.co.mtanaka;

import java.util.ArrayList;
import java.util.List;

public class Sample2 {
    public static void main(String[] args) {
        //右辺の型は省略可能
        List<Comedian> list = new ArrayList<>();

        Comedian a = new Comedian();
        a.name = "盛山";
        // Comedian型の変数aをListに格納
        list.add(a);
        System.out.println("要素数" + list.size());

        a = new Comedian();
        a.name = "盛山";
        // 名前が盛山のお笑い芸人を削除   
        list.remove(a);
        System.out.println("要素数" + list.size());
    }
}

ここで行っているのは「盛山」という名前を持つお笑い芸人インスタンスを作って格納した後、同じ要素を削除しようとしています。
しかし、実行すると以下のように上手く削除できません。

実行結果:
素数=1
素数=1

【意図した削除ができない理由】
まずは、一行ずつコードを見ていきましょう

List list = new ArrayList<>();

⇒Listに格納する型をComedian型に指定しています。
左辺がList型になっている理由は、
「ざっくりListとして扱う方がメリットが大きい」からです。
ArrayListやLinkedListなどのListは、両者ともjava.util.Listインターフェースを実装しています。
また、get()、remove()、size()などは、全てListインターフェースに実装してあるメソッドになります。
そのため、ListにArrayListは格納が可能であり、基本的なメソッドも使用可能です。
メリットは、Listとしてざっくり扱っていても、その後の実装の違いを気にすることは少ないので、
他のListも受け渡し可能にしておけば、その後の変更など含めて中身の受け渡しが容易になることです。
このようなやり方は定石のようです。

Comedian a = new Comedian();

Comedianインスタンス化し変数aに代入
この変数のアドレスは705番です


a.name = "盛山";

Comedian型のインスタンスaのnameフィールドに盛山を代入


list.add(a);

Comedian型のインスタンスa(名前が盛山のお笑い芸人)をList型の変数名listに格納
ArrayListの要素0番のアドレスは705番です


System.out.println("要素数" + list.size());

⇒この時点でのListの要素数は1


a = new Comedian();

⇒新しくコメディアンをインスタンス化する
先ほどとは違うインスタンスになるので、当然アドレスは変わる。
アドレスは722番、それをaに代入する
aのアドレスは704番だったが、同じ変数aを使用するためaはここで722番に上書きされる


a.name = "盛山";

⇒新しい722番のComedian型のインスタンスaのnameフィールドに盛山を代入

list.remove(a);

⇒現在aは722番を指しているので、722番のアドレスを削除しに行く、
しかし、ArrayListの要素の0番のアドレスは最初に追加した704番なので削除はしない


System.out.println("要素数" + list.size());

⇒結果、要素数は1のまま


remove()は引数として渡したインスタンスと同じものを削除してとJVMに依頼するメソッドです。
その後、JVMArrayListから同じものを探すため、equals()による等価判定を行います。
しかし、オーバライドしていない、java.lang.Objectクラスに定義されているequals()の中身は「ただの等値判定」ロジックになっています。
要は同じアドレスかどうかを判定しています。

つまりeauals()で何をもってインスタンスを同じものと見なすかをオーバライドして決めておかないと、
remove()した時に、等値判定(同じアドレスか)で削除対象を決めてしまうということです。
このような等価判定(同じ内容か)に不具合があるクラスをコレクションに格納すると、
要素の検索や削除が正しく行われないのです。

普段よく使用しているStringやIntger、DateのようなAPIクラスのインスタンスを格納してもこのような問題は起きません。
それは、それらのクラス内では正しくequals()がオーバライドされているためです。

自分たちで作るクラスも、いつ、誰に、どのような使われ方をするかわかりません。
今回のComedianクラスのように等価判定(何をもって同じ内容か)されるか考えにくいクラスや、
すでに親クラスでequals()が正しくオーバライドされているクラスを除き、
クラスを作ったら必ずequals()をオーバーライドしておく必要があります。

【まとめ】
remove()は引数として渡したインスタンスと同じものを削除してとJVMに依頼するメソッドです。
依頼を受けた、JVMArrayListから同じものを探すため、equals()による等価判定を行います。
しかし、オーバライドしていない、java.lang.Objectクラスに定義されているequals()の中身は「ただの等値判定」ロジックです。
要は同じアドレスかどうかを判定しています。
つまりeauals()で何をもってインスタンスを同じものと見なすかをオーバライドして決めておかないと、
remove()した時に、等値判定(同じアドレスか)で削除対象を決めてしまうため、意図した削除ができません。
すでに親クラスでequals()が正しくオーバライドされているクラスを除き、
クラスを作ったら「何をもって同じ内容と見なすか」をequals()でオーバーライドしておく必要があります。


【今回の例をオーバライドしてみた】

package jp.co.mtanaka;

public class Comedian {
    public String name;

    // equals()をオーバーライドして、2つのインスタンスの何をもって同一と見なすかを決めて判定する
    @Override
    public boolean equals(Object obj) {
        // 自分自身(同じ参照)が引数として渡されたら無条件でtrueを返す
        if (this == obj) {
            return true;
        }
        // nullが引数で渡されたらfalseを返す
        if (obj == null) {
            return false;
        }
        // obj instanceof Personで左辺は右辺の型と同じかを判断
        // !で左辺は右辺と違う型だったらfalseを返す
        if (!(obj instanceof Comedian)) {
            return false;
        }
        // 次の処理に備えて比較ができるように適切にキャストする
        Comedian c = (Comedian) obj;

        // 2つのインスタンスのnameが等価(同じ内容)で無ければfalseを返す
        // Stringクラスのequalsメソッドで文字列の値が等しいか判定しています
        if (!this.name.equals(c.name)) {
            return false;
        }
        return true;
    }
}
package jp.co.mtanaka;

import java.util.ArrayList;
import java.util.List;

public class Sample2 {
    public static void main(String[] args) {
        //右辺の型は省略可能
        List<Comedian> list = new ArrayList<>();

        Comedian a = new Comedian();
        a.name = "盛山";
        // Comedian型の変数をListに格納
        list.add(a);
        System.out.println("要素数" + list.size());

        Comedian b = new Comedian();
        b.name = "盛山";
        // 名前が盛山のお笑い芸人を削除
        list.remove(b);
        System.out.println("要素数" + list.size());
    }
}


実行結果:
素数=1
素数=0

nameが同じなら削除とし、正しく削除できました。


【配列の等価判定について】
intや先ほど私が作成したクラスの配列であるComedianもObjectクラスを継承しているため、
java.lang.Objectクラスに定義されているequals()は使用可能です。
しかし、配列同士を比較して用いると等値判定(同じアドレスか)が行われてしまします。
これはjava.lang.Objectクラスに定義されているequals()の中身は「ただの等値判定」ロジックになっているためです。
先ほどのインスタンス同士の等価などではequals()をオーバーライドしておく必要がありましたが、
配列同士で等価判定(同じ内容か)をしたい場合は、
java.util.Arraysクラスのstaticメソッド(静的メソッド)のequals()を使用します。


配列同士をequals()で比較する
構文:
Arrays.equals(a, b);


【staticメソッドとは】
構文:
クラス名.メソッド名(引数);
または、
インスタンス変数名.メソッド名();


例:
var day =LocalDate.of(2020,2,28)

・staticメソッドは実体が各インスタンスではなくクラスに属するため、クラス名を使って呼び出せるようになります
・staticメソッドはクラスをインスタンス化しなくても呼び出すことのできるメソッドです
インスタンスにも分身が作られるため、インスタンス変数名からも呼び出せます

package jp.co.mtanaka;

import java.util.Arrays;

public class Sample {
    public static void main(String[] args) {
        // 配列をequals()で比較する
        int a[] = {1, 2, 3, 4, 5};
        int b[] = {1, 2, 3, 4, 5};

        System.out.println("誤った配列同士の比較;" + a.equals(b));            // true
        System.out.println("正しい配列同士の比較;" + Arrays.equals(a, b));    // false
    }
}