値渡し・参照渡し・参照の値渡し
JavaScriptには変数を共有する「参照渡し」は存在しません。オブジェクトを共有するのは「参照の値渡し」や「共有渡し」と呼ばれます。
関数の引数に変数を渡す方式
値渡し (Pass by Value)
値渡しは、変数が保持している値をコピーして渡す方式です。
この方式では、関数内で仮引数に値を代入しても、呼び出し元の実引数の値は変化しません。これは、関数に渡されるのが値のコピーであり、呼出し元の変数とは独立しているためです。
function updateValue(val: number) {
val = 99;
}
let value = 50;
updateValue(value);
console.log(value); // 50
上記の例では、updateValue関数内でvalの値を変更しても、元のvalueの値は変わりません。これは値渡しが行われているためです。
参照渡し (Pass by Reference)
参照渡しは、変数の値を渡すのではなく、変数そのものを渡して変数を共有する方式です。変数を渡す手段として「変数への参照」(つまり、変数のアドレス)を渡すので「参照渡し」と呼ばれます。
実引数と仮引数が同一の変数として扱われ、仮引数に代入すると実引数に代入されます。
JavaScriptは参照渡しができないため、C++言語での例を示します。
#include <iostream>
void updateValue(int &val) {
val = 90;
}
int main() {
int value = 50;
updateValue(value);
std::cout << value << std::endl; // 90
}
参照の値渡し (Pass by Sharing)
JavaScriptでは、配列やオブジェクトを変数に代入すると、値そのものではなく「値への参照」(つまり、値のアドレス)を変数の値として保持します。
その変数を関数の引数に渡すと、変数の値である「値への参照」を渡します。変数の値である参照のコピーを渡すので「参照の値渡し」と呼ばれます。
変数から参照されている値(配列やオブジェクト)はコピーせずに共有します。
関数内で仮引数に代入しても実引数は変更されませんが、仮引数から参照している値(配列やオブジェクト)の内容を変更すると呼出し元の値の内容が変更されます。
こちらが「参照渡し」として誤記されることがよくありますが、JavaScriptには参照渡し(変数共有)は存在しません。
こちらの誤解について気付かせていただいた以下の記事に感謝します
function updateArray(arr: number[]) {
arr.push(99);
}
let array = [1, 2, 3];
updateArray(array);
console.log(array); // [1, 2, 3, 99]
上記の例では、updateArray
関数内でarrに新たな要素を追加すると、元のarrayも更新されます。これは参照の値渡しが行われて配列データが共有されているためです。
プリミティブ型とオブジェクト型
JavaScriptやTypeScriptでは、値の種類に応じてプリミティブ型とオブジェクト型の2つの大きなカテゴリに分類します。プリミティブ型は値そのものを表現し、オブジェクト型は複数の値や構造を表現します。それぞれについて詳しく見ていきましょう。
プリミティブ型
プリミティブ型には以下の7つがあります。
- number: すべての数値を表現します。例:let num: number = 123;
- string: テキストデータを表現します。例:let str: string = "Hello, World!";
- boolean: 真偽値を表現します。例:let isTrue: boolean = true;
- null: 値が存在しないことを表現します。例:let empty: null = null;
- undefined: 値が未定義であることを表現します。例:let notDefined: undefined = undefined;
- symbol: ユニークで不変の値を作成します。例:let sym: symbol = Symbol("key");
- bigint: 非常に大きな整数値を表現します。例:let big: bigint = 1234567890123456789012345678901234567890n;
これらの型は、原始的な値を表現するためのもので、これらの値は不変です。つまり、一度作成されたプリミティブ型の値は変更することができません。
オブジェクト型
オブジェクト型はプリミティブ型とは対照的に、複数の値をまとめて表現することができます。また、オブジェクト型の値は可変で、その内容を変更することが可能です。以下に主なオブジェクト型を挙げます。
- Object: キーと値のペアの集合を表現します。例:let obj: object = {key: "value"};
- Array: 順序付けられた要素の集合を表現します。例:let arr: number[] = [1, 2, 3];
- Tuple: 固定数の要素の型が既知の配列を表現します。例:let tup: [string, number] = ["hello", 10];
- Enum: 列挙型を表現します。例:enum Color {Red, Green, Blue}; let c: Color = Color.Green;
- Function: 関数を表現します。例:let add: (x: number, y: number) => number = function(x: number, y: number): number { return x + y; };
- Class: クラスを表現します。例:class Example { constructor(public name: string) {} }
- Interface: 構造を表現します。例:interface Example { name: string; }
これらの型は、TypeScriptで使用できる主な型です。
オブジェクト型の値のコピー
オブジェクト型の値をコピーするときには、シャロー(浅い)コピーとディープ(深い)コピーの2つの方法があります。それぞれの特徴と使用例について説明します。
シャローコピー (Shallow Copy)
シャローコピーは、オブジェクトの最上位のプロパティのみをコピーする方式です。この方式では、ネストされたオブジェクトはコピーせずに共有します。
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = { ...obj1 };
obj2.a = 9;
obj2.b.c = 99;
console.log(obj1.a); // 1
console.log(obj1.b.c); // 99
上記の例では、obj2.aを変更してもobj1は影響を受けませんが、obj2.b.cを変更すると、obj1は影響を受けます。これはシャローコピーが行われ、ネストされたオブジェクトは共有されているためです。
ディープコピー (Deep Copy)
ディープコピーは、オブジェクトのすべてのレベルのプロパティを新たにコピーする方式です。この方式では、ネストされたオブジェクトも新たにコピーされます。そのため、コピー元とコピー先は完全に独立したオブジェクトになります。
let obj1 = { a: 1, b: { c: 2 } };
let obj2 = JSON.parse(JSON.stringify(obj1));
obj2.b.c = 99;
console.log(obj1.b.c); // 2
上記の例では、obj2.b.cを変更しても、obj1は影響を受けません。これはディープコピーが行われ、ネストされたオブジェクトもコピーされているためです。ただし、この方法はJSONでシリアライズ可能なオブジェクトのみに適用可能であり、関数や循環参照を含むオブジェクトには使用できません。
実例
では、実際にオブジェクト型を使って、オブジェクトをそのまま渡して共有する場合とコピーしてから渡した場合の違いをみてみましょう。まずはそのまま渡して共有する例から始めます。
オブジェクトをそのまま渡すケース
function updateObject(obj: { [key: string]: any }) {
obj.key = "updated";
}
let object = { key: "original" };
updateObject(object);
console.log(object); // { key: "updated" }
上記の例では、updateObject関数内でobjのプロパティを変更すると、元のobjectも更新されます。これは参照の値渡しが行われてオブジェクトが共有されているためです。
シャローコピーしてから渡すケース
function updateObject(obj: { [key: string]: any }) {
obj.key = "updated";
}
let object = { key: "original" };
updateObject({ ...object }); // オブジェクトをシャローコピーしてから関数に渡す
console.log(object); // { key: "original" }
上記の例では、updateObject関数内でobjのプロパティを変更しても、元のobjectは更新されません。これは、関数に渡す前にオブジェクトをコピーしているためです。
コピーを使って更新を行うことで、元のオブジェクトの状態が保持されます。これは特にイミュータビリティ(不変性)を重視するプログラミングスタイル、たとえばReactのステート管理やReduxなどで重要となります。イミュータビリティを維持することで、データの状態変化を予測しやすくし、バグを減らすことが可能となります。
しかし、注意点として、先程説明したようにオブジェクトのシャローコピーではネストされたオブジェクトは共有されるため、ネストされたオブジェクトの状態を変更すると呼び出し元に影響します。
function updateNestedObject(obj: { [key: string]: any }) {
obj.nested.key = "updated";
}
let object = { nested: { key: "original" } };
updateNestedObject({ ...object }); // オブジェクトをシャローコピーしてから関数に渡す
console.log(object); // { nested: { key: "updated" } }
したがって、ネストされたオブジェクトを含む場合は、各レベルでシャローコピーを行うか、ディープコピーを行う必要があります。
ディープコピーしてから渡すケース
let object = { nested: { key: "original" } };
let copiedObject = { ...object, nested: { ...object.nested } };
updateNestedObject(copiedObject);
console.log(object); // { nested: { key: "original" } }
この例では、ネストされたオブジェクトを含むオブジェクトの各レベルでシャローコピーを行っています。この結果、updateNestedObject
関数内での更新が元のobjectに影響を与えません。
以上が、TypeScriptにおけるオブジェクトをそのまま渡して共有したときとコピーしてから渡したときの違いの説明です。オブジェクトの挙動には注意が必要で、特にネストされたオブジェクトが関与する場合、どのようにコピーを行うかが重要となります。
ついでに、ReactのStateについて補足いたします。
ReactでのState管理
ReactでのState管理では、原則としてStateはイミュータブル(不変)であるべきです。つまり、Stateを直接変更せず、新たなStateオブジェクトを生成して置き換えるという形を取ります。これは、ReactがStateの変更を検知して再レンダリングを行うための基本的な原則です。
以下にReactのクラスコンポーネントと関数コンポーネントでの例を示します。
クラスコンポーネント
クラスコンポーネントでは、setStateメソッドを使ってStateの更新を行います。このメソッドは新しいStateオブジェクトを引数に取り、既存のStateオブジェクトを新しいものでマージします。新しいStateオブジェクトは既存のStateをシャローコピーして生成されるべきです。
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
items: ['item1', 'item2', 'item3']
};
}
handleClick = () => {
this.setState(prevState => ({ items: [...prevState.items, 'item4'] }));
}
render() {
return (
<div>
<button onClick={this.handleClick}>Add Item</button>
{this.state.items.map((item, index) => <p key={index}>{item}</p>)}
</div>
);
}
}
関数コンポーネント
関数コンポーネントでは、useStateフックを使ってStateの更新を行います。このフックは現在のStateとその更新関数をペアで返します。更新関数を使う際には、新しいStateオブジェクトを引数に渡すようにします。
import React, { useState } from 'react';
const MyComponent = () => {
const [items, setItems] = useState(['item1', 'item2', 'item3']);
const handleClick = () => {
setItems(prevItems => [...prevItems, 'item4']);
};
return (
<div>
<button onClick={handleClick}>Add Item</button>
{items.map((item, index) => <p key={index}>{item}</p>)}
</div>
);
};
これらの例では、新たなアイテムを追加する際に既存のアイテムリストを直接変更せず、新たなリストを生成しています。これにより、ReactはStateの更新を確実に検知し、再レンダリングを正しく行います。