はじめに
最近、プログラミングに関するQ&Aのサイトstackoverflowに興味深い質問が投稿されました。
java - How to reverse a string that contains complicated emojis? like “🏴” - Stack Overflow
興味を持ったので解答してみようとしたのですが時間がかかってしまい、もたもたしているうちにクローズされてしまいました。質問者がチャレンジしたコードが記載されていないために「This question needs to be more focused. It is not currently accepting answers.」となってしまったようです。
この投稿ではこの質問に対する解答を与えてみようと思います。
(ここで記述したコードはすべてTestEmojiReverse.javaにあります)
質問の内容
質問は簡単で、以下の文字列を反転するにはどうしたらいいか?ということです。
Hello world👩🦰👩👩👦👦 🏴
期待する結果は以下の文字列です。
🏴 👩👩👦👦👩🦰dlrow olleH
StringBuilderを使う
文字列の反転と言えば、StringBuilder#reverse()を使うのが定番です。
static String reverse0(String s) {
return new StringBuilder(s).reverse().toString();
}
@Test
void testReverse0() {
System.out.println(reverse0("Hello world👩🦰👩👩👦👦 🏴"));
}
🏴 👦👦👩👩🦰👩dlrow olleH
大体うまく反転していますが、なんかおかしいです。
サロゲートペアを考慮する
Javaの文字列は基本的に16bitのcharで1文字を表すようになっていますが、絵文字などはchar2個で1文字を表現します。これをサロゲートペアと言います。最初のやり方はこのchara2個で1文字のサロゲートペアを入れ替えているためにおかしくなっているようです。Javaには文字列からサロゲート文字も非サロゲート文字もひとつのint(Javaでは「コードポイント」と呼びます)として取り出すAPIがあります(String#codePoints())。これを使って正しく1文字ずつ反転してみましょう。
static void reverse(int[] ints) {
for (int i = 0, j = ints.length - 1; i < j; ++i, --j) {
int temp = ints[i];
ints[i] = ints[j];
ints[j] = temp;
}
}
static String reverse1(String s) {
int[] codePoints = s.codePoints().toArray();
reverse(codePoints);
return new String(codePoints, 0, codePoints.length);
}
@Test
void testReverse1() {
System.out.println(reverse0("Hello world👩🦰👩👩👦👦 🏴"));
}
🏴 👦👦👩👩🦰👩dlrow olleH
結果は同じでした。
ゼロ幅ジョイナー
いずれの場合も、うまくいっていないのは4人がセットになった絵文字「👩👩👦👦」です。この文字でググると以下のサイトが見つかりました。
👨👩👧👦意味: 家族: 男性 女性 女の子 男の子 Emoji絵文字コピペ | Emoji 絵文字一覧 📓 | EmojiAll 日本語公式サイト
どうやらこの文字は一文字のように見えて、4つの絵文字を「ゼロ幅ジョイナー」という「文字」を使って結合したもののようです。
ゼロ幅ジョイナー(ZWJ)は、2つ以上の文字を結合して新しい文字または絵文字を形成できるUnicode文字です。 UnicodeコードポイントはU + 200Dです。
ゼロ幅ジョイナーを使用して新しい絵文字を作成できますが、それ自体は絵文字ではありません。単独で使用すると、見えない文字になります。
問題の文字列から取り出したコードポイント列を16進でプリントしてみます。
@Test
void testPrintHex() {
"Hello world👩🦰👩👩👦👦 🏴".codePoints()
.forEach(c -> System.out.print(Integer.toHexString(c) + " "));
System.out.println();
}
48 65 6c 6c 6f 20 77 6f 72 6c 64 1f469 200d 1f9b0 1f469 200d 1f469 200d 1f466 200d 1f466 20 1f3f4 e0067 e0062 e0077 e006c e0073 e007f
1fxxx
が絵文字を表すコードポイントですが、その間に200d
がはさまっています。これが4つの絵文字をひとつに合成しているようです。しかし「絵文字1+ゼロ幅ジョイナー+絵文字2+ゼロ幅ジョイナー+絵文字3+ゼロ幅ジョイナー+絵文字4」をコードポイント単位でひっくり返すと「絵文字4+ゼロ幅ジョイナー+絵文字3+ゼロ幅ジョイナー+絵文字2+ゼロ幅ジョイナー+絵文字1」となって、これはこれで1文字に合成されそうな気もします。試しにコードポイント単位でひっくり返したものを印刷してみます。
@Test
void testJoiner() {
System.out.println("👨\u200d👩\u200d👧\u200d👦");
System.out.println("👦\u200d👧\u200d👩\u200d👨");
}
👨👩👧👦
👦👧👩👨
どうやら順番を変えると合成されたりされなかったりするようです。「男性、女性、女の子、男の子」は1文字になりますが、「男の子、女の子、女性、男性」は「男の子」、「女の子」、「女性、男性」の3文字になってしまうようです。
Regexを使う
ゼロ幅ジョイナーで結合された文字は順序を変えないように反転すればよいことがわかりました。1文字ずつ操作するプログラムを書くのは面倒なので正規表現を使ってみます。JavaのRegexは「.」(ピリオド)が任意の1文字にマッチしますが、これはchar単位ではなくコードポイント単位でマッチします。
static final Pattern JOINED = Pattern.compile(".(\u200D.)+|.");
static String reverse2(String s) {
List<String> list = new ArrayList<>();
Matcher m = JOINED.matcher(s);
while (m.find())
list.add(m.group());
Collections.reverse(list);
return String.join("", list);
}
@Test
void testReverse2() {
System.out.println(reverse2("Hello world👩🦰👩👩👦👦 🏴"));
}
🏴 👩👩👦👦👩🦰dlrow olleH
なんとかうまくいきました。ただし、どんな文字列でもこれで反転できるかどうかは確信が持てません。Wikipediaのゼロ幅接合子を見ると、ゼロ幅結合子は単純に文字と文字の間に配置するだけではないようです。
最後に
16bitコードで世界中の文字を表すという方針で登場したUnicodeでした。しかし、絵文字などを追加するに至って16bitでは足りなくなり20bitに拡張しました。さらにそれを4つ組み合わせて1文字を表現するなど複雑怪奇な規格に発展しました。JavaはUnicodeの当初の方針に沿って仕様が決められたため、「文字列の反転」のような単純なことをするだけで複雑なプログラミングを強いられます。ネットで見てもこの分野は情報量が少ないような気がします。発端となったstackoverflowのページに載っている脳天気なコメント群を見てもそれは明らかです。
この領域に関して何か気が付いたらまた投稿しようと思います。