読者です 読者をやめる 読者になる 読者になる

【Effective Java】項目75:カスタムシリアライズ形式の使用を検討する(後半)

Java Effective Java

前回の記事ではデフォルトシリアライズとカスタムシリアライズの概要について説明しました。

hjm333.hatenablog.com

本記事では、カスタムシリアライズ実の注意事項について説明します。

StringList のカスタムシリアライズ実装を再掲します。

public class StringList implements Serializable {
    private transient int size = 0;
    private transient Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }

    /**
     * この{@code StringList}インスタンスをシリアライズする。
     *
     * @serialData リストのサイズ({@code int})を書き出して、
     * 適切な順番にすべての要素({@code String})が続くようにする
     */
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int num = s.readInt();
        for (int i = 0; i < num; i++) {
            add((String) s.readObject());
        }

    }
}

注意点

transient フィールド

StringList のカスタムシリアライズ実装では private フィールドに transient 修飾子が指定されています。

transient 修飾子は、デフォルトのシリアライズ対象からそのフィールドを除外することを指定します。 すなわち、カスタムシリアライズ実装では StringList のすべての private フィールドは自動的にはシリアライズされなくなります。

デフォルトシリアライズを利用するかどうかに関わらず、defaultWriteObject を呼び出すと transient 指定されたフィールド以外のフィールドはすべてシリアライズされます。 無用なフィールドのシリアライズを避けるために、可能な限りフィールドは transient 指定するべきです。

特に transient を指定するべきフィールドとして、

  • データフィールドから値を計算できるフィールド(キャッシュなど)
  • ネイティブのデータ構造へのポインタを表すフィールド
  • 特定の JVM の1回の実行にひもづくフィールド

があります。

基本的にすべてのフィールドを transient 指定しようと考えてから、『このフィールドは transient を外そう』と考えるようにします。 この時、本当に transient でなくてよいかはよく確認してください。

transient フィールドのデフォルト値

transient 修飾子を指定した場合、デシリアライズされた際のフィールド値は、その型のデフォルト値になります。 オブジェクトならば null、boolean なら false、int ならば 0 です。

これらの値が受け入れられない場合、値を適切に設定する readObject メソッドを実装しなければなりません。 デシリアライズは言語外の仕組みを使うので、コンストラクタによる不変式の強制は利用できません。

defaultReadObject

StringList のフィールドはすべて transient 修飾子が指定されています。

すなわち、デフォルトシリアライズシリアライズされるフィールドはないはずです。 それにも関わらず writeObject 内で defaultWriteObject() を呼び出し、さらに readObject で defultReadObject() を呼んでいることに注意していください。

この呼び出しをすることで、後のリリースで StringList に transient ではないフィールドを追加することを可能にします。

もし、新しいバージョンで transient ではないプロパティが追加されて古いバージョンでデシリアライズした際、仮に defaultReadObject() を呼んでいないとしたら StreamCorruptedException が発生してしまいます。

@serialData

writeObject() は private メソッドにも関わらず、ドキュメンテーションコメントがつけられていることに注意してください。

writeObject() はシリアライズ形式を示しています。そして、シリアライズ形式は公開 API の一部です。 そのため、@serialData タグを用いてシリアライズされるデータの定義を書かなければなりません。

シリアライズが不適切な場合

前回述べたように StringList をデフォルトシリアライズにすることはいくつかの点で不適切です。 しかし、StringList のデフォルトシリアライズは『元のオブジェクトを正しくシリアライズし、デシリアライズによって正しく復元できる』という意味では正しいものです。

一方、デフォルトシリアライズを選ぶと、明確にエラーを引き起こすクラスがあります。

たとえばハッシュテーブルでは、デフォルトのシリアライズは機能しません。 なぜなら、キーとなるハッシュ値が、異なる JVM 間では違う可能性があります。 さらにいうとプログラムの実行ごとにハッシュ値が異なる可能性すらあります。

そのため、ハッシュテーブルにおけるデフォルトシリアライズによってハッシュ値シリアライズされると、デシリアライズした際に不変式が破られている可能性があります。

synchronized

もし、オブジェクトが同期によってスレッドセーフを実現しているならば、 writeObject メソッドは synchronized にする必要があります。

シリアルバージョン UID

Serializable を実装する場合、すべてのクラスで明示的なシリアルバージョン UID を定義してください。

シリアルバージョン UID は次のように宣言します。

private static final long serialVersionUID = randomLongValue;

明示的にシリアルバージョン UID を宣言すると、クラスのシリアライズに関する互換性を保つことができます。 これを宣言しない場合、シリアルバージョン UID はクラスの名前やフィールドを元にして自動生成されます。 すなわち、フィールド一つ追加しただけで、シリアルバージョン UID が変わってしまい、互換性が失われてしまいます。

また、明示的にシリアルバージョン UID を宣言しないと、実行時に UID を生成するためコストの高い計算が必要となります。

randomLongValue の値はどのような値でも問題ありませんが、serialvar ユティリティを実行すると値を生成できます。 最近の IDE には seiralVersionUID 生成機能が含まれています。

もし、クラスの古いバージョンにシリアルバージョン UID がついていない場合、新しいバージョンには古いバージョンのシリアルバージョン UID 自分で計算して、付け加える必要があります。

もし、異なるシリアルバージョン UID を持つクラスをデシリアライズしようとすると InvalidClassException が発生します。