またまたドハマリをしたので報告します。シチュエーション的にはまれだと思いますが、同じ罠にはまると、解決するのに時間がかかると思うので報告します。
現象としては、以下の2記事の組み合わせです。
TreeSetのComparatorではまったのでメモ(初心者向け)
http://d.hatena.ne.jp/s-ishigami/20110615/p1
噛み砕いていうと「compareの結果が0と、equalsが同値でないと、TreeSetは異常な動きをするよ」というところです。
そろそろ2年間Mayaa使ってわかったことを書く
http://d.hatena.ne.jp/s-ishigami/20110708
mayaaでは、Javaオブジェクトとテンプレートとの橋渡しにJavaScript(Rhino)を使用します。〜略〜「Javaのように見えて少し違う」書き方をしなければなりません。Javaだと思って書いていると、うっかり思いもよらないバグを作ってしまってハマります。
今回はこの具体例の好例だと思います。
コード例
実務では書かないと思いますが、実験のため以下の様なコードを書いたとします。
public class Example {
public static SortedMap<String, String> newNumberSortedMap() {
return new TreeMap<String, String>(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
int i1 = 0, i2 = 0;
try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
return i2 - i1;
}
});
}
}
ここでやっていることは、"1", "2"のように、数字だけで構成された文字を入れたとすると、"1", "10", "2"...のようなアスキー順にならず、"1", "2", "10", ...のように数値順に列挙できるマップをつくろうとしています。数字以外をキーするケースはビジネス上考えなくてよく(バリデートされているか、DBで検査されているとする)、万が一設定された場合は数値"0"として判断されます。
mayaaファイルをこのように実装します。実務ではないかもしれませんが、mapに値を格納して、エントリーセットを変数に格納しています。
<?xml version="1.0" encoding="UTF-8"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org">
<m:beforeRender>
<![CDATA[
var map = Packages.com.example.Example.newNumberSortedMap();
map.put(\'0\', "zero");
map.put(\'1\', "one");
map.put(\'2\', "two");
]]>
これを実行するすると、以下のようなエラーが発生します。
TypeError: Cannot call property put in object {0=zero}. It is not a function, it is "string".
原因
これはどういう事でしょうか?直訳すると、「プロパティー"put"をオブジェクト{0=zero}にcallできません。それは関数ではなく、それはstringです。」です。JavaScriptのことがわからないとこのエラーは意味不明です。
JavaScriptには、以下のようなシンタックスシュガーがあります。
var hoge = { a: 'a' };
このとき、
hoge.a
と
hoge['a']
は同等です。したがって、先程の
map.put('1', 'one')
は、
map['put']('1', 'one')
のように変化します。
このままなら、map['put']が、Javaのmap#put(String,String)を呼び出す関数オブジェクトになって、それにパラメータ('1', 'one')が渡されるだけなので、問題ありません。
ところが、さらに、今後はスクリプトエンジンRhino特有のシンタックスシュガーが悪さをします。RhinoはJSPのEL式のような書式をサポートしています。例えば、
hoge.fuga
と書くと、
hoge.getFuga()
へのショートカットになったりします。Map型に対してはさらに、
map['hoge']
が、
map.get("hoge")
に割り当てられます。すると、先ほどの式は、
map.get("put")
へと変化します。
続いて、このMapオブジェクトのgetの実装に処理は移譲されます。このmapは上記では、独自Comparatorを搭載したTreeMapです。このTreeMapはアプリケーションの制約により、数字以外の文字列を格納しないルールでした。数字以外の文字列を格納したときの動作は保証されません。具体的には、最初にリンクをした記事にあるように、TreeMapのgetはComparatorで0になるキーの値を返してしまいます。そのため、
map.get("put")
は
map.get("0")
と同じ結果を返してしまいます。運が悪いことに、直前に
map.put("0", "zero")
が呼び出されていました。そのため、この式は"zero"というString値を返してしまいます。このままJavaScript側に処理を戻すと、今このようになっています。
'zero'('1', 'one')
まとめ
上記をまとめると以下のようになります。
map.put('1', 'one'); map['put']('1', 'one'); // JavaScriptのシンタックスシュガー map.get("put")('1', 'one'); // Rhinoのシンタックスシュガー map.get("0")('1', 'one'); // Comparatorの実装ミス "zero"('1', 'one');
はい、
It is not a function, it is "string"
というわけです(笑)
修正方法
今回の問題の根本原因はComparatorの実装にバグがあることでした。正しい実装は以下のとおりです。(キーにnullを許容しない場合、許容する場合は上記の記事の最後に正解があります)
@Override
public int compare(String o1, String o2) {
int i1 = 0, i2 = 0;
try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
if (i1 == i2) {
return o1.compareTo(o2);
}
return i2 - i1;
}
最初に引用したとおり、TreeMapのComparatorを実装するときは、o1とo2が完全に一致する場合を除いて0を返してはいけません。そのようなコードを書いたときの動作はAPIによって保証されていません。
今回は、SunVMで検証したので、もしかしたらVMによっては動作が異なるかもしれません。
考察
今回は、TreeMapで陥りやすい罠、にMayaaあるいはテンプレート系DSL全般で陥りやすい罠が組み合わさって非常に発見しない不具合が発生してしまいました。まだおかしな動作をしてデータを破壊することはなく、例外で落ちていたからましかも知れません(前回TreeMapで問題を起こしたときは最悪データを壊す可能性がありました)
言えることは、他言語間(JavaとJavaScriptなど)のコラボレーションはシームレスには行かないということです。境界がある以上どこかにほつれは必ず存在します。そのための方針としては、両者の役割をできるだけ分離し、境界でコードが動くことを少なくすることです。JavaとJavaScriptを行ったり来たりするケースでは、プリミティブ型とString、配列など典型的な型のみを使用し、やむなくListやMap、その他複雑なオブジェクトを使う場合は十分に注意するべしというところです。
それは分かっていても、今回のような問題は発生します。そこでできるのは情報共有だと思います。少しでも誰かの役に立てるよう、今回のように問題にはまったときは、これからも情報を発信し続けようと思います。