よしたろうブログ

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

別名参照問題とディフェンシブコピー

こちらの記事は以下の記事にて他のトピックと共に統合的に扱っています。ちょっと長いですが、多角的により深く、ある程度網羅的にまとめています。こちらの方がより深い理解が得られると思いますので時間がある場合はこちらを参照してください。

yoshitaro-yoyo.hatenablog.com

別名参照問題は、参照と複製が明示的に記されないオブジェクト指向言語において発生する

前回、別名参照問題についての記事を書きました。

yoshitaro-yoyo.hatenablog.com

yoshitaro-yoyo.hatenablog.com

別名参照問題の本質は「共有されたオブジェクトの状態を意図せずに変更してしまう」ことです。下図のDate partyDate = retirementDate;のように代入時に同じインスタンスの参照値が代入されていることで起きる問題です。

引用元:AliasingBug スクリーンショット 2022-12-25 18.51.54.png

これは、JavaRubyPythonのような、参照と複製が明示的に記されないオブジェクト指向言語においてのみ発生し、プログラマが常に意識しなければいけない問題です。

変数への代入時、それが複製か共有か?を意識するのは、上記の様なOOPのみしか経験のない場合、中々難しいと思います。そもそもこの別名参照(エイリアス)問題の存在に気付くのも難しいのかもしれません。

C言語ではポインタという概念があり、参照か複製化を、プログラマが明示的に意識し記述する必要があります。 関数型言語では不変(immutability)が規則であり可変が例外であるという考え方があり、複数の変数が共有するインスタンスをどこかの変数で変更処理を行えばコンパイルエラーなどで防ぐことが出来ます。これらが言語レベルのサポートがなされていると、別名参照問題「共有されたオブジェクトの状態を意図せずに変更してしまう」 という場面は非常に置きづらいのだろうと思います。

これらを踏まえた上で考えると、参照と複製が明示的に記されないオブジェクト指向言語において発生する別名参照問題は以下の二点を意識すれば防ぐことが出来ます。

  1. 代入は複製のみに限定する
  2. オブジェクトや変数を不変( immutability )にする

  3. 複製とは「インスタンスそのものコピー」を指します。

  4. 不変オブジェクトとは「インスタンス生成後、そのインスタンスの状態(メモリ領域に保持されている値)が変化しない」という意味を指します。
  5. 変数の不変性に関しては、ローカル変数に final 修飾子を付けることで不変となり再代入をコンパイルエラーで防ぐことが出来ます。

正確に言うと、クラスのメタデータを取得・操作するリフレクションという機能を使えば不変性は壊せるのですが割愛します。あと、不変性はマルチスレッドにおけるスレッドセーフであることや自由にキャッシュできるなどの利点がありますが、今の自分には自身の言葉で説明できないので割愛します。

複製とディフェンシブコピー

Java においてオブジェクト変数は参照型です。オブジェクトをコピーをする場合、参照型変数とオブジェクト自身を区別して考える必要があります。プリミティブ型(intやdoubleなど)は、表す値を、何の追加メタデータもないビット・パターンとして直接的に符号化したものです。一方のオブジェクト参照は、Javaヒープ領域のアドレスを示すポインタです。Javaヒープとは、仮想マシンのみがガベージ・コレクションを通じて管理するメモリ領域です。

参照型変数の参照値(ポインタ)コピーか、参照しているインスタンスそのもののコピー(複製)が必要かを考慮しなくてはいけません。前者と後者のコピーは以下の様に呼ばれます。

  1. シャローコピー:shallow copy
  2. ディープコピー:deep copy

シャローコピー

シャロー(shallow )とは「浅い」という意味です。コピー元のオブジェクトから、同じ実体(インスタンス)を参照する別オブジェクトを生成する方式です。つまり、参照値のコピー・共有に他なりません。

上記で引用した画像でいえば以下の様なコードはシャロウコピーです

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
Date partyDate = retirementDate;

上記のコードでのメモリ管理と参照値の共有状態は以下になります。

image.png

この通り、二つのオブジェクトの指し示すインスタンスは同一なので別名参照問題を引き起こします。この参照型の特徴に注意してコーディングしないと、クラス定義などによるデータのカプセル化やプログラムのモジュール化が台無しになってしまう危険があります。Java におけるcloneメソッドによるコピーは、オーバライドでロジックが変更されない限りシャロウコピーです。

