2022年10月1日

オブジェクト参照とコピー

オブジェクトとプリミティブの根本的な違いの1つは、オブジェクトは「参照によって」保存およびコピーされるのに対し、プリミティブ値(文字列、数値、ブール値など)は常に「値全体として」コピーされることです。

値のコピー時に何が起こるかを少し詳しく見てみると、簡単に理解できます。

文字列などのプリミティブから始めましょう。

ここでは、messageのコピーをphraseに入れています。

let message = "Hello!";
let phrase = message;

その結果、それぞれが文字列"Hello!"を格納する、2つの独立した変数ができました。

かなり分かりやすい結果ですよね?

オブジェクトはそうではありません。

オブジェクトに割り当てられた変数は、オブジェクト自体ではなく、「メモリアドレス」、つまりオブジェクトへの「参照」を格納します。

このような変数の例を見てみましょう。

let user = {
  name: "John"
};

そして、これがメモリに実際に保存される方法です。

オブジェクトはメモリのどこか(図の右側)に保存され、user変数(左側)はその参照を持っています。

userのようなオブジェクト変数は、オブジェクトのアドレスが書かれた紙切れのようなものだと考えることができます。

オブジェクトで操作を行う場合(例:プロパティuser.nameを取得する場合)、JavaScriptエンジンはそのアドレスに何が存在するかを確認し、実際のオブジェクトに対して操作を実行します。

これが重要な理由です。

オブジェクト変数をコピーすると、参照はコピーされますが、オブジェクト自体は複製されません。

例えば

let user = { name: "John" };

let admin = user; // copy the reference

これで、それぞれ同じオブジェクトを参照する2つの変数ができました。

ご覧のように、オブジェクトは1つしかありませんが、参照する変数は2つあります。

どちらの変数を使用してでもオブジェクトにアクセスし、その内容を変更できます。

let user = { name: 'John' };

let admin = user;

admin.name = 'Pete'; // changed by the "admin" reference

alert(user.name); // 'Pete', changes are seen from the "user" reference

これは、2つの鍵のあるキャビネットがあり、そのうちの1つ(admin)を使ってキャビネットに入り、変更を加えたようなものです。その後、別の鍵(user)を使用しても、同じキャビネットを開けて、変更された内容にアクセスできます。

参照による比較

2つのオブジェクトは、同じオブジェクトである場合にのみ等しくなります。

例えば、ここではabは同じオブジェクトを参照しているので、等しくなります。

let a = {};
let b = a; // copy the reference

alert( a == b ); // true, both variables reference the same object
alert( a === b ); // true

そしてここでは、2つの独立したオブジェクトは、たとえ似ていても(どちらも空ですが)、等しくありません。

let a = {};
let b = {}; // two independent objects

alert( a == b ); // false

obj1 > obj2のような比較や、プリミティブに対する比較obj == 5の場合、オブジェクトはプリミティブに変換されます。オブジェクトの変換方法についてはすぐに学習しますが、正直なところ、このような比較はめったに必要ありません。通常、プログラミングミスによって発生します。

Constオブジェクトは変更できます

オブジェクトを参照として格納することの重要な副作用は、constとして宣言されたオブジェクトは変更できるということです。

例えば

const user = {
  name: "John"
};

user.name = "Pete"; // (*)

alert(user.name); // Pete

(*)行がエラーを引き起こすと思われるかもしれませんが、そうではありません。userの値は一定であり、常に同じオブジェクトを参照する必要がありますが、そのオブジェクトのプロパティは自由に変更できます。

つまり、const userは、全体としてuser=...を設定しようとするときにのみエラーが発生します。

つまり、オブジェクトのプロパティを本当に定数にする必要がある場合も、全く異なる方法を使用できます。それはプロパティフラグと記述子の章で説明します。

クローンとマージ、Object.assign

したがって、オブジェクト変数をコピーすると、同じオブジェクトへの参照がもう1つ作成されます。

しかし、オブジェクトを複製する必要がある場合はどうでしょうか?

新しいオブジェクトを作成し、そのプロパティを反復処理してプリミティブレベルでコピーすることにより、既存のオブジェクトの構造を複製できます。

このように

let user = {
  name: "John",
  age: 30
};

let clone = {}; // the new empty object

// let's copy all user properties into it
for (let key in user) {
  clone[key] = user[key];
}

// now clone is a fully independent object with the same content
clone.name = "Pete"; // changed the data in it

alert( user.name ); // still John in the original object

Object.assignメソッドを使用することもできます。

構文は次のとおりです。

