MENU

JavaScriptで完全なディープコピーを実装する方法と注意点

みなさん、こんにちは!今日は「js deepcopy」についてお話しします。JavaScriptでオブジェクトを扱っていると、「あれ?なんかデータが勝手に変わっちゃった…」なんて経験ありませんか?そんなときこそ、ディープコピーの出番です。単純そうで奥が深いこのテーマ、一緒に探っていきましょう!

目次

オブジェクトのディープコピーが必要な状況と基本概念

まずは、なぜディープコピーが必要なのか、その基本から押さえていきましょう。JavaScriptでオブジェクトを扱う際、思わぬところでハマってしまうことがあります。特に、複雑なデータ構造を扱うときは要注意!ディープコピーの概念を理解することで、多くの落とし穴を避けることができるんですよ。

参照型データの特性と浅いコピーの限界を理解する

JavaScriptのオブジェクトや配列って、ちょっと特殊な動きをするんです。例えば、こんなコードを見てみましょう。

let original = { name: 'オリジナル', details: { age: 25 } };
let copy = original;
copy.name = 'コピー';
console.log(original.name); // 'コピー'が出力されちゃう!

「えっ?originalも変わっちゃった?」って感じですよね。これが参照型データの特徴なんです。copyはoriginalの「参照」をコピーしているだけで、中身は同じものを指しているんです。

じゃあ、Object.assign()を使えばいいんでしょ?って思った方、惜しい!

let betterCopy = Object.assign({}, original);
betterCopy.name = '別のコピー';
console.log(original.name); // 'コピー'のまま、OK!
console.log(betterCopy.details === original.details); // true...あれ?

これが「浅いコピー」の限界です。表面的なプロパティはコピーできてますが、ネストされたオブジェクトは依然として同じ参照を共有しちゃってるんですね。

ネストされたオブジェクトや配列を正確に複製する重要性

じゃあ、なぜネストされたオブジェクトまでちゃんとコピーする「ディープコピー」が重要なのでしょうか?

例えば、ユーザーデータを管理するアプリを作っているとしましょう。

let user = {
  name: '山田太郎',
  preferences: {
    theme: 'dark',
    notifications: {
      email: true,
      push: false
    }
  }
};

let userBackup = Object.assign({}, user);
userBackup.preferences.theme = 'light';

console.log(user.preferences.theme); // 'light'になっちゃった!

ユーザーの設定をバックアップしたつもりが、元のデータまで変更されちゃいました。これって大問題ですよね。ユーザーの大切なデータを守るためにも、ディープコピーの理解は欠かせないんです。

データの整合性を保ちつつ、安全に操作するためには、このようなネストされた構造もしっかりコピーする必要があるんです。ディープコピーを使えば、元のデータはそのままに、完全に独立したコピーを作れるんですよ。

JavaScriptでディープコピーを行う主要な手法を比較

さて、ここからが本題です。JavaScriptでディープコピーを実現する方法はいくつかあります。それぞれに一長一短があるので、状況に応じて使い分けるのがポイントですよ。簡単な方法から順に見ていきましょう。

JSON.parse()とJSON.stringify()を使用した簡単な実装方法

最も手軽なディープコピーの方法といえば、JSON.stringify()とJSON.parse()の組み合わせです。こんな感じで使います:

let original = { a: 1, b: { c: 2 } };
let deepCopy = JSON.parse(JSON.stringify(original));

deepCopy.b.c = 3;
console.log(original.b.c); // 2のまま
console.log(deepCopy.b.c); // 3

わぁ、すごく簡単ですね!でも、ちょっと待ってください。この方法には落とし穴があるんです。

  1. 関数はコピーされません。
  2. undefined、Symbol、BigIntなどの値は失われます。
  3. 循環参照を含むオブジェクトでエラーが発生します。
let obj = { a: undefined, b: function() {}, c: Symbol('test') };
let attempt = JSON.parse(JSON.stringify(obj));
console.log(attempt); // { } ...あれ?全部消えちゃった!

だから、単純なデータ構造ならこの方法でOKですが、複雑なオブジェクトには向いていないんです。でも、手軽さではピカイチですよね。

