🚀

JavaScriptのディープコピーとシャローコピーについて

2023/11/21に公開

(2023年11月21日:以前「JavaScriptの値渡し・参照渡し&ディープコピー・シャローコピーについて調べた」という記事をアップしていましたが、理解に誤りがあったので修正し新しい記事として公開します。以前の記事にコメントをいただいた方、ありがとうございました。)

1.記事の目的

ディープコピーとシャローコピーという概念がよく理解できていないので、自分のためにまとめたいと思います。

2.プリミティブ型とオブジェクト型について

ディープコピーとシャローコピーという話の前に、JavaScriptで使用できるデータ型について確認します。JavaScriptで使用できる型にはプリミティブ型オブジェクト型があります。

- プリミティブ型

  • 文字列
  • 数値
  • 長整数
  • 論理値
  • undefined
  • シンボル
  • null

https://developer.mozilla.org/ja/docs/Glossary/Primitive

上記のプリミティブ型以外は全てオブジェクト型です。配列、オブジェクト、関数etc...

プリミティブ型とオブジェクト型ではコピーの作成方法や挙動が変わってきます。

3.変数への値の代入で何が起きているのか

下記の動きの通り、プリミティブ型は新たな変数に代入することでコピーが作成されます。

let num = 10;
let newNum = num;
newNum = 5;
console.log(num,newNum);//10 5 という結果になる

この時に何が起こっているかというと、変数という箱に値を直接入れているわけではなく、変数に値と参照先がまとめて入っています。
numに10という値が直接格納されているのではなく、例えばα=10のようなセットで格納されています。
console.log(num);した際に見に行っているのは、10ではなく、αです。
(ここでαとした参照先は外部から定義できたり、確認したりできるものではありません。JSの動きとしてそうなっている、ということです。)
新たな変数に再代入などしてコピーすることで、このセットもコピーされて共有されます。

先ほどのコードをもう一度確認します。

let num = 10; // α=10が格納
let newNum = num; // newNumにα=10も渡される
newNum = 5; // 値の再代入により、新たなセット:β=5がnewNumに代入される
console.log(num,newNum); // numはαを見にいき10 newNumはβを見にいき5という結果になる

オブジェクト型の再代入を利用したコピーは、上記のように単純にいきません。
なぜなら値一つ一つが格納されている場所は変数ではない(配列要素やプロパティーのキー)からです。

let nums = [1,2,3]; // α=[1,2,3]が格納
let newNums = nums; // newNumsにα=[1,2,3]も渡される
newNums[0] = "change"; 
// 値を再代入したが、変数を利用して変更したわけではないので参照先は変わらずαのまま
//α=["change",2,3];と変更される
console.log(nums,newNums); // numsもnewNumsもαを見にいきどちらも["change",2,3]となる
let nums = {
    1:"one",
    2:"two",
 }; 
 // α={1:"one",2:"two",}が格納
let newNums = nums; // newNumsにα={1:"one",2:"two",}も渡される
newNums["1"] = "change"; 
// 値を再代入したが、変数を利用して変更したわけではないので参照先は変わらずαのまま
//α={1:"change",2:"two",}と変更される
console.log(nums,newNums); 
// numsもnewNumsもαを見にいきどちらもα={1:"change",2:"two",}となる

このようにオブジェクト型のデータをコピーする際に変数の再代入をしてしまうと、予期しない挙動が起きてしまいそうです。
そこでシャローコピーディープコピーを使用します。

3.シャローコピーとディープコピー

1.シャローコピー

シャローコピーはオブジェクト型をコピーした際に、コピーがコピー元のオブジェクトとプロパティにおいて同じ参照を共有するコピーのことです。

https://developer.mozilla.org/ja/docs/Glossary/Shallow_copy

同じ参照を共有しているのなら、変数への再代入と変わらないのでは?と思いますが、シャローコピーの場合ネストされていない要素のみ(一次元配列など)はコピー元とコピー先を切り離して要素の変更をしてくれます。

JavaScript では、すべての標準組込みオブジェクトのコピー操作(スプレッド構文, Array.prototype.concat(), Array.prototype.slice(), Array.from(), Object.assign(), Object.create())において、ディープコピーではなくシャローコピーを生成します。

