難解なSerializableという仕様について俺が知っていること、というか俺の理解

java.io.Serializable …、ある程度Javaに触れて来た人は必ず見たことがあるインターフェイスだと思う。私も何度も見てきたし、必要に迫られて自分の作ったクラスにSerializableをつけたこともある。しかし、こいつは一体何なのか?

継承の便利さ

僕らがまだJava初心者だった頃。継承というメカニズムに助けられながら育って来た。簡単に言えば、HttpServletクラスを継承しさえすれば、自分の作ったクラスがサーブレットとして認識されるのだ。また、abstractメソッドなどという便利な機能もあり、継承にあたって実装しなければいけないメソッドは確実に指示され、言われた通りにそのメソッドを実装すれば良い。

StrutsのActionも然り。そう、多くの場合は「継承さえすれば、望む物がだいたい出来上がる」というのがJavaの世界だと思っていた。

だが、世の中そんなに甘くない

そう、ここで出てくるのは、俺の好きな「リスコフの置換原則」って奴です。「派生型は、基本型と置換可能でなければなりません。」…抽象的で分かりづらいですね。わかりやすく具体的にしていきましょう。例えば以下のようなメソッドがあったとします。

public void foo(List<String> strings) { ... }

この場合、foo()の引数には、ArrayList, LinkedListなど、いずれを渡しても正常に動作しなければならない、という原則です。fooを実装する際、どんなリストなのかに関わらず、ひとまずListのルールに従ったもの(つまりListを継承した何らかのインスタンス)であれば動くように作れば良いのです。ここまではある程度簡単ですね。

では逆に、Listを継承した独自のMyListクラスを作る場合、どうなるでしょう。List型の引数というのは、さっき自分が作ったfoo()以外にも、あちこちで数え切れないほど使われています。そう、MyListクラスは、世の中に存在するList型を引数に持つ全てのメソッドに対して使用可能(正常に動作)でなければならないのです。

見たことも聞いたこともないメソッドでも正常に動作することを保証する、なんて出来るんでしょうか。まぁ、普通に考えたらムリです。ただ、一つ前提を作ることによって、それが可能になります。

「世の中に存在するList型を引数に持つ全てのメソッドは、Listインターフェイスの仕様(Javadoc)を前提に作られている」

この前提があると「MyListはListの仕様に従っていれば問題ない」ということになります。つまり "size()メソッドは要素の個数を返す" だとか、"要素が10個しかないのにget(100)したらIndexOutOfBoundsExceptionが飛ぶ" だとか、そういうルールです。

やろうと思えば、size()メソッドで負数を返したり、get(100)の時にIllegalArgumentExceptionを投げるようなMyListだって書く事ができるんですね。ひょっとして「そんなことしようと思わないだろww」と考えました? では、以下の状況はありえないでしょうか。

  • sizeの戻り値について
    • MyListでは「newされた直後でまだ一個も要素がaddされていない状況」と「様々なadd/removeを経て、たまたま要素の個数が0である状況」を区別したい
    • だから、前者の時は特殊な状況と考えて size() が -1 を返すようにしよう!
  • get時の例外について
    • 10個しか要素がないのに get(100) したら、たしか何かの例外が飛んでたよな…
    • 何が飛んでたか忘れた…けど、まぁ、IllegalArgumentException投げておけば例外的状況であるって意思表示はできるよね!

自分では「やらない」と言い切れても、チーム内に少なくとも一人くらい、こんな悪しき考えを実行に移す人が居るんじゃないでしょうか? size()に-1を返すようなMyListを下記のbarメソッドに渡したら、無限ループですね。しかし、barは悪くないです。List#size()のJavadocを見ると(暗黙的にですが)負数を返すような記述は書かれていないから、負数は返ってこないという前提でコードを書いて良いのです。

void bar(List<String> list) {
  int size = list.size();
  while (size != 0) {
    // ...
    size--;
  }
}

あーー、だいぶ話が逸れました。要するに、継承さえすれば仕様的に完全なものが作れる訳ではない。つまり、継承だけして安心すんな、って話です。逆です。クラスを継承したら責任も継承するんです。基底クラスが持っている責任(size()は個数を返さなければならん等)を、派生クラスでも果たさなきゃならんのです。

さて、Serializableにはどんな責任が…?

やっと戻って来れた。まず単語の意味から紐解いてゆこう。Serializable。つまり「Serialize可能な何か」という意味らしい。(シリアライズとは何か?については後ほど突き詰めます)

