Skip to content

値の表現

JavaScript のあらゆる値 — 数値・真偽値・文字列・オブジェクト・nullundefined・シンボル・BigInt — を、エンジンはメモリ上の一つの共通の形で扱えなければなりません。変数・配列要素・スタック上の一時値は、どんな種類の値でも同じ大きさの「枠」に収まる必要があるからです。この枠をどう設計するかは、演算の速度・メモリ使用量・移植性、さらにはガベージコレクションの実装にまで影響する、中核的な設計判断です。この章では、値を表現する代表的な方式と、それぞれのトレードオフ、そして実装で陥りやすい罠を扱います。

求められる要件

値表現の枠には、二つの相反する要求があります。

  • どんな種類の値も一様に格納できること: 数値もオブジェクトへの参照も同じ枠に入る。
  • 種類を高速に判別できること: 「この値は数値か、オブジェクトか」を、演算のたびに安価に判定できる。

加えて、実用上は次も重要です。

  • 数値演算が速いこと: JavaScript のあらゆる数値は言語仕様上は倍精度浮動小数点数(IEEE 754 の 64 ビット)ですが、実際には整数として使われる場面が多く、整数を効率よく扱えると多くのコードが速くなります。
  • メモリが小さいこと: 枠は膨大な数が作られるため、1 値あたりのバイト数がそのままメモリ使用量に効きます。

方式1: タグ付き共用体(素朴な表現)

もっとも素直な表現は、「種類を表すタグ」と「値本体」を別々のフィールドに持つ構造体です。

Value = { tag: 種類, payload: 64ビットの生データ }

payload には、数値ならそのビット列、オブジェクトならポインタを入れ、tag でどちらかを区別します。

  • 利点: 実装が明快で、種類の判定は tag を見るだけ。移植性も高い。
  • 欠点: タグと本体で 1 値が 16 バイト前後になりメモリ効率が悪い。値の読み書きで two-word のコピーが発生する。

小さなエンジンや、単純さを優先する場合には十分実用的です。

方式2: NaN-boxing

より省メモリで高速な表現として広く使われるのが NaN-boxing です。これは、IEEE 754 倍精度浮動小数点数のビット構造を逆手に取り、すべての値を 64 ビット一語に押し込む技法です。

IEEE 754 では、指数部が全 1 でかつ仮数部が 0 でない多数のビットパターンが「NaN(非数)」を表します。実際に演算結果として現れる NaN は一つあれば足りるので、残りの膨大な NaN パターンは未使用です。この未使用領域に、数値でない値(ポインタ・整数・真偽値・nullundefined など)を埋め込みます。

  • 通常の浮動小数点数は、そのままのビット列として格納する。
  • それ以外の値は、「NaN を表す上位ビット」+「種類を表すタグビット」+「本体(ポインタのアドレスや小整数など)」という形で、NaN のビット空間に格納する。

こうすると、判定は「このビット列は正規の浮動小数点数か、それとも NaN 空間のタグ付き値か」を見るだけで済み、浮動小数点数はゼロコストでそのまま扱えます。ポインタは 64 ビット環境でも実際には下位 48 ビット程度しか使わないため、上位ビットをタグに流用できます。

ビット構造で見る

IEEE 754 の倍精度は 64 ビットを次のように使います。指数部が全 1(0x7FF)で仮数部が 0 でないものが NaN です。

 63   62             52  51                               0
+----+------------------+----------------------------------+
| S  | exp (11 bits)    | fraction (52 bits)               |
+----+------------------+----------------------------------+

S = 符号ビット、exp = 指数部(11 ビット)、fraction = 仮数部(52 ビット)。

正規の数値 3.14      : 0x40091EB851EB851F   (そのままのビット列)
標準的な quiet NaN   : 0x7FF8000000000000   (指数部が全1、仮数部の最上位ビットが1)

