🕳️

JavaScript のオブジェクトが引き起こす思わぬ罠と対策

2025/01/31に公開
4

JavaScript のデータは、大きく プリミティブ(例: Number, String など)と オブジェクト(例: 配列 [], オブジェクト {} など)に分けられます。

しかし、配列やオブジェクトを扱うときには、「変数を使い回していたら別のところまで値が変わってしまった…」などの思わぬトラブルが起こりがちです。

この記事では、プリミティブとオブジェクトの違い、そして 「変数の再代入」と「オブジェクトのミューテーション(内容の変更)」を区別する ことの重要性について、具体例を交えて解説します。


1. プリミティブ vs オブジェクト:何が違うの?

プリミティブ

  • 代表例: Number, String, Boolean, null, undefined, Symbol, BigInt
  • ECMAScript 上は 不変 (immutable) であり、プロパティを持たないものとして定義
  • メソッドやプロパティにアクセスするときは、裏で一時的に Object() 相当のラッパーが作られる

ポイント:
プリミティブは「変数に直接、 を保持するイメージ」として扱うことが多いですが、実装レベルで「メモリのどこに格納されるか」は規格上は明確に定義されているわけではありません。あくまで 「再代入で常に新しい値が生成される」 という振る舞いが重要です。

オブジェクト

  • 代表例: {}, [], function など
  • mutable(変更可能) であり、プロパティを自由に追加・変更できる
  • 変数に代入するときは「オブジェクトそのもの」ではなく 「オブジェクトへの参照」 を持つ

ポイント:
1 つのオブジェクトを複数の変数が参照している場合、そのオブジェクトを変更すると他の変数から見ても変更が反映される という点が大きな特徴です。


2. 「再代入」と「オブジェクトの内容変更」は別物

2-1. プリミティブの例

再代入 (新しいプリミティブをセット)

let x = 10;
let y = x;   // x(10) を y にコピー
x = 20;      // x に新しい値 (20) を再代入

console.log(x); // 20
console.log(y); // 10
  • x = 10 → 変数 x は「10」という値を指す
  • y = xy も「10」という値をコピー
  • x = 20 → 変数 x は「20」へ上書きされるが、y は「10」のまま

プリミティブの場合、「再代入」しても それ以前にコピーした変数には影響しない ことがわかります。

2-2. オブジェクトの例:再代入 vs ミューテーション

(A) 再代入 (新しいオブジェクトをセット)

let a = [1, 2, 3];
let b = a;        // a が参照している配列を b でも参照
a = [4, 5, 6];    // a に新しい配列を再代入

console.log(a); // [4, 5, 6]
console.log(b); // [1, 2, 3]
  • 最初 a[1, 2, 3] の参照を持つ
  • b = a; によって b も同じ配列 [1, 2, 3] を参照
  • その後 a = [4, 5, 6];a は新たな配列 を参照する
  • b は引き続き古い配列 [1, 2, 3] を参照したままなので、ba で異なる配列を指す

(B-1) ミューテーション (既存のオブジェクトの内容を変更)

let arr1 = [1, 2, 3];
let arr2 = arr1;    // arr1 が参照している配列を arr2 でも参照
arr1.push(4);       // 「配列オブジェクト」の内容を変更

console.log(arr1); // [1, 2, 3, 4]
console.log(arr2); // [1, 2, 3, 4] ← arr2 も同じオブジェクトを共有
  • arr1arr2 は同じ配列オブジェクトを共有
  • push(4) は新しい配列を作るのではなく、既存の配列を変化させる(ミューテーション)
  • 共有しているので、どちらを通して配列を見ても [1, 2, 3, 4] に変わる

(B-2) ミューテーション (既存のオブジェクトの内容を変更) のプリミティブバージョン

プリミティブは 不変 であり、本来「内容を変更する」という概念は存在しません。

しかし、以下の例のように 「プリミティブにプロパティを追加しようとする」 などの操作が、オブジェクトと同じようには動かない(あるいはエラーになる)ことを確認できます。

