← 記事一覧へ
AI生成画像

"👨‍👩‍👧" の長さは、JavaScriptで8、Pythonで5、Rustで18、Swiftで1:文字数を数えるだけなのに

同じ文字列なのに、言語ごとに「長さ」が違う。𠮷野家、国旗、家族の絵文字を、4つの言語で実際に数えて、理由と、壊れない扱い方を確かめた。

AI作成 · 約9分 · どれか1つの言語が読めれば十分
Unicode文字列JavaScriptPython
この記事で扱うこと

「文字数」には、バイト、UTF-16の単位、コードポイント、見た目の1文字、の4通りの数え方があります。言語ごとの標準の「長さ」は、どれを数えるかが違います。4言語で実測して確かめました。

先に結果

次の絵文字、👨‍👩‍👧 は、見た目は1文字です。この長さを、各言語の標準の方法で数えると、答えがばらばらになります。

家族の絵文字の長さは、数え方で変わる👨‍👩‍👧見た目は1文字コードポイント(5つ)👨ZWJ👩ZWJ👧UTF-16(8単位)21212UTF-8(18バイト)43434各言語の「標準の長さ」JavaScript .lengthUTF-16の単位8Python len()コードポイント5Rust .len()UTF-8のバイト18Swift .count見た目の1文字1
図1 家族の絵文字は、人には1文字に見える。中身は、コードポイント5つ(ゼロ幅結合子を含む)、UTF-16で8単位、UTF-8で18バイト。言語ごとの標準の「長さ」が、どれを数えるかで、答えが変わる。
JavaScript .lengthPython len()Swift .countRust .len()
👨‍👩‍👧85118
🇯🇵4218
𠮷野家43310

どれも間違いではありません。数えているものが違うだけです。同じ「長さ」という名前で、言語ごとに別のものを返しています。この記事は、4つの言語で実際に実行して確かめた結果です。

自分の文字で、数えてみる

好きな文字を入れてください。名前、絵文字、国旗でも構いません。4つの数え方の数字が変わり、1文字ずつに割れる様子が見えます。下のスライダーで、先頭から取ったときに、壊れる文字と壊れない文字の違いも確かめられます。計算はすべて、あなたのブラウザの中で行われます。

この部品は、JavaScriptが必要です。

4つの数え方

「文字数」は、次の4つのどれを数えるかで、値が変わります。

  • UTF-8のバイト数:ファイルや通信に書き出すときの大きさ。実測では、Rustの.len()がこれを返しました
  • UTF-16の単位数:JavaScriptのlengthが数えるもの。MDNは「UTF-16のコードユニットでの長さ」とし、1つのUnicode文字が1つか2つの単位になるので、実際の文字数と合わないことがある、と書いています
  • コードポイント:Unicodeの文字1つ1つに付いた番号。Pythonのstrは「Unicodeコードポイントの不変の列」で、len()はその数です(公式ドキュメント)
  • 見た目の1文字(書記素クラスタ):人が1文字と感じる単位。UnicodeのUAX #29は、「G」とアクセント記号が、人には1文字でも、実際には2つのコードポイントで表される、という例を挙げています。Swiftのcountは、実測でこれを数えました

コードで確かめる

同じ9つの文字列を、4つの言語で、4つの数え方で数えました(筆者がNode.js 26.1.0、Python 3.11.5、Swift 6.1、Rust 1.86.0で実行して確認)。4言語の結果は、共通の数え方では、完全に一致しました。Rustは、書記素を標準では数えられないので、3つです。Pythonで書記素を数えるにはregexライブラリを使うので、あらかじめ pip install regex が必要です。

count.py
# 同じ文字列を、4つの数え方で数える(Python)。書記素の数え方だけは、標準ライブラリにないので regex を使う
import regex  # pip install regex

samples = [
    ('A', 'A'),
    ('あ', 'あ'),
    ('が(1文字で書いたもの)', 'が'),
    ('が(か+濁点で書いたもの)', 'が'),
    ('𠮷', '\U00020BB7'),
    ('𠮷野家', '\U00020BB7野家'),
    ('😀', '\U0001F600'),
    ('🇯🇵', '\U0001F1EF\U0001F1F5'),
    ('👨‍👩‍👧', '\U0001F468‍\U0001F469‍\U0001F467'),
]
for name, s in samples:
    utf8 = len(s.encode('utf-8'))
    utf16 = len(s.encode('utf-16-le')) // 2       # UTF-16 の単位数
    codepoints = len(s)                           # Python の len は、コードポイントの数
    graphemes = len(regex.findall(r'\X', s))      # \X は、見た目の1文字
    print(f'{name}\tutf8={utf8}\tutf16={utf16}\tcodepoints={codepoints}\tgraphemes={graphemes}')