再帰関数を活用したカスタムディープコピー関数の作成手順

もっと柔軟にディープコピーを行いたい場合は、自分で関数を作るのが一番です。再帰を使うと、どんな深さのオブジェクトでもコピーできますよ。

function deepCopy(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;

  let copy = Array.isArray(obj) ? [] : {};

  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      copy[key] = deepCopy(obj[key]);
    }
  }

  return copy;
}

let complex = {
  array: [1, 2, 3],
  object: { a: 'test', b: { c: true } },
  func: function() { console.log('Hello!'); }
};

let complexCopy = deepCopy(complex);
complexCopy.object.b.c = false;
console.log(complex.object.b.c); // true
console.log(complexCopy.object.b.c); // false
complexCopy.func(); // 関数もちゃんとコピーされてる!

すごいでしょう?これなら関数もコピーできるし、どんな深いネストでも大丈夫です。でも、この方法にも注意点があります。

  1. プロトタイプチェーンは考慮していません。
  2. 循環参照があるとスタックオーバーフローの可能性があります。

完璧を求めるなら、これらの点も考慮に入れる必要があります。でも、多くの場合はこれで十分ですよ。

lodashやRamda等のライブラリが提供するディープコピー機能の活用

「自分で実装するのは大変そう…」そう思った方、安心してください。便利なライブラリがたくさんあるんです。中でも人気なのがlodashとRamdaです。

lodashを使う場合:

const _ = require('lodash');

let original = { a: 1, b: { c: 2 } };
let copy = _.cloneDeep(original);

copy.b.c = 3;
console.log(original.b.c); // 2
console.log(copy.b.c); // 3

Ramdaを使う場合:

const R = require('ramda');

let original = { a: 1, b: { c: 2 } };
let copy = R.clone(original);

copy.b.c = 3;
console.log(original.b.c); // 2
console.log(copy.b.c); // 3

これらのライブラリは非常によく最適化されていて、循環参照や特殊なオブジェクトタイプにも対応しています。大規模なプロジェクトや、複雑なデータ構造を扱う場合は、こういったライブラリの使用を検討してみるのもいいでしょう。

ただし、ライブラリを使用すると、プロジェクトの依存関係が増えたり、バンドルサイズが大きくなったりする可能性があります。小規模なプロジェクトなら、カスタム関数で十分かもしれませんね。

ディープコピーにおける注意点と最適化テクニック

ディープコピーは強力ですが、使い方を間違えると思わぬ問題を引き起こす可能性があります。ここでは、よくある落とし穴と、それを避けるためのテクニックを紹介します。パフォーマンスを意識しながら、安全にディープコピーを行う方法を学んでいきましょう。

循環参照問題の検出と解決策を実装する方法

循環参照って聞いたことありますか?オブジェクトの中に、自分自身や親オブジェクトへの参照があるケースのことです。これがあると、単純な再帰的ディープコピーでは無限ループに陥ってしまいます。

例えば、こんな感じ:

let obj = { a: 1 };
obj.self = obj;

// 以下の関数は無限ループに陥る!
function badDeepCopy(obj) {
  let copy = {};
  for (let key in obj) {
    copy[key] = typeof obj[key] === 'object' ? badDeepCopy(obj[key]) : obj[key];
  }
  return copy;
}

これを解決するには、コピー済みのオブジェクトを記録しておく必要があります。WeakMapを使うと効率的に実装できますよ:

function safeDeepCopy(obj, hash = new WeakMap()) {
  if (Object(obj) !== obj) return obj; // プリミティブはそのまま返す
  if (hash.has(obj)) return hash.get(obj); // 既にコピー済みならそれを返す

  let result;

  if (obj instanceof Date) {
    result = new Date(obj);
  } else if (obj instanceof RegExp) {
    result = new RegExp(obj.source, obj.flags);
  } else if (obj instanceof Map) {
    result = new Map(Array.from(obj, ([key, val]) => [key, safeDeepCopy(val, hash)]));
  } else if (obj instanceof Set) {
    result = new Set(Array.from(obj, val => safeDeepCopy(val, hash)));
  } else if (Array.isArray(obj)) {
    result = obj.map(val => safeDeepCopy(val, hash));
  } else {
    result = Object.create(Object.getPrototypeOf(obj));
    hash.set(obj, result);
    for (let key of Reflect.ownKeys(obj)) {
      result[key] = safeDeepCopy(obj[key], hash);
    }
  }

  hash.set(obj, result);
  return result;
}

