値渡しと参照渡し
Pass by Value / Pass by Reference ・ あたいわたしとさんしょうわたし
関数へ値のコピーを渡すか、同じ実体への参照を渡すかの区別。共有された参照が思わぬ書き換え事故を生む。
✦ 30秒で試す — 航海関数の中で名前を書き換えたら、呼び出し元の名前まで変わった関数から戻ったあと、user.name は?予想してみる概要
値渡しと参照渡しは、「変数を関数に渡したり別の変数に代入したりしたとき、渡るのは中身のコピーか、それとも同じ実体を指す参照か」という区別です。数値を渡した場合、呼び出し先でいくら書き換えても呼び出し元の変数は変わりません。ところがオブジェクトや、配列のようなデータ構造を渡すと、呼び出し先での書き換えが呼び出し元にも「見えて」しまいます。この非対称な挙動の正体を説明するのがこの語彙です。
この区別を知らないまま書いたコードは、「関数に渡しただけの配列がいつの間にか並び替わっていた」「コピーしたつもりの設定オブジェクトを変更したら元も変わった」といった、症状と原因が離れた追いにくいバグを量産します。逆にこの仕組みが「変数という箱と、そこから伸びる矢印」の絵として頭に入っていれば、どの言語に移っても「これはコピーされるのか、共有されるのか」を自信を持って判断できるようになります。
なぜ生まれたか
プログラミング言語が関数という抽象を持った瞬間から、「引数に渡した変数は、呼び出し先で書き換えたら呼び出し元も変わるのか」という問いが生まれました。1960年の ALGOL 60 はすでに、値をコピーして渡す「値呼び(call by value)」と、式そのものを渡す「名前呼び(call by name)」を区別していました。厄介なのは、この答えが言語ごと、さらに同じ言語でも型ごとに違うことです。C は常に値渡し、C++ は & で参照渡しを選べ、Java は「すべて値渡し」なのにオブジェクトの書き換えは呼び出し元に見え、JavaScript や Python もそれに似た挙動をします。用語の使われ方も曖昧で、「Java は参照渡し」という誤った説明が広まるなど、混乱と事故の温床になっていました。
この混乱を整理する鍵は、「変数の中に何が入っているか」を区別することです。変数に値そのもの(数値や、値型の構造体まるごと)が入っているのか、それともどこか別の場所にある実体を指す参照(番地)が入っているのか。渡すときにコピーされるのは、いつでも「変数の中身」です。中身が値そのものならコピー後は無関係になり、中身が参照なら「参照はコピーされたが、指す先は同じ1つの実体」という共有状態が生まれます。この絵さえあれば、言語ごとの違いは「その型の変数に入っているのは値か参照か」というバリエーションに見えてきます。
詳細
プリミティブは値のコピー
数値・真偽値のようなプリミティブ型は、変数そのものが値を直接持っています。代入や関数呼び出しではその値がまるごとコピーされるため、コピー後の2つの変数は完全に独立です。let a = 1; let b = a; b = 2; としても a は 1 のままで、ここに驚きはありません。
ただし「値を直接持つ」ことと「スタックとヒープのどちらに置かれるか」は別の話です。関数のローカル変数に入った数値はふつうスタックのフレームに置かれますが、同じ数値でもオブジェクトのフィールドならヒープの中に置かれます。Python に至っては整数もヒープ上のオブジェクトで、変数は常に参照です。それでも整数が値渡しのように振る舞うのは、整数オブジェクトが不変で、x += 1 が「中身の書き換え」ではなく「別の整数オブジェクトへの付け替え」になるからです。「値型はスタック、参照型はヒープ」という覚え方は、入り口としては便利でも正確ではありません。
オブジェクトは「参照の値渡し」
一方、オブジェクトや配列の変数が持っているのは実体そのものではなく、ヒープ上の実体を指す参照です。代入や関数呼び出しでコピーされるのはこの参照だけで、指す先の実体は1つのまま共有されます。JavaScript や Java の挙動はこれで説明できます。
関数の中で obj.name = "Bob" と中身を書き換えれば、同じ実体を見ている呼び出し元にも変更が見えます。しかし obj = { name: "Bob" } と引数の変数そのものに再代入しても、付け替わるのはコピーされた矢印だけなので、呼び出し元の変数は元のオブジェクトを指し続けます。この2つを並べると違いがはっきりします。
この挙動は「参照の値渡し」(共有渡し、call by sharing とも)と呼ぶのが正確です。「参照渡し」という言葉の誤用には注意してください。本来の参照渡し(C++ の & や C# の ref)は、引数が呼び出し元の変数そのものの別名になる仕組みで、呼び出し先での再代入までもが呼び出し元に反映されます。Java・JavaScript・Python にこの機能はなく、コピーされるのがたまたま参照という値である値渡ししか存在しません。「中身の書き換えは共有されるが、再代入は共有されない」。この一行が両者を見分けるリトマス試験紙です。
下の部品で確かめてください。オブジェクトの name を押すと中身の書き換え、obj の矢印の先(金の丸)をつかんで別の箱へ運ぶと再代入です。渡し方を「参照渡し」「値型のコピー」に切り替えると、同じ操作の結果が変わります。
- rename(user, n) // 矢印と数値のコピーを渡す
main() が rename(user, n) を呼んだところです。まず右の箱の name を押して、関数の中から中身を書き換えてみてください。
値型という選択肢 — C# の struct と Swift の copy-on-write
「共有されると困るなら、オブジェクトも値として扱えばよい」という発想で作られたのが値型です。C の構造体、C# の struct、Swift の struct や enum は、代入や引数渡しのたびに中身がまるごとコピーされるので、渡した先で書き換えても呼び出し元には影響しません。ここでも置き場所は別の話で、C# の struct はローカル変数ならスタックに置かれますが、クラスのフィールドになればそのオブジェクトと一緒にヒープに置かれます。値型かどうかを決めるのは「コピーの意味論」であって、置き場所ではありません。
大きな値を毎回コピーすると遅くなるので、Swift の Array や String は copy-on-write という工夫をしています。代入した時点では内部のバッファを共有しておき、どちらかが書き換えようとした瞬間に初めて複製します。見かけは値のコピー、中身は必要になるまで共有、という折衷で、イミュータビリティの構造共有と同じく「書き換えが起きなければコピーは要らない」という考え方に立っています。
シャローコピーとディープコピー
参照型のまま共有を断ちたいときは複製を作りますが、複製にも深さがあります。シャローコピー(浅い複製)は最上位のプロパティだけをコピーする方法で、JavaScript のスプレッド構文 { ...obj } や Object.assign、配列の slice() がこれにあたります。手軽ですが、プロパティの値が参照なら参照がコピーされるだけなので、入れ子のオブジェクトやデータ構造は依然として共有されたままです。「コピーしたのに中の配列を変えたら元も変わった」という第二の罠です。入れ子の底まで複製するディープコピー(深い複製)には、現代の JavaScript なら structuredClone が使えます。古くからの定番だった JSON シリアライズ経由の複製(JSON.parse(JSON.stringify(obj)))も広く見かけますが、JSON で表現できないもの(Date は文字列に化け、undefined や関数は消え、循環参照はエラー)が壊れる点は知っておく必要があります。
防御的コピーとミューテーション事故
共有の危険がわかると、境界でコピーを取る「防御的コピー」という定石が生まれます。外から受け取ったオブジェクトを保存する前に複製する、内部状態を返すときは複製を返す。こうすれば呼び出し側の書き換えが内部に、内部の変更が呼び出し側に漏れません。典型的な事故は、JavaScript の sort や reverse が配列を破壊的に並び替えるのを忘れて元データを壊すケース(非破壊版の toSorted や toReversed も今は使えます)、関数に渡したオプションオブジェクトをライブラリ側が書き換えてしまうケースなどです。より根本的な解決は、そもそも書き換え自体をやめてしまうイミュータビリティの採用で、防御的コピーはその手前にある実務的な護身術と言えます。どちらを選ぶにせよ、出発点は「その変数に入っているのは値か、参照か」という絵です。
試問
JavaScript の main() が rename(user, n) を呼んだ直後の、2つのフレーム(変数)とヒープの箱です。関数の中の obj と呼び出し元の user は、どちらも name が “Alice” の箱を指しています。箱の name を押すと中身の書き換え(obj.name = …)、obj の矢印の先(金の丸)をつかんで別の箱へ運ぶと再代入(obj = …)になり、上の切り替えで渡し方を変えられます。
- rename(user, n) // 矢印と数値のコピーを渡す
main() が rename(user, n) を呼んだところです。まず右の箱の name を押して、関数の中から中身を書き換えてみてください。
