文字列の表現¶
文字列は JavaScript でもっとも多用される値の一つでありながら、その内部表現には見た目以上に多くの設計判断が絡みます。言語仕様は文字列を「UTF-16 コード単位の列」と定義しており、この一点があらゆる挙動 — length が数える単位、絵文字などが二つ分に見える理由、インデックスアクセスの計算量 — を規定します。この章では、文字列を内部でどう符号化するか、その選択に伴うトレードオフ、正規化や数値変換といった仕様上の要求、そして実装で陥りやすい罠を扱います。
文字列はコード単位の列である¶
JavaScript の文字列は、文字(人間が読む一文字)の列でも、Unicode コードポイントの列でもありません。仕様上は UTF-16 コード単位(16 ビットの符号なし整数)の列です。この区別が、文字列にまつわる多くの直感のずれの根源になります。
str.lengthは文字数でもコードポイント数でもなく、コード単位の個数を返す。- 各要素へのインデックスアクセス(
str[i]やstr.charCodeAt(i))は、i 番目のコード単位を指す。
Unicode のコードポイントは U+0000 から U+10FFFF まであり、このうち U+FFFF を超える領域(アストラル面と呼ばれる、絵文字や一部の漢字・記号など)は 16 ビットに収まりません。UTF-16 ではこれを サロゲートペア — 上位サロゲート(U+D800〜U+DBFF)と下位サロゲート(U+DC00〜U+DFFF)の二つのコード単位の組 — で表します。
その結果、たとえば絵文字一文字は length の上では 2 と数えられ、str[0] はその文字の前半(上位サロゲート)だけを指します。
codepoint U+1F600 (astral)
|
v UTF-16
+----------+----------+
| 0xD83D | 0xDE00 |
+----------+----------+
[0] [1] length = 2
high low
surrogate surrogate
アストラル面の一文字が UTF-16 で二つのコード単位(上位・下位サロゲート)になり、length は 2、str[0] は前半だけを指す。
コードポイント単位で扱いたい場合は、for...of や String.prototype[Symbol.iterator]、codePointAt、Array.from など、サロゲートペアを一つのコードポイントとして解釈する API を使う必要があります。エンジンの内部実装も、この「コード単位が基本単位である」という前提をあらゆる文字列操作の土台に据えることになります。
内部エンコーディングの選択¶
「コード単位の列」という論理モデルは決まっていても、それをメモリ上でどのバイト列として持つかは実装の自由です。主な選択肢を見ていきます。
UTF-16(コード単位モデルに直結)¶
もっとも素直なのは、コード単位をそのまま 16 ビット幅で並べる UTF-16 表現です。言語の論理モデルと一対一に対応するため、length はバイト数の半分、str[i] は i 番目の 2 バイトを読むだけ、と実装が明快になります。
- 利点: コード単位モデルに直結し、インデックスアクセスが定数時間で自明。サロゲートも含めどんな文字列も無変換で保持できる。
- 欠点: 1 コード単位あたり最低 2 バイトを消費する。ASCII や Latin-1 だけの文字列(現実のコードやデータの大半)でも 2 倍のメモリを使う。
多くのエンジンは、この弱点を補うために「文字列が Latin-1 の範囲に収まるなら 1 バイト/単位、そうでなければ 2 バイト/単位」という二本立ての表現を持ちます。いずれも固定幅である点は共通で、インデックスアクセスの定数時間性は保たれます。
コンパクト表現(UTF-8 系)¶
メモリをさらに切り詰めたい場合、可変長の UTF-8 系エンコーディングを内部表現に使う選択肢があります。ASCII は 1 バイト、多くの文字は 2〜3 バイトで表せるため、テキストの多い用途ではメモリを大きく節約できます。
しかしここで、JavaScript 特有の壁にぶつかります。厳密な UTF-8 では JavaScript の文字列を表現しきれないのです。
なぜ厳密な UTF-8 では足りないか — 孤立サロゲート¶
JavaScript の文字列は「任意の 16 ビットコード単位の列」であり、サロゲートが正しくペアになっている保証はありません。上位サロゲート単体、下位サロゲート単体、順序が逆のペアといった 孤立サロゲート(lone surrogate、対をなさない孤立したサロゲート) も、完全に正当な文字列の中身です。たとえば "\uD800" は長さ 1 の有効な文字列として存在できます。
ところが、厳密な UTF-8 はサロゲートコードポイント(U+D800〜U+DFFF)の符号化を禁止しています。UTF-8 はコードポイントを符号化する規格であり、サロゲートはそもそも「UTF-16 のための内部符号」であってコードポイントとしては不正だからです。したがって、孤立サロゲートを含む JavaScript 文字列を厳密な UTF-8 に変換しようとすると、表現できずに失敗するか、置換文字(U+FFFD)で潰してしまい情報が失われます。文字列の同一性が壊れるため、これは許容できません。
この問題を解くのが、UTF-8 を拡張してサロゲートを扱えるようにした符号化です。
- WTF-8 (Wobbly Transformation Format): 厳密な UTF-8 を拡張し、孤立サロゲートを個別に 3 バイトで符号化できるようにしたもの。正しくペアになったサロゲートは通常のアストラル文字として 4 バイトに符号化する(ペアを二つの 3 バイト列に分けない)ため、正当な Unicode テキストに対しては UTF-8 と完全に一致する。孤立サロゲートという例外だけを追加で表現できる、実用的な内部表現。
- CESU-8: すべてのアストラル文字を「サロゲートペアに分解してから各サロゲートを 3 バイトで符号化」する方式。結果としてアストラル文字は 6 バイトになる。UTF-16 のコード単位境界と揃うため一部システムとの互換に使われるが、正当な UTF-8 とはバイト列が異なり、同じ文字列でも冗長になる。
要点は、JavaScript の内部文字列表現は「不正なサロゲートを含みうる列」を無損失で往復できなければならないということです。この要求が、素朴な UTF-8 ではなく WTF-8 のような拡張を必要とさせます。
インデックスアクセスの計算量という罠¶
可変長エンコーディング(UTF-8 系)を内部表現に選ぶと、致命的な副作用が生じます。i 番目のコード単位への定数時間アクセスが自明でなくなることです。
固定幅の UTF-16 なら、i 番目のコード単位はアドレス「先頭 + i×2」を読むだけで、str[i] も charCodeAt(i) も O(1) です。ところが可変長表現では、各コード単位のバイト位置が内容に依存するため、i 番目を求めるには原理的に先頭から数えるしかありません。ここでアクセスのたびに先頭から走査し直す素朴な実装をすると、str[i] が O(i) になり、文字列を一文字ずつ舐めるだけのループ全体が O(n²) に膨れ上がります。文字列長が伸びるほど二次的に遅くなる、典型的な性能の罠です。
エンジンはこれを設計で回避します。代表的な手段は次の通りです。
- 固定幅表現を使う: そもそも UTF-16(または Latin-1/UTF-16 の二本立て)にして、可変長の問題を持ち込まない。多くのエンジンの現実的な選択。
- カーソルを持ち回す: 逐次走査であることが分かっている操作(イテレータ、正規表現マッチなど)では、現在位置のバイトオフセットを保持して次の単位へ進み、毎回先頭から数え直さない。
- 補助インデックスを持つ: 一定間隔ごとにバイトオフセットの目印を張り、任意位置アクセスを「近い目印からの短い走査」に抑える。
いずれにせよ、「可変長表現はメモリを節約する代わりに、インデックスアクセスの定数時間性を自力で保証しなければならない」というトレードオフを常に意識する必要があります。
エンコーディング方式の比較¶
| 方式 | 格納サイズの目安 | インデックスアクセス | 孤立サロゲート | メモリ効率 | 実装の難しさ |
|---|---|---|---|---|---|
| UTF-16(固定幅) | 2バイト | O(1) 自明 | 無損失で保持 | 低い(ASCII でも 2 倍) | 易しい |
| Latin-1 / UTF-16 二本立て | 1〜2バイト | O(1) 自明 | 無損失で保持 | 中(ASCII は 1 バイト) | 中 |
| WTF-8 | 1〜4バイト | 要工夫(素朴だと O(i)) | 無損失で保持 | 高い | 難しい |
| CESU-8 | 1〜6バイト | 要工夫 | 無損失で保持 | 中(アストラルが冗長) | 中〜難 |
| 厳密な UTF-8 | 1〜4バイト | 要工夫 | 表現不可 | 高い | — |
厳密な UTF-8 は孤立サロゲートを表現できないため、JavaScript の汎用文字列表現としては採用できません。メモリを優先するなら WTF-8、実装の単純さとアクセス速度を優先するなら固定幅表現、というのが基本的な分岐です。
正規化(normalize)¶
同じ「見た目の文字」が、Unicode では複数の異なるコード単位列で表せることがあります。たとえば「é」は、単一のコードポイント U+00E9(合成済み)としても、「e」(U+0065)+ 結合アクセント(U+0301)の 2 コードポイント(分解形)としても表現できます。この二つは バイト等価ではない(内部バイト列が異なるので === でも等しくない)一方、正準等価(canonically equivalent、人間にとって同一の文字)です。
String.prototype.normalize は、こうした表現の揺れを決められた正規形に揃えるためのメソッドです。仕様は四つの正規化形式を定めます。
- NFC / NFD: 正準等価に基づく正規化。NFC は可能な限り合成済みの形に、NFD は分解した形に揃える。文字の見た目は変えず、表現だけを統一する。
- NFKC / NFKD: 互換等価(compatibility equivalence)まで含めた正規化。たとえば全角の「A」と半角の「A」、合字「fi」と「fi」のような、見た目が異なりうる文字も統一してしまう。検索・照合の前処理などに使う一方、情報が落ちるため用途を選ぶ。
エンジンの実装は、Unicode が定める正規化アルゴリズム(UAX #15)と、そのためのデータ(結合クラス・分解表・合成表)を持ち、指定された形式へ変換します。重要なのは、文字列の等価性には「バイト等価」と「正準等価」という二つの水準があるという認識です。=== や Map のキー比較はバイト等価ですが、テキストとして意味的に比較したいなら、あらかじめ双方を同じ正規形へ normalize してから比較する、という使い分けが必要になります。
数値変換の空白集合¶
文字列を数値へ変換する場面(Number(str)、単項 +、算術演算子による強制変換など)では、前後の空白を無視してから数値として解釈します。ここで見落としやすいのが、仕様が定める空白の集合が ASCII の空白より広いことです。
StringToNumber が前後で読み飛ばすのは、通常の空白・タブ・改行(\n, \r)といった ASCII の空白だけではありません。次のような文字も含まれます。
- 各種の Unicode 空白(ノーブレークスペース U+00A0、全角スペース U+3000、その他 Zs カテゴリの空白、行区切り U+2028・段落区切り U+2029 など)。
- バイトオーダーマーク(BOM、U+FEFF)。
たとえば Number(" " + "42" + " ") は NaN ではなく 42 になります。ASCII の空白だけをトリムする実装にすると、これらの文字を含む文字列で本来 42 を返すべきところを NaN にしてしまう、という準拠性の欠陥を生みます。逆に、範囲を広げすぎて空白でない文字まで読み飛ばしても誤りです。数値変換の空白判定は、String.prototype.trim が使う空白集合とも一致させる必要があり、仕様が列挙する集合を正確に実装することが求められます。
実装上の罠: 表現の同期と非有限値のキャスト¶
インデックス・個数引数の非有限値と巨大値¶
slice、substring、indexOf、padStart、repeat、codePointAt など、文字列メソッドの多くは位置や個数を表す引数を取ります。これらの引数は JavaScript の数値であり、Infinity、1e300、NaN、負の巨大値といった 非有限・巨大な値が渡されうることに注意が要ります。
仕様上、これらは所定の手順で整数へ変換してから使いますが、素朴に「浮動小数点数を固定幅整数(たとえば 32/64 ビット整数)へキャスト」する実装をすると危険です。1e300 や Infinity を固定幅整数へ変換する操作は、多くの環境で範囲外変換となり未定義動作やクラッシュを引き起こします。NaN の整数化も同様に予測不能です。
対策は、整数へ落とす前に必ずクランプ(範囲内へ丸める)・ガードすることです。仕様の ToIntegerOrInfinity などの手順に従い、NaN は 0 とみなし、正負の無限大や巨大値は文字列長でクランプしてから内部の固定幅整数に変換します。「まず有限の整数インデックスに正規化し、次に 0〜length の範囲へ収め、それから初めて固定幅整数として使う」という順序を徹底すれば、範囲外変換のクラッシュは防げます。ホットパスに置かれるメソッドほど、この前処理を怠りやすい点に注意します。
ホットパスでの無用なコピー¶
文字列操作の多くは、結果として新しい文字列を返します。しかし、結果が入力とまったく同じ内容になる場合にまで、律儀に全体の複製を作るのは無駄です。たとえば、トリム対象の空白が一つもない文字列に trim を、範囲全体を指す slice(0) を、置換対象が見つからない replace を適用したとき、内容は入力と一字一句同じです。それでも新しいバッファを確保して全コピーする実装だと、長い文字列に対する操作がループの中で繰り返されるホットパスで、確保とコピーのコストが積み上がります。
対策は、内容・同一性がすでに一致しているなら既存の文字列オブジェクトをそのまま再利用することです。「変更が生じるか」を先に安価に判定し、変更がなければ入力をそのまま返す。文字列は不変(immutable)なので、同じ内容のオブジェクトを共有しても安全です。この「必要になるまでコピーしない」方針は、部分文字列を元文字列への参照+範囲として持つ(スライス表現)といった手法にも通じます。
連結の戦略¶
文字列連結は頻出操作であり、その実装方式が性能を左右します。
- 素朴な繰り返し
+: ループ内でs = s + pieceを繰り返すと、各回で「これまでの全体 + 新しい断片」を新規バッファに複製することになりがちです。n 個の断片を連結すると全体で O(n²) のコピーが発生し、断片数が増えるほど急激に遅くなります。 - ビルダー方式: 断片を配列などにためておき、最後にまとめて一度だけ連結する(
parts.join("")に相当する内部処理)。総文字数を先に見積もって一度バッファを確保すれば、全体を O(n) で構築できます。 - ロープ(rope)/連結ノード方式: 連結の結果を、実際にコピーするのではなく「左の文字列 + 右の文字列」を指す木構造(ロープ)として遅延表現する方式です。連結自体は定数時間で済み、実際に内容が必要になった時点(インデックスアクセスや外部への受け渡し時)で平坦化(flatten)します。連結が多く、結果全体を即座には読まない用途で有効です。
代償として、ロープはインデックスアクセスや線形走査の際に木をたどる必要があり、実装も複雑になります。多くのエンジンは「連結はいったん軽量なノードで表し、走査や外部渡しの直前に平坦化する」折衷を採り、連結の安さと走査の速さを両立させます。連結戦略もまた、前述のエンコーディング選択と同じく、メモリ・速度・実装コストのトレードオフの中にあります。
まとめ¶
- JavaScript の文字列は UTF-16 コード単位の列であり、
lengthはコード単位を数え、アストラル文字はサロゲートペアで 2 単位を占める。この前提があらゆる文字列操作の土台になる。 - 内部エンコーディングは、コード単位モデルに直結する固定幅 UTF-16 と、メモリを節約する可変長の UTF-8 系のトレードオフ。ASCII 向けに 1 バイト表現を併せ持つ折衷も広く使われる。
- JavaScript 文字列は孤立サロゲートを含みうるため、それを表現できない厳密な UTF-8 は採用できない。WTF-8 や CESU-8 のような拡張が必要になる。
- 可変長表現ではインデックスアクセスが素朴には O(i) となり、走査全体が O(n²) になりうる。固定幅表現・カーソル・補助インデックスで定数時間性を保つ。
- 正規化(NFC/NFD/NFKC/NFKD)は「バイト等価」と「正準等価」という二水準の等価性を橋渡しする。意味的な比較には事前の正規化が要る。
- 数値変換で読み飛ばす空白集合は ASCII より広く、Unicode 空白や BOM を含む。狭く実装すると準拠性の穴になる。
- インデックス・個数引数は非有限・巨大値を取りうる。整数化の前にクランプ・ガードしないと範囲外変換でクラッシュする。内容が変わらない操作で全体を複製せず、既存の文字列を再利用する。
参考文献¶
- ECMA-262: The String Type — 文字列が UTF-16 コード単位の列であることの根拠。
- ECMA-262: String.prototype.normalize — 正規化メソッドの仕様。
- ECMA-262: StringToNumber — 数値変換で読み飛ばす空白集合の定義。
- The Unicode Standard — サロゲート・コードポイント・正規形の一次資料。
- UAX #15: Unicode Normalization Forms — NFC/NFD/NFKC/NFKD の正規化アルゴリズム。
- WHATWG Encoding Standard — テキストエンコーディングの標準。
- The WTF-8 encoding (Simon Sapin) — 孤立サロゲートを扱う WTF-8 の一次資料。