「この文章は何文字?」という素朴な問いには、実は一意な答えがありません。同じテキストでも、バイト数・コードユニット数・コードポイント数・書記素(見た目の1文字)数のどれで数えるかによって結果が変わります。本記事では Unicode と UTF-8 の基礎から、プログラムでよく起きる「絵文字が2文字に数えられる」問題までを、検算した実例とともに整理します。
1. 「文字数」は数え方で変わる
たとえば "a" は誰が数えても1文字・1バイトです。ところが "あ" は1文字ですが UTF-8 では3バイト、"😀" は見た目1文字なのに JavaScript の "😀".length は 2 を返します。これは間違いではなく、それぞれが異なる単位を数えているためです。まずはこの4つの数え方を区別することが出発点になります。
- バイト数:エンコード後の実際のデータ量。エンコーディング(UTF-8 など)で変わる。
- コードユニット数:エンコード方式の最小単位の個数。JS の
str.lengthは UTF-16 コードユニット数。 - コードポイント数:Unicode の符号位置(U+XXXX)の個数。
- 書記素クラスタ数:人が「1文字」と感じる単位の個数。
2. Unicode の基礎 — コードポイントと面
Unicode は世界中の文字に一意の番号を割り当てる規格で、その番号をコードポイントと呼び、U+ に続く16進数で表記します。たとえば "A" は U+0041、"あ" は U+3042、"😀" は U+1F600 です。
コードポイントは U+0000 から U+10FFFF までの範囲を持ち、64Kごとに「面(plane)」で区切られます。
- BMP(基本多言語面):
U+0000〜U+FFFF。ラテン文字、ひらがな、漢字の多くなど、日常的な文字が含まれる。 - 補助面(Supplementary Planes):
U+10000〜U+10FFFF。多くの絵文字、一部の漢字、歴史的文字など。
「BMPに収まるか、補助面か」は、後述する UTF-16 のサロゲートペアや str.length のズレに直結する重要な境界です。
3. UTF-8 — 可変長エンコーディング
UTF-8 はコードポイントを1〜4バイトの可変長でバイト列に変換する方式で、現在の Web の事実上の標準です。コードポイントの大きさに応じてバイト数が決まります。
| コードポイント範囲 | UTF-8 のバイト数 | 例 |
|---|---|---|
U+0000〜U+007F | 1バイト | ASCII(A, 数字, 記号) |
U+0080〜U+07FF | 2バイト | ラテン拡張、ギリシャ文字、キリル文字など |
U+0800〜U+FFFF | 3バイト | ひらがな・漢字など日本語の多く(あ) |
U+10000〜U+10FFFF | 4バイト | 多くの絵文字(😀)、一部の漢字 |
UTF-8 の利点は、ASCII と後方互換であること(ASCII文字はそのまま1バイト)と、エンディアンに依存しないことです。一方で、1文字あたりのバイト数が一定でないため、「文字数 ≠ バイト数」が常に成り立ちます。
4. コードユニット vs コードポイント vs 書記素クラスタ
ここがプログラミングで最も誤解されやすい部分です。JavaScript の文字列は内部的に UTF-16 で表現され、str.length はUTF-16 コードユニットの個数を返します。コードポイントや書記素の数とは限りません。
サロゲートペア(UTF-16 の補助面表現)
UTF-16 では BMP の文字を1コードユニット(2バイト)で表しますが、補助面(U+10000 以上)の文字は2つのコードユニット=サロゲートペアで表します。だから "😀".length === 2 になります。コードポイント単位で数えたいときは、スプレッド構文や for...of を使います。
"😀".length→ 2(UTF-16 コードユニット数)[..."😀"].length→ 1(コードポイント数)
結合文字・ZWJ と書記素クラスタ
さらに、複数のコードポイントが結びついて1つの見た目になる場合があります。結合文字(例:é を e + 結合アクセント U+0301 で表す)や、ZWJ(ゼロ幅接合子, U+200D)で絵文字を連結する家族絵文字などです。これらを「人が見た1文字」として数えるには Intl.Segmenter を使います。
// 書記素単位で数える
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
[...seg.segment("😀")].length; // 1
[...seg.segment("👨👩👧")].length; // 1
👨 + ZWJ + 👩 + ZWJ + 👧 という5つのコードポイント(UTF-16 では .length が 8)で構成されますが、見た目は1つの書記素です。SNS の文字数制限やバリデーションでは、どの単位で数えているかが体験を大きく左右します。
5. 実例で検算する
代表的な文字を、4つの数え方で並べてみます(UTF-8 のバイト数と JS の値は実際に検算済みです)。
| 文字列 | 書記素 | コードポイント | UTF-16 length | UTF-8 バイト |
|---|---|---|---|---|
a | 1 | 1 | 1 | 1 |
あ | 1 | 1 | 1 | 3 |
漢 | 1 | 1 | 1 | 3 |
😀 | 1 | 1 | 2 | 4 |
é(e+U+0301) | 1 | 2 | 2 | 3 |
👨👩👧 | 1 | 5 | 8 | 18 |
たとえば "あ" は U+3042 で 3バイト範囲なので 1文字=3バイト。"😀" は U+1F600 で補助面のため、書記素1・コードポイント1だが length は2、UTF-8 では4バイトです。"👨👩👧" は絵文字3つ+ZWJ2つの計5コードポイント。各絵文字が4バイト×3=12、ZWJ(U+200D)が3バイト×2=6で、合計18バイトとなります。
6. 改行・空白とバイト数が必要な場面
実務では「何を1文字と数えるか」だけでなく、空白や改行をどう扱うかも問題になります。
- 改行:環境により
LF(1バイト)かCRLF(2バイト)。改行を文字数に含めるか、バイト数でCRLFを2と数えるかで結果が変わる。 - 空白:半角スペース・全角スペース(
U+3000)・タブはいずれも「文字」。原稿用紙換算などでは扱いを定義しておく。 - 原稿用紙換算:日本語の「400字詰め」は書記素ベースの文字数で数えるのが一般的で、空白や改行の扱いはルールを決めて統一する。
一方、バイト数が必要になる場面もはっきりしています。
| 場面 | 数えるべき単位 | 理由 |
|---|---|---|
| 入力欄の文字数制限(UI) | 書記素 | 人の「見た目の1文字」と一致させるため |
DBのカラム長(VARCHAR 等) | バイトまたは文字 | DB・文字セットの定義により上限の単位が異なる |
| 通信量・ファイルサイズ | バイト | 実際に転送・保存される量だから |
| 暗号・ハッシュの入力 | バイト | 処理対象は常にバイト列だから |
よくある質問(FAQ)
なぜ絵文字が2文字と数えられることがあるのですか?
JavaScript の str.length は UTF-16 のコードユニット数を返します。「😀」のような補助面(U+10000 以上)の文字は 2 つのコードユニット(サロゲートペア)で表されるため、length では 2 と数えられます。人が見た目で数える文字数(書記素)と一致させたい場合は [...str].length や Intl.Segmenter を使います。
文字数とバイト数はどう違いますか?
文字数は「いくつの文字があるか」、バイト数は「保存・送信に何バイト必要か」です。UTF-8 では ASCII は 1 バイト、日本語の多くは 3 バイト、絵文字は 4 バイトになるため、同じ 1 文字でもバイト数は異なります。データベースの桁数設計や通信量の見積もりではバイト数が重要です。
書記素クラスタとは何ですか?
人が「1 文字」として知覚する単位のことです。結合文字や ZWJ(ゼロ幅接合子)によって複数のコードポイントが 1 つの見た目になることがあり、例えば「👨👩👧」は複数のコードポイントから成りますが書記素としては 1 つです。JavaScript では Intl.Segmenter で書記素単位に分割できます。