Help us understand the problem. What is going on with this article?

Serializable実装クラスのテストについて考える (3)

More than 1 year has passed since last update.

java.io.Serializable 実装クラスに対してどのようなテストを行うべきなのかを考えるシリーズの3回目です。

これまで2回にわたり、Serializable 実装クラスに対して必要なテストについて実例を交えて見てきました。

今回のテーマは「ケース3:そのクラス、本当に Serializable ですか?- 最低限、シリアライズ/デシリアライズしてみる」です。

Serializable を implements しただけではシリアライズ/デシリアライズ可能にならない場合がある

Serializable を implements しさえすれば自動的にシリアライズ/デシリアライズ可能になるというのは、よくある大きな誤解です。実例を見てみましょう。

実例1

次の Odd クラスは、奇数値を保持するだけの単純な int のラッパーです。

Odd.java(あまり役に立ちそうにないが、実装上の問題はない)
public class Odd {
    private int n;

    public Odd(int n) {
        set(n);
    }

    public final void set(int n) {
        if (n < 1 || n % 2 == 0) {
            throw new IllegalArgumentException("n must be an odd. n==" + n);
        }
        this.n = n;
    }

    public int get() {
        return n;
    }
}

このままではあまり役に立たないので、Odd を継承して ExtendedOdd クラスを作りました。値が何回参照されたかカウントする機能を追加するとともに、それをファイルに保存できるよう、Serializable を実装しています。

ExtendedOdd.java(注意!このクラスには問題がある)
public class ExtendedOdd extends Odd implements Serializable {
    private AtomicInteger counter = new AtomicInteger();

    public ExtendedOdd(int n) {
        super(n);
    }

    @Override
    public int get() {
        counter.incrementAndGet();
        return super.get();
    }

    public int getCount() {
        return counter.get();
    }
}

さて、ExtendedOdd クラスはシリアライズ/デシリアライズ可能でしょうか? いいえ、このクラスのオブジェクトをデシリアライズすることはできません
実際にやってみましょう。

ExtendedOddTest.java(関連ソースは本稿末尾に掲載)
public class ExtendedOddTest {

    @Test
    public void test1() throws ClassNotFoundException, IOException {
        ExtendedOdd eOdd = new ExtendedOdd(7);
        byte[] bytes = TestUtil.serialize(eOdd);
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(ExtendedOdd.class));
    }
}

このテストを実行すると、Object obj = TestUtil.deserialize(bytes); の行で次の例外が発生して失敗します。

java.io.InvalidClassException: mypkg.ExtendedOdd; no valid constructor

原因は、Odd が引数なしのコンストラクタを持たないことです。
Odd 自体は Serializable ではないので、インスタンス化するためにはコンストラクタを用いるほかありません1。ExtendedOdd をデシリアライズする過程で、デシリアライズ機構は引数なしのコンストラクタを用いて Odd 部分をインスタンスに復元しようとします。このため失敗するわけです。
Odd 自体を Serializable にすることでこの問題は解消します2

実例2

次の例はオセロ盤クラスです。
盤上の位置を (i, j) で指定できるようにしつつ、内部では Position オブジェクトに変換することにより、データを HashMap で管理できるようにしています。
(え? 2次元配列で実装すりゃ良いって? いやいやこれは例ですから...)

OthelloBoard.java(注意!このクラスには問題がある)
public class OthelloBoard implements Serializable {

    private final Map<Position, Color> map = new HashMap<>();

    public void put(int i, int j, Color color) {
        // 紙幅の関係でパラメータチェックは省略(以下同様)
        map.put(new Position(i, j), color);
    }

    public Color get(int i, int j) {
        return map.get(new Position(i, j));
    }

    private static class Position {
        private final int i;
        private final int j;

        private Position(int i, int j) {
            this.i = i;
            this.j = j;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof Position) {
                Position p = (Position) obj;
                return i == p.i && j == p.j;
            }
            return false;
        }

        @Override
        public int hashCode() {
            return 8 * i + j;
        }
    }
}

OthelloBoard は正しく動きますが、シリアライズできません
実際にやってみましょう。

OthelloBoardTest.java
public class OthelloBoardTest {

    @Test
    public void test1() throws IOException, ClassNotFoundException {
        byte[] bytes = TestUtil.serialize(new OthelloBoard());
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(OthelloBoard.class));
    }

    @Test
    public void test2() throws IOException, ClassNotFoundException {
        OthelloBoard board = new OthelloBoard();
        board.put(3, 4, Color.BLACK);
        board.put(4, 3, Color.BLACK);
        board.put(3, 3, Color.WHITE);
        board.put(4, 4, Color.WHITE);

        byte[] bytes = TestUtil.serialize(board);
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(OthelloBoard.class));
    }
}

test1 には合格しますが、test2 は byte[] bytes = TestUtil.serialize(board); の行で次の例外が発生して失敗します。

java.io.NotSerializableException: mypkg.OthelloBoard$Position

原因は内部クラス Position が Serializable でないことです。
OthelloBoard クラスのインスタンスフィールド map は HashMap クラスのオブジェクトであり、これは Serializable です。(そのため、test1 には合格しました。)
しかし、map の内部から参照している Position オブジェクトが Serializable ではないため、test2 に失敗しました。

このように、シリアライズ機構は基本的に、シリアライズ対象のオブジェクトだけでなく、そのオブジェクトの内部から参照している他のオブジェクトを芋づる式にすべてシリアライズしようとします。
そして、そのオブジェクトグラフの中にひとつでも Serializable でないクラスのオブジェクトがあると、元のオブジェクトのシリアライズ自体が失敗するというわけです。

