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

コードレビューの現場から:`const` の「不変性」という危険な幻想

プロダクションコードのレビューをしていると、いまだにこんな誤解に基づいたコードに出会うことがある。

// レビュー対象コード
const user = { name: ‘Alice’, role: ‘guest’ };
user.role = ‘admin’; // 「あれ、constなのに書き換えられた!バグか?」

エンジニアが `const` を使うとき、心の中では「この変数はイミュータブル(不変)になった」と錯覚している。しかし、JavaScriptにおける `const` が保証しているのは、「変数バインディングの固定」だけであり、「値の不変性(Immutability)」ではない。

V8エンジンのメモリ空間と、ブラウザのランタイムの挙動を知る者にとって、この違いの理解はパフォーマンスとバグ耐性を左右する死活問題だ。今回は、`const` で宣言されたオブジェクトの裏側で何が起きているのか、V8の最適化メカニズムである「隠しクラス(Hidden Classes / Maps)」の観点から徹底的に解剖し、実務で使える堅牢な設計パターンを伝授する。

V8エンジン視点:`const` とメモリ上のデータ構造

まず、JavaScriptのプリミティブ型とオブジェクト型が、メモリ上でどう扱われているかを正確に把握しよう。

const MAX_ RETRIES = 3; // プリミティブ型
const config = { timeout: 5000 }; // オブジェクト型

`MAX_RETRIES` の場合、スタック領域(厳密にはV8のコンテキスト内)に値 `3` が直接バインドされる。`const` であるため、この変数名が別の値を指すように再代入することはV8によってコンパイル時に禁止される。

一方、`config` の場合、スタック上に置かれるのはオブジェクトそのものではなく、ヒープメモリ上に生成されたオブジェクト実体への「メモリアドレス(ポインタ)」である。`const` が固定するのは、この「ポインタの向き先」だ。つまり、「`config` が別のオブジェクトを指すように代入し直すこと」は禁止されるが、「ポインタの先にあるオブジェクトの内部プロパティを書き換えること」は、メモリの構造上、何の手出しもできないのである。

隠しクラス(Hidden Classes)とインラインキャッシュの破壊

ここからが本題だ。オブジェクトのプロパティを後から変更することが、なぜV8エンジンのパフォーマンスに悪影響を与えるのか。

プロトタイプベースのJavaScriptには、JavaやC++のような厳密なクラス構造がない。しかし、高速な実行を求められるV8は、実行時に「このオブジェクトはこういう構造をしている」というメタ情報を裏で動的に生成する。これが Hidden Classes(V8の内部実装用語では `Map` と呼ばれる) だ。

V8は、同じ構造を持つオブジェクトに対して同一のHidden Classを割り当て、プロパティのオフセット(メモリ上の位置)を予測可能にすることで、機械語レベルの高速なプロパティアクセス(インラインキャッシュ)を実現している。

しかし、以下のようなコードを書いたとき、V8の内部で何が起きているだろうか?

// 非効率なオブジェクトの構築プロセス
const user = {}; // 空のオブジェクトとして生成(Hidden Class A)
user.name = ‘Bob’; // プロパティ追加により、Hidden Class Bへ遷移
user.age = 30; // さらにプロパティ追加により、Hidden Class Cへ遷移

このように、後から次々とプロパティを追加・変更していくコードは、V8に無駄なHidden Classの遷移コストを支払わせ、最適化の恩恵(Deoptimization)を台無しにする。さらに、動的なプロパティの削除(`delete` 演算子)などを行おうものなら、V8は一瞬で最適化を諦め、極めて低速な辞書モード(Dictionary Mode)へとフォールバックさせる。これが、DOM操作や大量の配列処理の最中に予期せぬカクつきを引き起こす主因だ。

実務で直結する:堅牢で最適化されたコード設計

では、パフォーマンスを担保し、かつ「意図しない書き換え」を防ぐためにはどう設計すべきか。テクニカルリードとして推奨する2つのアプローチを提示する。

