初心に立ち帰る!JavaScriptの「オブジェクト型」入門:変数の中身の仕組み
はじめに
こんにちは。JavaScriptを学び始めると、「変数」という言葉に頻繁に出会いますね。変数は、データを一時的に保管しておくための箱のようなもの、と学習したかもしれません。
さらに深みまで理解するために久しぶりにJavascriptを復習しました。
忘備録として残します。
let score = 100; // scoreという変数に100を代入
let message = "こんにちは"; // messageという変数に「こんにちは」という文字列を代入
このように、数値や文字列といったデータを直接、変数という箱に入れるイメージ、これが プリミティブ型 と呼ばれるデータの扱い方です。
しかし、JavaScriptにはもう一つ、異なるデータの扱い方が存在します。それが今回のテーマである オブジェクト型 です。
「オブジェクト型とは何でしょうか? なぜ知る必要があるのでしょうか?」と感じるかもしれませんが、JavaScriptでオブジェクトや配列など、より複雑なデータを扱う上で、このオブジェクト型の概念は非常に重要になります。
この記事を読むことで、以下の点が理解できるようになります。
- プリミティブ型とオブジェクト型の違い
- オブジェクト型がデータをどのように扱っているかのイメージ
- オブジェクトや配列を扱う際の注意点
一緒に学んでいきましょう。
プリミティブ型 vs オブジェクト型:データの保持方法の違い
まず、プリミティブ型とオブジェクト型の根本的な違いを確認しましょう。重要なのは「変数(箱)の中に何が格納されているか?」という点です。
プリミティブ型:変数の中に「値そのもの」が格納される
プリミティブ型(数値、文字列、真偽値など)は、変数という箱の中に、データ(値)そのものが直接格納されているとイメージしてください。
例え話:ジュースのコップ
-
let juiceA = "オレンジジュース";
-
juiceA
という名前のコップに、直接「オレンジジュース」が入っています。
-
-
let juiceB = juiceA;
-
juiceA
の中身(オレンジジュース)を コピーして 、juiceB
という新しいコップに入れます。 - これにより、
juiceA
とjuiceB
は、それぞれ独立した「オレンジジュース」を持っている状態になります。
-
-
juiceB = "リンゴジュース";
-
juiceB
のコップの中身を「リンゴジュース」に入れ替えます。 -
juiceA
のコップの中身は「オレンジジュース」のままで、影響を受けません。
-
コードでの確認
let numA = 10;
let numB = numA; // numAの値「10」がコピーされます
console.log(numA); // 結果: 10
console.log(numB); // 結果: 10
numB = 20; // numBの値を変更します
console.log(numA); // 結果: 10 (numAは影響を受けません)
console.log(numB); // 結果: 20
プリミティブ型では、代入 (=
) によって値そのものがコピーされるため、「値渡し」とも呼ばれます。
オブジェクト型:変数の中に「データの所在地(アドレス)」が格納される
一方、オブジェクト型(オブジェクト、配列、関数など)は、変数という箱の中に、データそのものではなく、データが実際に格納されている場所(メモリ上の住所、アドレス) が入っているとイメージしてください。
例え話:家の住所が書かれたメモ
-
let addressA = { place: "私の家", rooms: 3 };
- まず、メモリ上のどこかに
{ place: "私の家", rooms: 3 }
という実際の家(データ本体)が確保されます。 - そして、
addressA
という名前の変数(メモ帳)に、その家の 住所 が書き込まれます。変数の中身は家そのものではなく、家の場所を示す情報です。
- まず、メモリ上のどこかに
-
let addressB = addressA;
-
addressA
の変数に書かれている 住所 を コピーして 、addressB
という新しい変数に書き込みます。 - これにより、
addressA
とaddressB
の変数には、 同じ家の住所 が書き込まれている状態になります。つまり、二つの変数はメモリ上の同じ家(データ本体)を指しています。
-
-
addressB.rooms = 5;
-
addressB
の変数に書かれた住所を基に、実際の家(データ本体)を探します。 - その家の
rooms
プロパティの値を5
に変更します。 -
addressA
の変数にも同じ家の住所が書かれているため、addressA
を使って家を参照しても、rooms
プロパティは5
に変更されています。
-
コードでの確認
let objA = { name: "太郎", age: 15 };
let objB = objA; // objAが指すデータ(オブジェクト)の「住所」がコピーされます
console.log(objA.name); // 結果: 太郎
console.log(objB.name); // 結果: 太郎 (同じデータを指しています)
// objBを使ってデータを変更します
objB.name = "花子";
console.log(objA.name); // 結果: 花子 (objAでアクセスしてもデータが変更されています!)
console.log(objB.name); // 結果: 花子
オブジェクト型では、代入 (=
) によってデータの住所(参照)がコピーされます。そのため、複数の変数がメモリ上の同じデータ本体を共有することになります。これは「参照渡し」(より正確には「参照の値渡し」)と呼ばれます。
代表的なオブジェクト型:オブジェクトと配列
JavaScriptで頻繁に使用される代表的なオブジェクト型、オブジェクトと配列について見ていきましょう。
{}
):名前付きデータの集合
オブジェクト (オブジェクトは、名前(キー)と値(バリュー)のペアを用いて、複数のデータをまとめて管理するための仕組みです。
// 生徒の情報をまとめるオブジェクト
let student = {
name: "山田 一郎", // name というキーに "山田 一郎" という値
grade: 10, // grade というキーに 10 という値 (高校1年生)
subjects: ["数学", "英語", "物理"], // subjects というキーに配列(これもオブジェクト型)
sayHello: function() { // sayHello というキーに関数(これもオブジェクト型)
console.log("こんにちは!" + this.name + "です。");
}
};
// データへのアクセス (ドット記法)
console.log(student.name); // 結果: 山田 一郎
console.log(student.grade); // 結果: 10
// データへのアクセス (ブラケット記法) - キーが変数や特殊文字を含む場合に使用
console.log(student["subjects"]); // 結果: ["数学", "英語", "物理"]
// メソッド(オブジェクト内の関数)の呼び出し
student.sayHello(); // 結果: こんにちは!山田 一郎です。
// データの上書き
student.grade = 11; // 2年生に進級
console.log(student.grade); // 結果: 11
オブジェクトは、関連する複数の情報を整理して保持する際に非常に便利です。
[]
):順序付きデータのリスト
配列 (配列は、複数のデータを順序付けてリスト形式で管理するための仕組みです。
// 好きな果物のリスト(配列)
let favoriteFruits = ["りんご", "バナナ", "いちご"];
// データへのアクセス (インデックス番号を使用。0から始まります)
console.log(favoriteFruits[0]); // 結果: りんご (最初の要素)
console.log(favoriteFruits[1]); // 結果: バナナ (2番目の要素)
console.log(favoriteFruits[2]); // 結果: いちご (3番目の要素)
// 要素数を取得
console.log(favoriteFruits.length); // 結果: 3
// 新しい要素を末尾に追加
favoriteFruits.push("ぶどう");
console.log(favoriteFruits); // 結果: ["りんご", "バナナ", "いちご", "ぶどう"]
// 要素の上書き
favoriteFruits[1] = "メロン"; // 2番目の要素をメロンに変更
console.log(favoriteFruits); // 結果: ["りんご", "メロン", "いちご", "ぶどう"]
配列は、順序が重要なデータのコレクションを扱う際に役立ちます。
便利な機能:分割代入 (Destructuring Assignment)
オブジェクトや配列から、特定のプロパティや要素を取り出して、個別の変数に簡単に代入できる「分割代入」という便利な機能があります。
配列の分割代入
配列の要素を、順番に対応する変数に代入します。
let colors = ["赤", "緑", "青"];
// 通常の取り出し方
// let firstColor = colors[0]; // 赤
// let secondColor = colors[1]; // 緑
// 分割代入を使った取り出し方
let [firstColor, secondColor, thirdColor] = colors;
console.log(firstColor); // 結果: 赤
console.log(secondColor); // 結果: 緑
console.log(thirdColor); // 結果: 青
// 一部の要素だけ取り出すことも可能
let [, green] = colors; // 最初の要素を飛ばして2番目を取得
console.log(green); // 結果: 緑
オブジェクトの分割代入
オブジェクトのプロパティを、キーと同じ名前の変数に代入します。
let user = {
id: 123,
userName: "Taro Yamada",
age: 16,
country: "Japan"
};
// 通常の取り出し方
// let id = user.id;
// let name = user.userName;
// 分割代入を使った取り出し方 (変数名はキー名と一致させる)
let { id, userName, age } = user;
console.log(id); // 結果: 123
console.log(userName); // 結果: Taro Yamada
console.log(age); // 結果: 16
// 別の変数名で受け取りたい場合 (キー名: 新しい変数名)
let { country: userCountry } = user;
console.log(userCountry); // 結果: Japan
// ネストしたオブジェクトからも取り出せる
let book = {
title: "JavaScript入門",
author: {
firstName: "John",
lastName: "Doe"
}
};
let { title, author: { firstName } } = book;
console.log(title); // 結果: JavaScript入門
console.log(firstName); // 結果: John
分割代入の仕組みとメモリ上の動き
分割代入は、コードを簡潔にするための便利なシンタックスシュガー(糖衣構文)です。これは、JavaScriptが内部的にオブジェクトのプロパティや配列の要素にアクセスし、それらを新しい変数に代入する処理を、より短い記述で実現しているものです。
メモリ上の動きという観点では、分割代入は特別なことをしているわけではありません。基本的な仕組みは通常のプロパティ/要素アクセスと代入と同じです。
-
アクセス: まず、代入元のオブジェクトや配列(例:
user
やcolors
)にアクセスします。これは、その変数が保持しているメモリ上のアドレス(所在地)を辿って、データ本体を見つけることを意味します。 -
値の取得: 次に、指定されたキー(オブジェクトの場合)やインデックス(配列の場合)に対応する値を取り出します。例えば、
let { id } = user;
であれば、user
オブジェクトの中からid
というキーに対応する値(ここでは123
)を取得します。let [firstColor] = colors;
であれば、colors
配列のインデックス0
にある値(ここでは"赤"
)を取得します。 -
新しい変数への代入: 最後に、取得した値を、分割代入で宣言された新しい変数(例:
id
やfirstColor
)に代入します。この代入の際のメモリの動きは、プリミティブ型とオブジェクト型で異なります。- 取得した値がプリミティブ型(数値、文字列など)の場合:その値自体がコピーされて、新しい変数のメモリ領域に格納されます。
- 取得した値がオブジェクト型(他のオブジェクト、配列、関数など)の場合:そのオブジェクトが格納されているメモリ上のアドレス(参照)がコピーされて、新しい変数のメモリ領域に格納されます。
重要な注意点: 分割代入でオブジェクト型の値を取り出した場合、新しく作られた変数には元のオブジェクトへの参照(アドレス)がコピーされるだけです。つまり、新しい変数も、元のオブジェクト内のプロパティや配列内の要素が指していた同じデータ本体を指すことになります。
let data = {
id: 1,
items: ["A", "B"] // itemsプロパティは配列(オブジェクト型)
};
// 分割代入でitemsを取り出す
let { id, items } = data;
// 新しい変数itemsを使って、配列を変更する
items.push("C");
// 元のdataオブジェクトの中身を確認すると…
console.log(data.items); // 結果: ["A", "B", "C"] <-- 元のオブジェクト内の配列も変更されている!
console.log(items); // 結果: ["A", "B", "C"]
このように、分割代入はデータへのアクセスと代入を簡潔にするものですが、オブジェクト型を扱う際は、値そのものではなく参照(アドレス)がコピーされるという原則を理解しておくことが重要です。これは、分割代入が自動的にデータの深いコピー(Deep Copy)を作成するわけではないことを意味します。
分割代入を使うと、オブジェクトや配列から必要なデータを取り出すコードを、より短く、分かりやすく書くことができます。
オブジェクト型の注意点:意図しないデータ変更とコピーの種類
オブジェクト型の「住所を共有する」という性質は便利ですが、意図せずにデータが書き換わる可能性があるため注意が必要です。
先ほどの家の例えを思い出してください。addressA
と addressB
は同じ家の住所を持っていました。addressB
を使って家の部屋数を変更すると、addressA
から参照しても部屋数が変更されていましたね。
これと同様の現象が、オブジェクトや配列でも発生します。
let scores1 = [80, 90, 75];
let scores2 = scores1; // scores1の「住所」をscores2にコピー
scores2[0] = 100; // scores2を通じて最初の要素を100点に変更
// scores1の中身を確認すると…
console.log(scores1); // 結果: [100, 90, 75] <-- scores1のデータも変更されています!
console.log(scores2); // 結果: [100, 90, 75]
scores2
を変更したはずが、scores1
のデータまで変更されてしまいました。これは、scores1
と scores2
がメモリ上の同じ配列データ(同じ住所にあるデータ)を指していたためです。
データをコピーしたい場合:浅いコピー (Shallow Copy) と 深いコピー (Deep Copy)
オブジェクトや配列の「中身」を、元のデータとは完全に独立した別のデータとしてコピーしたい場合(つまり、別の家を新しく建てたい場合)は、単純な代入 (=
) ではなく、コピー操作を行う必要があります。ここで重要になるのが「浅いコピー」と「深いコピー」の違いです。
浅いコピー (Shallow Copy)
先ほど紹介したスプレッド構文 (...
) を使ったコピー方法は、「浅いコピー」と呼ばれるものです。
// 配列の浅いコピー
let originalArray = [1, 2, [10, 20]]; // 配列の中に配列(オブジェクト型)が含まれる
let shallowCopiedArray = [...originalArray];
// オブジェクトの浅いコピー
let originalObject = { a: 1, b: { c: 3 } }; // オブジェクトの中にオブジェクト(オブジェクト型)が含まれる
let shallowCopiedObject = { ...originalObject };
// 浅いコピーでコピーされた配列の要素を変更してみる
shallowCopiedArray[0] = 100; // プリミティブ型の要素を変更
shallowCopiedArray[2][0] = 999; // ★ネストされた配列(オブジェクト型)の要素を変更
console.log(originalArray); // 結果: [1, 2, [999, 20]] <-- ★元の配列のネストされた部分も変更されている!
console.log(shallowCopiedArray); // 結果: [100, 2, [999, 20]]
// 浅いコピーでコピーされたオブジェクトのプロパティを変更してみる
shallowCopiedObject.a = 100; // プリミティブ型のプロパティを変更
shallowCopiedObject.b.c = 999; // ★ネストされたオブジェクト(オブジェクト型)のプロパティを変更
console.log(originalObject); // 結果: { a: 1, b: { c: 999 } } <-- ★元のオブジェクトのネストされた部分も変更されている!
console.log(shallowCopiedObject); // 結果: { a: 100, b: { c: 999 } }
浅いコピーは、オブジェクトや配列の第一階層(一番外側)のプロパティや要素はコピーしますが、その値がさらにオブジェクトや配列(オブジェクト型)だった場合、その中身(データ本体)まではコピーせず、住所情報のみをコピーします。
その結果、コピー元とコピー先で、ネストされた(入れ子になった)オブジェクトや配列のデータ本体を共有してしまうのです。上記の例で、shallowCopiedArray[2]
や shallowCopiedObject.b
を変更すると、originalArray[2]
や originalObject.b
も影響を受けてしまうのはこのためです。
深いコピー (Deep Copy)
一方、「深いコピー」は、オブジェクトや配列の内部に含まれるオブジェクトや配列も含めて、すべての階層のデータを再帰的に(奥深くまで)完全にコピーし、元のデータとは全く依存関係のない、完全に独立した新しいデータを作成します。
深いコピーを行う標準的な簡単な方法はJavaScriptには用意されていません。実現するには、以下のような方法がありますが、それぞれ特性や注意点があります。
-
JSON.parse(JSON.stringify(object))
:- オブジェクトを一度JSON文字列に変換し、それを再度パース(解析)して新しいオブジェクトを作る方法です。シンプルですが、関数や
Date
オブジェクト、undefined
など、JSONで表現できない一部のデータ型は正しくコピーできないという制限があります。
let original = { a: 1, b: { c: 2 }, d: new Date() }; let deepCopied = JSON.parse(JSON.stringify(original)); // deepCopied.d はDateオブジェクトではなく文字列になってしまう
- オブジェクトを一度JSON文字列に変換し、それを再度パース(解析)して新しいオブジェクトを作る方法です。シンプルですが、関数や
-
ライブラリの利用: Lodashなどのライブラリには、高機能な深いコピー用の関数 (
_.cloneDeep()
) が用意されています。 - 自作関数: 再帰(関数が自分自身を呼び出すこと)などを利用して、深いコピーを行う関数を自分で作成することも可能ですが、複雑になりがちです。
オブジェクト型のデータをコピーする際は、単純な代入 (=
)、浅いコピー (...
など)、深いコピーのどれが必要なのかを意識することが重要です。特に、ネストされたデータ構造を扱う場合は、浅いコピーでは意図しない副作用(元のデータまで変更されてしまうこと)が発生する可能性があるため、注意しましょう。
まとめ
今回は、JavaScriptの「オブジェクト型」について学習しました。
- プリミティブ型 は変数に 値そのもの を格納します。代入すると値がコピーされます(値渡し)。
- オブジェクト型 (オブジェクト、配列など) は変数に データの住所 を格納します。代入すると住所がコピーされ、複数の変数が同じデータを指すことがあります(参照渡し)。
- オブジェクトや配列から値を取り出す際に便利な 分割代入 という機能があります。分割代入はメモリの観点では通常のアクセス・代入と同様に動作し、オブジェクト型の場合は参照(アドレス)をコピーします。
- オブジェクト型は、
=
で代入しただけではデータ本体はコピーされません。意図せずデータが書き換わる可能性があるため注意が必要です。 - データ本体をコピーしたい場合は、浅いコピー(例:
...
スプレッド構文)や深いコピー(例:JSON.parse(JSON.stringify())
、ライブラリ、自作関数)を使い分ける必要があります。浅いコピーではネストされたオブジェクト型は共有される点に注意が必要です。
Discussion
※参照渡し に対して 参照の値渡し をより正確にはと書いておられますが、
参照の値渡し はオブジェクトの参照(※値型)を、
参照渡しは変数の参照(=つまり変数そのもの)を渡すので大きく違うことに注意が必要です。
(参照の値渡しは変数は自由にできないので代入が反映されず
(敢えていうならば import した変数が いわゆる 参照渡しの挙動をします