コードレビューの現場から:その`let`、本当に必要ですか?
プロダクションコードのレビューをしていると、思考停止ですべての変数宣言に`let`を使っているコードに出くわすことが少なくない。「後で値を変えるかもしれないから」「再代入できない`const`を使う理由が特に思い浮かばないから」――フロントエンドエンジニアからよく聞く言い訳だ。
しかし、大規模なWebアプリケーションにおいて、その「なんとなくの`let`」がV8エンジンの最適化パイプラインを破壊し、ガベージコレクション(GC)のスパイクを引き起こしているとしたらどうだろうか?
今回は、V8の内部挙動である「隠しクラス(Hidden Classes / Shapes)」と「インラインキャッシュ(Inline Caches)」の観点から、変数の宣言と再代入がランタイムのパフォーマンスに与える影響を徹底的に解剖する。
—
1. V8エンジンをハックする:隠しクラスとインラインキャッシュの正体
JavaScriptは動的型付き言語だ。開発者は型の定義を気にせず、オブジェクトに自由プロパティを追加し、変数へ任意の型の値を再代入できる。しかし、C++やRustのような静的言語とは異なり、実行時に毎回プロパティのメモリ上のオフセット(何番地のメモリにあるか)を計算していたのでは、Webブラウザの60fps(あるいは120fps)のレンダリングパイプラインに到底追いつかない。
そこでV8は、「隠しクラス(あるいはShapes / Maps)」という概念を導入した。
隠しクラスの生成と破綻のメカニズム
V8は、オブジェクトが持つプロパティの構造(キーと順序)ごとに内部的な「隠しクラス」を割り当てる。同じ構造を持つオブジェクトは同じ隠しクラスを共有し、プロパティアクセスを高速化(C++の構造体アクセスと同等レベル)する。
ここに、「型が頻繁に変わる再代入」が絡むと何が起きるか。
// パフォーマンスを劣化させるアンチパターン
let data = { id: 1, value: “initial” }; // 隠しクラス C1 が生成される
// 処理の途中でまったく異なる型の値を再代入
data = { id: 1, value: 42 }; // プロパティ ‘value’ の型が string から number へ変容
一見、何の問題もないコードに見える。しかし、`let`によって変数`data`に異なる構造・型のオブジェクトが再代入されると、V8のJITコンパイラ(TurboFanなど)は頭を抱えることになる。
インラインキャッシュ(IC)のメガモルフィック化
プロパティアクセスを最適化する仕組みがインラインキャッシュ(Inline Cache)だ。
ICの状態には以下の3つがある。
1. モノモルフィック(Monomorphic): 常に同じ隠しクラスが渡される。(最速)
2. ポリモルフィック(Polymorphic): 少数の異なる隠しクラスが渡される。(許容範囲)
3. メガモルフィック(Megamorphic): あらゆる隠しクラスが混在し、キャッシュが機能しなくなる。(最悪・最適化の放棄)
`let`を用いた変数の再代入によって、変数にバインドされる値の型や構造がコロコロ変わると、V8は型推論を諦め、インラインキャッシュをメガモルフィックへと格下げする。結果として、プロパティアクセスごとの動的なルックアップが発生し、CPUサイクルが無駄に消費される。
—
2. 実務における影響:DOM操作とデータパイプラインの罠
この挙動は、フロントエンドのコンポーネント状態管理や、巨大な配列を処理するデータパイプラインにおいて深刻なパフォーマンス劣化を招く。
特に、頻繁に実行されるイベントハンドラや、Reactなどの仮想DOM差分アルゴリズムの内部、あるいはCanvasやWebGLを駆使したグラフィックス処理において、型が揺らぐ変数は微小なマイクロタスクの遅延を積み重ね、メインスレッドをブロックする原因となる。
改善されたプロダクションコード例
では、V8の型推論を助け、インラインキャッシュの恩恵を最大限に受けるためのコードはどうあるべきか。
以下の実用的なデータ処理パイプラインの設計を見てほしい。
/
- @typedef {Object} UserProfile
- @property {number} id
- @property {string} name
- @property {number} score
/
/
- ユーザーデータの正規化とスコア計算を行う高パフォーマンスパイプライン
- 【設計のポイント】
- 1. 可能な限り `const` を使用し、変数の指すオブジェクトの「型と構造」をイミュータブル(不変)に保つ。
- 2. プロパティの欠損や動的な追加を行わず、最初から全てのキーを定義することで隠しクラスを固定化する。
- 3. 再代入が必要なカウンターなどは、型(Primitive Number)を混在させない。
/
class UserMetricsProcessor {
/
- @param {Array
- @returns {Array
}
/
processUsers(rawData) {
// const により再代入を完全に禁止し、V8に「この参照先は変わらない」と伝える
const sanitizedData = [];
// ループ内のインデックス変数以外は const で宣言
const length = rawData.length;
for (let i = 0; i < length; i++) { const raw = rawData[i]; // 【重要】オブジェクトの形状(Shape)を完全に一致させる。 // プロパティの有無や型がアイテムごとにブレると、隠しクラスが乱立する。 / @type {UserProfile} / const processedItem = { id: typeof raw.id === 'number' ? raw.id : 0, name: typeof raw.name === 'string' ? raw.name : 'Anonymous', score: typeof raw.score === 'number' ? raw.score : 0 }; sanitizedData.push(processedItem); } return sanitizedData; } /
- 合計スコアを算出する(モノモルフィックなアクセスを維持)
- @param {Array
} users - @returns {number}
/
calculateTotalScore(users) {
// アキュムレータ(累積値)の型を明確に維持
let totalScore = 0; // 型は常に number
const length = users.length;
for (let i = 0; i < length; i++) {
// users[i] の隠しクラスが一定であるため、V8はプロパティアクセスを高速化(インラインキャッシュ)する
totalScore += users[i].score;
}
return totalScore;
}
}
// --- 実行例 ---
const processor = new UserMetricsProcessor();
const mockRawData = [
{ id: 1, name: 'Alice', score: 85 },
{ id: 2, name: 'Bob', score: 92 },
{ id: 3, name: 'Charlie', score: 78 }
];
const users = processor.processUsers(mockRawData);
const total = processor.calculateTotalScore(users);
console.log(`総スコア: ${total}`); // 総スコア: 255
---
3. チーフアーキテクトからのコードレビュー指針
チームのコードベースをV8フレンドリーにするために、次のルールをチームのlint設定やコードレビューの共通認識として徹底してほしい。
1. `let` は「再代入が不可避な場合」に限定せよ
「もしかしたら変えるかも」という曖昧な理由での`let`の使用を禁止する。初期化時に`const`で宣言し、どうしてもスコープ内で状態を変える必要がある場合(リデューサーの累積値やループのカウンタなど)にのみ`let`を許可する。
2. オブジェクトの「形状(Shape)」を動的に変えるな
一度生成したオブジェクトに対して、後から`delete`演算子でプロパティを消したり、条件分岐で突発的に新しいプロパティを生やしたりするコードは最悪だ。
オブジェクトは生成時点で必要なプロパティをすべて揃え(未確定な値は`null`や適切なデフォルト値を設定する)、隠しクラスの分岐(ポリモルフィズム)を最小限に抑えろ。
3. 型の揺らぎ(Type Pollution)を排除せよ
JavaScriptは動的言語ゆえに、変数に「最初は数値、途中から文字列、最後はオブジェクト」といった代入が文法上可能だ。しかし、V8のJITコンパイラはそのような変数を扱うコードの最適化をあきらめ、インタープリター実行にフォールバックさせる。変数や引数の型は、メンタルモデル上でも常に一意に固定せよ。
—
結びにかえて
モダンなJavaScriptフレームワークや高度なフロントエンドアーキテクチャを支えているのは、他でもないV8をはじめとするJavaScriptエンジンの極限の最適化努力だ。
私たちエンジニアは、「動くコード」を書くだけでなく、「ランタイムが効率よく解釈し、最速で実行できるコード」を書く責任がある。その第一歩が、`const`と`let`の厳格な使い分けであり、オブジェクトの形状に対する美意識を持つことだ。
次のプルリクエストを出す前に、自分の書いたコードのすべての`let`を見つめ直してほしい。その変数は本当に、型を変える宿命を背負っているだろうか?