0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaのString.length()は「文字数」ではない:絵文字が文字数制限に引っかかる原因をデバッグする

0
Last updated at Posted at 2026-08-18

はじめに

表示名の上限を「3文字」と決めたとき、ABCは通るのにA😀Bだけが拒否されることがあります。見た目にはどちらも3文字ですが、JavaのString.length()は利用者が意図する文字数ではなく、UTF-16のコード単位数を返します。

この記事では、表示名を登録する小さなJava APIで、A😀Bを4文字として拒否する問題を再現します。受理結果だけでなく、判定に使った測定値と登録後の最終状態を分けて観測し、codePointCount()を使う最小修正で直します。Javaの文字数制限で、どの単位を数えるべきかも整理します。再現コードはjava-unicode-character-limit-debug-labにあります。

先に結論

原因は、Unicodeコードポイント数で定義した表示名の上限に対して、String.length()のUTF-16コード単位数を使っていたことです。JavaのStringはUTF-16形式で補助文字をサロゲートペアとして表し、補助文字はString内で2つのchar位置を使います。Java SE 21 API: String

修正では、displayName.length()displayName.codePointCount(0, displayName.length())へ置き換えます。String.codePointCount()は指定範囲のUnicodeコードポイント数を返します。Java SE 21 API: String

入力 バグあり 修正後
ABC 3として受理 同じ
A😀B 4として拒否 3として受理・登録
A😀BC 5として拒否 4として拒否

再現プロジェクト

このラボはJava 21の標準ツールだけで動きます。外部ライブラリやWebフレームワークを使わず、文字列の計測単位だけを原因に保っています。

表示名の上限は3コードポイントです。A😀BA😀Bの3コードポイントなので受理し、登録済み集合へ入ることを契約にします。

最初に失敗するテストを書く

テストでは、同じ操作に対して3つの観測を行います。まず直接結果のaccepted、次に判定に使われたmeasuredCharacterCount、最後にレジストリへ保存された最終状態です。

void acceptsThreeUnicodeCodePointsAndStoresTheDisplayName() {
    DisplayNameRegistry registry = new DisplayNameRegistry(3);

    RegistrationResult result = registry.register("A😀B");

    assertTrue(result.accepted(), "3コードポイントの表示名を受け入れる");
    assertEquals(3, result.measuredCharacterCount(), "コードポイント数を結果へ返す");
    assertTrue(registry.contains("A😀B"), "受理した表示名を登録する");
    assertEquals(Set.of("A😀B"), registry.registeredNames(), "最終状態は受理した表示名だけを持つ");
}

バグを含むコミット8610ca6でテストを実行すると、設定やコンパイルではなく、期待対実際の差分で失敗します。

Exception in thread "main" java.lang.AssertionError: expected=true actual=false: 3コードポイントの表示名を受け入れる
expected=3 actual=4: コードポイント数を結果へ返す
expected=true actual=false: 受理した表示名を登録する
expected=[A😀B] actual=[]: 最終状態は受理した表示名だけを持つ
expected=4 actual=5: 超過判定にもコードポイント数を使う

ここで重要なのは、単に拒否されたことではありません。計測値が4であることと、レジストリが空であることを確認したため、入力、判定、保存後状態のつながりを確認できます。

観測と切り分け

「登録処理が呼ばれていない」「集合への追加を忘れた」「文字数を誤って数えた」という候補を、観測を分けて比較します。

確認したこと 観測結果 分かったこと
入力 A😀B 上限ちょうどの3コードポイントを意図した入力である。
直接結果 accepted=false 上限判定の結果が契約と異なる。
測定値 measuredCharacterCount=4 拒否の直接原因は保存後の処理ではなく、判定前の数え方にある。
最終状態 registeredNames=[] レスポンスだけの問題ではなく、登録が実行されていない。
実装 displayName.length() UTF-16コード単位を文字数として利用している。

レジストリへの追加漏れなら、測定値は3で受理結果だけが異なるはずです。しかし実測値は4です。したがって、集合実装や保存順序ではなく、文字の単位を取り違えたことが直接原因だと絞り込めます。

原因

修正前の実装です。

