🐈

スプレッド構文とコピーの種類の話

2024/04/05に公開

概要

何の記事か

スプレッド構文を用いた変数のコピーの種類について、変数の記憶領域に注目して解説した記事となります。

キーワード

  • スプレッド構文
  • Shallow Copy
  • Deep Copy
  • 変数の記憶領域(storage area)

こんな方向けの記事

  • スプレッド構文を用いた変数のコピーを何となく使っている方
  • Shallow Copy、Deep Copyの違いについて理解が曖昧な方

序論

以下のようにオブジェクト型の変数を2パターンでコピーしたとします。どちらもconsoleでアウトプットを見てみると変数の値は同じに見えますが、変数copyObj1, copyObj2の構造的な違いは説明できるでしょうか?

特にReact等を用いているとパターン2のようなスプレッド構文を用いた変数のコピーをよく見かけますが、何となく使っていると思わぬエラーを引き起こすことがあります。本記事では問題形式で具体例に触れながらこれらの構造的な違いについて理解を深めていくことを目的とします。

const obj = {id: 1, name: "taro"};

# パターン1
const copyObj1 = obj;
console.log(copyObj1); // output: {id: 1, name: "taro"}

# パターン2
const copyObj2 = {...obj};
console.log(copyObj2); // output: {id: 1, name: "taro"}

本論

問題1

copyObj1, copyObj2の値は次の①〜④のうちどれになるでしょうか?パッと回答が思い浮かんだ方も理由まで考えてみてください。

// 図1,2

const obj = {id: 1, name: "taro"};
const copyObj1 = obj;
const copyObj2 = {...obj};

obj.name = "jiro"; // 値の更新

console.log(copyObj1);
console.log(copyObj2);

【選択肢】
① どちらも {id: 1, name: "taro"}
② どちらも {id: 1, name: "jiro"}
③ copyObject1: {id: 1, name: "taro"}、copyObject2: {id: 1, name: "jiro"}
④ copyObject1: {id: 1, name: "jiro"}、copyObject2: {id: 1, name: "taro"}

回答と解説

【正解】④

【解説】
ひとくちに言ってしまえば「objとcopyObj1は同じメモリ領域を参照しているから値の更新を行うと同じ結果になる」となりますが、図にして理解を深めていきましょう。各変数を定義した時点で変数の記憶領域は図1のようになっています。

図1
図1

そしてこれを値の更新まで実施すると図2のようになります。スプレッド構文で定義したcopyObj02の方は全く別のメモリ領域を確保し各プロパティ値を格納しています。

図2
図2

よって、参照するメモリ領域に違いがあるため変数objの更新を行った際のcopyObj1とcopyObj2の挙動は異なります。

問題2

同様に、copyObj1, copyObj2の値は次の①〜④のうちどれになるでしょうか?こちらも回答が思い浮かんだ方は理由まで考えてみてください。

// 図3

const originObject = {
  id: 1,
  pets: [
    {animalId: 1, name: "dog", age: 3},
    {animalId: 2, name: "cat", age: 5},
    {animalId: 3, name: "bird", age: 7},
  ]
};
const copyObject1 = originObject;
const copyObject2 = {...originObject};

originObject.items[0].name = "elephant"; // 値の更新

console.log(copyObj1);
console.log(copyObj2);

【選択肢】

// A
{
  id: 1,
  pets: [
    {animalId: 1, name: "dog", age: 3},
    {animalId: 2, name: "cat", age: 5},
    {animalId: 3, name: "bird", age: 7},
  ]
}

// B
{
  id: 1,
  pets: [
    {animalId: 1, name: "elephant", age: 3},
    {animalId: 2, name: "cat", age: 5},
    {animalId: 3, name: "bird", age: 7},
  ]
}

① どちらもA
② どちらもB
③ copyObject1:A、copyObject2:B
④ copyObject1:B、copyObject2:A

回答と解説

【正解】②

