【実務・中級編】constによる参照の固定と「不変性」の限界:オブジェクトのプロパティ変更がメモリ上の隠しクラス(Hidden Classes)に与える影響 – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

コードレビューをしていて、最も頻繁に遭遇する誤解の一つがこれだ。

const user = { name: ‘Alice’ };
user.name = ‘Bob’; // 「あれ?constなのに書き換えられたんだけど!」

ジュニアエンジニアからシニアへの質問として、入社以来何度聞いてきたことか分からない。しかし、テクニカルリードであるあなたなら、これが「当たり前の挙動」であることくらい一瞬で理解しているはずだ。`const`が固定するのは、オブジェクトそのものではなく、スタック領域に格納された「メモリ上のポインタ(参照アドレス)」だからだ。

だが、この仕様の表面的な理解にとどまっていると、V8エンジンの裏側の挙動を見誤り、フロントエンドのレンダリングパフォーマンスをジワジワと蝕む「見えない時限爆弾」をコードベースに埋め込むことになる。

今回は、`const`の限界と、それがV8エンジンの最適化の要である「隠しクラス(Hidden Classes / Shapes)」およびインラインキャッシュに与える致命的な影響について、コアエンジニアの視点から徹底的に解剖しよう。

—

1. V8のメモリ空間における `const` の正体

JavaScriptのランタイム(V8)において、プリミティブ型(数値や文字列など)はコールスタック上に直接値が保持される。そのため、`const a = 1;` と書けば、`a` の再代入は絶対に不可能になる。

しかし、オブジェクトや配列などの参照型は、データ本体がヒープ領域に確保され、スタックにはそのヒープ上のアドレス(ポインタ)のみが保持される。`const config = {}` が保証するのは、「このスタック上のアドレスの値を上書きさせない(別のポインタを代入させない)」という一点のみである。ヒープ上に存在するオブジェクトのプロパティの書き換えや追加は、ポインタの変更を伴わないため、`const` の検知範囲外なのだ。

[ Call Stack ] [ Heap Memory ]
config (const) —-> [ ポインタ: 0x7fff… ] —> { theme: ‘dark’ }
│
プロパティ変更はここを書き換えるだけ
(スタックのポインタは不変)

この「参照の固定」と「内部ミュータビリティ」の分離こそが、JavaScriptの柔軟性を生む源泉であると同時に、V8エンジンを最適化地獄へと引きずり込む元凶となる。

—

2. 隠しクラス(Hidden Classes)の遷移とパフォーマンス劣化のメカニズム

動的言語であるJavaScriptには、C++やJavaのような「厳格なクラス定義」が存在しない。そのため、V8はオブジェクトがどのようなプロパティを持っているかを実行時に推論する必要がある。そのままではプロパティアクセスのたびに辞書引き(ハッシュマップ検索)が発生し、到底実用に耐えない速度になってしまう。

そこでV8が導入したのが「隠しクラス(Hidden Classes / プロパティマップ)」である。

V8は、オブジェクトのプロパティ構造ごとに「形状(Shape)」を割り当て、同じ構造を持つオブジェクト間でこれを共有する。これにより、プロパティへのアクセスをメモリ上のオフセット(何番地にあるか)による直接参照へと昇華させている。

悪夢の動的プロパティ追加

ここで、先ほどの「`const` だから安全」と勘違いして、次のようなコードを書いたとする。

// 不安を煽るアンチパターンなコンポーネントの状態管理
const state = {};
// 後から動的にプロパティを生やす
state.isLoading = false;
state.data = null;
state.error = null;

このコードが実行されるたびに、V8のヒープ内では何が起きているのか?

1. 空のオブジェクトが生成される(隠しクラス `C0`)。
2. `isLoading` が追加された瞬間、V8は新しい隠しクラス `C1` を生成し、オブジェクトの内部ポインタを `C1` に付け替える(遷移(Transition)の発生)。
3. `data` が追加され、隠しクラス `C2` へ遷移。
4. `error` が追加され、隠しクラス `C3` へ遷移。

この動的なプロパティの追加・変更は、V8のインラインキャッシュ(Inline Caches: ICs)を「メガモルフ(Megamorphic:多態的=最適化放棄)」の状態へと叩き落とす。結果として、V8は高速な機械語のインライン展開を諦め、遅いランタイムルックアップへとフォールバックする。数千・数万回ループする配列処理や、高頻度で再描画されるReactのレンダリングサイクルの内部でこれをやれば、ブラウザのメインスレッドは確実にドロップフレームを起こす。

—

3. 堅牢な設計パターン:イミュータビリティと予測可能な形状

では、実務のフロントエンド開発において、どうコードを設計すべきか。
答えは明確だ。「オブジェクトの形状(Shape)を生成時に確定させ、後からの動的追加・変更を一切行わない」こと。そして、状態の変更が必要な場合は、ミュータブルな破壊的変更ではなく、イミュータブルな新しいオブジェクトの生成(スプレッド構文など)を行う。

