【JS応用】配列はコピーされる?

配列の参照とコピー:JavaScriptにおけるメモリ管理の真実

JavaScriptにおいて、配列やオブジェクトなどの「参照型(Reference Type)」を扱う際、多くの開発者が「変数を代入したときにデータがコピーされた」と誤解してバグを生じさせています。フロントエンド開発において、状態管理(State Management)の不整合や、予期せぬ副作用(Side Effects)の原因の多くは、この「参照による代入」の仕組みを正しく理解していないことに起因します。本稿では、JavaScriptのメモリモデルを紐解き、配列のコピーに関する深い技術的理解と、実務で推奨されるベストプラクティスを解説します。

なぜ配列は「コピー」されないのか:参照の仕組み

JavaScriptの変数は、大きく分けて「プリミティブ型(数値、文字列、真偽値など)」と「参照型(オブジェクト、配列、関数など)」の2つに分類されます。

プリミティブ型の場合、変数は値そのものを保持しています。例えば `let a = 1; let b = a;` としたとき、`b` には `1` という値が直接コピーされます。しかし、配列はメモリ上の特定の場所に格納されており、変数にはその「メモリ上の場所(アドレス)」へのポインタ、すなわち「参照」が格納されています。

配列を別の変数に代入するという行為は、配列の中身を複製しているのではなく、単に「同じメモリ上の場所を指し示すポインタをコピーしている」に過ぎません。その結果、どちらの変数から配列を操作しても、同じ実体に影響が及ぶことになります。これが、「配列はコピーされない」という現象の正体です。

シャローコピー(浅いコピー)の限界と実装方法

前述の通り、代入演算子(=)では参照が共有されるだけですが、実務では配列を「複製」したい場面が多々あります。そこで用いられるのが「シャローコピー」です。シャローコピーとは、配列の直下にある要素は新しいメモリ領域にコピーされるものの、配列の中にさらにオブジェクトや配列が含まれている場合、その内部の参照先までは複製されないコピー手法を指します。

代表的なシャローコピーの手法には以下のものがあります。

// スプレッド構文を用いたコピー
const original = [1, 2, { id: 3 }];
const copy = [...original];

// Array.fromを用いたコピー
const copy2 = Array.from(original);

// sliceメソッドを用いたコピー
const copy3 = original.slice();

// 検証
copy[0] = 99;
console.log(original[0]); // 1 (値は独立している)
copy[2].id = 999;
console.log(original[2].id); // 999 (内部のオブジェクトは参照が共有されたまま)

このコードが示す通り、シャローコピーは配列の第1階層を新しいメモリ領域に確保しますが、ネストされたオブジェクトについては依然として参照が共有されます。これを「不完全なコピー」と捉えるか、「パフォーマンスとのトレードオフ」と捉えるかが、フロントエンドエンジニアとしての腕の見せ所となります。

ディープコピー(深いコピー)の必要性と注意点

ネストされた構造まで完全に複製したい場合、ディープコピーを行う必要があります。しかし、ディープコピーはパフォーマンスコストが大きく、また循環参照(オブジェクトが自分自身を指している状態)がある場合にスタックオーバーフローを引き起こすリスクがあります。

現代のJavaScriptでは、ブラウザ標準の `structuredClone` 関数を使用するのが最も推奨される手法です。

const original = [1, 2, { id: 3 }];
const deepCopy = structuredClone(original);

deepCopy[2].id = 999;
console.log(original[2].id); // 3 (完全に独立したコピーが作成された)

`structuredClone` は、JSON形式では表現できない `Date` オブジェクトや `Map`、`Set`、`RegExp` なども扱えるため、従来の `JSON.parse(JSON.stringify(array))` というハック的な手法よりも遥かに安全で強力です。

実務における「不変性(Immutability)」の重要性

フロントエンド開発、特にReactやVue.jsなどのUIライブラリを使用する環境において、「配列を直接書き換える(ミューテーション)」ことは厳禁です。

例えば、Reactで状態を更新する際、`state.push(newItem)` のように配列を直接操作しても、Reactは状態が変化したことを検知できません。これは、参照先のアドレスが変わっていないためです。UIライブラリは、新しい参照(新しい配列インスタンス)が渡されたときに初めて「更新」とみなして再レンダリングを行います。

実務では、以下のように「元の配列を変更せずに新しい配列を返す」書き方を徹底する必要があります。

// 悪い例:副作用を伴う
const addItem = (list, item) => {
  list.push(item); // 外部の状態を汚染する
  return list;
};

// 良い例:不変性を守る
const addItem = (list, item) => {
  return [...list, item]; // 新しい配列を生成して返す
};

// 良い例:特定の要素を更新する
const updateItem = (list, id, newValue) => {
  return list.map(item => item.id === id ? { ...item, ...newValue } : item);
};

このように、`map`、`filter`、`reduce` といったイミュータブルなメソッドを積極的に活用することで、デバッグの難易度が劇的に下がり、予測可能なコードベースを構築できます。

パフォーマンスとメモリ管理のトレードオフ

最後に、パフォーマンスの観点について触れておきます。常に新しい配列を作成(コピー)することは、メモリ消費量を増大させ、ガベージコレクション(GC)の負荷を高める可能性があります。数万件以上の要素を持つ巨大な配列を頻繁にコピーするのは避けるべきです。

もし、巨大なデータ構造の変更を頻繁に行う必要がある場合は、`Immer` のようなライブラリを利用するか、あるいはデータ構造そのものを見直す(例えば、配列ではなくMapやSetを利用する、あるいは正規化する)ことを検討してください。

結論として、以下の原則を守ることがフロントエンドエンジニアとしての最適解です。

1. 基本的に、すべての配列操作は「非破壊的(Non-destructive)」に行う。
2. 参照のコピーと値のコピーの違いを常に意識する。
3. ネストされた構造がある場合は、`structuredClone` を活用する。
4. UIの再レンダリングをトリガーするためには、常に新しい配列インスタンスを生成する。

この「配列のコピー」という概念は一見単純ですが、その裏側にあるメモリ管理を理解することは、複雑なフロントエンドアプリケーションを堅牢に保つための基盤となります。この記事が、あなたのエンジニアリングにおける「予期せぬバグ」を減らす一助となれば幸いです。

コメント

タイトルとURLをコピーしました