【テクニカル・上級編】Object.is と === 演算子の決定的な違い:NaN と -0 を巡る比較の深淵 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

NaNと-0の深淵:`Object.is`と`===`演算子を分かつIEEE 754の暗部とV8ランタイムの防壁

JavaScriptにおける等価性比較は、初学者が最初に躓くポイントでありながら、言語の仕様の深淵に踏み込むシニアエンジニアであっても舌を巻く複雑さを秘めている。`===`(厳密等価演算子)と、ES2015で導入された`Object.is`。この2つの挙動の違いを「`NaN`を正しく判定できるかどうか」という表層的な理解だけで語るべきではない。

本稿では、IEEE 754浮動小数点数規格のバイナリレベルの仕様から、V8エンジンのJITコンパイラ(Maglev / TurboFan)における最適化パス、そしてセキュリティの文脈におけるプロトタイプ汚染(Prototype Pollution)の防御に至るまで、JavaScriptランタイムの全貌を見据えた極限の知見を紐解く。

—

1. IEEE 754の亡霊:`NaN`と`-0`が突きつける数学的矛盾

JavaScriptのすべての数値(`BigInt`を除く)は、IEEE 754倍精度浮動小数点数(64ビット)としてV8のヒープ上、あるいはインラインのSMI(Small Integer)/ HeapNumberとして表現されている。この規格が抱える「設計上の特異点」が、そのままJavaScriptの比較演算におけるエッジケースを生み出している。

`NaN`(Not-a-Number)の自己不整合

IEEE 754において、`NaN`は「数値ではない何か」ではなく、「数値演算の結果として定義できない値」を指す。符号ビットがどちらであれ、指数部がすべて`1`で、仮数部(有効数字)が非ゼロであるビット列はすべて`NaN`とみなされる。

IEEE 754の仕様書には、極めて冷徹なルールが定められている。
> “The comparison operations shall return false if the invalid operand is NaN.”
> (不正なオペランドの一方がNaNである場合、比較演算は常にfalseを返さなければならない)

これは数学的には論理的だ。「未知の値」と「未知の値」を比較したとき、それらが等しいかどうかは誰にも証明できないため、`NaN === NaN` が `false` になるのは仕様として正しい。しかし、プログラミングの実務において、配列のフィルタリングやキャッシュのキー判定でこの挙動がバグの温床になってきたことは言うまでもない。

`-0`(負のゼロ)という幻影

もう一つの厄介な存在が`-0`である。符号ビットが `1` で、指数部と仮数部がすべて `0` であるビット列は `-0` と表現される。数学のリアルナンバーシステムにおいて、`-0` と `+0` は完全に等価である。実際、`===` 演算子はこれを同一視する。

console.log(+0 === -0); // true

しかし、CPUのハードウェアレベルや特定の数学的文脈(例:逆数の計算 `1 / -0` は `-Infinity` を返し、`1 / +0` は `Infinity` を返す)において、この2つは厳密に区別されなければならない。

—

2. `===` vs `Object.is`:ランタイムにおける比較ロジックの差異

ECMAScript仕様において、`===` は Strict Equality Comparison Algorithm (`11.15.1`) に従い、`Object.is` は SameValue Algorithm (`7.2.12`) に従う。

両者の決定的な違いは、以下の2点に集約される。
1. `NaN` 同士の比較
2. `+0` と `-0` の比較

// === (Strict Equality)
console.log(NaN === NaN); // false (仕様の通り)
console.log(+0 === -0); // true (同一視する)

// Object.is (SameValue)
console.log(Object.is(NaN, NaN)); // true (ビットパターンが一致)
console.log(Object.is(+0, -0)); // false (符号ビットの差異を検知)

V8エンジン内部における実装の深淵

V8(C++)のソースコードにおいて、`Object.is` は内部的に `SameValue` 関数として実装されている。
V8はオブジェクトの比較やプリミティブの比較を行う際、ポインタの等価性(Pointer Equality)を最初にチェックする。もし比較対象の双方が同じメモリアドレスを指していれば、型の評価をスキップして即座に `true` を返す(Fast Path)。

しかし、HeapNumber(倍精度浮動小数点数を格納するヒープオブジェクト)やSMIのアンボクシング(Unboxing)が行われる際、`Object.is` はビットレベルの比較(Bitwise Comparison)を実行する。これにより、`-0` の符号ビット(最上位ビット)の差異や、ペイロードが異なる `NaN` のビットパターンまで厳密に看破することが可能になるのだ。

—

3. 実践:数値計算と状態管理におけるエッジケースの駆逐

フロントエンドの複雑な状態管理(ReduxやZustandのセレクター)や、高頻度で実行される金融系の数値計算エンジンにおいて、この差異を見落とすと致命的なバグを生む。

メモ化(Memoization)と不要な再描画の地獄

ReactなどのUIライブラリにおいて、前回のレンダリング結果と現在のプロパティを比較して再計算をスキップするメモ化ロジックを自作する場合を想像してほしい。

