これは、なにをしたくて書いたもの?
Javaで文字数を数える時に、String#length
ではサロゲートペアがうまく数えられないことは有名だと思うのですが。
結合文字というものを踏まえた数え方をちゃんと知らなかったので、今回見てみることにしました。
タイトルは、確認したバージョンがJava 11なだけです。
注)BreakIterator
ではうまく数えられない絵文字があるので、その場合はJava 17へ
BreakIterator
結合文字は、基底文字と組み合わせて文字を作ることができる文字のことを言います。
結合文字は、文字の組み合わせで文字を表現するとはいえ、見た目の文字数と実際の文字数が異なることになります。
ここで、人が認識する区切り位置で文字を認識してくれるクラスがBreakIterator
であり、BreakIterator
を使うと
このような文字も人から見て違和感のない形で扱うことができます。
BreakIterator (Java SE 11 & JDK 11 )
Java 5の時点で、存在していたんですね。
BreakIterator (Java 2 Platform SE 5.0)
また、BreakIterator
を使うと、文字単位だけではなく行や単語を意識した区切りもできるようです。
今回は、BreakIterator
やその他の方法で文字を数えてみます。
環境
今回の環境は、こちら。
$ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04) OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.3 (ff8e977a158738155dc465c6a97ffaf31982d739) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-90-generic", arch: "amd64", family: "unix"
準備
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.8.1</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.21.0</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> </plugin> </plugins> </build>
テストコードで確認することにします。
また、テストコードの雛形はこちらです。
src/test/java/org/littlewings/CountCharTest.java
package org.littlewings; import java.text.BreakIterator; import java.util.function.Function; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; public class CountCharTest { // ここに、テストを書く }
お題
今回は、BMPの範囲の文字、サロゲートペア、異体字セレクタを使った文字、絵文字をそれぞれ数えてみます。
対象は以下にしました。
@Test public void print() { String bmpString = "羽"; String surrogatePairString = "\uD867\uDE3D"; // U+29E3D String variationSelectorString = "飴\uDB40\uDD01"; // U+98F4 U+E0101 String emojiString = "🍀"; System.out.printf("BMP example: %s%n", bmpString); System.out.printf("SurrogatePair example: %s%n", surrogatePairString); System.out.printf("VariationSelector example: %s%n", variationSelectorString); System.out.printf("Emoji example: %s%n", emojiString); }
UTF-16で表現している文字もありますが、標準出力で確認するとこのようになります。
BMP example: 羽 SurrogatePair example: 𩸽 VariationSelector example: 飴󠄁 Emoji example: 🍀
また、Unicodeコードポイントで見るとこのようになります。
@Test public void unicodeCodePoints() { String bmpString = "羽"; String surrogatePairString = "\uD867\uDE3D"; // U+29E3D String variationSelectorString = "飴\uDB40\uDD01"; // U+98F4 U+E0101 String emojiString = "🍀"; assertThat(bmpString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList())) .containsExactly("0x7fbd"); assertThat(surrogatePairString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList())) .containsExactly("0x29e3d"); assertThat(variationSelectorString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList())) .containsExactly("0x98f4", "0xe0101"); assertThat(emojiString.codePoints().mapToObj(v -> "0x" + Integer.toHexString(v)).collect(Collectors.toList())) .containsExactly("0x1f340"); }
今回は、この4つをいろいろな方法で数えてみます。
String#length
まずはString#length
から。
@Test public void stringLength() { String bmpString = "羽"; String surrogatePairString = "\uD867\uDE3D"; // U+29E3D String variationSelectorString = "飴\uDB40\uDD01"; // U+98F4 U+E0101 String emojiString = "🍀"; assertThat(bmpString.length()).isEqualTo(1); assertThat(surrogatePairString.length()).isEqualTo(2); assertThat(variationSelectorString.length()).isEqualTo(3); assertThat(emojiString.length()).isEqualTo(2); }
BMPの範囲の文字は長さ1、サロゲートペア(絵文字も)は長さ2、異体字セレクタを含んだ文字は長さ3になりました。
異体字セレクタが入った文字が長さ3になるのは、2つ目の文字がサロゲートペアだからですね(長さ1+長さ2)。
String#codePointCount
次は、String#codePointCount
。
@Test public void codePointCount() { String bmpString = "羽"; String surrogatePairString = "\uD867\uDE3D"; // U+29E3D String variationSelectorString = "飴\uDB40\uDD01"; // U+98F4 U+E0101 String emojiString = "🍀"; assertThat(bmpString.codePointCount(0, bmpString.length())).isEqualTo(1); assertThat(surrogatePairString.codePointCount(0, surrogatePairString.length())).isEqualTo(1); assertThat(variationSelectorString.codePointCount(0, variationSelectorString.length())).isEqualTo(2); assertThat(emojiString.codePointCount(0, emojiString.length())).isEqualTo(1); }
こちらの場合、サロゲートペアは長さ1になりましたが、異体字セレクタを使った場合は長さ2になっており、
見た目の文字数と異なるままになっています。
BreakIterator
最後はBreakIterator
。いわゆるIteratorでの数え上げになるので、処理はFunction
にまとめました。
@Test public void breakIterator() { String bmpString = "羽"; String surrogatePairString = "\uD867\uDE3D"; // U+29E3D String variationSelectorString = "飴\uDB40\uDD01"; // U+98F4 U+E0101 String emojiString = "🍀"; Function<String, Integer> counter = w -> { BreakIterator iterator = BreakIterator.getCharacterInstance(); iterator.setText(w); int count = 0; while (iterator.next() != BreakIterator.DONE) { count++; } return count; }; assertThat(counter.apply(bmpString)).isEqualTo(1); assertThat(counter.apply(surrogatePairString)).isEqualTo(1); assertThat(counter.apply(variationSelectorString)).isEqualTo(1); assertThat(counter.apply(emojiString)).isEqualTo(1); }
BreakIterator
を使うと、異体字セレクタを使った文字(結合文字)があっても見た目の同じ長さ(長さ1)になります。
こうすれば、見た目の文字数とカウントした文字数が同じになります、と。
オマケ: BreakItratorで文字を切り出す
BreakIterator
でString
から「見かけ上と合致する1文字ずつ」を切り出すには、以下のようにすれば良いみたいです。
@Test public void splitChars() { String string = "羽\uD867\uDE3D飴\uDB40\uDD01\uD83C\uDF40"; BreakIterator iterator = BreakIterator.getCharacterInstance(); iterator.setText(string); for (int start = iterator.first(), end = iterator.next(); end != BreakIterator.DONE; start = end, end = iterator.next()) { String c = string.substring(start, end); System.out.printf("char(%d, %d) = %s%n", start, end, c); } }
扱っている文字自体は、先ほどまで使っていたBMPの範囲、サロゲートペア、異体字セレクタ、絵文字を全部
つなげたものです。
String string = "羽\uD867\uDE3D飴\uDB40\uDD01\uD83C\uDF40";
最後に標準出力に書き出していますが、結果はこのようになります。
char(0, 1) = 羽 char(1, 3) = 𩸽 char(3, 6) = 飴󠄁 char(6, 8) = 🍀
BreakIterator
の使い方自体は、Javadocを参照するとよいでしょう。
BreakIterator (Java SE 11 & JDK 11 )
まとめ
これまで、String#length
およびString#codePointCount
などがJavaでの文字を数える方法だと思っていたのですが、
BreakIterator
について知らなかったのでこの機会に試してみました。
今後のことを考えると知っていた方が良さそうなので、覚えておこうと思います。