let circular = { a: 1 };
circular.self = circular;

let copy = safeDeepCopy(circular);
console.log(copy.a); // 1
console.log(copy.self === copy); // true
console.log(copy !== circular); // true

すごいでしょう?これで循環参照も怖くありません。Date、RegExp、Map、Setなどの特殊なオブジェクトタイプにも対応していて、プロトタイプチェーンも維持されます。

でも、この方法にも注意点があります。WeakMapを使っているので、コピー中はメモリ使用量が増えます。大量のオブジェクトをコピーする場合は要注意ですね。

大規模データ構造のディープコピーにおけるパフォーマンス改善策

大規模なデータ構造をディープコピーする際、パフォーマンスが問題になることがあります。特に再帰的な方法は、スタックオーバーフローのリスクがありますよね。そこで、いくつかの最適化テクニックを紹介します。

  1. イテレーティブな実装:
    再帰の代わりにスタックを使用する方法です。スタックオーバーフローを避けられますが、コードは複雑になります。
function iterativeDeepCopy(obj) {
  const stack = [{obj: obj, copy: Array.isArray(obj) ? [] : {}}];
  const rootCopy = stack[0].copy;

  while (stack.length) {
    const {obj, copy} = stack.pop();

    for (let key in obj) {
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        copy[key] = Array.isArray(obj[key]) ? [] : {};
        stack.push({obj: obj[key], copy: copy[key]});
      } else {
        copy[key] = obj[key];
      }
    }
  }

  return rootCopy;
}
  1. 遅延評価:
    必要になるまでコピーを行わない方法です。大規模なオブジェクトの一部しか使わない場合に効果的です。
function lazyDeepCopy(obj) {
  return new Proxy(obj, {
    get(target, prop) {
      if (typeof target[prop] === 'object' && target[prop] !== null) {
        return lazyDeepCopy(target[prop]);
      }
      return target[prop];
    },
    set(target, prop, value) {
      if (typeof value === 'object' && value !== null) {
        target[prop] = lazyDeepCopy(value);
      } else {
        target[prop] =value;
      }
      return true;
    }
  });
}

let original = {a: 1, b: {c: 2}};
let lazyCopy = lazyDeepCopy(original);

lazyCopy.b.c = 3;
console.log(original.b.c); // 2
console.log(lazyCopy.b.c); // 3

この方法は、実際にアクセスされたプロパティだけをコピーするので、大規模なオブジェクトを扱う際にメモリ効率が良くなります。ただし、全ての操作がProxyを経由するので、頻繁にアクセスする場合はオーバーヘッドが大きくなる可能性があります。

  1. 構造共有:
    変更がない部分は元のオブジェクトと共有する方法です。イミュータブルなデータ構造を扱う際によく使われます。
function structuralSharing(obj) {
  return new Proxy(obj, {
    get(target, prop) {
      return target[prop];
    },
    set(target, prop, value) {
      const newObj = {...target};
      newObj[prop] = value;
      return true;
    }
  });
}

let original = {a: 1, b: {c: 2}};
let shared = structuralSharing(original);

shared.a = 100;
console.log(original.a); // 1
console.log(shared.a); // 100
console.log(shared.b === original.b); // true

この方法は、変更があった部分だけ新しいオブジェクトを作成するので、メモリ効率が良くなります。ただし、深いネストがある場合は、そのままでは対応できないので注意が必要です。

これらの方法を組み合わせたり、状況に応じて使い分けたりすることで、大規模なデータ構造でもパフォーマンスを維持しながらディープコピーを実現できます。でも、常に完全なディープコピーが必要かどうかも考えてみてくださいね。場合によっては、浅いコピーや部分的なコピーで十分なこともありますよ。