NaN-boxing は、この「指数部が全 1」の領域(先頭 13 ビット = 符号 1 + 指数 11 + quiet ビット 1 が 0x7FF8… に一致する)を土台に使います。ただし正規の NaN もこの領域に入るため、両者はその下位に設けたタグ場で見分けます。タグ場が全ゼロなら通常の浮動小数点 NaN、非ゼロならタグ付き値とみなし、残りのビットに種類を表すタグと本体を詰めます。おおよそのレイアウトは次のイメージです(タグの具体的なビット位置や値は実装ごとに異なる一例)。

+--------------+--------+------------------------------------+
| NaN marker   |  tag   | payload (48-bit ptr / small int)   |
+--------------+--------+------------------------------------+

NaN marker = NaN 目印(上位ビット)、tag = タグ(数ビット)、payload = 本体(ポインタ 48 ビット / 小整数 など、下位ビット)。

種類ごとの格納イメージを表にすると次のようになります(ビットパターンは説明用の一例で、実際の値は実装依存)。

格納の考え方 64ビットの例
3.14(浮動小数点) ビット列をそのまま 0x40091EB851EB851F
NaN 単一の正規形に正規化 0x7FF8000000000000
小整数 42 整数タグ + 下位に値 0x7FFC…0000002A
true 真偽タグ + 1 0x7FFD…00000001
null null タグ 0x7FFE…00000000
オブジェクト参照 ポインタタグ + 48ビットアドレス 0x7FFA…<48bit addr>

判定は「指数部が全 1 の目印に一致するか、一致するならタグ場が何か」を調べるだけです。目印に一致しない値はそのまま浮動小数点数として扱い、一致してもタグ場が全ゼロなら正規化された NaN として数値扱いにし、タグ場が非ゼロならそのタグに応じて下位ビットから本体(アドレスや整数)を取り出します。たとえばオブジェクト参照なら、下位 48 ビットをアドレスとして取り出して間接参照します。

  • 利点: 1 値が 8 バイトに収まりメモリ効率が良い。浮動小数点数を変換なしで直接扱える。判定が安価。
  • 欠点: ビット演算が入り組み実装が繊細。タグ設計がアーキテクチャのポインタ幅・アドレス空間の前提に依存するため、移植性に注意が要る(後述)。

整数のファストパス

数値は仕様上すべて倍精度ですが、NaN 空間には小整数(たとえば 32 ビット整数)専用のタグを設けることもできます。ループカウンタや配列インデックスの多くは小整数で表せるため、整数専用の表現を持つと、加算や比較を浮動小数点演算を経ずに整数演算で行え、多くのコードが速くなります。

たとえば次のようなごくありふれたループを考えます。isumn はいずれも小整数の範囲に収まります。

let sum = 0;
for (let i = 0; i < n; i++) sum += i;

このとき毎回実行される演算は i < n(比較)・sum + i(加算)・i + 1(インクリメント)です。整数表現があれば、これらを浮動小数点のフォーマットに変換することなく、CPU の整数命令で直接処理できます。加算オペコードの実装は、概念的には次のように「両辺が整数か」を最初に判定します。

op_add(a, b):
    if a が整数 かつ b が整数:
        (result, overflow) = 整数加算(a, b)      # CPU の整数加算 + 桁あふれ判定
        if overflow なし:
            return 整数値(result)                # ここが高速パス
        else:
            return 浮動小数点値(toFloat(a) + toFloat(b))  # 桁あふれ時のみ倍精度へ
    # どちらかが非整数(浮動小数点・文字列・オブジェクト等)なら一般経路へ
    return 一般加算(a, b)                         # ToPrimitive / 文字列連結 / 倍精度加算 など

小整数どうしの加算は、桁あふれしない限り整数命令一つと桁あふれ判定だけで済みます。仕様上の「すべて倍精度」という観測結果は保たれます。桁あふれした場合だけ倍精度にフォールバックすれば、2147483647 + 1 のような境界も正しく 2147483648 になります。

ただし整数ファストパスは、この「両辺が整数か」の判定を演算経路の最初に置いて初めて効果が出ます。もし一般経路(文字列連結の判定やオブジェクトの ToPrimitive など)を先に通ってから最後に整数加算を試すような順序だと、もっとも頻度の高い整数どうしの加算が毎回それらの分岐を通過してしまい、整数表現を持っている意味が薄れます。「速くしたい経路を先に判定する」ことが肝心です。