1. オブジェクトの形状を最初に確定させる(コンストラクタパターンの徹底)

オブジェクトは、生成する瞬間にすべてのプロパティを揃えて初期化し、後からの追加や変更を禁止する(あるいは避ける)。TypeScriptを使っていればインターフェースで縛ることができるが、バニラJSや動的なJSONパース時でもこの思想は重要だ。

/

  • 堅牢でV8の最適化を阻害しないオブジェクトファクトリ
  • @param {string} id
  • @param {string} name
  • @param {string} role
  • @returns {Readonly<{id: string, name: string, role: string}>}

/
function createUser(id, name, role) {
// 生成時に形状(Shape)を完全に固定する
// これによりV8は単一のHidden Classを割り当て、最適化を維持できる
return Object.freeze({
id,
name,
role,
createdAt: Date.now()
});
}

const currentUser = createUser(‘usr_01’, ‘Alice’, ‘admin’);

// 誤って書き換えようとしても、厳格モード(strict mode)ではTypeError、
// 非厳格モードでもサイレント失敗(または変更不可)となる
// currentUser.role = ‘guest’; // NG

2. イミュータブルな状態更新(Spread Syntax と Structural Sharing)

フロントエンドの状態管理(ReactのStateやRedux、あるいはモダンなVanilla JSアーキテクチャ)において、オブジェクトのミューテーション(直接書き換え)はバグの温床だ。参照が共有されている他のコンポーネントが予期せぬ影響を受ける。

スプレッド構文を用いた「新しいオブジェクトの生成」による状態更新は、一見非効率に思えるかもしれないが、近年のV8のJITコンパイラ(SparkplugやMaglevなど)は非常に優秀であり、小規模なオブジェクトのコピーと生成を極めて高速に処理する。むしろ、予測不可能なミューテーションによるデバッグの困難さと比較すれば、メリットは圧倒的に大きい。

/

  • 既存の状態を汚染せず、安全に一部のプロパティを更新する関数
  • @param {Object} state – 現在の状態オブジェクト
  • @param {Object} updates – 変更差分
  • @returns {Object} 新しいメモリ領域に生成されたオブジェクト

/
function updateState(state, updates) {
// 元のオブジェクトの構造を維持しつつ、新しい参照を返す
// V8にとっても新しい形状の予測がつきやすい
return {
…state,
…updates,
updatedAt: Date.now()
};
}

// 使用例
const initialState = { theme: ‘dark’, notifications: true, version: 1 };
const nextState = updateState(initialState, { notifications: false });

console.log(initialState.notifications); // true (元データは不変)
console.log(nextState.notifications); // false

パフォーマンスの罠:配列とDOM要素の取扱い

この「参照の固定」と「不変性」の誤解は、配列処理やDOM操作の文脈でさらに牙をむく。

よくあるアンチパターンとして、`const` で宣言した配列に対して、ループ内で無闇に `push` やインデックス指定による代入を繰り返すコードがある。

// 【アンチパターン】constだから安全と勘違いしつつ、内部をミューテートし続ける例
const items = [];
function processUserData(rawData) {
for (const raw of rawData) {
// 配列の動的なリサイズとメモリの再割り当てが頻発する
items.push({ id: raw.id, valid: raw.value > 10 });
}
}

V8の配列(ElementsKind)は、密な配列(Packed)からスパースな配列(Holey)、さらには格納されるデータの型(Smi, Double, Object)によって内部表現が動的に変化する。ループ内で次々と要素を追加・変更すると、配列のメモリ再割り当て(レイアウト変更)がコストとなり、レンダリングのフレームレートを押し下げる。

改善されたプロダクションコード

あらかじめサイズが推測できる場合、あるいは関数型アプローチをとる場合は、`map` や `reduce`、あるいは適切なサイズ見積もりを持った処理を行うべきだ。

/