function shallowEqual(objA, objB) {
if (Object.is(objA, objB)) return true;
// オブジェクトのプロパティ走査ロジック…
}

もしここで `===` を使ってプリミティブ値を比較していると、APIから返ってきたデータの計算結果に `NaN` が含まれていた場合、「毎回 `NaN !== NaN` が発火し、メモ化が無効化されて無限ループやパフォーマンスの著しい劣化(LCP/INPの悪化)」を引き起こす。

さらに、アニメーションの補間ライブラリや物理演算エンジンにおいて、`-0` の消失は向き(Direction)の反転バグを引き起こす。

// 物理演算の速度ベクトルが負のゼロに向かっている状態
let velocity = -0;

// 誤った比較
if (velocity === 0) {
// 常にtrueになるため、進行方向の判定が狂い、
// 画面の端でオブジェクトが予期せぬ挙動を示す
}

// 正しい判定
if (Object.is(velocity, -0)) {
// 負の方向への微小なベクトルとして正確に処理する
applyNegativeVectorDamping();
}

—

4. セキュリティ・文脈:プロトタイプ汚染(Prototype Pollution)と比較の隙

ここからが、シニアエンジニアやセキュリティ研究者が最も直視しなければならない領域だ。JavaScriptの動的なオブジェクトモデルの根幹を揺るがす「プロトタイプ汚染」と、値の比較ロジックの脆弱性は、サプライチェーン攻撃において直結することがある。

脆弱なマージ関数と `Object.is` による防壁

JSONの再帰的マージ(Deep Merge)を実装する際、悪意あるペイロードが `__proto__` や `constructor`、`prototype` といったキーを含んでいる場合、オブジェクトのプロトタイプチェーンが汚染され、アプリケーション全体で任意のコード実行(RCE)や権限昇格を誘発する。

脆弱なマージ実装の典型例を見てみよう。

// 【危険】絶対に真似してはならない脆弱なマージ関数
function vulnerableDeepMerge(target, source) {
for (let key of Object.keys(source)) {
if (source[key] && typeof source[key] === ‘object’) {
if (!target[key]) target[key] = {};
vulnerableDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}

// 攻撃者が送信するJSONペイロードの例
const payload = JSON.parse(‘{“__proto__”: {“polluted”: true}}’);
vulnerableDeepMerge({}, payload);

console.log({}.polluted); // true! アプリケーション全体が汚染された

この脆弱性を突く攻撃者は、キー名として直接文字列を渡すだけでなく、型変換の隙や、`Object.is` を使わない甘いキー検証のバイパスを試みる。

堅牢なアーキテクチャでは、オブジェクトのプロパティを走査・検証する際や、状態の変化をトラッキングしてサニタイズを行うレイヤーにおいて、予期せぬ型や値(例えば、キー自体が意図せず `NaN` に評価されるようなエッジケースや、オブジェクトの構造破壊を狙った入力)を完全に弾く必要がある。

セキュアなコードでは、キーの存在確認や値の整合性検証において、型の揺らぎを許容しない厳密な比較が求められる。

// セキュアなキー検証の断片
function isValidKey(key) {
// 原型汚染を防ぐため、危険なキーをシャットアウトしつつ、
// 厳密な同一性で予期せぬ型変換を防ぐ
const dangerousKeys = [‘__proto__’, ‘constructor’, ‘prototype’];

if (typeof key !== ‘string’) return false;

for (let i = 0; i < dangerousKeys.length; i++) { // 厳密な文字列比較 if (Object.is(key, dangerousKeys[i])) { return false; } } return true; } V8エンジンの隠しクラス(Hidden Classes / Shapes)とインラインキャッシュ(Inline Caches: ICs)は、オブジェクトのプロパティアクセスを高速化するために、プロパティの追加順序や型をメモリ上で最適化している。プロトタイプ汚染が発生すると、このV8の最適化パスが無効化され(Megamorphic状態への転落)、ガベージコレクションの負荷が急増し、CPU使用率が跳ね上がる。つまり、プロトタイプ汚染はセキュリティ上の脅威であると同時に、Denial of Service (DoS) 攻撃のベクトルでもあるのだ。

—

5. 結び:ランタイムの物理法則を支配する者へ

JavaScriptは「手軽なスクリプト言語」という仮面をかぶった、極めて高度な仮想マシン上で動くランタイムである。

`===` と `Object.is` の選択は、単なるコーディングスタイルの好みではない。それは、IEEE 754というハードウェアの物理法則に由来する数値の歪みをどうハンドリングするか、そしてV8エンジンの最適化とメモリ安全性をどう維持するかという、アーキテクトとしての深い洞察の表れに他ならない。

エッジケースを恐れるな。ランタイムの底層で何が起きているのかを常に脳内トレースし、コードの1行1行にエンジニアリングの魂を宿せ。それが、真に信頼性の高いシステムを構築するための唯一の道である。

タイトルとURLをコピーしました