🧩

サロゲートペア: なぜJavaScriptは絵文字で壊れるのか

UTF-16 サロゲートペアの仕組み。JavaScript/Java/C# で問題になる理由と正しい対処法。

BMP の境界: U+FFFF

Unicode が 1980 年代後半に設計された当初、コンソーシアムは 65,536 個のコードポイント(16 ビット)ですべての言語のすべての文字を収容できると考えていました。この元の範囲 — U+0000 から U+FFFF — を基本多言語面(BMP: Basic Multilingual Plane)と呼びます。

しかし、それは誤りでした。より多くの文字体系、歴史的文字、そして絵文字が追加されるにつれ、Unicode は 17 面・合計 1,114,112 コードポイント(U+0000〜U+10FFFF)に拡張されました。BMP を超える 16 の追加面を補助面(Supplementary Planes)と呼びます。これらの面の文字は U+FFFF を超えるコードポイントを持ち、UTF-16 では特殊なエンコーディング手法が必要です。

範囲名称文字例
0 (BMP)U+0000..U+FFFF基本多言語面A, é, 漢, ♠
1 (SMP)U+10000..U+1FFFF追加多言語面🌍, 𝄞, 𐐀
2 (SIP)U+20000..U+2FFFF追加漢字面𠮟, 𠀀
3-16U+30000..U+10FFFF第三漢字面 + 未割当𰀀 (拡張G)

JavaScript、Java、C# は 16 ビットで十分と思われていた時代に UTF-16 を内部文字列エンコーディングとして採用しました。Unicode が 16 ビットを超えた際、これらの言語は基本的な文字列型を変更せずに補助文字に対応する必要がありました。その解決策がサロゲートペアです。

サロゲートペアによる補助文字のエンコード

UTF-16 は U+FFFF を超えるコードポイントを、サロゲートペアと呼ばれる 16 ビットコードユニットのペアで表現します。アルゴリズムは厳密に定義されています:

コードポイント U が与えられたとき(U > 0xFFFF の場合):

1. 0x10000 を減算:  U' = U - 0x10000
   (結果は 0x00000 .. 0xFFFFF、ちょうど20ビット)

