【JavaScript】初心者向けオブジェクトのコピー詳説:シャローコピー、ディープコピー、JSON、スプレッド構文、そして structuredClone()

みなさんはどのように 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 のみならず、ObjectFunciton などにも適用できます。以上を踏まえて、オブジェクトのコピーを深堀しましょう。

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

オブジェクトをコピーする場合、その方法によって、シャローコピー (shallow) とディープコピー (deep) に分けられます。名前の通り、浅いコピーと深いコピーなのですが、ここで言う「浅い」や「深い」はオブジェクトの階層を指しています。この違いを理解するために次のオブジェクト構造を考えてみましょう。

const phone = {
  name: "iPyon 14 Pro",
  spec: {
    storage: 512,
    chip: "A16 Bionic" 
  }
};

まずはディープコピーから説明します。ディープコピーはすべての階層を丸ごと複製します。つまり、name の値はもちろんのこと、specstoragechip の値も複製します。

一方でシャローコピーとは一番上の階層だけを読み取って、前述の「代入」と同じことを行います。この処理を順を追って説明します。

まず、最も浅い階層にある namespec というプロパティを持ったオブジェクトを新たに作ります。ここまではオリジナルと別物になります。次に新たに用意したオブジェクトの namespec に、オリジナルのオブジェクトの namespec の値を代入します。

「代入」という点に注目してください。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 のプロパティ myobjMyClass クラスのインスタンスをセットしています。そして origstructuredClone()copy にコピーします。

この場合、MyClass クラスのプロパティ name は期待通りにコピーされますが、メソッド hello() はコピーされていません。この挙動は JSON オブジェクトによるコピーでも同様です。このように、クラスのインスタンスをコピーしようとすると、プロトタイプチェーンは切り離されてしまいます。

全体を通して見ると、JSON オブジェクトと比べて structuredClone() のほうが、undefinedDate オブジェクトを忠実に再現している点で、コピーを頑張っているように見えますね。

まとめ

以前はオブジェクトのコピーといえば JSON オブジェクト一択でしたが、ブラウザーの実装状況を考えるに、今後は structuredClone() が主流になるのでしょう。いまだに私はクセで JSON オブジェクトを使ってしまいますが、今後は意識的に structuredClone() を使っていこうと思います。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share