実装上の罠: NaN の正規化

NaN-boxing には、見落とすとメモリ安全性を損なう深刻な罠があります。

「数値でない値」は NaN のビット空間に格納されるため、判定は「NaN 空間かどうか」で行われます。ところが、外部から任意のビット列が浮動小数点数として持ち込まれる経路があります。たとえば、型付き配列(Float64Array)の生バイト列、DataView からの読み出し、あるいは事前コンパイル済みバイトコードの定数などです。

もし、そうして持ち込まれた「非正規の NaN」(仮数部にたまたまタグやポインタらしきビットが立っている NaN)を、正規化せずにそのまま値として扱うと、エンジンはそれを「NaN 空間のタグ付き値=たとえばオブジェクトへのポインタ」と誤認します。その結果、攻撃者が制御したビット列が有効なポインタとして解釈され、任意アドレスの参照(メモリ破壊)につながりえます。

たとえば、上で「オブジェクト参照」の目印に 0x7FFA… を使う実装で、スクリプトが Float64Array に次のビット列を書き込み、それを数値として読み出したとします。

new Float64Array(new BigUint64Array([0x7FFA000000001234n]).buffer)[0]

このビット列は指数部が全 1 の NaN でありながら、オブジェクト参照のタグと一致します。正規化を怠ると、エンジンはこれを「アドレス 0x1234 のオブジェクトへの参照」と解釈し、その番地を間接参照してしまいます。スクリプトから任意のアドレスをでっち上げられる、典型的なポインタ偽造です。

対策は明快です。浮動小数点数を値の枠に格納するすべての経路で、NaN を単一の正規形(標準的な quiet NaN)に正規化すること。こうすれば、いかなる NaN もタグ付き値のビットパターンと衝突しなくなります。正規化は「その値が NaN かどうか」を一度判定して差し替えるだけの安価な処理ですが、外部由来のビット列が浮動小数点数として枠に入る箇所を一つでも見落とすと穴が残ります。型付き配列・DataView・バイトコード定数など、値を生成する経路をすべて洗い出すことが重要です。

実装上の罠: ポインタとタグの前提

NaN-boxing はポインタの上位ビットが未使用であることを前提にします。多くの一般的な 64 ビット環境ではユーザ空間のアドレスは下位 48 ビットに収まりますが、これは保証ではありません。アドレス空間の広い構成では上位ビットが立ちうるため、そうした環境へ移植する際にはタグ設計の見直しが必要になります。整数とポインタを 1 ビットの目印だけで区別する設計では、その目印が指すビットがアドレスに現れないという前提が崩れないか確認が要ります。

移植性を最優先する場合は、方式1のタグ付き共用体が安全側の選択です。省メモリと速度を優先し、対象環境の前提を管理できる場合に NaN-boxing が生きます。

方式の比較

方式 1値のサイズ 数値演算 種類判定 実装の難しさ 移植性
タグ付き共用体 16バイト前後 追加の分岐 タグ参照 易しい 高い
NaN-boxing 8バイト 浮動小数点は無変換 ビット判定 繊細 前提に依存

いずれの方式でも、整数ファストパスや、GC が値の中からポインタを見分ける仕組み(次章以降の題材)と密接に関係します。値表現の選択は単独では完結せず、演算実装・オブジェクトモデル・GC の設計と一体で考える必要があります。

まとめ

  • 値表現は「あらゆる種類を一様に格納」しつつ「種類を高速に判定」できる枠を設計する問題である。
  • 素朴なタグ付き共用体は明快で移植性が高いが、メモリ効率で劣る。
  • NaN-boxing は 8 バイトに収め浮動小数点を無変換で扱える一方、実装が繊細で環境前提に依存する。
  • NaN-boxing では、外部由来の浮動小数点数を枠に入れる全経路で NaN を正規化しないと、メモリ破壊につながる穴が生じる。
  • 整数ファストパスは、表現を持つだけでなく演算実装が最初に判定して初めて効果が出る。

参考文献