私が直面したのはマッチメーキングをするアプリの作成中でした。
プレイヤーとプレイヤーを組み合わせた対戦履歴のリストがあり、対戦はプレイヤーのペアを保持し、プレイヤーは自分の対戦を保持します。
リレーショナルデータベースではありがちな構造だと思います。
ObjectOutputStream.writeObjectの実装は対戦履歴を読みに行ってプレイヤーを見つけ、プレイヤーの持っている対戦履歴を見つけ、さらにその中のプレイヤーを読んで…と走査していくはずです。
なんのチェックもなければ無限ループに嵌ってスタックオーバーフローを起こすでしょう。
最初はこの無限ループに嵌ったのだと思っていました。
しかし、小さいサンプルや少ない組み合わせだとループを作ったつもりでもシリアライズが成功するので、理屈がわからずにしばらく悩んでしまいました。
おそらく、走査したオブジェクトのリストを持っていて、一度走査したオブジェクトを発見した時点で走査を切り上げる、という実装になっていると思われます。
それでも結局スタックオーバーフローに陥ってしまうのだと思われます。しかも、それほど高負荷をかけなくても。
スタックをどの位確保しているかは実装依存だと思いますが、私の手元のGalaxy S2 Android 4.0.2の場合、8人の対戦を2回戦するとスタックオーバーフローが発生します。
これは実用上無視できる規模ではもちろんありません。
本物のコードは少々複雑なので、問題を簡易化したサンプルを作って検証しました。
AとB、2つのクラスがそれぞれのインスタンスを保持しあう構造で、どこまでシリアライズが可能かを確認します。
class A implements Serializable{ B b; } class B implements Serializable { A a; } A instance; for(int i=0; i<128; ++i){ A a = new A(); instance = a; for (int j = 0; j < i; ++j) { a.b = new B(); a.b.a = new A(); a = a.b.a; } } ObjectOutputStream o = new ObjectOutputStream(openFileOutput("test",0)); o.writeObject(instance); o.close();
これもGalaxy S2 Android 4.0.2の場合、i=15でスタックオーバーフローが発生します。
解決編に進む前に、もうひとつ面白い挙動を見つけたので、そちらも紹介しておきます。
件のマッチメーカーアプリは回転に対応しています。
回転した際にActivityが再構成されますから、データを引き渡さなければなりません。
アプリはプレイヤーのリストと対戦履歴のリストをどちらも保持するゲームのオブジェクトを持っていて引き渡します。
onSaveInstanceStateで保存して、onCreateで再生するという、一般的な処理です。
オブジェクトを引き渡すのに使うメソッドputSerializable()、引数はObjectではなくSerializableです。
なので私はすっかり旧Activityから新Activityにデータが引き渡される際にデータはシリアライズ>デシリアライズが行われていると思い込んでいました。
しかし、だとしたら、回転時にStackOverflowExceptionが起こってしかるべきです。
実際、例外は発生せず、データを引き継いでの回転が行われていました。(事実は、先に回転の実装を行い、実績があるので保存もシリアライズを使おう、と思って嵌ったのですが。)
ストリームへのシリアライズとActivity間のシリアライズにどんな違いがあるのか、やはりしばし悩んでしまいました。
デバッガで確認したところなんのことはない、回転後のゲームオブジェクトは回転前と同じidを持っていました。
シリアライズなどされずにインスタンスそのものが引き渡されていたのです。
さてこれらの実験からオブジェクトのシリアライズは小さい単位でやろうねとか、回転したときには同じインスタンスが渡されるよとか結論するのは絶対ダメです。
シリアライズで無限ループが起きていないのも、同一インスタンスが送られているのも、実装依存です。
スペックに実装を規定するような記述はありませんでした。
他の端末では無限ループになってしまうような実装がされているかもしれません。
他の端末では毎回まじめにシリアライズが行われているかもしれません。
タイミング依存で、我がGalaxy S2でもシリアライズをすることがあるかもしれません。
例えばActivityが隠されていて、結構な時間がたった後に再表示される場合とか、ありそうな話です。
上品プログラマーはスペックに記述されていないいかなる挙動も想定してはいけないのです。
結論です。
Android Javaにおいては、相互参照するオブジェクトをシリアライズしてはいけない、と考えるべきでしょう。
データはデータベースで管理して、JavaオブジェクトはIDだけを持っていなさい、というのが一番Android向きの設計なのだと思います。が、それは言わないお約束にして、私はこんな感じで解決してみました。
class A implements Serializable{ transient B b; int id; void setB(B b){ this.b = b; this.id = b.id; } boolean bind(B[] bs){ for(B b : bs){ if(b.id == id){ this.b = b; return true; } } return false; } } class B implements Serializable { int id; A a; B(int id){ this.id = id;// it must be unique } }クラスAは保存用のidとBの参照を持つというハイブリッド構造です。
Bの参照がtransient宣言されているので保存されない、というのがミソです。
これで必要以上の循環参照は発生しません。要は参照構造が途切れていればいいので、Bに仕掛けは入れません。
この仕組みだと、デシリアライズしたときtransient宣言された参照bに値は入っていませんから、bind()でidをもとにデータバインディングをやり直さなければなりません。
余談になりますが、C#には何種類ものシリアライザが用意されています。
代表的なもののうち、BinaryFormatterは相互参照しているオブジェクト群をシリアライズすることができます。Windows OSで動くことが事実上決まっているので、上記のStackOverflowのようなことは起きないと思われ、使用するのは安全だと思っています。
もう一方のXmlObjectSerializerは相互参照しているオブジェクトのシリアライズを試みるとエラーになります。不親切にも見えますが、自分にできないことは受け入れないというのは正しい振る舞いです。
Javaはさまざまなプラットフォームで動かなければいけない宿命を持っているので見極めが難しいのですが、今の振る舞いならXmlObjectSerializerと同じく最初からNotSupportedExceptionを出してくれる方がむしろ親切なのではないかと思いました。