constの幻想:V8ヒープの物理構造から暴く「不変性(Immutability)」の正体
JavaScriptのコアランタイム、特にV8エンジンやTC39の仕様策定の現場に長く身を置いていると、初学者からシニアに至るまで、いまだに根強く蔓延する誤解に出くわす。
「`const`で宣言したオブジェクトはイミュータブル(不変)である」という神話だ。
結論から言えば、これは完全に誤りである。`const`が保証するのは、変数識別子への再代入(Re-assignment)の禁止だけであり、指し示されたメモリ領域(ヒープ)に存在するオブジェクトの構造的変異(Mutation)の禁止ではない。
今回は、この挙動をV8エンジンのメモリ管理、隠しクラス(Hidden Classes / Maps)、そしてセキュリティの文脈であるプロトタイプ汚染(Prototype Pollution)に至るまで、極限の低レイヤ視点から解き明かしていこう。
—
1. 変数は「箱」ではない:「参照(Reference)」という名のポインタ
多くのプログラミング初学者向け教材では、変数を「値を入れる箱」と説明する。しかし、現代のJSランタイムにおいて、このアナロジーは有害ですらある。
`const config = { timeout: 1000 };` と記述したとき、V8エンジンのメモリ空間(ヒープ)で何が起きているか?
1. ヒープ領域の確保: V8は、実データであるオブジェクト `{ timeout: 1000 }` を格納するためのメモリ領域をヒープ上に確保する。
2. スタック領域のポインタ: 実行コンテキストのスタック上にある変数 `config` には、そのヒープ領域を指し示すメモリ上のアドレス(参照)が書き込まれる。
3. `const`の制約: `const`キーワードは、スタック上の変数 `config` に格納された「アドレス値」の書き換えをコンパイル時にブロックする。
ここで重要なのは、「アドレスの指し先にある実データ」は保護されていないという点だ。
const config = { timeout: 1000 };
// これはエラーになる(スタック上のアドレスの書き換えを試みるため)
// TypeError: Assignment to constant variable.
// config = { timeout: 2000 };
// しかし、これは完全に合法であり、成功する
config.timeout = 2000;
console.log(config.timeout); // => 2000
`config.timeout = 2000;` の実行時、V8はスタック上のポインタを辿り、ヒープ上のオブジェクトのプロパティ値が格納されているオフセットを直接書き換えている。変数 `config` 自体のアドレスは1ビットたりとも変わっていないため、`const`のルールに違反しないのだ。
—
2. V8エンジン内部:隠しクラス(Hidden Classes)とインラインキャッシュの明暗
では、オブジェクトのプロパティを書き換えるとき、V8の内部では何が起きているのか? ここを知ることが、パフォーマンスチューニングの第一歩となる。
JavaScriptは動的言語であり、実行時にオブジェクトの構造(プロパティの追加・削除)が自由に変更できる。しかし、これを毎回ハッシュマップとしてルックアップしていたのでは、コンパイル言語並みの速度は出せない。そこでV8は「隠しクラス(Hidden Classes / Maps)」という概念を導入している。
const user = {}; // 空のオブジェクトのHidden Class (C0) が生成される
user.name = ‘Alice’; // プロパティ追加により、新しい Hidden Class (C1) に遷移
user.age = 30; // さらに新しい Hidden Class (C2) に遷移
`const`で定義されたオブジェクトであっても、その中身を変更(ミューテート)するということは、V8に対して「隠しクラスの遷移」や「プロパティのインプレース(in-place)上書き」を強要することになる。
最適化を阻害するミューテーション
もし、あなたがパフォーマンスクリティカルなホットパス(Hot Path)で、`const`オブジェクトのプロパティを動的に書き換えまくっていたらどうなるか?
// 悪い例:V8のインラインキャッシュ(IC)を破壊するパターン
const processPayload = (data) => {
data.processed = true; // constであっても中身を書き換える
return data;
};
const obj1 = { id: 1 };
const obj2 = { id: 2, type: ‘admin’ };
processPayload(obj1);
processPayload(obj2);
V8のJITコンパイラ(TurboFan)は、オブジェクトの形状(Hidden Class)が一定であることを前提に機械語を最適化(Inline Caching)する。`const`だからといって中身を安易にミューテートし続けると、オブジェクトの形状がバラバラになり、V8はメガモルフィック(Megamorphic)な状態に陥って最適化を放棄、ガベージコレクション(GC)の負荷も増大する。
真の意味でパフォーマンスと安全性を両立させたいのであれば、オブジェクトのミューテーションを避け、「イミュータブルなデータ構造の差し替え(新しいオブジェクトの生成)」を行うべきだ。
// 良い例:スプレッド構文による新しいオブジェクトの生成
const processPayload = (data) => {
// 新しいオブジェクトを返すことで、元のオブジェクトの形状を汚さない
return { …data, processed: true };
};
もちろん、これでは `const` の意味(再代入禁止)を突き詰めることにはなるが、メモリのライフサイクルやGCの観点からはトレードオフが存在する。ここがアーキテクトの腕の見せ所だ。
—
3. セキュリティの深層:プロトタイプ汚染(Prototype Pollution)とサプライチェーンの脅威
`const`の「中身書き換え可能」という仕様は、アプリケーション層のバグだけでなく、深刻なセキュリティ脆弱性の温床にもなる。その最たる例がプロトタイプ汚染(Prototype Pollution)だ。
Node.jsのバックエンドや大規模なフロントエンドSPAにおいて、サードパーティ製のライブラリ(例えば、深いオブジェクトのマージを行う古いユーティリティ関数など)が、外部からの入力値(JSONリクエストボディ等)を適切にサニタイズせずに関数に渡したとする。
// 脆弱なマージ関数のイメージ
function unsafeMerge(target, source) {
for (let key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 攻撃者が注入する悪意のあるJSON(ペイロード)
const maliciousJSON = ‘{“__proto__”: {“isAdmin”: true}}’;
const payload = JSON.parse(maliciousJSON);
const appConfig = { debug: false };
// マージ実行
unsafeMerge(appConfig, payload);
このコードを実行した後、アプリケーション内のまったく別の場所で、以下のようなコードが実行されたとしたらどうだろうか?
// すべてのオブジェクトのプロトタイプが汚染されているため、真偽値が書き換わる
const unauthorizedUser = {};
console.log(unauthorizedUser.isAdmin); // => true (!?)
`const appConfig` としてどれほど厳重に変数を保護していようとも、JavaScriptのプロトタイプチェーンの根底(`Object.prototype`)が書き換えられてしまえば、言語ランタイム全体のセキュリティ防壁が崩壊する。
これがサプライチェーン攻撃の恐ろしさであり、依存関係(Dependencies)の奥深くにある微小な「オブジェクトのミューテーション」を許容する設計の隙を突いたRCE(リモートコード実行)への布石となる。
—
4. 完全なる不変性を手に入れるために:Runtimeでの防衛策
では、JavaScriptにおいて「本当に書き換え不可能なオブジェクト」を作るにはどうすればよいのか?
TC39は、ランタイムレベルでオブジェクトを凍結(Freeze)するAPIを提供している。
`Object.freeze()` の実効性と限界
const secureConfig = Object.freeze({
apiEndpoint: ‘https://api.example.com’,
timeouts: {
connection: 5000,
read: 3000
}
});
// 厳格モード(strict mode)ではTypeErrorが発生する。非厳格モードでは単に無視される
secureConfig.apiEndpoint = ‘https://malicious.com’;
console.log(secureConfig.apiEndpoint); // => ‘https://api.example.com’
しかし、ここでシニアエンジニアとして見落としてはならない重要な事実がある。`Object.freeze()` は「シャロー(浅い)」である。
上記のコードの `secureConfig.timeouts` オブジェクト自体は凍結されていない。
// これは成功してしまう!
secureConfig.timeouts.connection = 10000;
console.log(secureConfig.timeouts.connection); // => 10000
真のディープイミュータビリティ(Deep Immutability)を実現するには、再帰的に `Object.freeze` を適用するユーティリティを書くか、TC39のプロポーザルにあるイミュータブルデータ構造、あるいは Immutable.js のような専用のライブラリ、さらには近年のモダンJavaScriptでサポートされつつある `Record` と `Tuple`(プリミティブなイミュータブル合成型)の動向を注視する必要がある。
// 堅牢なディープフリーズの実装例
function deepFreeze(obj) {
// オブジェクトのプロパティ名を列挙
const propNames = Object.getOwnPropertyNames(obj);
// プロパティが持つオブジェクトも凍結する
propNames.forEach((name) => {
const prop = obj[name];
if (typeof prop === ‘object’ && prop !== null && !Object.isFrozen(prop)) {
deepFreeze(prop);
}
});
return Object.freeze(obj);
}
const immutableConfig = deepFreeze({
apiEndpoint: ‘https://api.example.com’,
timeouts: { connection: 5000 }
});
// これもうまくいかない(TypeError)
// immutableConfig.timeouts.connection = 10000;
—
結言:アーキテクトとしてのコードデザイン
`const` は魔法の防壁ではない。それは単に、コードの可読性を高め、「この変数はスコープ内で再代入されない」という意図をV8のパーサーと他の開発者に伝えるための構文上のシグナルに過ぎない。
オブジェクトの参照、ヒープメモリの挙動、そしてプロトタイプチェーンの仕組み。これらJavaScriptの根幹を成すランタイムの物理法則を理解した上でコードを書くこと。それこそが、バグを未然に防ぎ、セキュアで高速なアプリケーションを構築する唯一の道である。
変数宣言の左側に `const` と書いたとき、その背後でヒープ上のメモリがどう呼吸しているか——そこまで視覚化できて初めて、君は真のJavaScriptマイスターの領域に到達するのだ。