Object.assign(dest, ...sources)
  • 最初の引数destはターゲットオブジェクトです。
  • それ以降の引数は、ソースオブジェクトのリストです。

すべてのソースオブジェクトのプロパティをターゲットdestにコピーし、結果として返します。

たとえば、userオブジェクトがあり、いくつかの権限を追加してみましょう。

let user = { name: "John" };

let permissions1 = { canView: true };
let permissions2 = { canEdit: true };

// copies all properties from permissions1 and permissions2 into user
Object.assign(user, permissions1, permissions2);

// now user = { name: "John", canView: true, canEdit: true }
alert(user.name); // John
alert(user.canView); // true
alert(user.canEdit); // true

コピーされたプロパティ名が既に存在する場合は、上書きされます。

let user = { name: "John" };

Object.assign(user, { name: "Pete" });

alert(user.name); // now user = { name: "Pete" }

Object.assignを使用して、単純なオブジェクトのクローンを作成することもできます。

let user = {
  name: "John",
  age: 30
};

let clone = Object.assign({}, user);

alert(clone.name); // John
alert(clone.age); // 30

ここでは、userのすべてのプロパティを空のオブジェクトにコピーして返します。

オブジェクトのクローン作成には、スプレッド構文clone = {...user}を使用するなど、他の方法もあります。これはチュートリアルの後半で説明します。

ネストされたクローン

これまでのところ、userのすべてのプロパティはプリミティブであると仮定していました。しかし、プロパティは他のオブジェクトへの参照である可能性があります。

このように

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

alert( user.sizes.height ); // 182

ここでclone.sizes = user.sizesをコピーするだけでは不十分です。なぜなら、user.sizesはオブジェクトであり、参照によってコピーされるため、cloneuserは同じサイズを共有することになるからです。

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

let clone = Object.assign({}, user);

alert( user.sizes === clone.sizes ); // true, same object

// user and clone share sizes
user.sizes.width = 60;    // change a property from one place
alert(clone.sizes.width); // 60, get the result from the other one

これを修正し、usercloneを真に別々のオブジェクトにするには、user[key]の各値を検査し、オブジェクトの場合はその構造も複製するクローンループを使用する必要があります。これは「ディープクローン」または「構造化クローン」と呼ばれます。structuredCloneメソッドは、ディープクローンを実装しています。

structuredClone

structuredClone(object)呼び出しは、すべてのネストされたプロパティを持つobjectをクローンします。

これが私たちの例での使用方法です。

let user = {
  name: "John",
  sizes: {
    height: 182,
    width: 50
  }
};

let clone = structuredClone(user);

alert( user.sizes === clone.sizes ); // false, different objects

// user and clone are totally unrelated now
user.sizes.width = 60;    // change a property from one place
alert(clone.sizes.width); // 50, not related

structuredCloneメソッドは、オブジェクト、配列、プリミティブ値など、ほとんどのデータ型をクローンできます。

オブジェクトのプロパティがオブジェクト自体を参照する場合(直接的または参照のチェーンを介して)、循環参照もサポートします。

例えば

let user = {};
// let's create a circular reference:
// user.me references the user itself
user.me = user;

let clone = structuredClone(user);
alert(clone.me === clone); // true

ご覧のように、clone.meuserではなくcloneを参照しています!したがって、循環参照も正しくクローンされました。

ただし、structuredCloneが失敗する場合もあります。

たとえば、オブジェクトに関数プロパティがある場合

// error
structuredClone({
  f: function() {}
});

関数プロパティはサポートされていません。

このような複雑なケースに対処するには、クローン作成方法を組み合わせたり、カスタムコードを書いたり、あるいは既存の実装を使用したりする必要があります(例:JavaScriptライブラリlodash_.cloneDeep(obj))。

まとめ

オブジェクトは参照によって割り当てられ、コピーされます。つまり、変数は「オブジェクト値」ではなく、「参照」(メモリアドレス)を格納します。そのため、そのような変数をコピーしたり、関数引数として渡したりすると、オブジェクト自体ではなくその参照がコピーされます。

コピーされた参照によるすべての操作(プロパティの追加/削除など)は、同じ1つのオブジェクトに対して実行されます。

「実際の複製」(クローン)を作成するには、いわゆる「シャローコピー」(ネストされたオブジェクトは参照によってコピーされます)の場合はObject.assignを使用するか、「ディープクローン」関数structuredCloneを使用するか、_.cloneDeep(obj)などのカスタムクローン実装を使用します。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善すべき点があれば、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。