toLowerCaseの落とし穴とCase Foldingの話

こんにちは。LINEでAndroid Clientを開発しているMasakuniです。

これはLINE Advent Calendar 2016の4日目の記事となります。

LINEのアプリ・サービスは多くの国で使われているため、国際化や多言語化はサービス開発時における重大なテーマの一つです。 今回は、その中でも「大文字・小文字変換」について話をします。

Javaにおける String#toLowerCase() / toUpperCase() の挙動

まずは一つ、問題を出してみましょう。

Q. 以下のJavaテストコードは常にpassすることが保証されているでしょうか?

assertEquals("i", "I".toLowerCase()); 

A. No.

一見単純なテストコードですが、これはJavaの実行環境によっては失敗することがあります。何故かと言うと、 "I".toLowerCase()"I".toLowerCase(Locale.getDefault()) と等価であり、実行環境のデフォルトロケールによって動作が変わるからです。

具体的には、ロケールがトルコ語(“tr”)のときに上記のテストは失敗します。トルコ語ではドット付きのIとドット無しのIが区別されており、”I”に対応する小文字は “ı” (U+0131, LATIN SMALL LETTER DOTLESS I) で、”i”に対応する大文字は “İ” (U+0130, LATIN CAPITAL LETTER I WITH DOT ABOVE) となります。

つまり、Javaのテストコードで書くと以下のようになるわけです。

 // U+0131 = ı 'LATIN SMALL LETTER DOTLESS I' 
assertEquals("\u0131", "I".toLowerCase(new Locale("tr")));
assertEquals("I", "\u0131".toUpperCase(new Locale("tr")));
  
// U+0130 = İ 'LATIN CAPITAL LETTER I WITH DOT ABOVE'
assertEquals("\u0130", "i".toUpperCase(new Locale("tr")));
assertEquals("i", "\u0130".toLowerCase(new Locale("tr")));

別の例を挙げましょう。Grave accentが付いている “Ì” (U+00CC, LATIN CAPITAL LETTER I WITH GRAVE) に対応する小文字は、多くのロケールにおいては “ì” (U+00EC, LATIN SMALL LETTER I WITH GRAVE) になります。しかしリトアニア語(“lt”)においては、”i” (U+0069, LATIN SMALL LETTER I, 普通のi) に結合文字 U+0307 (COMBINING DOT ABOVE) と U+0300 (COMBINING GRAVE ACCENT) が続いた “i̇̀” (U+0069 U+0307 U+0300) となります。つまり、Javaテストコードでは以下のようになります。

assertEquals("\u00EC", "\u00CC".toLowerCase(Locale.ENGLISH));
assertEquals("i\u0307\u0300", "\u00CC".toLowerCase(new Locale("lt"))); 

このようにUnicodeでのキャラクター数が増加する変換は他にもあります。たとえば、ドイツ語の ß (U+00DF, LATIN SMALL LETTER SHARP S) いわゆるエスツェット(Eszett)は大文字に変換すると “SS” になります。

assertEquals("FUSS", "Fuß".toUpperCase(Locale.GERMAN));
assertEquals("fuß", "Fuß".toLowerCase(Locale.GERMAN)); 

もっと厄介な例としては、ギリシア文字の小文字シグマがあります。これは通常 σ (U+03C3, GREEK SMALL LETTER SIGMA) で表されますが、単語の末尾では字形が変化した ς (U+03C2, GREEK SMALL LETTER FINAL SIGMA) となります。

assertEquals("σσς σσσς", "ΣΣΣ ΣΣΣΣ".toLowerCase(new Locale("el")));

「同じ文字が単語内の位置によって字形変化する」といったものは、その文字自体は単一の文字コードで表して、レンダリング時に文脈に応じて字形を変化させる、とするのが現代的なやり方でしょう。しかし、小文字シグマについては歴史的経緯もあって字形ごとに別々の文字コードが与えられているのです。

“Case Mapping” と “Case Folding”

Javaの String#toLowerCase()toUpperCase() はこのように複雑な挙動をするのですが、これはJavaが勝手に決めたものではありません。これらはUnicode標準が定めた “Case Mapping” の仕様に従っており、上記のような例外的なケースは SpecialCasing.txt という文書にまとまっています。”Case Mapping” は、例えば「文書の見出しを全て大文字で表記する」などのユースケースにおいて、その文書の言語に則ったルールで大文字・小文字を変換するためのものです。ですから、ロケール依存の複雑な例外などが数多くあるのです。

一方で、我々は toLowerCase()toUpperCase() といった操作を別の目的で使うこともよくあります。それは「大文字・小文字の違いを無視して文字列を比較したい」という目的のために文字列を一旦どちらかの文字種に統一する、というようなものです。Unicode標準は、このような目的のために “Case Folding” という操作を定義しています。Case Foldingは小文字へのCase Mappingとよく似ていますが、「caseless matchingを言語独立に行う」ために最適化されており、例えばギリシア文字の Σ は単語内の位置に関わらず常に σ (U+03C3) に変換されます。