「いやいや、新しくオブジェクトを作ったらメモリ効率が悪くなるでしょ?」という声が聞こえてきそうだが、現代のV8のガベージコレクタ(Generational GC / OMRなど)は、短命なオブジェクト(Young Generation)の回収において驚異的なパフォーマンスを発揮する。下手にミュータブルに使い回して隠しクラスを迷子にさせるよりも、「形状が完全に予測可能なオブジェクトを新しく作る」方が、V8の最適化エンジンにとっては遥かに好都合なのだ。

プロダクションコード例:型安全かつV8最適化を考慮した状態管理

以下に、実務のAPI連携やコンポーネント設計でそのまま使える、保守性とパフォーマンスを両立したコードを示す。

/

  • @typedef {Object} UserState
  • @property {boolean} isLoading
  • @property {import(‘./api’).User | null} data
  • @property {Error | null} error

/

/

  • 初期状態を定義。
  • 【ポイント】この時点でオブジェクトの「形(隠しクラス)」を完全に固定する。
  • 後からプロパティを追加・削除してはならない。
  • @returns {UserState}

/
export function createInitialState() {
return {
isLoading: false,
data: null,
error: null,
};
}

/

  • イミュータブルに状態を更新する純粋関数。
  • オブジェクトの形状を変えず、値の差し替えのみを行うため、
  • V8の隠しクラスの遷移を引き起こさない。
  • @param {UserState} currentState
  • @param {Partial} nextValues
  • @returns {UserState} 新しいオブジェクト参照を返す

/
export function produceState(currentState, nextValues) {
// スプレッド構文により、常に同一の隠しクラスを持つ新しいオブジェクトを生成
// インラインキャッシュ(IC)のヒット率を維持し、V8の最適化を最大限に引き出す
return {
isLoading: nextValues.isLoading ?? currentState.isLoading,
data: nextValues.data !== undefined ? nextValues.data : currentState.data,
error: nextValues.error !== undefined ? nextValues.error : currentState.error,
};
}

// — 使用例 —
let state = createInitialState();
// V8最適化パスに乗った状態で安全に状態遷移
state = produceState(state, { isLoading: true });
state = produceState(state, { isLoading: false, data: { id: 1, name: ‘Architect’ } });

—

4. 配列とDOM操作における実務上の注意点

この「形状の固定」の哲学は、オブジェクトだけでなく配列やDOM操作にも直結する。

1. 配列の「穴(Holes)」を作らない

JavaScriptの配列は、内部的にはオブジェクトの特殊形(または連続したメモリブロック)だ。例えば、次のようなコードはV8を泣かせる。

const list = [];
list[0] = ‘apple’;
list[100] = ‘orange’; // インデックス 1~99 に「穴」が空く

配列に「穴(Holes)」が空くと、V8は要素へのアクセスを「高速な連続メモリのオフセット計算」から「ハッシュマップ的なスパース配列の探索」に切り替えてしまう。配列操作系のメソッド(`map`, `filter`, `forEach` 等)のパフォーマンスが激減するため、配列を構築する際は必ず連続したインデックスで要素を詰めるか、最初から適切なサイズ・構造で初期化すること。

2. DOM要素のプロパティ動的付与

DOMノード(`HTMLElement`)もまた、JavaScriptのオブジェクトの延長線上にある。

// 毎回異なるプロパティを動的に生やすアンチパターン
const div = document.createElement(‘div’);
div.dataset.userId = user.id;
if (isAdmin) {
div.dataset.adminLevel = user.level; // 条件によって形状が変わる
}

DOM操作自体がブラウザのレンダリングパイプライン(Style -> Layout -> Paint -> Composite)を刺激する高コストな処理であるが、JS側で不要にオブジェクトの形状を揺らすと、V8とBlink(レンダリングエンジン)の境界でのバインディングコストも増加する。コンポーネント初期化の段階で、必要なプロパティや属性は一括して構築する設計を徹底しよう。

—

結びにかえて:シニアエンジニアが守るべき美学

`const` は魔法の杖ではない。それは単に「ポインタの再代入を防ぐための構文上のガードレール」に過ぎない。

真にパフォーマンスが高く、バグの起きない堅牢なコードベースを築き上げるためには、言語の表面的な挙動に惑わされず、「V8エンジンがメモリ上でどのようにオブジェクトを解釈し、どのように最適化パス(JITコンパイル)を走らせているか」というランタイムの呼吸を感じ取る必要がある。

コードレビューの際、後から `obj.foo = bar` と平然とプロパティを生やしているコードを見かけたら、こう問いかけてほしい。

「その書き方、V8の隠しクラスを何回遷移させてインラインキャッシュを殺す気かい?」

このレベルの解像度を持ってチームを牽引することこそが、真のテクニカルリードの役割である。

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