every Tech Blog

株式会社エブリーのTech Blogです。

Goで実装するUnicode文字数カウントが実はわりと難しい的な話

開発2部の内原です。文字コードの話は大好物です。

一般的に、アプリケーションの開発において文字数カウントは非常に身近な機能です。パラメータ取得時やフォーム入力時など、様々な場面で文字数計算を実装する機会があります。

しかし、Unicode文字、特に絵文字や結合文字などが混在するテキスト処理において、「正しい文字数カウント」は意外に複雑な問題です。

この記事では、Go言語でのUnicode文字数カウントに焦点を当てて、実装時に注意すべき点を述べます。

文字数カウントの罠

まず、以下のコードについて考えます。

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "Hello👍🏿" // 6文字?

    fmt.Printf("バイト数: %d\n", len(s))                    // バイト数: 13
    fmt.Printf("ルーン数: %d\n", utf8.RuneCountInString(s)) // ルーン数: 7
}

一見6文字のように見える文字列ですが、実際にカウントすると異なる値が返却されます。これはどういう状況でしょうか?

この差異は、例えば以下のような場面で問題が発生し得ます。

  • フォーム入力時
    • 文字数制限を超過して入力できる
  • データベース投入時
    • カラム文字数の制限に抵触しDBエラーが発生する
  • UI表示
    • 文字数カウンターの表示がユーザーの感覚と合わなくなる

文字数カウント手法

Unicode文字を正しく扱うために、用途に応じて適切なカウント手法を選択する必要があります。

1. byte数カウント len()

UTF-8のバイト列として参照します。

func countBytes(s string) int {
    return len(s)
}

// 例
fmt.Println(countBytes("Hello"))  // 5
fmt.Println(countBytes("こんにちは")) // 15 (ひらながはUTF-8では3バイト)
fmt.Println(countBytes("café"))   // 5 (é[U+00E9]はUTF-8では2バイト)

2. rune数カウント utf8.RuneCountInString()

Go言語の内部コードである rune 単位で、 rune 1つがUnicodeのコードポイント1つに対応します。

func countRunes(s string) int {
    return utf8.RuneCountInString(s)
}

// 例
fmt.Println(countRunes("Hello"))  // 5
fmt.Println(countRunes("こんにちは")) // 5
fmt.Println(countRunes("café"))   // 4

3. 正規化後カウント

Unicodeでは、一見同じ見た目でも異なるUnicode表現になるものが存在します。結合文字(Combining Character)という文字数としてカウントしないコードポイントが存在します。

例えば以下の文字は一見同じ文字に見えます(環境によっては別物に見えるかも知れません)が、コードポイントしては別物で、また文字数も異なります。

  • が [U+304C]
  • が [U+304B U+3099]

これを一つの表現に統一するのが正規化で、正規化してからカウントします。

import "golang.org/x/text/unicode/norm"

func countNormalizedRunes(s string) int {
    normalized := norm.NFC.String(s)
    return utf8.RuneCountInString(normalized)
}

// 例
s1 := "が" // [U+304C]
s2 := "が" // [U+304B U+3099]

fmt.Println(s1 == s2)                            // false
fmt.Println(countRunes(s1), countRunes(s2))      // 1 2
fmt.Println(countNormalizedRunes(s1))            // 1
fmt.Println(countNormalizedRunes(s2))            // 1

正規化形式の選択

Unicodeには4つの正規化形式があります。最終的に獲得したい形式に対応した正規化方法を選ぶ必要があります。

名前 説明 特徴
NFC 正規化(合成) 視覚的に同じなら同じコードにまとめる
NFD 正規化(分解) 組み合わせ可能な文字を分解する
NFKC 互換正規化(合成) 表示上同じ意味の文字を1つにまとめる(全角→半角など)
NFKD 互換正規化(分解) 分解かつ互換性も加味
func compareNormalizationForms() {
    s1 := "が"
    fmt.Printf("NFD: %s(%U)→%s(%U)\n", s1, []rune(s1), norm.NFD.String(s1), []rune(norm.NFD.String(s1))) // NFD: が([U+304C])→が([U+304B U+3099])
    s2 := "が"
    fmt.Printf("NFC: %s(%U)→%s(%U)\n", s2, []rune(s2), norm.NFC.String(s2), []rune(norm.NFC.String(s2))) // NFC: が([U+304B U+3099])→が([U+304C])
    s3 := "Ⅳ"
    fmt.Printf("NFKC: %s(%U)→%s(%U)\n", s3, []rune(s3), norm.NFKC.String(s3), []rune(norm.NFKC.String(s3))) // NFKC: Ⅳ([U+2163])→IV([U+0049 U+0056])
    s4 := "A1"
    fmt.Printf("NFKC: %s(%U)→%s(%U)\n", s4, []rune(s4), norm.NFKC.String(s4), []rune(norm.NFKC.String(s4))) // NFKC: A1([U+FF21 U+FF11])→A1([U+0041 U+0031])
    s5 := "㎝"
    fmt.Printf("NFKD: %s(%U)→%s(%U)\n", s5, []rune(s5), norm.NFKD.String(s5), []rune(norm.NFKD.String(s5))) // NFKD: ㎝([U+339D])→cm([U+0063 U+006D])
}

4. 書記素クラスタ数(ユーザー知覚文字数)

Unicodeにはゼロ幅接合子(Zero Width Joiner)やModifierなど、文字数としてはカウントしない種類のコードポイントも存在します。

これらのコードポイントを用いると、特定のコードポイントと組み合わせて別の文字表現ができるようになります。これにより必要となるコードポイント数が減らすことができます。絵文字における肌の色を変更したり、🇯🇵をJ+Pのように表現する、といった用途に使われます。

最終的に、環境に依るところはありますがここで表示される文字列の表現がユーザーが実際に認識する文字数と言えます。

ただ、この算出を自力で実装するのはかなり大変なので、公開されているライブラリに頼るほうがよいと思います。

注意:最後の2つの文字がブログの仕様で複数文字に分割して表示されていますが、本来は 🏳️‍🌈 と 👨‍👩‍👧‍👦 です。

import "github.com/rivo/uniseg"

func countGraphemes(s string) int {
    gr := uniseg.NewGraphemes(s)
    count := 0
    for gr.Next() {
        count++
    }
    return count
}

// 例
fmt.Println(countGraphemes("Hello"))     // 5
fmt.Println(countGraphemes("こんにちは"))    // 5
fmt.Println(countGraphemes("café"))      // 4
fmt.Println(countGraphemes("😀"))      // 1 [U+1F600] (絵文字は1文字として認識)
fmt.Println(countGraphemes("🇯🇵"))     // 1 [U+1F1EF U+1F1F5](Regional Indicator Symbolsは1文字として認識)

fmt.Println(countGraphemes("🏳️<200d>🌈"))     // 1 [U+1F3F3 U+FE0F U+1F308] (白旗 + 異体字セレクタ + 虹 = レインボーフラッグは1文字として認識)
fmt.Println(countGraphemes("👨<200d>👩<200d>👧<200d>👦")) // 1 [U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466] (家族絵文字も1文字)

まとめ

文字数のカウントという一見簡単そうで実はいろいろとややこしい問題について記事を書いてみました。

Go言語でUnicodeを扱う場合、ある程度までは言語としてのサポートを受けられますが、そもそもUnicodeの仕様としてコードポイントと人間が認識する文字数には齟齬があるため、これらの差分を埋めるためにはどうしても実装時に考慮が必要となります。

やりたいことに応じた適切なカウント手法を採用するように心がけましょう。