JavaScriptは本当に「値渡し」と「参照渡し」を区別しているのか?—ECMAScript仕様から読み解く

JavaScriptは本当に「値渡し」と「参照渡し」を区別しているのか?—ECMAScript仕様から読み解く

はじめに (対象読者・この記事でわかること)

本記事は、JavaScriptの「オブジェクトは共有渡し(参照渡し)、プリミティブは値渡し」という俗説に違和感を持つ中級者以上のフロントエンド・バックエンド開発者を対象に、ECMAScript仕様の用語と実装挙動を突き合わせて整理します。読み終えると、「引数は常に値渡し」「オブジェクト変数は参照値を値として持つ」という正確な理解に到達し、面接やレビューでの説明、バグの根本原因の切り分けができるようになります。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 - 変数・スコープ・関数呼び出しの基礎 - プリミティブとオブジェクトの基本的な違い

JavaScriptの代入と引数受け渡しはどうモデル化されているのか

JavaScriptの議論で「オブジェクトは共有渡し、プリミティブは値渡し」と語られることがあります。しかしECMAScript仕様はC/C++の「参照渡し」やC#のrefのような機構を直接的には採用していません。仕様が扱う中心概念は「値(Value)」と「参照(Reference)」ですが、ここでのReferenceは「変数やプロパティを解決するための抽象的な内部レコード」であり、ランタイムのポインタとは異なる内部用語です。

実務上の心構えとしては、次の2点が大枠の真実です。 1) 代入も引数渡しも「値のコピー」です。違いは、その「値」がプリミティブか、オブジェクトを指す参照値(ポインタ様の値)か。
2) 変数にはオブジェクト自体が格納されるのではなく、「ヒープ上のオブジェクトを指す参照値」が格納されます。参照値自体はコピーされますが、コピー先と元の参照値は同じオブジェクトを指すため、オブジェクトの内部を変更すれば双方から観測できます。

このため、「引数は常に値渡し(pass-by-value)」が正確な説明であり、「参照渡し(pass-by-reference)」は誤用です。オブジェクトに関して言えば「参照値を値渡ししている」が実態です。これが「共有渡し」と呼ばれることもありますが、言語仕様の正式用語ではありません。

仕様と挙動を丁寧に読み解く:値、参照値、Referenceレコード、そしてよくある誤解

ここが記事のメインパートです。ECMAScript仕様の用語、コード例、アンチパターン、面接での問答に耐える説明を通して理解を固めます。

ステップ1: まずは最小の実験で直感を整える

次のコードを観察します。

// 1) プリミティブ
function bump(n) {
  n++;
}
let a = 1;
bump(a);
console.log(a); // => 1(変わらない)

// 2) オブジェクト
function touch(obj) {
  obj.x = 42;
}
const o = { x: 0 };
touch(o);
console.log(o.x); // => 42(変わる)

// 3) オブジェクト参照の再束縛は呼び出し側に影響しない
function reassign(obj) {
  obj = { x: 99 }; // パラメータ変数に新しい参照値を代入
}
const p = { x: 0 };
reassign(p);
console.log(p.x); // => 0(変わらない)

観測される事実: - プリミティブは関数内の操作が呼び出し元に影響しない。 - オブジェクトの「内部変更」は呼び出し元へ反映される。 - しかしパラメータ変数そのものを別オブジェクトに再代入しても、呼び出し元の変数は変わらない。

この3つを同時に満たすメンタルモデルは「引数は常に値渡し。オブジェクトの場合、その値は『参照値』であり、参照値のコピーが渡る。よって内部変更は共有されるが、再代入は共有されない」です。

ステップ2: 仕様に沿って用語を正しく整理する

仕様上の用語を実務視点で最低限に噛み砕きます。

1) 値(Value)
ECMAScriptでの値は、Undefined, Null, Boolean, Number, BigInt, String, Symbol, Object など。ここでのObjectはヒープ上に格納されたエンティティへの「参照値」で取り扱われます。変数はこの値を保持します。

2) 変数とEnvironment Record
変数名からその「格納場所」を解決する抽象メカニズムがEnvironment Recordです。get/setが抽象的に定義され、実装(エンジン)が具体化します。

3) Reference(仕様内部の抽象参照)
「識別子を解決した結果」を運ぶ内部レコードで、C++でいう参照渡しではありません。評価時に「どのオブジェクトのどのプロパティか」「どのEnvironment Recordのどのバインディングか」を表すための、コンパイラ・インタプリタ向けの概念です。開発者が触るポインタではない点に注意。

4) 引数渡し
関数呼出しの際、仮引数への束縛は「引数の評価結果である値」をコピーして作られます。プリミティブなら数値や文字列などがコピーされ、オブジェクトなら「参照値」がコピーされます。したがって「pass-by-value」。ただし参照値が同じオブジェクトを指すため、内部のミューテーションは共有されます。

5) 「参照渡し」と誤認されがちなケース
配列にpushする、オブジェクトのプロパティを書き換える、といった操作は「同一のオブジェクトを共有している」からこそ伝播します。これは「参照値がコピーされただけ」でも起きます。反対に、関数内で新しいオブジェクトを代入した場合は、呼び出し元の参照値には影響しません。

コードで強調します。

function mutate(arr) {
  arr.push(1); // 共有された配列オブジェクトの内部変更
}
function rebind(arr) {
  arr = []; // 形参に新しい参照値を代入(呼び出し元には無関係)
}

const xs = [];
mutate(xs);
console.log(xs); // [1]

rebind(xs);
console.log(xs); // [1] のまま

この挙動は「pass-by-reference(呼び出し側の変数バインディング自体を関数が直接いじれる)」では説明されません。もし本当に参照渡しなら、rebindで呼び出し元の変数が新しい配列を指すはずですが、実際には変わりません。

ハマった点やエラー解決

1) 不変更新が必要なのに意図せず共有ミューテーションしてしまう
リアクティブフレームワークやRedux系のデータ管理では、オブジェクトの共有ミューテーションがバグ源に。スプレッドや構造的共有を使い、意識的に新しいオブジェクトを作る。

// 悪い: 共有オブジェクトを書き換える
state.user.name = "Alice";

// 良い: 浅いコピー or イミュータブル更新
const newState = { ...state, user: { ...state.user, name: "Alice" } };

2) デフォルト引数やオプションオブジェクトの書き換え副作用
関数内で受け取ったオブジェクトをそのまま変更し、呼び出し元で想定外の副作用が起きる。防御的コピーを行う。

function configure(opts = {}) {
  const o = { ...opts }; // 防御的コピー
  o.timeout ??= 3000;
  return o;
}

3) Set/MapやDateなどのミュータブルオブジェクト
浅いコピーでは中身が共有されうる。必要に応じて深いクローンや専用コピーを行う。

const s1 = new Set([1,2]);
const s2 = new Set(s1); // 要素はコピーされるが、各要素がオブジェクトなら共有に注意

4) equals判定の誤解(== vs === vs 同一参照)
参照値が同じであることと、構造的に等しいことは別。オブジェクトの等価は通常「同一参照」かどうかで判定されます。構造比較が必要ならライブラリか自作のディープイコールを用いる。

解決策

まとめ

本記事では、JavaScriptの引数・代入は「常に値渡し」であり、オブジェクトの場合は「参照値を値としてコピーする」ため内部変更が共有される、という正確なメンタルモデルを整理しました。

これにより、挙動の一貫した説明ができ、バグの芽を早期に摘めます。今後は、配列/Map/Set/Date/TypedArrayなど各ビルトインのミューテーション特性や、構造的共有・イミュータブルデータ構造の実践パターンも解説する予定です。

参考資料