// 反映されないプロパティ
{
    let a = 1;
    let b = a;
    a.test = 2;

    console.log(a.test); // undefined
    console.log(b.test); // undefined
}

プリミティブ a (値は 1) に対して a.test = 2 を行っても、実際にはラッパーオブジェクトが一時的に生成されるだけで、プリミティブ自体に変更は反映されません。

// strict モード(プロパティを生やそうとするとエラーになる)
(() => {
    "use strict";
    try {
        let a = 1;
        let b = a;
        a.test = 2; // -> TypeError: Cannot create property 'test' on number '1'
    } catch(e) {
        console.error(e);
    }
})();

strict モードでは、プリミティブにプロパティを追加しようとするとエラー (TypeError) になります。

// ラッパーオブジェクト経由で変更
{
    let a = 1;
    let a2 = Object(a);
    let b2 = a2;
    a2.test = 2;

    console.log(a.test);  // undefined
    console.log(a2.test); // 2
    console.log(b2.test); // 2
}

Object(a) は「Number オブジェクト」という オブジェクト を生成します。それにより a2.test = 2; は Number オブジェクト(a2)にプロパティを追加しており、プリミティブの a そのものではありません。

// ラッパーオブジェクト自体を加工すれば プリミティブのプロパティアクセスから値の参照は可能
{
    Number.prototype.hoge = 4;
    console.log(3..hoge); // 4

    let b = 5;
    b.hoge = 7;
    console.log(b.hoge);  // 4
}

Number.prototype にプロパティを追加すると、プリミティブに対するプロパティアクセスもそれを拾います。ただし、プリミティブ自体にプロパティを「直接」追加しているわけではなく、継承元の Number.prototype を介して値を取得している仕組みです。

b.hoge = 7; と書いても、実際にはプリミティブには反映されず、再度読み取ると Number.prototype.hoge の値が返ってきます。

このように、プリミティブは本質的にオブジェクトのような「内容の変更(ミューテーション)」を行う対象にはなり得ません。

一見似たように書けることもありますが、実際には「ただのリテラル(数値)」にプロパティを生やしているわけではない という点に注意しましょう。


3. 「同じオブジェクト参照してるって知らなかった!」を防ぐコピー方法

複数の変数で同じオブジェクトを意図せず共有したくない場合は、新しくオブジェクトを複製(コピー)する必要があります。配列の場合、次のように書くのが一般的です。

3-1. スプレッド構文(...)

let arr1 = [1, 2, 3];
let arr2 = [...arr1];  // 新しい配列を作成
arr1.push(4);

console.log(arr1); // [1, 2, 3, 4]
console.log(arr2); // [1, 2, 3]

スプレッド構文は、1 階層目の要素を展開して別の配列を生成します。
よって、arr1arr2 は同じ実体を共有しません。

3-2. slice() メソッド

let arr1 = [1, 2, 3];
let arr2 = arr1.slice();
arr1.push(4);

console.log(arr1); // [1, 2, 3, 4]
console.log(arr2); // [1, 2, 3]

slice() の引数を省略すると配列全体の浅いコピーが作られます。


4. 浅いコピー vs 深いコピー

[...]slice()浅いコピー なので、多重配列やネストしたオブジェクトがある場合は、深い階層の参照はそのまま共有されてしまいます。

let nested1 = [[1, 2], 3];
let nested2 = [...nested1];  // 浅いコピー

nested1[0].push(999);
console.log(nested1); // [[1, 2, 999], 3]
console.log(nested2); // [[1, 2, 999], 3] ← 内部配列も共有されている

完全に別のオブジェクトとして使いたい場合は、深いコピー (Deep Copy) が必要。
ブラウザが対応していれば structuredClone() を使えますし、Lodash の _.cloneDeep() などを利用するのも選択肢です。