ES6+の機能を活用した最新のディープコピーアプローチ

さて、ここまでディープコピーの基本と応用テクニックを見てきましたが、最後に最新のJavaScript機能を使ったアプローチも紹介しましょう。ES6以降、オブジェクトの操作がより簡単になっています。これらの新機能を活用すると、より簡潔で読みやすいコードでディープコピーを実現できるんです。

スプレッド構文とObject.assign()を組み合わせた効率的な方法

スプレッド構文(…)とObject.assign()を使うと、シンプルながら効果的なディープコピーが可能です。ただし、この方法は1階層目までしかディープコピーされないので、より深いネストがある場合は再帰的に適用する必要があります。

function spreadDeepCopy(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;

  if (Array.isArray(obj)) {
    return obj.map(item => spreadDeepCopy(item));
  }

  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => [key, spreadDeepCopy(value)])
  );
}

let original = {a: 1, b: {c: 2, d: [3, 4]}};
let copy = spreadDeepCopy(original);

copy.b.c = 5;
copy.b.d.push(6);

console.log(original.b.c); // 2
console.log(original.b.d); // [3, 4]
console.log(copy.b.c); // 5
console.log(copy.b.d); // [3, 4, 6]

この方法の良いところは、コードが読みやすく、理解しやすいことです。Object.fromEntries()とObject.entries()を使うことで、オブジェクトのキーと値を効率的に処理できます。

ただし、この方法にも注意点があります:

  1. 特殊なオブジェクト(Date, RegExp, Map, Setなど)は正しくコピーされません。
  2. プロトタイプチェーンは維持されません。
  3. Symbol型のキーは失われます。

これらの制限に注意しながら使う必要がありますね。

構造化クローンアルゴリズムを利用した高度なディープコピー技術

最後に紹介するのは、構造化クローンアルゴリズムを利用した方法です。これはブラウザ環境でのみ使用可能ですが、非常に強力なディープコピー手法です。

function structuredClone(obj) {
  return new Promise(resolve => {
    const channel = new MessageChannel();
    channel.port1.onmessage = ev => resolve(ev.data);
    channel.port2.postMessage(obj);
  });
}

let original = {
  date: new Date(),
  regex: /test/,
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3]),
  array: [1, 2, {nested: 'object'}],
  object: {a: 1, b: {c: 2}}
};

structuredClone(original).then(copy => {
  copy.date.setFullYear(2000);
  copy.array[2].nested = 'changed';

  console.log(original.date.getFullYear()); // 現在の年
  console.log(copy.date.getFullYear()); // 2000
  console.log(original.array[2].nested); // 'object'
  console.log(copy.array[2].nested); // 'changed'

  console.log(copy.regex instanceof RegExp); // true
  console.log(copy.map instanceof Map); // true
  console.log(copy.set instanceof Set); // true
});

すごいでしょう?この方法なら、Date, RegExp, Map, Setなどの特殊なオブジェクトも正しくコピーできます。さらに、循環参照にも対応していて、エラーを起こさずにコピーできるんです。

ただし、この方法にも制限があります:

  1. 関数はコピーされません。
  2. プロトタイプチェーンは維持されません。
  3. Symbol型のキーは失われます。
  4. ブラウザ環境でしか使えません。

これらの点に注意すれば、構造化クローンアルゴリズムは非常に強力なディープコピー手法になりますよ。

さて、ここまでさまざまなディープコピーの方法を見てきましたが、どうでしたか?それぞれに長所と短所があって、一つの「完璧な」方法はないんです。大切なのは、自分のプロジェクトの要件に合わせて適切な方法を選ぶこと。小規模なプロジェクトならJSONを使う方法で十分かもしれないし、大規模で複雑なデータを扱うならライブラリの使用を検討するのも良いでしょう。

ディープコピーは一見単純そうで奥が深いテーマですが、理解して使いこなせれば、データの安全な操作に大いに役立ちます。ぜひ、実際のコードで試してみてくださいね。何か困ったことがあれば、また相談してくださいね。頑張ってコーディング、楽しんでいきましょう!

「#javascript」人気ブログランキング
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次