【解説】
前問の解説まで理解いただいた後だと違和感を感じる方(特に④を選んだ方)もいるかも知れません。

問題1と同様にこの問題も図にしてみます。各変数の定義を完了した時点で、変数の記憶領域は図3のようになります。

図3
図3

つまり、ネストされたオブジェクト型の変数まではcopyObject2のようなコピーの仕方では複製されず、同じメモリ領域を参照してしまいます。このように1段階目のプロパティ名(図3のpets)までコピーすることをShallow copyと言います。

問題3

originObjectの値を変えず、animalId=1のオブジェクトのnameを”elephant”に変えた変数copyObjectを定義する場合どう書けばよいでしょうか?本文ではここまでの理解を踏まえ、自分でコードを書いてみてください。

const originObject = {
  id: 1,
  pets: [
    {animalId: 1, name: "dog", age: 3},
    {animalId: 2, name: "cat", age: 5},
    {animalId: 3, name: "bird", age: 7},
  ]
};
回答と解説

【正解】これは複数の回答が考えられるので1つ1つ解説していきます。

【回答1】

const copyObject = JSON.parse(JSON.stringify(originObject));

copyObject[0].name = "elephant";

これは1度オブジェクトの変数を文字列化(stringify)してしまい、再度展開(parse)することで全く別の新しい変数の定義としているという方法です。このようにネストされた部分までoriginObjectと同じ構造をしている(けれども参照しているメモリ領域は全てoriginObjectと異なる)状態をDeep copyと言います。

注意点として関数やシンボルなど、JSONに変換できない値はコピーされません。また、Deep copyという手法はパフォーマンスの観点からも大きなオブジェクトに対しては効率が良くありません。

(補足)

関数やシンボルといった一部の値は JSON.stringifyをするとエラーが起きたりオブジェクトから省略されることがあります。詳細はMDNを参考ください。

【回答2】

const copyObject = {
  ...originObject,
  pets: originObject.pets.map(pet =>
    pet.animalId === 1 ? { ...pet, name: "elephant" } : pet
  )
};

1つ目の記法に比べると冗長的に見えますが、どちらかというとReactをやっている方はこちらの記法がオブジェクト変数の更新としては馴染み深いのではないでしょうか?考え方の順序としては以下の1.~3.になります。

  1. Shallow copyで一旦1段目のプロパティまでコピーする
  2. 同名のプロパティ名(pets)*を代入して上書きする
  3. map関数を使い、animalId=1の場合のみだけ1.2と同じことを繰り返す

(補足:2.について)

オブジェクトは同名のプロパティ名があると1番最後に定義したものが参照されるという性質を利用しています。

const sampleObj = {
	name: "apple",
	name: "banana",
	name: "melon"
};

console.log(sampleObj); // output: {name: "melon"}

(補足:3.について)

回答2の書き方はanimalId≠1は同じpetオブジェクトを用いているのでDeep Copyではないことに注意しましょう。仮にDeep copyにするならば以下のような書き方になります。

const copyObject = {
  ...originObject,
  pets: originObject.pets.map(pet =>
    pet.animalId === 1 ? { ...pet, name: "elephant" } : ...pet
  )
};

このようにスプレッド構文とmap関数を用いてオブジェクト型の変数のコピーを使うことはよくある例です。ネストが深くなりすぎると記述するのが大変ですが、lodashにはcloneDeep()というメソッドがあるようなので気になる方は参考にしてみてください。

まとめ

Reactの状態管理を学ぶ上でさらっと出てきたオブジェクト型の変数の更新について深掘っていったらShallow copy, Deep copy、そして変数の記憶領域とはというところにまで辿り着いてしまいました。やはり何を学ぶにしても仕組みを理解することは大事であり、それを実感するいい機会となりました。ご覧頂いた皆様にとって少しでも役に立つ内容であれば幸いです。

参考

うぐいすソリューションズTechBlog

Discussion