RunnableはRunする能力、つまりこいつを実装するとスレッドとして走らせることができるようになった。Comparableは比較する能力、つまりこいつを実装すると相互に比較できるように(つまり、ソートできるように)なった…。

継承だけして安心してるパターンの人たちにはなんと嬉しいことか。おめでたいとは正にこのことです。

ご存知の通り、Serializableにはメソッドの定義が1つもない、いわゆる「マーカインターフェイス」という奴らしいです。こいつをimplementsするだけで、自分のクラスにSerialize能力が付与されるんだぜ!! ヒーハー! …んなわけあるかぃw

逆です。「このクラスはSerializableインターフェイスが持つ責任を引き受けます。SerializableのJavadocに書いてある通りの挙動を保証します。」っていう宣言をしたことになるんです。「じぶん、シリアライズできまっせ。それを保証しまっせ。」と。

んじゃーシリアライズって何なのか

日本語で直列化とか言われるらしいです。余計わけわからんですね。要は、オブジェクトの状態をStreamの状態(1バイトずつ読み書きできるバイト配列状のデータ構造)に変換することをシリアライズと言うようです。逆にStreamの状態をオブジェクトに戻してやることをデシリアライズと言います。

package jp.xet.sample;
public class Person implements Serializable {
  public String firstName;
  public String lastName;
  public int age;
  public Person() {}
  public Person(String f, String l, int a) {
    firstName = f;
    lastName = l;
    age = a;
  }
}

例えばこんなPersonクラスがあったとする。new Person("Daisuke", "Miyamoto", 32) のインスタンスシリアライズすると、こんな形になるかもしれない。(シリアライズの形式は問わない。どんな形式でも、バイト列になっていて、復元可能なら良い。)

<jp.xet.sample.Person>
  <firstName>Daisuke</firstName>
  <lastName>Miyamoto</lastName>
  <age>32</age>
</jp.xet.sample.Person>

いや、またはこんな形かもしれない。

Daisuke,Miyamoto,32

ちなみに、ファイルってのは、1バイトずつのデータが並んでいる、要は直列の状態だ。つまりとにかく、ファイルに保存できる状態=ストリームと考えて良いと思う。この状態に変換することを「シリアライズ」と呼ぶわけだ。

オブジェクトは大抵「オブジェクトグラフ」と呼ばれる網状のデータ構造であって、直列データ構造ではない。このグラフ構造を直列構造に変換するのがシリアライズ。その逆変換がデシリアライズ。「どんな直列なのか」は問わない。XMLでもよし、カンマ区切りでもよし、バイナリでもよし。とにかく相互変換ができて、必要なときにシリアライズ・デシリアライズができればよいのだ。

シリアライズが非常に困難な例

先ほどのPersonクラスは、簡単に「シリアライズ可能であること」を保証できました。では逆に、シリアライズが困難(不可能ということはあり得ないらしい)なケースとはどういう状況か。

java.io.InputStreamやjava.sql.Connectionが代表例としてよく挙げられます。これらのインスタンスシリアライズできるでしょうか? 例えば、コネクションをシリアライズしてファイルに保存したとして…。次にこのコネクションをデシリアライズした時、またコネクションを以前と同じように利用できるでしょうか。まぁ、難しいですね。

あと、ファイル化できるということは、ネットワークを介してファイル転送して、遠隔のコンピュータとオブジェクトのやり取りができることを意味します。InputStreamをシリアライズして、別のコンピュータに送ってデシリアライズして使う…。要はネットワークファイルシステムの実装ですかね。実現が非常に難しいことがわかると思います。

さらに、ConnectionやInputStream自身がシリアライズ困難であるとともに、これらのインスタンスをフィールドに持つインスタンスシリアライズ困難です。例えばPersonクラスのフィールドにConnectionやInputStreamがあった場合、このPersonクラスはシリアライズ困難、ということになります。

Serializableをimplementsしているにもかかわらず実際にはシリアライズが困難なクラス、というのがいとも簡単に作れてしまう事が分かります。size()で-1を返すリストがいとも簡単に作れてしまうのと、同じなんですけどね。

Serializableの責任

「このクラスはConnectionとかInputStreamとか、シリアライズが困難なモンじゃないよ、ちゃんとシリアライズできるし、デシリアライズしたら元通りに動作する造りになってるよ!」という宣言をしているのがSerializableです。だから、それをimplementsするクラスは、その宣言通り、そういう造りでクラスを設計しなければならんのです。