実行結果は、次のとおりです(JavaScript版。4言語で同じ値になりました)。

文字列UTF-8のバイトUTF-16の単位コードポイント見た目の1文字
A1111
あ3111
が(1文字で書いたもの)3111
が(か+濁点で書いたもの)6221
𠮷4211
𠮷野家10433
😀4211
🇯🇵8421
👨‍👩‍👧18851

切り詰めると、壊れる

「先頭から何文字だけ表示する」という処理は、数え方を間違えると、文字が壊れます。次のコードは、「長さ」で切り詰めた結果です。

truncate.py
family = '\U0001F468‍\U0001F469‍\U0001F467'
print(family[:3])   # 家族ではなく、2人になる
print(family[:1])   # 1人だけになる

JavaScriptの実行結果は、次のとおりです。a𠮷b の先頭2単位を取ると、𠮷の途中で切れて、文字として壊れた値(孤立したサロゲート)になります。isWellFormed() は false を返しました。家族の絵文字は、コードポイントで3つ取ると、家族ではなく、2人の絵文字になりました。

実行結果(JavaScript)
"a\ud842"
false
👨‍👩

Pythonは、コードポイントで切るので、𠮷の途中で壊れることはありません。ただ、家族の絵文字は、同じように、2人や1人になります。

実行結果(Python)
👨‍👩
👨

同じ「が」が、2通りある

「が」は、1つのコードポイントでも、「か」と濁点の2つのコードポイントでも書けます。見た目は同じで、中身が違います。比べると、一致しません。

normalize.py
import unicodedata

a = 'が'          # が(1つのコードポイント)
b = 'が'    # か + 濁点(2つのコードポイント)
print(a, b)           # 見た目は同じ
print(a == b, len(a), len(b))
print(a == unicodedata.normalize('NFC', b))   # 正規化すれば、等しくなる
実行結果(JavaScript)
が が
false 1 2
true

Pythonでも、同じ結果になりました。正規化(NFC)すると、等しくなります。検索や重複チェックで、「見た目は同じなのに一致しない」ことが起きるのは、この違いが原因のことがあります。

実行結果(Python)
が が
False 1 2
True

直し方:見た目の1文字で扱う

見た目の1文字で数えたり、切ったりするには、書記素に分ける必要があります。JavaScriptは、標準のIntl.Segmenterで分けられます。Pythonは、標準ライブラリにないので、regexライブラリの \X を使います。Swiftは、標準のcountが、すでに見た目の1文字です。

safe.py
# 見た目の1文字(書記素)単位で、先頭から n 文字を取る
import regex  # pip install regex

def graphemes(s):
    return regex.findall(r'\X', s)

def truncate(s, n):
    return ''.join(graphemes(s)[:n])

print(truncate('a\U00020BB7b家', 2))                                                   # a𠮷
print(truncate('\U0001F468‍\U0001F469‍\U0001F467と\U0001F1EF\U0001F1F5', 1))  # 家族の絵文字がそのまま
print(len(graphemes('\U0001F1EF\U0001F1F5\U0001F1FA\U0001F1F8')))                       # 国旗2つ → 2

3つとも、同じ結果になりました(JavaScriptとPythonで一致を確認)。𠮷や、家族の絵文字が、途中で切れません。

実行結果
a𠮷
👨‍👩‍👧
2

Rustは、標準ライブラリに書記素の分割がないので、外部のクレートが必要です。この記事では、Rustの書記素は試していません。書記素の分け方は、Unicodeのバージョンや実装で、結果が変わることがあります。

チェックリスト

文字列を扱うときに、確認する項目です。

  • 「文字数」の制限を決めるとき、数える単位(バイト、UTF-16、コードポイント、見た目の1文字)を決めた
  • 切り詰めは、見た目の1文字単位で行っている(lengthやlen()で切っていない)
  • 比較や検索の前に、正規化(NFCなど)をそろえている
  • 絵文字や国旗は、見た目が1文字でも、内部は複数のコードポイントだと知っている
  • データベースの文字数の制限の単位は、使っているデータベースの仕様を確認した(この記事では試していません)

試していないこと:ほかの言語(Java、Go、C#など)、データベースの文字数の制限、Webサービスごとの文字数の数え方。これらは、この記事の結果には含まれません。

公式リファレンス

MDN — String: length ↗Unicode® Standard Annex #29: Unicode Text Segmentation ↗Python — Built-in Types: str ↗The Swift Programming Language — Strings and Characters ↗
PAIBOT / DEVELOPMENT JOURNAL記事一覧に戻る →

知りたいことを探す

タイトル・タグ・本文から検索 esc で閉じる