はじめに
同じような記事は多くありますが、肝であるランタイムメモリの説明がざっくりしている解説が多く、もう少し踏み込んで書き残したいと思った次第です。
JavaScriptで開発中、オブジェクトが格納された変数を何も考えずに別の変数にコピーし、コピー元オブジェクトのプロパティを更新するとコピー先も連動して更新されてしまった経験がある方は多いと思います。
後述しますが、これは「シャローコピー」というオブジェクトのコピー方式が原因となり、結果として参照情報が渡されていることで実体を共有しているような状態です。
しかし、オブジェクト型の変数ではなくプリミティブ型(stringやnumber等)の変数をコピーした時は同じようなことは起きず、コピー元とコピー先が干渉することはないですよね。
今回はこれらがどういった仕組みで成り立っているのか、JavaScriptのランタイムで使用されるメモリの仕様を交えながら解説していきます。
(結論としてはJavaScriptランタイムのメモリの仕様が肝になってきます。)
対象読者
- JavaScript初心者
- オブジェクト(というより変数)のコピーで想定通りに動かなかった経験のある方
結論だけ知りたい方
ここはざっくりした部分だけなので、細かい説明は本編で図解しています。
分かりにくければそちらをぜひ。
- JavaScriptのランタイムメモリには、「スタック領域」と「ヒープ領域」が存在する
- プリミティブ型(stringやnumber等)の値(厳密には「値の参照」)はスタック領域に格納される
- オブジェクト型は、「変数↔ヒープ領域にある実体のアドレスの紐付き」がスタック領域に格納され、実体(値を持つプロパティ情報)がヒープ領域に格納される
-
変数から別の変数にコピーする時はスタック領域の情報がコピーされる
- スプレッド構文等、特殊なコピー方法ではヒープ領域の情報も一部コピーされる
- 上記の仕様から、コピー元の変数の値が
- プリミティブ型の場合はそのまま値(値の参照)がコピーされる
- オブジェクト型の場合は参照情報がコピーされる
※プリミティブ型の項で使用している「値の参照」という表現は「値自体が存在するメモリのアドレス情報」のことです。
例えば変数aと変数bでそれぞれに同じ値「100」を代入したとして、それぞれの100という値は別々のメモリに割り当てられます。
コピー元の変数を新しい値で更新した時にコピー先の変数に影響しないのは、更新対象の変数の参照先が新しい値の割り当てられているメモリの参照情報に置き換わるだけあり、コピー先には干渉しないようになってるため。
プリミティブ型の変数が参照しているメモリの情報(値の実体)を書き換えることはできず、値の代入や変数のコピーをした時の挙動は下記のようになります。
- 新しいプリミティブな値を代入すれば、その値のメモリ情報が格納される
- 別のプリミティブ型の変数を代入(コピー)すれば、その変数が持つ値の参照情報が格納される
概要
- JavaScriptの変数コピーの種類
- プリミティブ型
- オブジェクト型
- ディープコピー
- シャローコピー
- JavaScriptにおける変数のメモリ割り当て方法
- JavaScriptに値渡しと参照渡しという概念は存在しない
JavaScriptの変数コピーの種類
JavaScriptの変数コピーにはいくつか種類があり、それもプリミティブ型とオブジェクト型で分かれています。
プリミティブ型
プリミティブ型はstringやnumberといった値を表す型になり、この型のコピーは所謂、「値渡し」と呼ばれているコピーなります。
let a = 1
const b = a;
a = 2;
console.log(a);
console.log(b);
// 2
// 1
変数aを変数bにコピーした後、変数aの値を更新していますが、変数bには影響しません。
オブジェクト型
オブジェクト型は{ id: 1, name: 'user01' } といったオブジェクト型や array型が含まれ、これらのコピー方式には
- シャローコピー
- ディープコピー
の2種類があります。
まずシャローコピーですが、「シャロー:浅い」部分だけをコピーし、オブジェクトのネストが深い部分については参照先を共有するような状態になります。
const a = {
id: 1,
name: 'user01'
};
// そのまま変数をコピー
const b = a;
a.id = 2;
console.log(a.id);
console.log(b.id);
// 2
// 2
コピー元の変数aのプロパティ「id」の値を更新すると、コピー先の変数bにも影響しています。
次にディープコピーですが、こちらはオブジェクトのネストが深い部分までコピーします。
const a = {
id: 1,
name: 'user01'
};
// スプレッド構文を使用することで、1階層深いところまでコピー(ディープコピー)することが可能
const b = { ...a };
a.id = 2;
console.log(a.id);
console.log(b.id);
// 2
// 1
コピー元の変数aのプロパティ「id」の値を更新しても、コピー先の変数bは元の状態のままになります。
JavaScriptにおける変数のメモリ割り当て方法
前述した変数コピーの挙動についてJavaScriptのランタイムメモリの仕組みを交えて解説していきます。
JavaScriptのメモリには「スタック領域」と「ヒープ領域」の2種類が存在します。
まず下記のようにプリミティブ型の値を代入した時は、値と変数の情報がスタック領域に割り当てられます。
const a = 10;
次に、下記のようなオブジェクト型の値を代入した時ですが、変数の参照情報がスタック領域に格納され、実体(値を持つプロパティ情報)がヒープ領域に格納されます。
const obj1 = {
id: 1,
name: 'user01'
};
ちなみに、Functionやそれ以外のオブジェクト型も全て共通してこのようなメモリ割り当てとなります。
では、今度はプリミティブ型・オブジェクト型の変数をそれぞれ別の変数に割り当てた時の動きを見ていきます。
// こんな感じの変数同士のコピー
const a = 10;
const b = a;
とその前に少し訂正があります。
前項でプリミティブ型はスタック領域に値を割り当てるというように説明しましたが、厳密には値が直接割り当てられているのではなく、「*値が割り当てられている別のメモリのアドレス」が割り当てられています。
例えば変数aと変数bでそれぞれに同じ値「100」を代入したとして、それぞれの100という値は別々のメモリに割り当てられており、スタック領域には「値が割り当てられているメモリのアドレス」が格納されているということです。
少し混乱させていると思うので、図で説明します。
まずプリミティブ型です。
最初に変数aを10で初期化し、それを変数bに代入した後に変数aの値を50で上書きします。
let a = 10;
const b = a;
a = 50;
新たに「s1」が出てきましたが、これが値「10」の割り当てられているメモリのアドレスだと考えてください。
変数aを10で初期化した時点で、アドレス「s1」のメモリに10という値が格納されます。
そして、変数aには値そのものではなくメモリのアドレス「s1」が入っています。
そこから変数aの参照情報を変数bに代入しているので、変数bも「s1」が格納されるようになります。
では、そこから変数aに値「50」を代入します。すると、
新たに必要となった値「50」を割り当てるためのメモリ「s2」が登場します。
「s2」は値「50」を格納するメモリのアドレスであり、変数bは「s2」を参照するようになります。
このため、プリミティブ型では「参照渡し」のようなことが起こらず、「値のコピー」というように呼ばれているのです。
では続いてオブジェクト型です。
const obj1 = {
id: 1,
name: 'user01'
};
const obj2 = obj1;
obj1.id = 5;
まず、変数obj1にオブジェクト型の変数を代入します。
そしてそれを変数obj2にそのままコピーします。
そのままコピーしているので、スタック領域の情報がコピーされ、参照先のヒープ領域の情報を共有しています。
(実際には、スタック領域のobj1、obj2の変数にヒープ領域のアドレス情報が格納されています。)
その後、コピー元の変数obj1のプロパティ「id」の値を「5」に更新します。
参照先の値を共有しているので、変数obj1と変数obj2の両方のプロパティが更新されているような形になります。
では続いて、オブジェクトのディープコピーを見てみます。
const obj1 = {
id: 1,
name: 'user01'
};
const obj2 = { ...obj1 };
obj1.id = 5;
変数obj1にオブジェクト型の変数を代入するところまでは前回と同様ですが、変数obj2にコピーする方法が変わったことで、ヒープ領域に新たなメモリ領域が確保されています。
そして変数obj1と変数obj2で参照先が別々となっているため、プロパティの値を更新しても変数間で干渉しないということです。
今回はスプレッド構文を使用していますが、これでは1階層のネストまでしかコピーされません。
そのため、2階層以上のオブジェクトが存在する場合は参照のコピーになってしまいます。
深い階層のネストをコピーするには、lodashといった外部ライブラリを使用するといった方法もあります。
ここまで色々試してみましたが、結論です。
- プリミティブ型(stringやnumber等)の値(厳密には「値の参照」)はスタック領域に格納される
- オブジェクト型は、「変数↔ヒープ領域にある実体のアドレスの紐付き」がスタック領域に格納され、実体(値を持つプロパティ情報)がヒープ領域に格納される
-
変数から別の変数にコピーする時はスタック領域の情報がコピーされる
- スプレッド構文等、特殊なコピー方法ではヒープ領域の情報も一部コピーされる
- 上記の仕様から、コピー元の変数の値が
- プリミティブ型の場合はそのまま値(の参照)がコピーされる
- オブジェクト型の場合は実体の参照情報がコピーされる
JavaScriptに値渡しと参照渡しという概念は存在しない
これについては有識者の方から鉞が飛んできそうな発言になりますが、個人的には
- 結果的に参照渡しになるが、仕組みとしての参照渡しは存在しない
ものだと考えています。
参照渡しというと、ソース上で明示的に参照情報を渡す(変数aであれば&aといった)ようなイメージがあり、JavaScriptはこれに当てはまらないように思います。
最後に
最後まで読んでいただきありがとうございました。
なにかご指摘があればコメントをお願いします。
メモリの詳細はJavaScriptのランタイムについてまとめた記事に書く予定です。
参考記事