2. 10ビットずつ2つに分割:
   上位10ビット: H = (U' >> 10) + 0xD800   → 範囲 0xD800..0xDBFF
   下位10ビット: L = (U' & 0x3FF) + 0xDC00  → 範囲 0xDC00..0xDFFF

例: 🌍 = U+1F30D
   U' = 0x1F30D - 0x10000 = 0xF30D
   H  = (0xF30D >> 10) + 0xD800 = 0x3C + 0xD800 = 0xD83C
   L  = (0xF30D & 0x3FF) + 0xDC00 = 0x30D + 0xDC00 = 0xDF0D

🌍 の UTF-16 表現: 0xD83C 0xDF0D

Unicode は U+D800 から U+DFFF の範囲(2,048 コードポイント)をサロゲート専用として永久に予約しています。これらの値自体は有効なコードポイントではなく、UTF-16 のエンコーディング上の産物としてのみ存在します。上位サロゲート(U+D800..U+DBFF)が必ず先に来て、下位サロゲート(U+DC00..U+DFFF)が続きます。

これにより UTF-16 は有効な Unicode コードポイント全 1,112,064 個をエンコードできます: BMP の 63,488 文字を直接(65,536 からサロゲート 2,048 を除外)、さらにサロゲートペアで 1,048,576 の補助文字(上位 1,024 x 下位 1,024)。

なぜ "🌍".length === 2 なのか

JavaScript の文字列は UTF-16 コードユニットの列です。.length プロパティはこのコードユニット数を返すのであって、文字数やコードポイント数ではありません:

説明
"A".length1BMP 文字 = 1 コードユニット
"漢".length1BMP 文字 = 1 コードユニット
"🌍".length2補助文字 = サロゲートペア = 2 コードユニット
"🌍"[0]"\uD83C"上位サロゲート(有効な文字ではない)
"🌍"[1]"\uDF0D"下位サロゲート(有効な文字ではない)
"🌍".charCodeAt(0)0xD83C上位サロゲートの数値
"🌍".codePointAt(0)0x1F30D実際のコードポイント(ES6+)

これは「バグ」ではなく、JavaScript が文字列を格納する根本的な仕組みです。インデックスベースのアクセスを使うすべての文字列操作(ブラケット記法、charAtcharCodeAtsubstringslice)は、コードポイントではなく UTF-16 コードユニット単位で動作します。

ES6 でコードポイント対応の代替手段が導入されました: codePointAt()String.fromCodePoint()、文字列イテレータ([...str]for...of)。これらはサロゲートペアを正しく 1 つの単位として処理します。

// ES6 コードポイント反復
const str = "A🌍B";

// 間違い: インデックスベース
for (let i = 0; i < str.length; i++) {
  console.log(str[i]);
}
// 出力: "A", "\uD83C", "\uDF0D", "B"  (4回、絵文字が壊れる)

// 正解: イテレータベース
for (const ch of str) {
  console.log(ch);
}
// 出力: "A", "🌍", "B"  (3回、正しい)

文字列の正しい反復処理

サロゲートペアを含む JavaScript 文字列を安全に / 非安全に反復する方法を比較します:

メソッドサロゲート安全?備考
for (i=0; i<str.length; i++)NoUTF-16 コードユニット単位で反復
str.split('')Noコードユニット境界で分割
for (const ch of str)Yes文字列イテレータを使用(ES6)
[...str]Yesスプレッドは文字列イテレータを使用
Array.from(str)Yes文字列イテレータを使用
str.match(/./gsu)Yes'u' フラグで . がコードポイントにマッチ
Intl.SegmenterYes書記素クラスタも処理可能

正規表現の u フラグは極めて重要です。なしの場合、/./g は個々のコードユニットにマッチし、サロゲートペアを分断します。/./gu にすると、ドットはコードポイント全体にマッチします。同様に、\u{1F30D} 構文(u フラグが必要)により、正規表現パターン内で補助文字を直接マッチできます。

// 文字列反転: サロゲートペアの典型的な落とし穴
const str = "A🌍B";

// 間違い: サロゲートペアが壊れる
str.split('').reverse().join('');
// → "B\uDF0D\uD83CA" (破損、置換文字が表示される)

// 正解: スプレッドでペアを保持
[...str].reverse().join('');
// → "B🌍A" (正しい)

// 文字列長: 実際の文字数をカウント
[...str].length;  // 3 (正しい)
str.length;       // 4 (コードユニットを数えている)

サロゲートペアに起因する実際のバグ

サロゲートペアのバグは、本番ソフトウェアで最もよく見られる Unicode 問題の一つです。実際のシナリオを紹介します:

  • データベースの切り詰め: UTF-16 コードユニット数でカウントする VARCHAR(100) カラムは、"99文字 + 🌍" を上位サロゲートで切断し、孤立サロゲートが後続処理を破壊します。
  • JSON エンコーディング: 孤立サロゲート(\uD83C が下位サロゲートなしで出現)は RFC 8259 上は無効な JSON です。パーサーによっては拒否され、他は U+FFFD に置換されます。
  • 部分文字列の抽出: "A🌍B" に対する str.substring(0, 3)"A" + 🌍 の上位サロゲートを返し、破損した文字列(A� と表示される可能性)になります。
  • Twitter/SMS の文字数制限: Twitter はコードポイント数(コードユニット数ではなく)で文字数をカウントします。絵文字 1 つは制限に対して 1 文字ですが、JavaScript の .length では 2 です。
  • テキストエディタのカーソル移動: 右矢印キーはサロゲートペアの両方のコードユニットをスキップすべきです。多くのカスタムテキスト入力実装がこれを誤り、上位・下位サロゲートの間にカーソルを置いてしまいます。

最も安全なアプローチ: テキスト処理には常にコードポイント対応 API(for...ofcodePointAtString.fromCodePoint)を使い、ユーザーに見える文字をカウントする場合は Intl.Segmenter を使用することです。

// サロゲートペアを分断しない安全な substring
function safeSubstring(str, start, end) {
  const chars = [...str];
  return chars.slice(start, end).join('');
}

safeSubstring("A🌍B", 0, 2); // "A🌍" (正しい)
"A🌍B".substring(0, 2);      // "A\uD83C" (壊れている!)

// 文字列に孤立サロゲートが含まれるか検出
function hasLoneSurrogates(str) {
  return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(str);
}

関連記事