じゃあ、どんなクラスがSerializableを満たすのか、その条件は? と言われると…。厳密には正直難しくてよくわからん。

俺は「自分でそのクラスをシリアライズするロジックを書こうと思った時、それがすんなり書けそうか否か」というビミョーで主観的な判断基準で、何とかやりすごしてますw

serialVersionUID

ところで、話題を少し変えて。Serializableなクラスを実装すると、しばしばコンパイラが警告を発します。

The serializable class Sample does not declare a static final serialVersionUID field of type long

こいつは、以下のようなフィールドを定義することにより、黙らせることができます。

public class Sample implements Serializable {
  private static final long serialVersionUID = 1L;
}

コンパイラを黙らせるだけの目的で、意味も分からず serialVersionUID フィールドを定義していませんか?

そもそもこの数字、何なの?

先ほどのPersonクラスの例でいきます。あるPersonをシリアライズしてファイルにしました。そのファイルをネットワークで転送して、別のマシン(JVM)上でデシリアライズを試みるとします。

送信側のPersonクラスと、受信側のPersonクラスが「同じ構造をしていること」が保証できるでしょうか。同じ jp.xet.sample.Person という完全修飾名を持っていたとしても、両者に全く同じクラスがロードされているとは限りません。受信側には「年齢フィールドがなく、代わりに誕生日フィールドがあるPersonクラス」が待ち構えているかもしれないのです。(新しいバージョンでは年齢ではなく誕生日でデータを扱うことになった、なんてよくある話)

package jp.xet.sample;
public class Person implements Serializable {
  public String firstName;
  public String lastName;
  public java.util.Date birthDate;
  public Person() {}
  public Person(String f, String l, java.util.Date b) {
    firstName = f;
    lastName = l;
    birthDate = b;
  }
}

このような行き違いを防ぐのが、serialVersionUID という値です。このフィールドには「クラス構造のハッシュ値のようなもの」が入っているとされています。クラスのメンバに何か変更があったときは、この数値も変わる、という約束があるんです。そして、送受信側のこの値が一致したらば、正常にシリアライズ・デシリアライズができる、という保証をするのも約束事ですね。

age版のPersonでは serialVersionUID = 596260986973177761L、birthDate版では serialVersionUID = 7307477313162123319L というような固有の値をつけておくのです。そして、シリアライズデータの中にこの数値を仕込んでおくと、デシリアライズ時に「おや、serialVersionUIDの値が一致しねーぞ」ということで、簡単にエラーを特定することができるようになります。

もし、IDEに促されるまま、警告を黙らせる為だけに 1L という値をセットした場合。"Miyamoto,Daisuke,32"というデータをbirthData版でデシリアライズすると…。

birthDate側のシリアライズルールで「Date型をlong型に直してシリアライズする」というものがあった場合は、new Date(32L); として解釈されるんでしょうね。

Thu Jan 01 09:00:00 JST 1970

上記の通り、エポックから32ミリ秒後となりました。

serialVersionUIDを定義する、ということ

あるクラスにserialVersionUIDを定義したら、「今後、このクラスの構造が変わった場合は、必ずこの値も更新します」という約束が有効になります。

しかし、serialVersionUIDによる型の同一性チェックなんか要らない、というアプリケーション特性は「よくあること」だと思います。別にネットワークで転送して別のVMで読み書きなんかしない。1つのVMの中で保存できて読み出しができれば十分なんだ。自分でシリアライズしたものを自分でデシリアライズするだけだから、クラスの構造が変わるなんてあり得ない、という状況。

でも、そのアプリケーションを外から見た際、こいつが「シリアライズ時の型同一性チェックに対応したアプリなのか否か」は分からないのです。従って、serialVersionUIDが定義されているかどうか、で判断するのが妥当です。例えばserialVersionUIDを定義していない場合「ああ、シリアライズ時の型同一性チェックに対応する気がないのね」というアピールになる。

逆に「わざわざserialVersionUIDを定義しているということは、型同一性チェックに対応しているんだ!」と判断されても文句は言えませんね。serialVersionUIDを定義したのにメンテナンスしなかった場合、「値が一致しているのになぜデシリアライズに失敗するんだ!」とキレられても文句が言えません。

よくある「そもそもそこまでの保証はしてないよ、必要がないしコストにも見合わないから」と判断したというアピールのためにも、serialVersionUIDは「定義しない」のが現実的だと考えています。というわけで @SuppressWarnings("serial") 対応ですね。

まとめ

と、まぁ、Serializableについて思う所を適当に書いてみました。