JavaScriptにはシャローコピーとディープコピーという概念があります。配列やオブジェクトを変数に代入するときに、非常に重要なものです。
シャローコピーとディープコピーを理解していないと、思っている処理とは違う結果になることが頻繁に発生します。
例えば、非破壊処理の一つであるスプレッド構文を使って新たな変数を作成したのに、値を変更したら、もともとの変数の値も変わってしまった、、、などです。
ここではシャローコピーとディープコピーの違いや、シャローコピーをディープコピーに変換する方法について実例を踏まえて解説しています。
シャローコピーとディープコピーとは何か?
シャローコピーとディープコピーとは何かを簡単にいうと、シャローコピーは「外側だけ新しいものをつくるけど、中身は一緒」。
ディープコピーは「外側も中身も含めて完全に新しいデータを作る」です。
シャローコピーとディープコピーをいつ考えるべきか?
シャローコピーとディープコピーという概念をしっかりと考えなければいけないときは、次の2つの状況が揃ったときです。(他にもあるかもしれませんが、頻発するのは以下の状況です)
スプレッド構文を使うと、配列の外側の[ ]を外すことができます。再度[ ]で囲めば新しい配列を作り出すことができます。
ところが、このときに全く新しいデータが生成されたわけではありません。
配列の中にオブジェクトが入っている場合や、配列の中に配列が入っている場合は、外側は新しくなっているけど、中の入れ子になった{ } や [ ]の部分は元のままになります。
これがシャローコピーです。
以下でシャローコピーについて実例で解説します。
シャローコピーとは何か?
シャローコピーとは、データをコピーするときに、表面は新しく作成するけど、中の入れ子になった要素はコピーせず元の要素を使い回すコピー方法です。
シャローとは英語で「shallow」と書き「浅い」という意味です。つまり表面の浅いところだけコピーするという意味です。
シャローコピーだけど上手くいく(ように見える)例
配列に対して、スプレッド構文を使って新しい要素を作り出す場合を考えてみます。
例えば、以下のように[1, 2]という配列が入った「arr」という変数があるとします。
arr = [1, 2]
これをスプレッド構文で開いて、新しい変数「brr」に代入します。
brr = [ ...arr ]
console.log(brr)
//出力
[1, 2]
arrもbrrも値は同じです。では変数自体が同じかどうかというとそうではありません。
arr == brr
//出力
false
「arr == brr」として変数どうしを比較すると、違うもの「false」として表示されます。
ここで、「arr」に新しい要素として、「3」を追加する処理を加えます。
arr.push(3)
console.log(arr)
//出力
[1, 2, 3]
「brr」の中身も見てみます。
console.log(brr)
//出力
[1, 2]
「arr」の値「1, 2, 3」に対して、「brr」の値は「1, 2」のままです。
そりゃ「arr」と「brr」は別物なんだからその通りだよ、と思うのが普通です。ところがここに大きなワナがあります。
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [1, 2]
brr = [ ...arr ]
console.log("brr:", brr)
console.log("arr == brr:", arr == brr)
arr.push(3)
console.log("arr:", arr)
console.log("brr:", brr)
シャローコピーがよくわかる例(配列の場合)
同じくスプレッド構文を使った処理でシャローコピーがよくわかる事例を紹介します。
例えば、以下のように[1, [2, 3] ]という配列の中に配列が入った2次元配列「arr」という変数があるとします。
arr = [1, [2, 3] ]
これをスプレッド構文で開いて、新しい変数「brr」に代入します。
brr = [ ...arr ]
console.log(brr)
//出力
[1, [2, 3] ]
arrもbrrも値は同じです。では変数自体が同じかどうかというとそうではありません。
arr == brr
//出力
false
「arr == brr」として変数どうしを比較すると、違うもの「false」として表示されます。ここまでは先ほどと同じです。
このときに、「arr」と「brr」の入れ子になった配列[2, 3]の部分を比較すると以下のようになります。
arr[1] == brr[1]
//出力
true
「true」すなわち、同じものとして表示されます。
試しに、「arr」の中の入れ子になった要素[2, 3]に新しい要素として、「4」を追加する処理を加えます。
arr[1].push(4)
console.log(arr)
//出力
[1, [2, 3, 4] ]
「brr」の中身も見てみます。
console.log(brr)
//出力
[1, [2, 3, 4] ]
すると、「arr」に対して処理を加えただけなのに、「brr」まで書き換わっています。
これがシャローコピーです。外側(第1階層)は全く別物としてコピーするけど、その中の入れ子になった配列は元のやつをそのまま使うよということです。
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [1, [2, 3] ]
brr = [ ...arr ]
console.log("brr:", brr)
console.log("arr == brr:", arr == brr)
console.log("arr[1] == brr[1]:", arr[1] == brr[1])
arr[1].push(4)
console.log("arr:", arr)
console.log("brr:", brr)
文字列や数値と置き換える場合は気にする必要がない
上記でarr[1] == brr[1]
がtrueとなりましたが、これは変数arrの配列番号1と、brrの配列番号1が完全に一致していることを示しているわけではありません。
「arr」と「brr」の配列番号1が、JavaScriptが裏側で持っている同じ要素(データ)を指しているということに過ぎません。
このため、arrの配列番号1つ目の要素を数値や文字列に置き換えても、brrの配列番号の1つ目が置き換わるわけではありません。
試しに、先ほどの「arr」と「brr」で「arr」の配列番号1の値を文字列に置き換えてみます。
arr = [1, [2, 3] ]
brr = [ ...arr ]
arr[1]="a"
console.log(arr)
//出力
[1, 'a']
当然、[2, 3]の部分が「a」と置き換わり、 [1, ‘a’]となります。
このときに「brr」は以下のようになります。
console.log(brr)
//出力
[1, [2, 3] ]
brrの配列番号1は置き換わらずそのままです。
これは、arrが今までJavaScriptが裏側で保持している配列を指していたけど、それが、「a」という文字列に指し変わったことを意味しています。
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [1, [2, 3] ]
brr = [ ...arr ]
console.log("brr:", brr)
console.log("arr == brr:", arr == brr)
arr[1]="a"
console.log("arr:", arr)
console.log("brr:", brr)
オブジェクトの場合のシャローコピー
シャローコピーを気にしなければいけないのは、入れ子になった配列だけではありません。配列の中に入ったオブジェクトも注意が必要です通常のオブジェクト
例えば、以下のように配列の中に{a:1, b:2}というオブジェクトが入った「arr」という変数があるとします。
arr = [ {a:1, b:2} ]
これをスプレッド構文で開いて、新しい変数「brr」に代入します。
brr = [ ...arr ]
console.log(brr)
//出力
[ {a:1, b:2} ]
arrもbrrも値は同じです。では変数自体が同じかどうかというとそうではありません。
arr == brr
//出力
false
「arr == brr」として変数どうしを比較すると、違うもの「false」として表示されます。
このときに、「arr」と「brr」の入れ子になった配列[2, 3]の部分を比較すると以下のようになります。
arr[1] == brr[1]
//出力
true
「true」すなわち、同じものとして表示されます。
試しに、「arr」の中の{a: 1, b:2}の、プロパティ「a」の値を「100」に変更してみます。
arr[0].a = 100
console.log(arr)
//出力
[ {a:100, b:2} ]
「brr」の中身も見てみます。
console.log(brr)
//出力
[ {a:100, b:2} ]
すると、「arr」に対して処理を加えただけなのに、「brr」まで書き換わっています。
配列と同様にシャローコピーであることがわかります。
外側(第1階層)は全く別物としてコピーするけど、その中のオブジェクトは元の要素をそのまま使っています。
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [ {a:1, b:2} ]
brr = [ ...arr ]
console.log("brr:", brr)
console.log("arr == brr:", arr == brr)
console.log("arr[1] == brr[1]:", arr[1] == brr[1])
arr[0].a = 100
console.log("arr:", arr)
console.log("brr:", brr)
外側(第1階層)のデータを編集する分には問題ない
配列の中に配列やオブジェクトが入っている変数から、新しいスプレッド構文を作成した場合でも、入れ子になっていない部分(第1階層)のデータを編集したり、加えたり、削除する分には問題ありません。(新しいデータとしてコピーされているため)
例えば、配列の中にオブジェクトが入った変数を、スプレッド構文を使ってコピーして、新しい変数に代入したとします。
arr = [ {a:1, b:2} ]
brr = [ ...arr ]
このときに、「arr」に新しい要素「3」を追加します。
arr.push(3)
console.log(arr)
//出力
[ {a:1, b:2}, 3 ]
「arr」の中身は当然[ {a:1, b:2}, 3 ]となります。
ですが、「brr」の中身は元のままで、「arr」とは異なる値になります。
console.log(brr)
//出力
[ {a:1, b:2} ]
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [ {a:1, b:2} ]
brr = [ ...arr ]
console.log("brr:", brr)
arr.push(3)
console.log("arr:", arr)
console.log("brr:", brr)
ディープコピーとは何か?
ディープコピーとは、内側も含めて完全に新しいデータを生成することです。
完全別物となるので、入れ子になった配列やオブジェクトを編集しても、他の変数の値は書き換わりません。
このため、スプレッド構文を使って配列をコピーして新しい要素を作る場合はディープコピーをすることが基本となります。
ディープコピーの方法
ディープコピーをする方法はいくつかあります。ここでは主な方法として以下の2つを紹介します。
mapメソッドを使ってディープコピーする方法
考え方
mapメソッドは配列の中の要素を一つ一つ取り出して処理を加えた結果を、返すものです。
[1, 2]という配列の各要素に2をかけて「2, 4」を返すといった処理ができます。
処理は非破壊のため、元の要素はそのまま残ります。
このmapメソッドとスプレッド構文を組み合わせることでディープコピーすることができます。
配列の中の値が1次元、あるいは2次元の場合は以下が使えます。
新しい変数 = 元の変数.map( elem => {
if( Array.isArray(elem) ){
return [ ...elem ]
}else if( typeof(elem) == "object" ){
return { ...elem }
}else return elem
} )
map処理では第1引数に、配列の各要素が入ります。
要素が配列であれば、スプレッド構文で展開して[ ]で囲み、オブジェクトであればスプレッド構文で展開して{ } で囲みます。
数値や文字列などそれ以外の場合はそのまま返します。
実例
上記の処理を使って、配列の中にオブジェクトと配列がある変数「arr」をディープコピーして変数「brr」に代入します。
arr = [ {a:1, b:2}, 3, [4, 5], "a" ]
brr = arr.map( elem => {
if( Array.isArray(elem) ){
return [ ...elem ]
}else if( typeof(elem) == "object" ){
return { ...elem }
}else return elem
} )
console.log(brr)
//出力
[ {a:1, b:2}, 3, [4, 5], "a" ]
変数「brr」のオブジェクトと配列に要素を追加します。
brr[0]['c'] = 3
brr[2].push(6)
console.log(brr)
//出力
[ {a:1, b:2, c:3 }, 3, [4, 5, 6], "a" ]
このとき「arr」を確認すると、変更されずにもとのままになっていることがわかります。
console.log(arr)
//出力
[ {a:1, b:2}, 3, [4, 5], "a" ]
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [ {a:1, b:2}, 3, [4, 5], "a" ]
brr = arr.map( elem => {
if( Array.isArray(elem) ){
return [ ...elem ]
}else if( typeof(elem) == "object" ){
return { ...elem }
}else return elem
} )
console.log(brr)
//出力
[ {a:1, b:2}, 3, [4, 5], "a" ]
brr[0]['c'] = 3
brr[2].push(6)
console.log("arr:", arr)
console.log("brr:", brr)
JSON.stringifyとJSON.parseを使う
ディープコピーを行うとても簡単な方法にJSON.stringifyとJSON.parseを使う方法があります。
しかも深い階層の配列やオブジェクトもディープコピーできます。
新しい変数 = JSON.parse( JSON.stringify( 元の変数 ) )
実例
例えば、以下のような配列があるとします。
arr = [ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]
この変数に対してJSON.parse( JSON.stringify( arr ) )を実行し、新たな変数「brr」に代入します。
brr = JSON.parse( JSON.stringify( arr ) )
//処理結果
[ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]
元の変数「arr」と全く同じ内容になっています。
「brr」に変更を加えてみます。
brr[0].a = 100
brr[2][2][1].push(9)
console.log(brr)
//出力
[ {a:100, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8, 9] ] ], "a" ]
この状態で「arr」を確認します。
console.log(arr)
//出力
[ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]
「arr」は元のままで変更が加わっていないことがわかります。
これがディープコピーです。
参考用コード
上記の処理をまとめて実施したい方は下記を実行してみてください。
arr = [ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]
brr = JSON.parse( JSON.stringify( arr ) )
console.log("brr:", brr)
brr[0].a = 100
brr[2][2][1].push(9)
console.log("arr:", arr)
console.log("brr:", brr)
JSON.stringifyとは何か?
JSON.stringifyは引数で指定したデータを文字列に変換するものです。
例えば、以下のような配列があるとします。
arr = [ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]
これをJSON.stringifyに渡すと、文字列が返ります。
JSON.stringify(arr)
//処理結果
'[{"a":1,"b":{"c":2,"d":4}},3,[4,5,[6,[7,8]]],"a"]'
JSON.parseとは何か?
JSON.parseは引数で指定した文字列にJSON形式のデータに変換するものです。
例えば、以下のような文字列があるとします。
str = '[{"a":1,"b":{"c":2,"d":4}},3,[4,5,[6,[7,8]]],"a"]'
これをJSON.parseに渡すと、文字列が返ります。
JSON.parse(str)
//処理結果
[ {a:1, b:{ c:2, d:4 } }, 3, [4, 5, [6, [7, 8] ] ], "a" ]