public RegistrationResult register(String displayName) {
    int measuredCharacterCount = displayName.length();
    if (measuredCharacterCount > maximumCharacterCount) {
        return RegistrationResult.rejected(
                measuredCharacterCount,
                "表示名は%d文字以内で入力してください".formatted(maximumCharacterCount)
        );
    }

    registeredNames.add(displayName);
    return RegistrationResult.accepted(measuredCharacterCount);
}

JavaのStringでは、インデックスやcharはUTF-16コード単位を基準にします。補助文字はサロゲートペアで表現されるため、😀のような補助文字はString中で2つの位置を使います。Java SE 21 API: String その結果、A😀Blength()は4です。

この挙動はJavaとして正しい一方、今回の表示名上限の契約とは異なります。問題はlength()が壊れていることではなく、「上限を何の単位で数えるか」を決めずに利用者向けの文字数へ流用したことです。

修正

上限契約に合わせ、Unicodeコードポイント数を数えます。

int measuredCharacterCount = displayName.codePointCount(0, displayName.length());

codePointCount()を使うと、A😀Bは3、A😀BCは4になります。既存の登録処理はそのままにし、数える単位だけを変える最小修正です。

public RegistrationResult register(String displayName) {
    int measuredCharacterCount = displayName.codePointCount(0, displayName.length());
    if (measuredCharacterCount > maximumCharacterCount) {
        return RegistrationResult.rejected(
                measuredCharacterCount,
                "表示名は%d文字以内で入力してください".formatted(maximumCharacterCount)
        );
    }

    registeredNames.add(displayName);
    return RegistrationResult.accepted(measuredCharacterCount);
}

再発防止テスト

修正後も同じテストを残します。テストは成功結果だけでなく、上限を超えるA😀BCが拒否されることも検証します。

観点 検証内容
受理 A😀Bが受理される。
計測 A😀Bが3、A😀BCが4と報告される。
最終状態 受理した名前だけが集合へ追加される。
拒否 A😀BCは集合へ追加されない。

この形にすると、将来length()へ戻してしまう変更だけでなく、受理結果と保存後状態が食い違う変更も検出できます。

文字数の単位を決める

codePointCount()length()より適切ですが、画面上で利用者が1文字と感じる単位と常に一致するわけではありません。文字数制限では、次の単位を区別して仕様を決めます。

数える単位 Javaでの例 A😀Bの数 用途
UTF-16コード単位 String.length() 4 charやUTF-16の位置を扱う処理
Unicodeコードポイント String.codePointCount() 3 コードポイント単位の入力制限
グラフェムクラスタ Unicode Text Segmentation 3 利用者が知覚する文字に近い入力制限

例えば、👨‍👩‍👧‍👦は画面上では1つの家族の絵文字に見えますが、複数のコードポイントで構成されます。また、も、1つのコードポイントで表す場合と、eと結合文字で表す場合があります。Unicode Standard Annex #29: Unicode Text Segmentation

今回の修正は、「上限をUnicodeコードポイントで数える」という契約には正しい修正です。画面上の1文字を上限にしたい場合は、グラフェムクラスタに基づく別の方針を設計し、UIとサーバーで同じ規則を使う必要があります。

バグを自分で再現する

git clone https://github.com/tonbiattack/java-unicode-character-limit-debug-lab.git
cd java-unicode-character-limit-debug-lab

git checkout 8610ca6
./scripts/test.sh
# 失敗: A😀Bが4として拒否される

git checkout main
./scripts/test.sh
# 成功: DisplayNameRegistryTest

まとめ

JavaのString.length()は、利用者向けの文字数をそのまま返すAPIではありません。UTF-16コード単位で扱うことが正しい場面もありますが、表示名の上限をUnicodeコードポイントで数える契約とは異なります。

デバッグでは、受理結果、測定値、保存後状態を別々に観測しました。これにより、集合への追加漏れではなく、上限判定に使った単位が原因だと確定できます。文字数制限では、最初に数える単位を仕様として決め、その単位を直接検証するテストを残すことが重要です。

参考資料

Java SE 21 API: String

Unicode Standard Annex #29: Unicode Text Segmentation

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?