#96 JavaScriptのシャローコピーとディープコピーについて
はじめに
Reactでイミュータブルを意識して実装をするにはImmerというライブラリを使うと良いということで色々調べてみると、オブジェクトのコピーに関して曖昧な理解をしていたことを実感しました。
そこで今回は、JavaScriptのシャローコピーとディープコピーについて備忘録としてまとめていきたいと思います。
シャローコピー
シャローコピーとは
MDN Web Docs 用語集では以下のように説明されています。
オブジェクトのシャローコピーとは、コピーがコピー元のオブジェクトとプロパティにおいて同じ参照を共有する(同じ基礎値を指す)コピーのことを指します。その結果、コピー元とコピー先のどちらかを変更すると、もう一方のオブジェクトも変更される可能性があります。
引用:https://developer.mozilla.org/ja/docs/Glossary/Shallow_copy
シャローコピーにおいて、そのオブジェクトでネストされている箇所については、コピー元と同じメモリアドレスが渡されます。
コピー元とコピー先で同じデータを共有しているため、共有箇所についてどちらかのオブジェクトで変更が加えられると、もう一方のオブジェクトにも影響が出てきます。
では「コピー元(またはコピー先)を変更すると、もう一方のオブジェクトも変更される可能性がある」とは具体的にどうなるのか、実際の処理から確認してみましょう。
なお、JavaScriptでは標準で組み込まれているオブジェクトのコピー操作はすべてシャローコピーで生成されます。
ここではわかりやすく、スプレッド構文を利用したコピーオブジェクトを使用します。
コピー元が変更されないパターン
まずは「シャローコピーしたオブジェクトに変更を加えてもコピー元が変更されない(=ネストが無い)」パターンについて確認していきましょう。
const list = ["greenTea", "oolongTea", "blackTea"];
const copyList = [...list];
copyList[0] = "緑茶";
console.log(list);
// -> [ 'greenTea', 'oolongTea', 'blackTea' ]
console.log(copyList);
// -> [ '緑茶', 'oolongTea', 'blackTea' ]
const properties = {
"unfermentedTea": "greenTea",
"semiFermentedTea": "oolongTea",
"fermentedTea": "blackTea",
};
const copyProperties = {...properties};
copyProperties.unfermentedTea = "緑茶";
console.log(properties);
// ->{
// unfermentedTea: 'greenTea',
// semiFermentedTea: 'oolongTea',
// fermentedTea: 'blackTea'
// }
console.log(copyProperties);
// ->{
// unfermentedTea: '緑茶',
// semiFermentedTea: 'oolongTea',
// fermentedTea: 'blackTea'
// }
コピー先の変更がコピー元に影響していないことが確認できました。
コピー元も変更されるパターン
では本題の「シャローコピーしたオブジェクトに変更を加えるとコピー元にも変更がある(=ネストがある)」パターンを見ていきましょう。
const properties = {
"unfermentedTea": {
"greenTea": ["shizuokaCha", "ujiCha", "sayamaCha"],
"processed": ["genmaicha", "hojicha"],
},
"semiFermentedTea": "oolongTea",
"fermentedTea": ["blackTea", "black_tea"],
};
const copyProperties = {...properties};
copyProperties.unfermentedTea.greenTea[0] = "静岡茶";
copyProperties.semiFermentedTea = "烏龍茶";
copyProperties.fermentedTea[0] = "紅茶";
copyProperties.fermentedTea = ["blackTea", "紅_茶"];
console.log(properties);
// ->{
// unfermentedTea: {
// greenTea: [ '静岡茶', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// semiFermentedTea: 'oolongTea',
// fermentedTea: [ '紅茶', 'black_tea' ]
// }
console.log(copyProperties);
// ->{
// unfermentedTea: {
// greenTea: [ '静岡茶', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// semiFermentedTea: '烏龍茶',
// fermentedTea: [ 'blackTea', '紅_茶' ]
// }
copyProperties.unfermentedTea.greenTea[0] = "静岡茶";
はネストされている箇所の値を変更しています。
先にも述べているように、共通のメモリアドレスを使用しているため、この場合ではコピー元であるpropertiesにも変更内容が影響してしまっているのが確認できます。
semiFermentedTeaキーはネストの無いパターン時のまま、コピー元の構成を変更していません。
(=共通使用のメモリアドレスがない)
そのためcopyProperties.semiFermentedTea = "烏龍茶";
はコピー元に影響せず、コピー先のみでその変更が確認できます。
copyProperties.fermentedTea[0] = "紅茶";
は "静岡茶" と同じようにネストしている箇所の値を変更しているため、コピー元でその変更が確認できます。
しかし上記の出力結果ではcopyProperties側で変更が確認できません。どうしてでしょうか。
理由はcopyProperties.fermentedTea = ["blackTea", "紅_茶"];
にあります。
copyProperties.fermentedTea[0] = "紅茶";
が共通で使用しているオブジェクトのプロパティを選択的に変更したのに対して、copyProperties.fermentedTea = ["blackTea", "紅_茶"];
はcopyPropertiesのfermentedTeaキーに全く新しい値として、["blackTea", "紅_茶"]を設定している処理です。
そのため、propertiesには "紅_茶" の変更が影響せず、かつcopyPropertiesでは"紅茶"の変更が反映されない、という出力結果になっています。
ちなみに、copyProperties.fermentedTea[0] = "紅茶";
とcopyProperties.fermentedTea = ["blackTea", "紅_茶"];
の処理順を入れ替えると出力結果は以下のようになります。
// 上記処理と同様のため省略
copyProperties.fermentedTea = ["blackTea", "紅_茶"];
copyProperties.fermentedTea[0] = "紅茶";
console.log(properties);
// ->{
// unfermentedTea: {
// greenTea: [ '静岡茶', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// semiFermentedTea: 'oolongTea',
// fermentedTea: [ 'blackTea', 'black_tea' ]
// }
console.log(copyProperties);
// ->{
// unfermentedTea: {
// greenTea: [ '静岡茶', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// semiFermentedTea: '烏龍茶',
// fermentedTea: [ '紅茶', '紅_茶' ]
// }
この場合のcopyProperties.fermentedTea[0] = "紅茶";
は、全く新しい値として設定されたcopyPropertiesのfermentedTeaキーの値を選択的に変更する処理、と言えるでしょう。
ネストの深いオブジェクトをシャローコピーする際は、意図せずに予期しない変更を加えていないか、注意しながら実装していきたいですね。
ディープコピー
ディープコピーとは
シャローコピーとは対照的に、コピー元(またはコピー先)を変更しても、もう一方のオブジェクトがその変更の影響を受けることはありません。
オブジェクトの ディープコピー とは、コピー先のオブジェクトのプロパティがコピー元のオブジェクトのプロパティと同一の参照(同じ値を指す)で共有しないコピー方法のことです。
引用:https://developer.mozilla.org/ja/docs/Glossary/Deep_copy
ディープコピーの使いどころとしては、ネストの深いオブジェクトにて、コピー元・コピー先のオブジェクトをそれぞれ別で参照したい場合などが該当するかと思います。
ディープコピーの方法
先にも述べているように、標準組み込みのオブジェクトコピー操作はすべてシャローコピーとして扱われます。
そのため、ディープコピーを実現しようすると、1階層ずつ再帰的にシャローコピーさせたり、ライブラリを利用したりなどの対応が必要になります。
ここでは、再帰的なシャローコピーをする方法と一度JSON文字列に変換する方法について紹介していきます。
再帰的にシャローコピーする
イメージとしては以下のようになるかと思います。
const properties = {
"unfermentedTea": {
"greenTea": ["shizuokaCha", "ujiCha", "sayamaCha"],
"processed": ["genmaicha", "hojicha"],
},
"semiFermentedTea": "oolongTea",
"fermentedTea": ["blackTea", "black_tea"],
};
const copyPropertis = (properties) => {
let newObject = {};
for (var key in properties ) {
if (isObject(properties[key])) {
newObject[key] = copyPropertis(properties[key]);
} else {
newObject[key] = properties[key];
}
}
return newObject;
}
const deepCopyProperties = copyPropertis(properties);
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
console.log(deepCopyProperties)
// ->{
// unfermentedTea: {
// greenTea: [ 'shizuokaCha', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// semiFermentedTea: 'oolongTea',
// fermentedTea: [ 'blackTea', 'black_tea' ]
// }
ちなみに上記のisObject()
の処理で、値が配列の場合はfalseを返すようにしていますが、Javascriptでは配列はArrayオブジェクトとして扱われます。
ここでは、isObject()
における配列の判定結果をtrueにしてしまうとdeepCopyProperties内でプロパティとして変換されてしまうので、falseとして返すようにしています。
JSON.parse と JSON.stringify を併用する
JSON.stringify()
でオブジェクトを JSON 文字列に変換してから、JSON.parse()
で新しいオブジェクトとして変換します。
こちらはMDN Web Docs 用語集で紹介されている方法でもあります。
ただし、この方法が使えるのは「オブジェクトがシリアライズ可能な場合」に限ります。
参考:https://morning-6.hatenablog.com/entry/2022/03/15/092127
そのため、値がundefined
だとコピー先のオブジェクトからは削除されてしまうなど、期待通りの挙動にならないので注意が必要です。
以下は JSON.parse と JSON.stringify を利用した実装イメージです。
値がundefined
の場合の挙動についても確認できます。
const properties = {
"unfermentedTea": {
"greenTea": ["shizuokaCha", "ujiCha", "sayamaCha"],
"processed": ["genmaicha", "hojicha"],
},
"semiFermentedTea": undefined,
"fermentedTea": ["blackTea", "black_tea"],
};
const deepCopyProperties = JSON.parse(JSON.stringify(properties));
console.log(deepCopyProperties)
// ->{
// unfermentedTea: {
// greenTea: [ 'shizuokaCha', 'ujiCha', 'sayamaCha' ],
// processed: [ 'genmaicha', 'hojicha' ]
// },
// fermentedTea: [ 'blackTea', 'black_tea' ]
// }
// ※コピー先ではsemiFermentedTeaキーが存在しない
再帰的にシャローコピーするよりも簡単な実装でディープコピーが実現できました。
ただし、上記でも確認できるように、semiFermentedTeaキーの値がundefined
の場合、コピー先のdeepCopyPropertiesではsemiFermentedTeaキーが存在しないことになってしまいました。
おわりに
今回はオブジェクトのコピー操作として、JavaScriptのシャローコピーとディープコピーについて取り上げました。
ディープコピーをするために様々な方法としては、今回取り上げた以外に、lodashというライブラリのcloneDeep()
を使用する方法やstructuredClone()
を使用する方法などもあるそうです。
興味のある方はぜひ調べてみてください。
ただし、どの方法でもそれぞれ考慮が必要な点があるみたいなので、実際に実装する場合はそれらを踏まえて方法を選択できるようになりたいです。
またImmerについても触れる予定でしたが、想像よりも文章量が多くなってしまったため、こちらは次回記事にてまとめていけたらと思います。
以上です。最後まで閲覧いただきありがとうございます。
参考
- https://developer.mozilla.org/ja/docs/Glossary
- https://zenn.dev/luvmini511/articles/722cb85067d4e9
- https://zenn.dev/akkie1030/articles/js-structured-clone
- https://staf.acesystems.co.jp/?p=680
- https://zenn.dev/suin/books/8985cbff87b524e11c2b/viewer/01e63d
- https://morning-6.hatenablog.com/entry/2022/03/15/092127
Discussion
オブジェクトのコピー操作とは……?
その言い方をしてしまうと deepCopy もできる 標準機能の structuredClone の立つ瀬がないのでは……?