JavaでCase Foldingを行う

では実際に Case Folding の操作をJavaで実行してみましょう。JavaでCase Foldingを行うにはICU4Jというライブラリを使うのが便利です。

ICU4Jで文字列のCase Foldingを行うには UCharacter#foldCase() メソッドを用います。

assertEquals("i", UCharacter.foldCase("I", UCharacter.FOLD_CASE_DEFAULT));
 
// U+0300 = 'COMBINING GRAVE ACCENT'
assertEquals("i\u0300", UCharacter.foldCase("I\u0300", UCharacter.FOLD_CASE_DEFAULT));
 
// U+00CC = Ì 'LATIN CAPITAL LETTER I WITH GRAVE'
// U+00EC = ì 'LATIN SMALL LETTER I WITH GRAVE'
assertEquals("\u00EC", UCharacter.foldCase("\u00CC", UCharacter.FOLD_CASE_DEFAULT));
 
// U+0131 = ı 'LATIN SMALL LETTER DOTLESS I'
assertEquals("\u0131", UCharacter.foldCase("\u0131", UCharacter.FOLD_CASE_DEFAULT));

// U+0130 = İ 'LATIN CAPITAL LETTER I WITH DOT ABOVE'
// U+0307 = 'COMBINING DOT ABOVE'
assertEquals("i\u0307", UCharacter.foldCase("\u0130", UCharacter.FOLD_CASE_DEFAULT));

assertEquals("σσσ σσσσ", UCharacter.foldCase("ΣΣΣ ΣΣΣΣ", UCharacter.FOLD_CASE_DEFAULT));  
assertEquals("fuss", UCharacter.foldCase("Fuß", UCharacter.FOLD_CASE_DEFAULT));

// A = U+FF21 'FULLWIDTH LATIN CAPITAL LETTER A'
// a = U+FF41 'FULLWIDTH LATIN SMALL LETTER A'
assertEquals("a", UCharacter.foldCase("A", UCharacter.FOLD_CASE_DEFAULT)); 

String#toLowerCase() との違いが分かりますでしょうか。なお、Case Foldingは基本的にはロケール非依存なのですが、上で述べたドット付きとドット無しのIの区別については汎用的には実現できないので、そのためのオプション UCharacter.FOLD_CASE_EXCLUDE_SPECIAL_I もあります。

また、Case Foldingを考慮した文字列比較については、基本的にはICU4Jの Normalizer#compare() を使うのが良いでしょう。

