【JavaScript】スプレッド構文やオブジェクト型を理解する
スプレッド構文の謎?
JavaScriptのスプレッド構文は配列やオブジェクトの前に「...」をつけることで、配列やオブジェクトを展開してくれる便利なもので、ご存知の方も多いかと思います。なので、スプレッド構文についての解説も多く存在します。それらの解説を見ると、
スプレッド構文を使えば元の配列に影響を及ばさない。ただし、ネストしている場合は注意。
といったような文言を見かけます。初心者の皆さんはこれについてすぐ理解できたでしょうか?
この記事では上記の内容を理解するために必要な知識をまとめていきたいと思います!
記事で触れる内容
この記事ではどういった内容に触れるかを以下に列挙します。
- スプレッド構文
- 配列のコピー
- JavaScriptのデータ型(プリミティブ型、オブジェクト型)
- 値渡し、参照渡し
- シャローコピーとディープコピー
スプレッド構文とは
知らない人のためにスプレッド構文の基礎的な話をしようかと思います。
スプレッド構文とはJavaScriptで配列やオブジェクトを展開してくれるものです。さらにわかりやすくするため、一言で言ってしまうと「...」を付けることで、配列やオブジェクトの一番外側の[]や{}を取り除いてくれる!っていうイメージです。
実例を見てみましょう。(参照:MDNスプレッド構文)
let numberStore = [0, 1, 2];
let newNumber = 12;
numberStore = [...numberStore, newNumber];
console.log(numberStore);
-> [0, 1, 2, 12]
こんな感じでnumberStoreは、元のnumberStoreの配列[0,1,2]が展開されて、newNumberの値12が後ろに追加されたという配列が最終的に返ってきました。イメージ的には以下のような感じです。
...[0,1,2] -> 0,1,2
これは{a:1,b:2}のようなオブジェクトでも同様に外側の{}を取り除くことができます。
配列のコピー
では、配列をコピーする際、スプレッド構文を用いないと配列の中身が変わってしまう場合があるという現象について見てみましょう。
参照:スプレッド構文(配列のコピーについて)
const arr1 = [10, 20]
const arr2 = arr1;
arr2[0] = 100;
console.log(arr2); // => [100, 20]
console.log(arr1); // => [100, 20]
上記では、arr2の配列の値だけを変えたかったのに、なぜかarr1の配列もarr2と同じ値に変わってしまっています。
ではスプレッド構文を用いた次の場合はどうでしょう?
const arr1 = [10, 20]
const arr2 = [...arr1];
arr2[0] = 100;
console.log(arr2); // => [100, 20]
console.log(arr1); // => [10, 20]
初学者にとっては先ほどとやってることは同じに見えます。しかし、今回はなぜかarr2の配列だけ中身の値を変えることに成功しています。
よくわからないけどそういうものなのか、と納得することもできますが、この現象がなぜ生じるのかを理解するために必要な知識を集めていきます。
JavaScriptのデータ型
JavaScriptには他のプログラミング言語と同様にデータの型というものが存在します。以下のリンクを見れば、わかりやすくJavaScriptの型について理解できると思います!
参考:プリミティブ型とオブジェクト型を理解したい
その中で一部を抜粋して紹介します。
JavaScriptは動的型付け言語である
変数などを定義するときにTypeScript等のように型を宣言しなくても勝手に判断してくれて、型が決まります。
let a = 10;
例えば上記だとaは数値を扱うNumber型という感じになります。
プリミティブ型とオブジェクト型
そしてJavaScriptのデータの型は大きく2種類に分類することができます。
「プリミティブ型(基本型)」は、真偽値や数値などの基本的な型のことです。プリミティブ型の値は、一度作成したらその値を変更することができません。(この特性をイミュータブルと呼ぶ)
一方、「オブジェクト型(複合型)」は、複数のプリミティブ型の値またはオブジェクトからなる集合体です。オブジェクトは、一度作成した後も、その値自体を変更することができます。(この特性をミュータブルと呼ぶ)
ちょっとわかりづらいかもしれませんが、2つの型があるということとオブジェクト型は中身を変更することができるということを伝えたかったです。
わかりやすく代表的なものとその例を示すと以下のような感じになります。
- プリミティブ型
- 数値→例:10
- 文字列→例:"Hello"
- オブジェクト型
- 配列→例:[1,2,3]
- オブジェクト→例:{a:1,b:2}
値渡しと参照渡し
値渡しとは、値そのものの情報を変数に渡すこと、参照渡しとは、参照している場所の情報を変数に渡すことです。JavaScriptの場合、データがプリミティブ型の場合は値渡し、オブジェクト型の場合は参照渡しになります。
シャローコピーとディープコピー
ここでシャローコピーとディープコピーというものに少し触れます。
以下リンクが参考になります(画像も1枚拝借させていただきます)。
ディープコピーとシャローコピーの違い
シャローコピーとは浅い(shallow)コピーと呼ばれ、実体(データ)のコピーを行わないで、オブジェクトをコピーする方式です。要するに見せかけの複製を作るコピーです。
ディープコピーとは深い(deep)コピーと呼ばれ、実体(データ)も含めてオブジェクトをコピーする方式です。要するに完全な複製を作るコピーです。
この画像はシャローコピーをした時の図となります。私が伝えたいことは、オブジェクト型の中身のデータはメモリに保存され、オブジェクト型はそこを参照しているということです。オブジェクト型である配列をシャローコピーしてもコピー先の配列も同じ参照先を見ているので、どちらかの配列の中身を変更するともう一方の配列の中身も同じように変化してしまうという現象が発生してしまいます。
コピー元配列のデータが書き換わる現象を理解する
これまでの知識を基に、コピー元の配列のデータが書き換わる現象を理解するために、もう一度先ほどのコードを用いて考えてみましょう。わかりやすくするために、オブジェクト型である配列が参照するメモリの場所を住所に例えて考えたいと思います。
const arr1 = [10, 20]
const arr2 = arr1;
arr2[0] = 100;
console.log(arr2); // => [100, 20]
console.log(arr1); // => [100, 20]
上図で示したように、arr1とarr2は住所Xを参照するオブジェクトとなるため、その住所X内の数値を変えるとarr1、arr2はどちらも同じ配列に変化しますね。これがシャローコピーとなります。
次にこちらも先ほどのスプレッド構文を用いたコードを見てみましょう。
const arr1 = [10, 20]
const arr2 = [...arr1];
arr2[0] = 100;
console.log(arr2); // => [100, 20]
console.log(arr1); // => [10, 20]
注目すべきはやはり②の部分でしょう。スプレッド構文を用いてarr1が展開され、10,20というプリミティブな2つの数字になります。それを配列[]で囲っているので、arr2=[10,20]となります。arr2は住所YというXとは違う住所を参照する新しいオブジェクトを定義しているという感じです。これでディープコピーということになるのでしょうが、自分としては新しくオブジェクトを定義するといった表現の方がしっくりきます。
当然③では住所Yの配列の値を変更しているので、住所Xを参照するarr1の配列には影響は及ぼしません。
ネストしているオブジェクトのスプレッド構文
一番初めにも少し触れたように、スプレッド構文を用いて配列(オブジェクト)をコピーしようと思った場合に元の配列に影響を及ぼす場合もあります。配列の中に配列が入っていたり、オブジェクトが入っている場合です(つまりネストされている時)。ここまでの話でなぜこれが起こるかは想像がつくかと思いますが、簡単に解説していきましょう。
const arr1 = [[10], {value:20}]
const arr2 = [...arr1];
arr2[0][0] = 100;
arr2[1].value = 200;
console.log(arr2); // => [ [100], {value: 200} ]
console.log(arr1); // => [ [100], {value: 200} ]
上のコードと図を見てもらえばわかる通り、②のスプレッド構文で展開した要素がプリミティブ型ではなくオブジェクト型なので、それもまたメモリのどこかにあるデータを参照するということになります。arr1とarr2は結局はどちらも同じ参照元を見ているので、参照元となるデータを変更すれば、どちらも値が変わるということになります。
まとめ
スプレッド構文やオブジェクト型について理解できたでしょうか?
長くなってしまったので、最後にまとめましょう。プリミティブ型は値を渡すが、オブジェクト型は参照渡しをします。そして、スプレッド構文を使うと外側の[]や{}を外すことができます。[]や{}を外した時の要素がプリミティブなら、当然値渡しされるが、要素がオブジェクト型ならまだそれらはどこかを参照しているといった感じになります。このイメージだけ持っていれば、応用して考えることができると私は思っています!
文字だけだと若干わかりづらいかと思い、まとめの図を以下に示します。この記事が誰かの参考になれれば幸いです。
Discussion
プリミティブ型はイミュータブルであることと関数を持たないことしか保証されておらず、
実装が 値型であるという保証は無いですよ……?
その為、その話でいうと プリミティブ型も参照渡しということになりませんでしょうか……?