CLOVER🍀

That was when it all began.

Java 11で文字を数える

これは、なにをしたくて書いたもの?

Javaで文字数を数える時に、String#lengthではサロゲートペアがうまく数えられないことは有名だと思うのですが。

Unicode - Wikipedia

結合文字というものを踏まえた数え方をちゃんと知らなかったので、今回見てみることにしました。

タイトルは、確認したバージョンがJava 11なだけです。

注)BreakIteratorではうまく数えられない絵文字があるので、その場合はJava 17へ

Java 17で文字を数える - CLOVER🍀

BreakIterator

結合文字は、基底文字と組み合わせて文字を作ることができる文字のことを言います。

Unicodeにおける、異体字セレクタも該当します。

異体字セレクタ - Wikipedia

異体字セレクタは、このサイトで確認するのがよさそうですね。

異体字セレクタセレクタ

異体字セレクタも、結合文字の一種です。

結合文字 - Wikipedia

結合文字は、文字の組み合わせで文字を表現するとはいえ、見た目の文字数と実際の文字数が異なることになります。
ここで、人が認識する区切り位置で文字を認識してくれるクラスが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"

準備

Maven依存関係とMavenプラグインの設定はこちら。

    <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で文字を切り出す

BreakIteratorStringから「見かけ上と合致する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について知らなかったのでこの機会に試してみました。

今後のことを考えると知っていた方が良さそうなので、覚えておこうと思います。

参考

文字数をカウントする7つの方法

(プログラマのための)いまさら聞けない標準規格の話 第2回 文字コード実践編 | オブジェクトの広場