サロゲートペア

UTF-16 で基本多言語面 (BMP) 外の文字を 2 つの 16 ビットコードユニットで表現する仕組み。

サロゲートペアとは、UTF-16 エンコーディングで基本多言語面 (BMP: U+0000〜U+FFFF) の範囲外にある文字を表現するための仕組みです。上位サロゲート (U+D800〜U+DBFF) と下位サロゲート (U+DC00〜U+DFFF) の 2 つの 16 ビットコードユニットを組み合わせて 1 文字を表します。Unicode のコードポイント U+10000〜U+10FFFF の範囲にある約 100 万文字がこの方式で表現されます。

サロゲートペアが必要になった背景には、Unicode の拡張の歴史があります。当初 Unicode は 16 ビット (65,536 文字) で世界中の文字を収録する計画でしたが、人名や地名に使われる漢字や歴史的文字を収めるには足りないことが判明し、1996 年 7 月の Unicode 2.0 で符号空間が U+10FFFF まで拡張されました。このときすべての文字を 4 バイト固定で表す案も出ましたが、メモリとディスクの消費が大きく、16 ビット前提で作られていた既存の実装を捨てることにもなるため、妥協案として BMP 内の未使用領域 (U+D800〜U+DFFF) をサロゲート用に予約し、2 つのコードユニットで 1 文字を表す仕組みが採られました。絵文字がサロゲートペアで表されるのは、2010 年の Unicode 6.0 以降の追加分がこの先に用意された仕組みに乗っているためであり、絵文字が拡張の原因だったわけではありません。

絵文字の多くは BMP 外に配置されているため、サロゲートペアで表現されます。たとえば「😀」(U+1F600) は上位サロゲート U+D83D と下位サロゲート U+DE00 のペアで表されます。JavaScript の String.length は UTF-16 コードユニット数を返すため、「😀」の length は 1 ではなく 2 になります。同様に charAt()charCodeAt() もコードユニット単位で動作するため、サロゲートペア文字を正しく扱えません。

コードポイント単位の文字数を得るには [...str].lengthArray.from(str).length を使います。これらはイテレータプロトコルを利用してコードポイント単位で文字列を分解するため、サロゲートペアを 1 文字として扱います。ES2015 以降では codePointAt() メソッドや for...of ループもコードポイント単位で動作します。ただし、書記素クラスタ (結合文字や ZWJ シーケンスで構成される視覚的な 1 文字) を正確にカウントするには Intl.Segmenter API が必要です。

実務で問題になりやすいのは、コードユニット単位で切ったときに残る片側だけのサロゲートです。「😀」を slice(0, 1) で切ると長さ 1 の不正な文字列になり、これを encodeURIComponent() に渡すと URIError で例外になります。UTF-8 へ変換すると置換文字 (U+FFFD) 1 文字に化け、JSON.stringify() では "\ud83d" というエスケープ形で出力されて元の文字には戻りません。入力値を固定長で切り詰めてから送信する処理では、この壊れた文字列が保存や外部連携の直前まで気づかれないことがあります。isWellFormed() で片側サロゲートの有無を判定でき、toWellFormed() で置換文字へ寄せられますが、根本的な対策は切り詰めの単位を UTF-16 コードユニットではなくコードポイントか書記素クラスタに揃えることです。

サロゲートペアは UTF-16 固有の概念であり、UTF-8 や UTF-32 には存在しません。UTF-8 は可変長 (1〜4 バイト) でコードポイントを直接エンコードし、UTF-32 は固定 4 バイトですべてのコードポイントを表現します。データベースの文字型カラム (MySQL の utf8utf8mb4 の違いなど) でもサロゲートペア文字の格納可否が問題になることがあります。

文字数カウントの観点では、サロゲートペアの存在は「1 文字とは何か」という根本的な問いを提起します。UTF-16 コードユニット数、コードポイント数、書記素クラスタ数のいずれを「文字数」とするかによって結果が異なります。文字数カウントツールを設計する際は、どの単位でカウントするかを明確にし、ユーザーの期待する結果と一致させることが重要です。

この記事を共有