UTF-16

Unicode の 16 ビット単位のエンコーディング方式。JavaScript や Java の内部文字列表現に使用される。

UTF-16 は Unicode を 16 ビット (2 バイト) 単位で表現するエンコーディング方式です。基本多言語面 (BMP, U+0000〜U+FFFF) の文字は 2 バイト、それ以外の文字はサロゲートペアを使って 4 バイトで表現します。JavaScript、Java、C#、Windows API など、多くのプログラミング環境の内部文字列表現として採用されています。

UTF-16 の最大の特徴はサロゲートペアの仕組みです。BMP に収まらない文字 (U+10000 以降) は、上位サロゲート (U+D800〜U+DBFF) と下位サロゲート (U+DC00〜U+DFFF) の 2 つの 16 ビットコードユニットの組み合わせで表現されます。絵文字、一部の漢字 (CJK 統合漢字拡張 B 以降)、古代文字などがサロゲートペアで表現されます。

JavaScript の文字列は内部的に UTF-16 で表現されているため、String.length は UTF-16 コードユニット数を返します。たとえば絵文字「😀」(U+1F600) はサロゲートペアで表現されるため length は 2 になります。charAt()charCodeAt() もコードユニット単位で動作するため、サロゲートペアの片方だけを取得してしまう問題があります。ES2015 以降では codePointAt()for...of ループでコードポイント単位の処理が可能になりました。

UTF-16 にはバイトオーダー (エンディアン) の問題があります。UTF-16BE (ビッグエンディアン) と UTF-16LE (リトルエンディアン) の 2 種類があり、ファイルの先頭に BOM (バイトオーダーマーク, U+FEFF) を付けてバイト順を識別します。Windows 系のツールには UTF-16LE を BOM 付きで保存する選択肢が用意されていることが多く、受け取る側が BOM を想定していないと、先頭に見えない文字が 1 つ混ざったデータとして扱われます。Web では UTF-8 が標準のため、UTF-16 のバイトオーダー問題に遭遇する機会は減っていますが、Windows 環境やレガシーシステムとの連携では依然として注意が必要です。

UTF-8 との比較では、ASCII 文字は UTF-8 が 1 バイト、UTF-16 が 2 バイトのため、英語テキストでは UTF-8 のほうがサイズ効率が良くなります。一方、CJK 文字は UTF-8 が 3 バイト、UTF-16 が 2 バイトのため、日本語や中国語のテキストでは UTF-16 のほうがコンパクトです。ただしこれは本文がほぼ CJK 文字で占められている場合の話です。HTML やソースコードのようにタグ・属性名・記号といった ASCII 文字が多く混ざる文書では、CJK 文字を含んでいても UTF-8 のほうが小さくなります (<p class="x">日本語</p> は UTF-8 で 26 バイト、UTF-16 で 40 バイト)。実際のファイルサイズを見積もるときは、文字種の比率まで見て判断してください。また、Web 標準が UTF-8 に統一されている現在、ファイル保存や通信には UTF-8 を使い、プログラム内部の文字列処理には UTF-16 を使うという使い分けが一般的です。

文字数カウントとの関連では、UTF-16 ベースの言語 (JavaScript、Java) で文字数を数える際にサロゲートペアの扱いが最大の課題です。String.length をそのまま使うと絵文字や一部の漢字で文字数が実際より多くカウントされます。数えたい単位に合わせて、[...str].length (スプレッド構文) や Array.from(str).length でコードポイント数を取得するか、Intl.Segmenter で書記素クラスタ数を取得します。この 2 つは同じ値になりません。たとえば家族の絵文字「👨‍👩‍👧‍👦」はコードポイント数では 7、書記素クラスタ数では 1 です (Node.js 26 で実測)。人が見たままの 1 文字に合わせたいなら Intl.Segmenter が必要です。

もう一つの落とし穴は、コードユニット単位で文字列を切ったときに生じる孤立サロゲートです。JavaScript の文字列は UTF-16 コードユニットの列として扱われ、ペアの片方だけになったサロゲートもそのまま保持できます。'😀'.slice(0, 1) は上位サロゲート U+D83D だけの 1 コードユニットになり、この状態で UTF-8 へ変換すると置換文字 U+FFFD に化け、encodeURIComponent()URIError を投げます (いずれも Node.js 26 で実測)。しかも JSON へ書き出すだけなら例外にならずエスケープ表記のまま残るため、壊れた文字列が保存や送信の後段まで気付かれずに流れていきます。入力を「先頭 N 文字」で切り詰める処理では、slice()substring() をそのまま使わず、Intl.SegmenterArray.from() で単位を揃えてから切るのが安全です。

この記事を共有