ケース3:そのクラス、本当に Serializable ですか?- 最低限、シリアライズ/デシリアライズしてみる

2つの例を見てきましたが、これらから得られる教訓は単純です。
Serializable を実装したのであれば、実際にシリアライズ/デシリアライズしてみるべきであるということです。

シリアライズ/デシリアライズに関する実装の妥当性について、コンパイラは殆ど何も警告してくれません。何らの警告なしにビルドが成功し、プログラムとして動作もし、そして実際にシリアライズ/デシリアライズしてみて初めて実装の間違いに気づくのです。
やはり最低限、実際にシリアライズ/デシリアライズしてみるというテストを行うべきでしょう。

さて 次稿 では、これまで3回にわたり見てきた内容をまとめます。

おまけ

OthelloBoard のもう少しマシな例として、以前の投稿『リバーシで遊んで覚える Java 8.』で紹介したソースの中の GitHub: StrictBoardクラスへのリンク を貼っておきます。
ソースへの突っ込みなどなど、お待ちしております。

 ~ ~ ~

しかし投稿者自ら言うのもナンですが、シリアライズ/デシリアライズのテストって try/catch だとか IOException だとか、とにかく面倒な印象しかないんですよねぇ。。。これをもっとカンタンに、省力化できればと。
これについては、稿を改めたいと思います。⇒ 投稿しました:『JUnit4でのシリアライズ/デシリアライズ検証を楽チンにする!』

参考

本稿で使用したソースを掲載します。
なお、java のバージョンは java 8 です。

実例1

Odd.java
package mypkg;

public class Odd {
    private int n;

    public Odd(int n) {
        set(n);
    }

    public final void set(int n) {
        if (n < 1 || n % 2 == 0) {
            throw new IllegalArgumentException("n must be an odd. n==" + n);
        }
        this.n = n;
    }

    public int get() {
        return n;
    }
}
ExtendedOdd.java
package mypkg;

import java.io.Serializable;
import java.util.concurrent.atomic.AtomicInteger;

public class ExtendedOdd extends Odd implements Serializable {
    private AtomicInteger counter = new AtomicInteger();

    public ExtendedOdd(int n) {
        super(n);
    }

    @Override
    public int get() {
        counter.incrementAndGet();
        return super.get();
    }

    public int getCount() {
        return counter.get();
    }
}
ExtendedOddTest.java
package mypkg;

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import java.io.IOException;

import org.junit.Test;

public class ExtendedOddTest {

    @Test
    public void test1() throws ClassNotFoundException, IOException {
        ExtendedOdd eOdd = new ExtendedOdd(7);
        byte[] bytes = TestUtil.serialize(eOdd);
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(ExtendedOdd.class));
    }
}

実例2

OthelloBoard.java
package mypkg;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

public class OthelloBoard implements Serializable {

    private final Map<Position, Color> map = new HashMap<>();

    public void put(int i, int j, Color color) {
        // 紙幅の関係でパラメータチェックは省略(以下同様)
        map.put(new Position(i, j), color);
    }

    public Color get(int i, int j) {
        return map.get(new Position(i, j));
    }

    private static class Position {
        private final int i;
        private final int j;

        private Position(int i, int j) {
            this.i = i;
            this.j = j;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj instanceof Position) {
                Position p = (Position) obj;
                return i == p.i && j == p.j;
            }
            return false;
        }

        @Override
        public int hashCode() {
            return 8 * i + j;
        }
    }
}
Color.java
package mypkg;

public enum Color {
    BLACK,
    WHITE;
}
OthelloBoardTest.java
package mypkg;

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;

import java.io.IOException;

import org.junit.Test;

public class OthelloBoardTest {

    @Test
    public void test1() throws IOException, ClassNotFoundException {
        byte[] bytes = TestUtil.serialize(new OthelloBoard());
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(OthelloBoard.class));
    }

    @Test
    public void test2() throws IOException, ClassNotFoundException {
        OthelloBoard board = new OthelloBoard();
        board.put(3, 4, Color.BLACK);
        board.put(4, 3, Color.BLACK);
        board.put(3, 3, Color.WHITE);
        board.put(4, 4, Color.WHITE);

        byte[] bytes = TestUtil.serialize(board);
        Object obj = TestUtil.deserialize(bytes);

        assertThat(obj, instanceOf(OthelloBoard.class));
    }
}

共通

package mypkg;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Objects;

public class TestUtil {

    public static byte[] serialize(Object obj) throws IOException {
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
                ObjectOutputStream oos = new ObjectOutputStream(bos)) {

            oos.writeObject(obj);
            return bos.toByteArray();
        }
    }

    public static Object deserialize(byte[] bytes)
            throws ClassNotFoundException, IOException {

        Objects.requireNonNull(bytes);

        try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
                ObjectInputStream ois = new ObjectInputStream(bis)) {

            return ois.readObject();
        }
    }

    @SuppressWarnings("unchecked")
    public static <T> T writeAndRead(T obj)
            throws IOException, ClassNotFoundException {

        byte[] bytes = serialize(obj);
        return (T) deserialize(bytes);
    }

    private TestUtil() {
    }
}

注釈


  1. 一方、Serializable なクラスがデシリアライズされる際には、デシリアライズ機能によりインスタンスが直接生成され、コンストラクタが迂回されます。このことは、ExtendedOdd クラスのコンストラクタにデバッグログ出力コードを仕込むことにより容易に確認できます。 

  2. ただし、これはあまりよい解決策と言えません。Odd を継承している、あるいはこれから継承する他の全てのクラスに影響が及ぶためです。詳細な議論は Amazon:『Effective Java 第2版』-「項目74: Serializable を注意して実装する」を参照してください。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした