みなさんはどのように JavaScript でオブジェクトをコピーしていますでしょうか。オブジェクトのコピーと言っても、シャローコピーとディープコピーがあります。この違いを認識せずにコピーすると、バグの原因にもなりかねません。今回は JavaScript 初心者向けに、値のコピーとは何か、シャローコピーとディープコピーの意味、そして、それらコピーの方法まで深く紹介していこうと思います。
プリミティブ値とオブジェクトの代入の違い
シャローコピーとディープコピーの違いを理解するには、ECMAScript のプリミティブ値を理解する必要があります。ECMAScript は関数や配列などの多くをオブジェクトとして扱いますが、メソッドやプロパティを持たない素の値も定義しています。具体的には未定義 (undefined
)、Null (null
)、真偽値 (Boolean
)、数値 (Number
), BigInt, シンボル (Symbol
), 文字列 (String
) の 7 つです。
これらプリミティブ値は簡単にコピーできます。
let orig = 'Taro';
let copy = orig;
上記のコードは変数 orig
にプリミティブ値である "Taro"
を代入しています。そして変数 copy
に変数 orig
を代入しています。これで文字列 "Taro"
が複製され、その複製された文字列が変数 copy
に代入されます。つまり、変数 orig
と変数 copy
に代入された文字列の内容は同じですが、実体は別です。その証拠に、変数 orig
に別の文字列を再代入したとしても、変数 copy
には影響を一切与えません。
orig = 'Jiro';
console.log(copy); // "Taro" のまま
一方、配列 (Array
) はプリミティブ値ではなくオブジェクトです。配列を別の変数に代入すると、どうなるか見ていきましょう。次のコードは変数 orig
に配列を代入し、変数 copy
に変数 orig
を代入しています。
const orig = [1, 2, 3];
const copy = orig;
では、変数 orig
の配列の最後に 4 を追加しましょう。すると、変数 copy
の配列にも最後に 4 が追加された状態になります。
orig.push(4);
console.log(copy); // [1, 2, 3, 4]
さて、いったい何が起こっているのでしょうか?現象を見る限り、変数 orig と変数 copy は、それぞれ別の配列を持っているのではなく、同じ配列を見ているということになります。
ECMAScript としての細かい定義やブラウザーの実際の実装がどうなっているのかは置いておいて、この現象を分かりやすく理解するためには、オブジェクトの場合は変数に実体ではなく参照 (リファレンス) が格納されていると理解するのが良いでしょう。もう少し分かりやすくいうと、オブジェクトの場合、変数には実体が格納されるのではなく、実体が保存されたメモリのアドレスが格納されると考えてください。
ここで改めて前述のコードを見返してみましょう。変数 orig
に配列を代入した際、実際には変数 orig
に配列が保存されたメモリアドレスが代入されます。
const orig = [1, 2, 3]; // 配列の実体ではなくメモリアドレスが代入される
次に変数 copy
に変数 orig
を代入した際には、配列の実体ではなくメモリアドレスがコピーされて代入されます。
const copy = orig; // 変数 orig のメモリアドレスがコピーされて代入される
この時点で、変数 orig
と変数 copy
には、同じ配列を参照しているメモリアドレスがセットされた状態になります。そのため、どちらの変数を経由して配列を操作しても、同じ配列を操作していることになるわけです。
この考え方は、ECMAScript のオブジェクトであればすべて成り立ちます。つまり Array
のみならず、Object
、Funciton
などにも適用できます。以上を踏まえて、オブジェクトのコピーを深堀しましょう。
シャローコピーとディープコピー
オブジェクトをコピーする場合、その方法によって、シャローコピー (shallow) とディープコピー (deep) に分けられます。名前の通り、浅いコピーと深いコピーなのですが、ここで言う「浅い」や「深い」はオブジェクトの階層を指しています。この違いを理解するために次のオブジェクト構造を考えてみましょう。
const phone = {
name: "iPyon 14 Pro",
spec: {
storage: 512,
chip: "A16 Bionic"
}
};
まずはディープコピーから説明します。ディープコピーはすべての階層を丸ごと複製します。つまり、name
の値はもちろんのこと、spec
の storage
と chip
の値も複製します。
一方でシャローコピーとは一番上の階層だけを読み取って、前述の「代入」と同じことを行います。この処理を順を追って説明します。
まず、最も浅い階層にある name
と spec
というプロパティを持ったオブジェクトを新たに作ります。ここまではオリジナルと別物になります。次に新たに用意したオブジェクトの name
と spec
に、オリジナルのオブジェクトの name
と spec
の値を代入します。
「代入」という点に注目してください。spec
の値は文字列ですからプリミティブ値です。したがって、ここは直感通り新しいオブジェクトに文字列 "iPhon 14 Pro"
が複製されます。しかし、chip
の値はオブジェクトです。コピー先に代入されると、メモリアドレスが複製されることになります。ということはコピー先の chip
の値は、オリジナルの chip
と同じ実体を見ていることになります。
シャローコピーの場合、もしコピーとして新たに作ったオブジェクトの spec.storage
の値を 512 から 128 に変更すると、オリジナルの spec.storage
も 128 になってしまいます。現象だけを見るとあたかも連動しているかのようですが、実際には連動ではなく、オリジナルもコピーも spec
の値に関しては同じ実体を見ているのですから当然の結果ですね。
なお、一番上の階層のプロパティの値がすべてプリミティブ値であれば、シャローコピーもディープコピーも結果は同じです。どちらのコピーでも、完全な複製を作ることになります。
const person = {
name: "Taro",
age: 34,
married: true
};
以降でさまざまなオブジェクトのコピー手段を紹介しますが、それらがシャローコピーなのかディープコピーなのかをしっかりと理解してください。
Object.assign() によるシャローコピー
オブジェクトのコピーと言えば、Object.assign()
メソッドが良く紹介されます。第一引数に空のオブジェクトを、第二引数にオリジナルの引数を指定すると、シャローコピーを返します。
// オリジナルのオブジェクト
const orig = {
name: "iPyon 14 Pro",
spec: {
storage: 512,
chip: "A16 Bionic"
}
};
// シャローコピーを生成
const copy = Object.assign({}, orig);
// シャローコピーの spec.storage の値を変更
copy.spec.storage = 128;
// オリジナルの spec.storage の値も変わってしまう
console.log(orig.spec.storage); // 128
もともと Object.assign()
は第一引数のオブジェクトに第二引数のオブジェクトをマージするメソッドです。第一引数に空のオブジェクトを指定することで、結果的に第二引数のオブジェクトのシャロ―コピーを生成しているわけです。
シャローコピーは本来の用途ではないので、コードを見ていても違和感を感じますよね。また、Object.assign()
はディープコピーと勘違いして使ってしまうこともありますので注意しましょう。
実は、これは過去の私のことです。正直に言うと、Object.assign()
は後述の JSON
オブジェクトの手法のナウい方法だと勘違いして失敗したことがありました。JSON
オブジェクトを使うなんて古臭い、Object.assign()
を使いこなす俺は最先端、とドヤっていた自分を今となっては恥ずかしく思います。勘違いとは恐ろしいものです。皆さんは気を付けましょう。
スプレッド構文によるシャローコピー
シャローコピーを最も簡単に実現するなら、はやりスプレッド構文でしょう。最近は JavaScript コードでよく見かける点々々です。
const copy = { ...orig };
スプレッド構文は、名前の通り、{}
の中で orig
の中身をシャローコピーで展開します。そのため深い階層は複製ではなく参照が引き渡されてしまいます。
JSON.parse(JSON.stringify()) によるディープコピー
前述の方法はシャローコピーですので、恐らく利用頻度は多くないでしょう。利用頻度が多いのはディープコピーだと思いますが、JavaScript でディープコピーの定番の方法といえば、JSON.parse(JSON.stringify())
です。
// オリジナルのオブジェクト
const orig = {
name: "iPyon 14 Pro",
spec: {
storage: 512,
chip: "A16 Bionic"
}
};
// ディープコピーを生成
const copy = JSON.parse(JSON.stringify(orig));
// コピーの spec.storage の値を変更
copy.spec.storage = 128;
// オリジナルの spec.storage の値は変わらない
console.log(orig.spec.storage); // 512
JSON.parse(JSON.stringify())
はかなり無理矢理な書き方だと思わないでしょうか?しかし以前はこれ以外に JavaScript にディープコピーの決定打といえる方法がありませんでした。とはいえ、この JSON
オブジェクトを使う方法は、ブラウザーベンダーが頑張ったせいなのかは分かりませんが、非常にパフォーマンスが良いと言われています。私もオブジェクトのコピーと言えば、この方法ばかりを使ってきました。
structuredClone() によるディープコピー
前述の通り、以前は JavaScript にオブジェクトのディープコピーの決定打がありませんでしたが、ついにそれが登場します。それは structuredClone()
です。非常に分かりやすくスッキリとしたコードになります。
const copy = structuredClone(orig);
structuredClone()
は Chrome はもちろんのこと、Edge、Safari、Firefox にも実装されています。
前述の JSON オブジェクトは ECMAScript で規定されたものですが、structuredClone()
は WHATWG の HTML 仕様で規定されたものです。つまり、window
オブジェクトに実装されたメソッドです。そのため、node.js では利用できないのではないかと心配になりますが、node v17 でちゃんとサポートされました。
ディープコピーの制約
JSON
オブジェクトであろうが structuredClone()
であろうが、ディープコピーで何でもかんでも複製でいるわけではありません。具体的には関数オブジェクト、未定義値 (undefined
)、組み込みオブジェクト (Date
オブジェクトなど) はエラーになる (例外が投げられる) か無視されるかのどちらかになります。
例えば structuredClone()
の場合、コピー元のオブジェクトに関数オブジェクトが含まれていると例外が投げられます。
const orig = {
func: () => {}
};
const copy = structuredClone(orig); // ここで例外が投げられる
しかし、JSON
オブジェクトの場合は func
というプロパティが存在しなかったかのように振舞います。
const orig = {
func: () => {}
};
const copy = JSON.parse(JSON.stringify(orig));
console.log(copy); // {}
ディープコピーの 2 つの方法で、こういった特殊な値がどう扱われるかは下表のとおりです。
値 | structuredClone() | JSON |
---|---|---|
関数オブジェクト | ✕ (例外) | ✕ (無視) |
未定義値 (undefined ) | 〇 | ✕ (無視) |
Date オブジェクト | 〇 | ✕ (文字列に変換) |
Class のインスタンス | △ | △ |
Class のインスタンスについてコードを見てみましょう。
class MyClass {
constructor() {
this.name = 'Taro'
}
hello() {
console.log('Hello');
}
}
const orig = {
myobj: new MyClass()
};
const copy = structuredClone(orig);
console.log(copy.myobj.name); // Taro
copy.myobj.hello(); // 例外が投げられる
このコードでは MyClass
というクラスを定義し、変数 orig
のプロパティ myobj
に MyClass
クラスのインスタンスをセットしています。そして orig
を structuredClone()
で copy
にコピーします。
この場合、MyClass
クラスのプロパティ name
は期待通りにコピーされますが、メソッド hello()
はコピーされていません。この挙動は JSON
オブジェクトによるコピーでも同様です。このように、クラスのインスタンスをコピーしようとすると、プロトタイプチェーンは切り離されてしまいます。
全体を通して見ると、JSON
オブジェクトと比べて structuredClone()
のほうが、undefined
や Date
オブジェクトを忠実に再現している点で、コピーを頑張っているように見えますね。
まとめ
以前はオブジェクトのコピーといえば JSON
オブジェクト一択でしたが、ブラウザーの実装状況を考えるに、今後は structuredClone()
が主流になるのでしょう。いまだに私はクセで JSON
オブジェクトを使ってしまいますが、今後は意識的に structuredClone()
を使っていこうと思います。
今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。