5. まとめ

  1. プリミティブ

    • 不変 (immutable) で、再代入してもほかの変数には影響しない
    • 「どこにどう格納されるか」は実装依存だが、毎回新しい値が生成される と考えるのが基本
  2. オブジェクト

    • 参照 を変数に持つため、同じオブジェクトを複数の変数が共有する場合がある
    • 「再代入」は変数が指す先を切り替えるだけ
    • 「オブジェクトの内容を変更 (ミューテーション)」は同じオブジェクトを共有しているすべての変数に影響
  3. 配列やオブジェクトのコピー

    • slice()[...arr] などで浅いコピーを作ると、「思わぬ共有」を防ぎやすい
    • 深くネストしている場合は深いコピーが必要

開発中に配列やオブジェクトの値を扱うときは、

  • 再代入 (新しいオブジェクトを割り当てる)か、
  • ミューテーション (元のオブジェクトを変更する)か、

本記事について、多くの方からご助言があり、自分の浅い知識を Update することができました!

誰かから教わった知識を shallow コピーしていました。

ご助言いただいた皆さま、ありがとうございました!

Discussion

junerjuner

値そのもの が変数に格納される

プリミティブが 値そのもの が変数に格納される かどうかは仕様としては保証されていなかったかと思われます。(それは実装の話ではないでしょうか?)

プリミティブは

  • 不変である
  • 関数を持たない

ことだけを保証されており、また プロパティやメソッドへのアクセスに Object() 相当のアクセスが行われる様に特別扱いされているだけです。

https://developer.mozilla.org/ja/docs/Glossary/Primitive

それ以外をオブジェクト と言います。(=表現上 参照型ではないです)

また、プリミティブでも 参照型と思われるものもあり、 不変で参照型であるならば それは 値型と同じ動作する為、プリミティブを 値型 / オブジェクト を 参照型 とする説明は不適切と思われます。


arr1 の「参照(アドレス)」を arr2 にコピー

arr1 の参照とは言っていますが、変数の参照(=参照渡しでいうところの参照はこちら)ではなく、arr1 に設定されている値の オブジェクトの参照 を arr2 に コピーしていることに注意が必要です。


「どこにどう格納されるか」は実装依存だが、毎回新しい値が生成される と考えるのが基本

それは仕様としては保証されない動作です。不変なのですから新しい値が生成されなくても問題無いので。


不変 (immutable) で、再代入してもほかの変数には影響しない

その再代入の動作はプリミティブもオブジェクトも同じではないでしょうか?

YuneKichiYuneKichi

よくこのような記事を見ますが、「プリミティブ型の挙動」と「 参照型(今回の要注意ポイント)」でやっていることが変わっています。
「 参照型(今回の要注意ポイント)」のコードを「プリミティブ型の挙動」とコードを合わせると

let a = [1, 2, 3];
let b = a;
a = [4, 5, 6]; // 「プリミティブ型の挙動」と同じく、リテラルを代入する

console.log(a); // [4, 5, 6]
console.log(b); // [1, 2, 3]

となり、挙動は変わりません。

t-coolt-cool

アドバイス、ありがとうございます!
後ほど記事を修正します!

junerjuner

参考

(B) ミューテーション (既存のオブジェクトの内容を変更) のプリミティブバージョン

// 反映されないプロパティ
{
    let a = 1;
    let b = a;
    a.test = 2;

    console.log(a.test); // undefined
    console.log(b.test); // undefined
}
// stcict モード(プロパティを生やそうとするとエラーになる)
(() => {
    "use strict";
    try {
        let a = 1;
        let b = a;
        a.test = 2;// -> error
    } catch(e) {
        console.error(e);
        // TypeError: Cannot create property 'test' on number '1'
    }
})();
// ラッパーオブジェクト経由で変更
{
    let a = 1;
    let a2 = Object(a);
    let b2 = a2;
    a2.test = 2;
    
    console.log(a.test); // undefined
    console.log(a2.test); // 2
    console.log(b2.test); // 2
}
// ラッパーオブジェクト自体を加工すれば プリミティブのプロパティアクセスから値の参照は可能 ただ、 プロパティアクセス時に毎回ラッパーオブジェクトを経由するのでインスタンスの持つプロパティの値としての変更は不可
{
    Number.prototype.hoge = 4;
    console.log(3..hoge); // 4
    let b = 5;
    b.hoge = 7;
    console.log(b.hoge); // 4
}