しばらく前から仕事でApache Shindigを触っている。Shindigは、Googleの提案しているソーシャルアプリケーション(日本だとソーシャルゲームと言った方が通りがいいかも)の共通API、OpenSocialの参照実装。
もそっと分かりやすく説明すると、mixiアプリって四角いハコ(実態はiframe)の中にアプリがあって、外側とは別々になっている感じがするでしょう。あの四角い箱もといiframeの中を描画しているのがShindig。
んでまあ、ソーシャルアプリケーション用のAPIなので、JavaScriptのライブラリとして提供されている関数を使って、例えば友達の情報(mixiでいうとマイミクとか)がゲットできて、そういったSNS上にあるソーシャルな情報を利用したアプリを皆が作れると。そういうわけです。
んで、そのShindig内部のJavaScriptライブラリのテストをしていて俺が昨日ぶち当たったコードがこれ。
/** * Adds the default profile fields to the desired array. * * @param {Map} params Parameter map. * @private */ opensocial.DataRequest.prototype.addDefaultProfileFields = function(params) { var fields = opensocial.DataRequest.PeopleRequestFields; var profileFields = params[fields.PROFILE_DETAILS] || []; params[fields.PROFILE_DETAILS] = profileFields.concat( [opensocial.Person.Field.ID, opensocial.Person.Field.NAME, opensocial.Person.Field.THUMBNAIL_URL]); };
これが何をしてるかっていうと、OpenSocialでは友達の情報などを獲得する時に「名前と、趣味と、出身地と…」みたいな感じにユーザ情報のうちどのフィールドが欲しいかを指定してリクエストを投げることが出来るわけです。それは
params[opensocial.DataRequest.PeopleRequestFields.PROFILE_DETAILS] = 必要なフィールドの配列
みたいな感じにparamsオブジェクトを作って、それをリクエストオブジェクトに含めて投げるんだけども、「OpenSocial APIで規定された関数をアプリ作成者が呼び出す→サーバにリクエストが投げられる」というフローの途中に上記の関数が挟みこまれていて、「ユーザがどんなフィールドを指定しようと必ずリクエストされるフィールド群」をparamsオブジェクトに追加しているわけ。
で、そこで、こんなコードを書いたとしたらどうなるか?
var params = {}; params[opensocial.DataRequest.PeopleRequestFields.PROFILE_DETAILS] = [ opensocial.Person.Field.NICKNAME, opensocial.Person.Field.ABOUT_ME, opensocial.Person.Field.DATE_OF_BIRTH, opensocial.Person.Field.PROFILE_URL, opensocial.Person.Field.THUMBNAIL_URL ]; var req1 = opensocial.newDataRequest(); req1.add(req1.newFetchPersonRequest(id, params), "person1"); req1.send(callback); var req2 = opensocial.newDataRequest(); req2.add(req2.newFetchPersonRequest(id, params), "person2"); req2.send(callback);
ちょっと分かり辛いけど、要はここで指定してるparamsが先程の関数に渡る。callbackは、ここでは書いてないけどレスポンスデータを処理するためのコールバック関数ということで。idはデータを要求したいユーザのID。
JavaScriptではオブジェクトは参照渡しのため、paramsは値ではなく参照がopensocial.DataRequest.prototype.addDefaultProfileFieldsに渡る。そして、addDefaultProfileFieldsは当然のようにparams[opensocial.DataRequest.PeopleRequestFields.PROFILE_DETAILS]にデフォルト指定されているフィールドを追加して…ってアレ?
なんとこれ、同じparamsを使った二回目のreq.sendだとparams[opensocial.DataRequest.PeopleRequestFields.PROFILE_DETAILS]にもう一度デフォルト指定のフィールドが追加されてしまうのである。オーマイガッ!オーマイガッ!
なんでこんなことになるのかというと、参照で渡したオブジェクトの状態を何の断りもなく変更しているから。こういうコード、あなたは邪悪だと思いますか?それとも思いませんか?というのが今回の話。
まあ関数型の一部とか、基本的にオブジェクトがimmutableな言語ダイスキーな人なら「mutableなオブジェクトだからこんなことが起こる」「副作用は悪だ」と言うだろうし、逆に、オブジェクトの状態などをあまり意識せずにどんどんmutableなオブジェクトを変更しまくるのが習慣になっている人は「別に邪悪って言うほど悪くないんじゃない、このコード」という感想を持つだろう。
確かに、「この関数は引数に取ったオブジェクトの中身を変更します!」とちゃんとコメントにでも書いてあるか、それがもっと明示的に関数名で示されていれば「邪悪はいくら何でも言い過ぎ」となるかもしれない。
でも思い出して欲しいのは、これはライブラリのコードで、通常はユーザから見えない部分でこの関数はこっそりと呼ばれていることだ。関数名が"thisFunctionWillChangeObjectStateSoPleaseUseItCarefully"だったとしても、その名前から挙動に気づくことは出来ないのだ。(といっても、JavaScriptなので頑張ればユーザが見ることが出来るだけ救いがある…かもしれない)
実際にアプリの開発者が上記のようにparamsを使いまわしたコードをどの程度の確率で書いてしまうかは分からないが、とにかく書くことは出来る。そして、場合によってはなぜそうなったのか分からない謎のError Responseに遭遇することになる。Error Responseになるかどうかは、このリクエストを最終的に受け取るJSON-RPCサーバの実装次第。
ちなみにShindigはオープンソースのプロジェクトで、「ブログにうだうだバグのこと書いてるんだったらパッチ投げろや」という話なのだが、これはバグというより「ユーザを驚かせる挙動」なので微妙なところではある。その辺どうなんでしょうな。
こういった挙動を避けつつ、仮にユーザがparamsを使いまわしたとしてもフィールドを増やさずにparamsにデフォルトのパラメータを付けるにはオブジェクトのdeep copyが必要になる。JavaScriptの標準ライブラリにはそういった機能をもった関数とかはない。一方OpenSocialのAPIには一応オブジェクトをJSONにシリアライズ/デシリアライズするための関数があるのでそれらを使ってこう書けないこともない。
function clone(object) { return gadgets.json.parse(gadgets.json.stringify(object)); }
はい、美事にダサい。
なんかええ方法はないんかいな、と会社のIRCでつぶやいていると、とあるお方が以下のリンクをくれた。
http://la.ma.la/blog/diary_200711270645.htm
function clone(obj){ var F = function(){}; F.prototype = obj; return new F; }
JavaScriptではプロトタイプ継承が行われるので、あるオブジェクトのプロパティを引き継いだ上で新しいオブジェクトを作りたいんなら継承してnewしちゃえばいいんでないの、というアイデア。これを思いつくokuさんは天才なのではなかろうか。
もちろんこれだと元のオブジェクトを変更すると全てのcloneオブジェクトに影響が及んでしまうし、オブジェクト以外のプリミティブ値には通用しないのだけど、今回はそういった心配も一般化もしなくていいケースだったのでこれで通した。シンプルかつスマートな解法だったので。
「オブジェクト指向」を名乗る世の多くの言語はクラスベースのものばかりだけれど、プロトタイプベースのオブジェクト指向も中々面白く、奥が深い。