Qiita Advent Calendar 2020のJava Advent Calendarの12月7日のエントリです。12月9日付けで追記と内容修正あります。
トルコ語問題については、ご存知の方はご存知だと思いますが、聞いたことがない人にはなかなか理解できない問題なので2020年の今、もう一回おさらいしておくのもいいかと思い書いています。(ほかにネタがなかった、とも言う。)
トルコ語の何が特殊なのか
トルコ語(とアゼルバイジャン語)には、dotted-iとdotless-iの二つのアルファベットがあります。
通常私たちは「i」の大文字が「I」であり、「I」の小文字が「i」であると理解しています。しかし、トルコ語ロケール(とアゼルバイジャン語ロケール)では「I」はドットなしIであるとみなされ、その小文字は「ı」になります。逆に「i」の大文字はドットありの「İ」なのです。
このため、以下のようなことが起こります。
Locale trLocale = new Locale("tr");
String largeDot = "i".toUpperCase(trLocale); // --> "\u130"
System.out.println("I".equals(largeDot)); // --> false
引数なしのtoUpperCase()/toLowerCase()はデフォルトのロケールを参照しますので、システム全体がトルコ語ロケールで動作している場合とそれ以外で以下のコードは異なる結果を返すというわけです。
String s1 = "Application-ID";
String s2 = "APPLICATION-ID";
System.out.println(s1.toUpperCase().equals(s2)); // --> true/false
たとえば、ヘッダの項目名とかで大文字小文字の区別のない定義済み文字列について一致を見たいケースなどありますよね。そのようなケースでシステムのロケールが変わると動かなくなるという問題が発生するわけです。
java.util.jar.Attributes$Name#equals()
分かりやすいかどうかは分からないですが、典型的な例としてピンポイントでこのメソッドを追いかけて見ます。JarファイルのManifestをハンドリングするときに、属性名の一致判定がトルコ語で失敗してしまい落ちるという話です。
JDK-4624534 : JarEntry.getCertificates() returns null on Turkish locale (tr_TR)
JDK1.4.2のソースコードです。java.util.jar.Attributesクラスの内部クラスであるNameのequals()メソッドの定義です。
public boolean equals(Object o) {
if (o instanceof Name) {
return name.equalsIgnoreCase(((Name)o).name);
} else {
return false;
}
}
単純な大文字小文字無視の一致判定であるString#equalsIgnoreCase()を呼び出しています。実はequalsIgnoreCase()は、JDK1.4.2の頃はロケールに依存した文字列比較を実行していました。つまりトルコ語ロケール下で"i"と"I"をequalsIgnoreCase()するとfalseが返っていました。
[追記]
嘘でした。String#equalsIgnoreCase()自体はこの時代からUnicode準拠の一致判定を実行していて"İ"と"I"を比較しても一致と判定するようになっていました。これはequalsIgnoreCase()が内部でCharacter.toUpperCase(Character)/toLowerCase(Character)を呼び出しているからで、String#toUpperCase()/toLowerCase()とは無関係でした。
何故Attributesがなぜ文字列一致で失敗し、Manifestの解析に失敗するかは後段に追記しました。しかしそうだとしたらなぜここを書き換えたのだろう…。
[追記終わり]
そこで、ここをまずは直したようです。
JDK1.5のソース。
public boolean equals(Object o) {
if (o instanceof Name) {
Comparator c = ASCIICaseInsensitiveComparator.CASE_INSENSITIVE_ORDER;
return c.compare(name, ((Name)o).name) == 0;
} else {
return false;
}
}
sun.misc.ASCIICaseInsensitiveComparator.CASE_INSENSITIVE_ORDERは、ASCIICaseInsensitiveComparatorクラスのstaticインスタンスです。これのcompare()メソッドは、
public int compare(Object o1, Object o2) {
String s1 = (String) o1;
String s2 = (String) o2;
int n1=s1.length(), n2=s2.length();
int minLen = n1 < n2 ? n1 : n2;
for (int i=0; i < minLen; i++) {
char c1 = s1.charAt(i);
char c2 = s2.charAt(i);
assert c1 <= '\u007F' && c2 <= '\u007F';
if (c1 != c2) {
c1 = (char)toLower(c1);
c2 = (char)toLower(c2);
if (c1 != c2) {
return c1 - c2;
}
}
}
return n1 - n2;
}
なので、ASCIIの範囲外のcharを含んだ文字列を引き渡すとassertionで落ちるのですが、JDK1.5の上記の箇所では「i」が含まれる文字列と「I」が含まれている文字列を比較しているだけなのでそこは大丈夫です。
この修正と関連しつつ独立して、以下のバグの記録のところで、当時UNICODE 3.0の規約での大文字小文字比較に準拠しているかどうか、みたいなコメントが出ていて、toUpperCase()、toLowerCase()から、equalsIgnoreCase()、compareToIgnoreCase()まで、このあたりの振舞を修正しながらバグをコツコツ直していったようです。
JDK-6208680 : Doc: Clarify issues with toLowerCase/toUpperCase and Turkish
なので、途中でString#equalsIgnoreCase()がドットあり/なしのIの比較で(Unicodeの振舞に準拠するために)trueを返すようになったりはしているのですが、こちらにはそのことは反映されず、_経緯はともかく_このあとしばらくは(JDK8まで)このコードが継承されていました。
しかし、安易にsun.misc.ASCIICaseInsensitiveComparatorを呼ぶコードが広がるのも問題です。JDK9では、そもそもjigsawの流れからも、sun.misc.*を呼ぶのはよろしくないよね、ということでしょうか、さらなる改訂が行われました。
ということでJDK9では以下のようになりました。
public boolean equals(Object o) {
if (o instanceof Name) {
Comparator<String> c = String.CASE_INSENSITIVE_ORDER;
return c.compare(name, ((Name)o).name) == 0;
} else {
return false;
}
}
ということで現在Manifestの属性名の一致判定でトルコ語で問題が発生することはなくなっています。
[追記]なぜJDK1.4.2はManifestでトルコ語で不具合を発生していたのか
これは、Attributes$Name#hashCode()の実装に問題がありました。
まずはAttributes#getValue()。
public String getValue(String name) {
return (String)get(new Attributes.Name((String)name));
}
Attributes#get()を呼んでいます。ではget()の中身はというと
public Object get(Object name) {
return map.get(name);
}
mapというインスタンスフィールドは、というと、Map型で宣言されていて実行時にはHashMap型が代入されています。HashMap#get(Object)の定義はというと、
public Object get(Object key) {
Object k = maskNull(key);
int hash = hash(k);
int i = indexFor(hash, table.length);
Entry e = table[i];
while (true) {
if (e == null)
return e;
if (e.hash == hash && eq(k, e.key))
return e.value;
e = e.next;
}
}
static int hash(Object x) {
int h = x.hashCode();
h += ~(h << 9);
h ^= (h >>> 14);
h += (h << 4);
h ^= (h >>> 10);
return h;
}
なので、渡したkeyのhashCode()が呼ばれているということになります。ここでAttributesの中で渡していたのはAttributes$Name型でした。そのhashCode()はどうなっているか、というと、
public int hashCode() {
if (hashCode == -1) {
hashCode = name.toLowerCase().hashCode();
}
return hashCode;
}
つまり、ここで、Name.name(String型)のtoLowerCase()(デフォルトロケール依存)のhashCode()を取っているのでした。nameにi/Iが含まれている文字列のケース違いを渡すとトルコ語ロケールでは値が異なるため、getValue()は実際には存在するNameとの一致判定でしくじってしまい、nullを返すという動作になってしまっていたのでした。
これがJDK1.5以降では、
public int hashCode() {
if (hashCode == -1) {
hashCode = ASCIICaseInsensitiveComparator.lowerCaseHashCode(name);
}
return hashCode;
}
となっており、大丈夫になったわけです。
なお、JDK9以降では(前述のsun.misc依存脱却のために)さらに
public int hashCode() {
if (hashCode == -1) {
hashCode = name.toLowerCase(Locale.ROOT).hashCode();
}
return hashCode;
}
になっています。(つくづく、ASCIICaseInsensitiveComparatorってその場しのぎのやっつけ仕事だったんだよなあ、と思わされますね…。)
[追記終わり]
これで終わりではない
ひとまずjarファイルのManifestのハンドリングのところだけの変遷を見てきましたが、トルコ語問題は、かなり初期から存在しており、しかもばらばらに発見されてはつぶされるという経緯をたどってきました。
JDK-6972385: issues with String.toLowerCase/toUpperCase
JDK-7059542 : JNDI name operations should be locale independent
正直に言うとまだまだ潜在する同種の問題が存在しているような気配です。常に気をつけていないといけないという意味ではかなり注意が必要なバグパターンであると思います。
当然ですが、Javaのランタイムに存在する問題をすべて解決してしまえば終わりというわけではなくライブラリが同じ問題を抱えていたりアプリ側の処理が不十分で問題を引き起こすこともあります。
Unicodeの文字列比較のルールは複雑ですし、バージョン改訂に伴って振舞が変わることもありえますので、十分に気をつけてハンドリングすることが必要です。
Tipsとしては、
- とにかく文字列比較なんて分かってるから、みたいな態度はいったん捨てる。
- toUpperCase()/toLowerCase()してからequals()/compareTo()を呼ぶのは本当に大丈夫なときだけ。できるだけ、equalsIgnoreCase()/compareToIgnoreCase()を呼ぶコードに変更する。
- toUpperCase()/toLowerCase()をデフォルトロケールで呼んでよいかどうかは十分吟味する。ロケールに依存しない変換が必要ならLocale.ROOTを使う。(大丈夫ならLocale.ENGLISHでもよい。)
- いきなりASCIICaseInsensitiveComparator.CASE_INSENSITIVE_ORDERやString.CASE_INSENSITIVE_ORDERが出てきても慌てない。
- 逆にロケール依存の文字列比較・一致判定が必要な場合、Collatorクラスを使うことも考慮する。
ちなみに、当然ですがこれはJavaだけの問題ではありません。ほかの言語を使っておられる方も参考にされてください。(とここで書いてもしょうがないけど。)
上にLDAPの例が出ていますが、筆者はSMBで類似件に当たったことがあります。通信プロトコルは大文字小文字を無視する系が多いので要注意です。(しかもサーバは直せないのでクライアントの実装が割を食う例が多い。)
もう一つ、ここではASCII前提だった処理にトルコ語・アゼルバイジャン語が混ざるのでトラブルになる例ですが、ベース処理の仮定する文字体系と入力される文字体系がミスマッチを起こすことで問題になる可能性はいろんなものの中にあります。CJKでもありそうだな、とちょっと嫌な予感がしています。(今のところはその種の話は聞いていないですが。)