とのことなので、スプレッド構文を利用して確認します。

const parson = {
  firstName: "John", 
  lastName: "Brown",  
};
// α={firstName: "John", lastName: "Brown"}という参照元と値のセットができる

const newParson = { ...parson }; // 参照先と値のセットごとコピー

newParson.firstName= "Mary"; 
// この時にシャローコピー側にだけβ={firstName: "Mary", lastName: "Brown"}という全く新しい参照元と値のセットができる。

console.log(parson,newParson); 
//parsonはαのデータを、newParsonはβのデータを参照しにいく

ネストのない一次元オブジェクトはシャローコピーすれば、要素の変更後コピー先とコピー元の参照が切り離されるので良さそうです。
しかしシャローコピーだと、ネストのあるオブジェクトではネスト内で参照を共有し続けます。

const parson = {
  firstName: "John", 
  lastName: "Brown",  
  birthday: {
    year: 1989,
    mounth:1
  }
};
//α={firstName:"John", lastName:"Brown", birthday:{year: 1989,mounth:1}}という参照先と値がセットされる
const newParson = { ...parson }; // 参照先と値のセットごとコピー

newParson.firstName= "Mary"; 
newParson.birthday.year=1990;
//このとき、シャローコピー側では
//β={firstName:"Mary", lastName:"Brown",birthday:{この部分はαと参照を共有:α=year: 1990,mounth:1}}というような動きになる

console.log(parson,newParson); 
//そのためparsonはα={firstName:"John", lastName:"Brown", birthday:{year: 1990,mounth:1}}
//newParsonはβ={firstName:"John", lastName:"Brown", birthday:{ここだけα=year: 1990,mounth:1}}というデータになる

シャロー(浅い)コピーの通り、ネストの一番上層はコピーし参照元と参照先が切り離される動きをするけれど、ネストの深い部分は参照を共有し続けます。
この状態を解消するコピーがディープコピーです。

2.ディープコピー

ディープコピーはオブジェクトの全てをコピー元を参照しない形でコピーをすることです。

https://developer.mozilla.org/ja/docs/Glossary/Deep_copy

そのオブジェクトが シリアライズ 可能であれば JSON.stringify() でオブジェクトを JSON 文字列に変換し、 JSON.parse() で文字列から(完全に新しい) JavaScript のオブジェクトに変換することです。

シリアライズ可能 なオブジェクトであれば、代わりに structuredClone() 関数を使用してディープコピーを作成することも可能です。

MDNでJSON.stringify()&JSON.parse()を使用する方法と、structuredClone()を使用する方法が紹介されていました。

JSON.stringify()&JSON.parse()

const parson = {
  familyname: "John",
  lastname: "Brown",
  birthday: {
    year: 1989,
    mounth:1
  }
};
const newParson = JSON.parse(JSON.stringify(parson));

newParson.familyname= "Mary";
newParson.birthday.year = 1990;

console.log(parson,newParson); 

structuredClone()

const parson = {
  familyname: "John",
  lastname: "Brown",
  birthday: {
    year: 1989,
    mounth:1
  }
};
const newParson = structuredClone(parson);

newParson.familyname= "Mary";
newParson.birthday.year = 1990;

console.log(parson,newParson); 

※シリアライズはデータを連続するバイナリデータやテキストデータに変換するプロセスのことを指す。javascriptにおけるシリアライズやシリアライズ可能オブジェクトは下記参照。
https://developer.mozilla.org/ja/docs/Glossary/Serialization
https://developer.mozilla.org/ja/docs/Glossary/Serializable_object

4.まとめ

色々なサイトでシャローコピーや変数代入が様々な説明をされていました。
教えていただいた記事(JavaScriptに参照渡し/値渡しなど存在しない)やMDNに立ち返って確認し、やっと理解できたかな?と感じています。

明らかな技術的な誤りに気づかれたかたは教えていただければ嬉しいです。

5.参考記事&図書

https://www.amazon.co.jp/ステップアップJavaScript-フロントエンド開発の初級から中級へ進むために-サークルアラウンド株式会社/dp/4798169838

https://qiita.com/yuta0801/items/f8690a6e129c594de5fb

Discussion