assertTrue(Normalizer.compare("i", "I", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("i\u0300", "I\u0300", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("\u00EC", "\u00CC", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("i\u0307", "\u0130", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("\u00EC", "I\u0300", Normalizer.COMPARE_IGNORE_CASE) == 0); // !!
 
assertTrue(Normalizer.compare("σσσ σσσσ", "ΣΣΣ ΣΣΣΣ", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("FUSS", "Fuß", Normalizer.COMPARE_IGNORE_CASE) == 0);
assertTrue(Normalizer.compare("a", "A", Normalizer.COMPARE_IGNORE_CASE) == 0); 

先程の UCharacter#foldCase() の変換前後の文字列が一致するのはもちろんですが、 "\u00EC""I\u0300" が一致だとみなされていることにも注目です。前者は一文字の “ì” (U+00EC, LATIN SMALL LETTER I WITH GRAVE) であり、後者は I に結合文字 U+0300 (COMBINING GRAVE ACCENT) が繋がった “Ì” (U+0049 U+0300) となります。Unicodeにおいては U+00EC のような合成済みの文字はそれを分解した文字列と 正準等価 とされます。Normalizer#compare() は正準等価性を考慮した比較を行うので、 "\u00EC""I\u0300" がcaseless matchだと判断できるのです。

このように、Case FoldingはUnicodeの等価性に基づく変換すなわち Unicode正規化 と共に用いられることがしばしばあります。ここではUnicode正規化の説明については省略しますが、NFKC (Normalization Form Compatibility Composition) という正規化とCase Foldingを組み合わせたものをUnicode標準では NFKC_Casefold と呼んでおり、

A mapping designed for best behavior when doing caseless matching of strings interpreted as identifiers.

と述べています。このNFKC_Casefold変換をICU4Jを使って行うには Normalizer2#getNFKCCasefoldInstance() を用います。

Normalizer2 normalizer2 = Normalizer2.getNFKCCasefoldInstance();

assertEquals("i", normalizer2.normalize("I"));
assertEquals("\u00EC", normalizer2.normalize("\u00CC"));
assertEquals("\u00EC", normalizer2.normalize("I\u0300"));
assertEquals("\u0131", normalizer2.normalize("\u0131"));
assertEquals("i\u0307", normalizer2.normalize("\u0130"));
assertEquals("σσσ σσσσ", normalizer2.normalize("ΣΣΣ ΣΣΣΣ"));
assertEquals("fuss", normalizer2.normalize("Fuß"));

assertEquals("a", normalizer2.normalize("A"));
assertEquals("1", normalizer2.normalize("1"));
assertEquals("%", normalizer2.normalize("%"));
assertEquals("パーセント", normalizer2.normalize("㌫"));
assertEquals("パーセント", normalizer2.normalize("パーセント"));
assertEquals("パーセント", normalizer2.normalize("パーセント"));
assertEquals("ぱーせんと", normalizer2.normalize("ぱーせんと")); 

NFKC_Casefoldは正準等価よりも広い概念である互換等価に基づく変換なので、半角片仮名を全角片仮名へ変換する正規化なども行われていることがわかります。

AndroidアプリでICU4Jを使う

ICU4Jには、本記事で紹介したものの他にも国際化対応のための有用なAPIが多数含まれています。もちろんAndroidアプリでもICU4Jのjarファイルを組み込むことでこれらのAPIを使うことができるのですが、このjarファイルは約11MBとかなり大きめです。

そのため、Android 7.0 (Nougat) からはICU4Jの一部の機能がAndroid frameworkの中に組み込まれました。「一部」と言っても本記事で扱ったAPIは全て含まれています。

https://developer.android.com/guide/topics/resources/icu4j-framework.html

Android frameworkに組み込まれたICU4Jは android.icu パッケージ内にあるので、以下のようなコードでAPIバージョンごとに呼び出し方法を切り替えると良いでしょう。

public static String foldCase(String sourceText) {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    return android.icu.lang.UCharacter.foldCase(sourceText, android.icu.lang.UCharacter.FOLD_CASE_DEFAULT);
  } else {
    return com.ibm.icu.lang.UCharacter.foldCase(sourceText, com.ibm.icu.lang.UCharacter.FOLD_CASE_DEFAULT);
  }
} 

これとMultiple APKの仕組みを組み合わせれば、Android 7.0以降向けにはAPKサイズを増やさずにICU4JのAPIを使えるようになります。

ところで String#equalsIgnoreCase() は?

そういえば String#equalsIgnoreCase() について触れるのを忘れていました。このメソッドはICU4Jの Normalizer#compare() のような正準等価性に基づく比較を行わないどころか、ßのような文字数が変化するCase Mappingを全く考慮していません。そのため、 toUpperCase() での変換前後の文字列が equalsIgnoreCase() では一致しないといったことも起こり得ます。

assertEquals("FUSS", "Fuß".toUpperCase(Locale.GERMAN));
assertFalse("FUSS".equalsIgnoreCase("Fuß")); // !! 

まとめ

Javaにおいて、Unicodeの大文字小文字の扱いについては以下のような方針で行うのが良いでしょう。

「ある文を全て大文字で表示する」などの目的のために、その言語に則ったルールで大文字・小文字を変換する場合

この場合は String#toUpperCase()toLowerCase() を用いるのが適切でしょう。ただし、これらのメソッドはデフォルトロケールによって動作が変わるので、必要に応じて明示的にロケールを指定するようにしましょう。 また、変換前後で文字数が変化することがある点にも注意しましょう。

どのようなUnicode文字が含まれているかわからない文字列を、大文字・小文字の違いを無視して比較したい場合

この場合はICU4Jの Normalizer#compare() を用いるのが適切でしょう。ただし、デフォルトでは ドット付きのIとドット無しのI を区別しないので、トルコ語で問題になる可能性があります。

ASCIIの範囲の文字しか含まないとわかっている文字列を、全て大文字(または小文字)に変換したい場合

この場合は "FooBar".toUpperCase(Locale.ROOT) のように明示的に Locale.ROOT を指定して String#toUpperCase()toLowerCase() を呼びましょう。(追記: Locale.ENGLISH よりも Locale.ROOT のほうが良いという指摘を受けたので修正しました。)
ロケールを明示的に指定しないと、デフォルトロケールがトルコ語のときに “I”, “i” の変換結果が意図しないものとなります。また、対象文字列がASCII文字しか含まないとわかっているのであれば、大文字・小文字の違いを無視した比較に String#equalsIgnoreCase() を用いても安全です。

参考文献

最後に、参考文献としてUnicodeのFAQページを紹介します。

http://www.unicode.org/faq/casemap_charprop.html

Unicodeにおける Case Mapping や Case Folding についてより深く知りたい方は、まずこの文書に目を通しておくと良いでしょう。

明日は Shoji さんによるAndroidで日付表記をお手軽に国際化するです。お楽しみに!

Related Post