プリミティブ型においては、プリミティブ型の挙動によってあらたなヒープ領域を確保しそこに値がコピーされますが参照型ではシャロウコピーになります。ただし、Stirng型においては異なります。実態はchar型の配列であり、さらに不変型です。その他ラッパークラスもイミュータブルな参照型な為、代入によってインスタンス変数を作成するたびにヒープ領域が新たに確保され、あたかもプリミティブ型の変数かのような挙動となります。

クローンメソッドについて

cloneメソッドは、オブジェクトのコピーを返すメソッドですが、Javaのcloneメソッドは特殊です。Java以外の言語(PHPRubyなど)のcloneメソッドではディープコピーを提供しますが、Javaのcloneメソッドでは、シャローコピーが提供されています。不変オブジェクト(状態が変更されないオブジェクト)のみをシャローコピー(不変オブジェクトは内部の状態が変わることが無い)とし、可変オブジェクト(状態が変更されるオブジェクト)をディープコピーとして提供出来るようにするにはcloneメソッドをオーバライドしロジックを変更する必要があります。

ただし、Effective Java には「cloneを注意してオーバーライドする」というセクションがあります。ここでは7ページにも渡り、注意点が説明されていてその中で欠陥があるやら問題があるやらと明言されてます。最終的には以下の様に代替案を提案しています。

本当にこれだけの複雑さが必要なのでしょうか。それはまれです。もし、 Cloneable を実装しているクラスを拡張するのであれば、正しく振る舞う clone メソッドを実装する以外の選択肢はほとんどありません。さもなければ、オブジェクトのコピーを行う何らかの代替手段を提供するか、 オブジェク トの複製を単に提供しない方がおそらく賢明です。 たとえば、不変クラスがオブジェクトのコピーをサポートすることはほとんど意味がありません。 なぜならば、 コピーされたものは、元のオブジェクトと実質的に区別がつきません。 オブジェクトのコピーに対する上手い方法は、 コピーコンストラクタ (copy constructor) かコピーファクトリー (copy factory) を提供することです。

コピーコンストラクタ (copy constructor) 、コピーファクトリー (copy factory) については別項で解説します。

ディープコピー

シャロウコピーとは違い、ヒープ領域に新たに領域を確保し、その領域に格納する値をコピーした後、新たな領域を指し示すアドレス値を変数に格納するような形でコピーするのがディープコピーです。上記でのプリミティブ型の挙動はディープコピーそのものです。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));
Date partyDate = new Date(retirementDate.getTime());

image.png

上記での方法はかなり原子的な方法です。この様にデータの状態が新たなヒープ領域に格納された状態を「ディープコピー」と言います。

代入とはこの様に常にディープコピーであるべきであり、これを複製といいます。「複製 = ディープコピー」です。これで別名参照問題は回避できます。

Javaではポインタか参照かを明示的に記述する必要がない、という言語的な特徴によってこの辺りがややこしくなってしまったのですね。ポインタという概念は、初学者時代の自分には鬼門だったと記憶しています。今となっては避けては通れないと感じています。

代入作業の度に複製なのか参照なのか?を気にするのははっきり言って無駄でしかないと感じています。再代入が好ましくないのは当然として、代入時は複製を徹底することが基本でいいのではないでしょうか。参照を用いるべき場面としては、インスタンス生成コストがパフォーマンスに影響を及ぼす処理の場合のようです。

引用元:コードコンプリート第二版 上 6.3.4 コンストラクタ

■ シャローコピーを使用する理由が特になければ、ディープコピーを優先する
 複合オブジェクトで最も悩むのは、オブジェクトのディープコピー(深いコピー)を実装するのか、それともシャローコピー(浅いコピー)を実装するのかである。「深い」と「浅い」の意味は状況によりけりだが、オブジェクトのディープコピーとは、オブジェクトのメンバデータをメンバごとにコピーするものだ。これに対し、シャローコピーは1つのオブジェクトをポイントするか、または参照するだけの参照コピーである。

 シャローコピーを作成する目的は、一般に、パフォーマンスを向上させることである。確かに、大きなオブジェクトのコピーをいくつも作成するのは、見た目が良くないかもしれないが、パフォーマンスに無視できないほどの影響を及ぼすことはめったにない。一方、パフォーマンスを低下させているのが少数のオブジェクトの場合、プログラマは周知のとおり、原因となっているコードを突き止めるのがへたである。パフォーマンスが改善するという確証がないのに複雑さを増大させることは、良い打開策であるとは言えない。ディープコピーとシャローコピーを選択する良い方法は、シャローコピーを使用すべき根拠が明確になるまでは、ディープコピーを使用することだ。

 ディープコピーはシャローコピーよりもコーディングや保守が容易である。シャローコピーは、どちらの方法でコピーされたオブジェクトにも含まれているコードの他に、参照をカウントするコード、オブジェクトを確実にコピー、比較、削除するためのコードなどを追加する。このようなコードはエラーの原因になりやすいので、シャローコピーを作成する理由が特になければ、避けた方がよいだろう。

ディフェンシブコピー

メソッドの引数を渡したり戻り値を返す際にオブジェクト参照を渡す場合、メソッドの呼び出し側と受け取り側で参照を共有することになります。そのため受け取ったメソッド側で、どんなにオブジェクトの状態を変更しないように努めたり、複数のオブジェクト間の不変条件が崩れないように努めても、メソッド呼び出し側で簡単にそれを崩すようなことができてしまいます。

以下の Period クラスは二つの Date インスタンスを受け取り、「終わりは開始よりも後である」という条件を満たし、かつそれが不変であることを表現しようとしています。前提条件として、コンストラクタで start < end の条件が成立するようにチェックを行っています。

引用元:Effective Java

public class Period {
    private final Date start;
    private final Date end;

    /**
     * @param start 期間の開始
     * @param end 期間の終わり。開始より前であってはならない。
     * @throws IllegalArgumentException start が end の後の場合。
     * @throws NullPointerException start か end が null の場合
     */
    public Period(Date start, Date end) {
        // start < endの不変式の前提条件チェック
        // 実際の計算や処理を行う前にパラメータをチェックする
        if (start.compareTo(end) > 0) {
            throw new IllegalArgumentException(start + " after " + end);
        }
        this.start = start;
        this.end = end;
    }

    public Date getStart() {
        return start;
    }

    public Date getEnd() {
        return end;
    }

.............省略
}

以下はディフィンシブコピーに修正したものです。

public class Period {
    private final Date start;
    private final Date end;

    /**
     * @param start 期間の開始
     * @param end 期間の終わり。開始より前であってはならない。
     * @throws IllegalArgumentException start が end の後の場合。
     * @throws NullPointerException start か end が null の場合
     */
    public Period(Date start, Date end) {
        // ディフェンシブコピーを先に行う
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());

        // コピー後にコピーした引数に対し正当性チェックを実施する(TOCTOU脆弱性対策)
        if(this.start.compareTo(this.end) > 0) {
            throw new IllegalArgumentException(this.start + " after " + this.end);
        }
    }

    public Date getStart() {
        // ディフェンシブコピー
        return new Date(start.getTime());
    }

    public Date getEnd() {
        // ディフェンシブコピー
        return new Date(end.getTime());
    }

.............省略
}

上記の様に、受け取った参照型の値をそのままフィールドに代入したり getter で参照を返すのではなく、受け取った参照を元に新しいインスタンスを作成してフールドに代入したり、getter の戻り値として返却します。

最初の不完全なクラスでは以下の様なコードによって生成された後のインスタンスの状態を簡単に変更できてしまいます。

Date start = new Date();
Date end = new Date();
Period period = new Period(start, end);

// 呼び出し側が不変性を崩す
end.setYear(1000);

// end への参照を getter で取得し不変式(start < end)を崩す
Date e = period.getEnd();
e.setTime(e.getTime() - 1000);

クラスの可変な内部状態をディープコピーしその参照を返すようにすると、コピーされるオブジェクト(引数で渡された参照先のオブジェクト)の変更可能であることに変わりはないですが、呼出し側はディープコピーされたオブジェクトの内部状態にはアクセスできません。これにより参照値の共有を回避できます。これがディフェンィブコピーです。

# 終わりに

実はまだまだ深い話が展開できるテーマです。不変性やセキュアコーディングなど。近いうちにまとめたいと思います。