LoginSignup
0

More than 1 year has passed since last update.

JavaScriptのObjectのDeep copyメソッドを書いてみた

Last updated at Posted at 2022-07-15

初めに

今回はJavaScriptのinheritanceについて part2から考えた、オブジェクトのディープコピーメソッドの記録です。

まだ予想外もの(undefined, null)の排除の仕方は考えていませんが、これから思い付くときまた更新します。
今回はオブジェクトの中、プリミティブ、配列、関数、オブジェクトが乱立しているのを想定して、ディープコピーをどうやっていくか、試してみたいと思います。

まずはJavaScriptで、Shallow copyDeep copyの違いの説明から入りたいと思います。

Shallow copy vs. Deep copy

  • Shallow copy:浅いコピー。コピーと本体が互いを影響しあうコピー方法
  • Deep copy:深いコピー。コピーと本体がそれぞれ独立しているというコピー方法。

でもそもそもなぜコピーなのに、片方の操作でもう片方も中身が影響されるのです?

JavaScriptではpass by value(call by value) という形式で値をアクセスするんです。
プリミティブのassignやaccessは、それぞれの値を直接にアクセスすればいいのですが、
オブジェクトのassignやaccessは、Reference(参照先)を参考してアクセスするから、
複数以上の変数にこのReference(参照先)という値を保存することができます。

しかし同じ参照先での中身の操作が、ほかに同じ参照先を保存した変数にも必ず影響する。
なのでこのReference(参照先)のアクセスの仕方をpass by reference とよく思われているのですが、実際はJavaScriptではpass by valueだけ、そしてプリミティブのアクセスと区別するためにpass by sharing、参照位置を共有するという言い方のほうが適切です。

let obj = {
  a: 1,
  b: 'abc'
}

let obj1 = obj
let obj2 = obj

delete obj.a
delete obj2.b
obj1.c = 5
obj2.d = 'efg'

console.log(obj1) // { c: 5, d: 'efg' }
console.log(obj2) // { c: 5, d: 'efg' }
console.log(obj) // { c: 5, d: 'efg' }

passbyvalue1.png
上のようにobj1obj2は、objに保存している値(Ref: 0x01、つまり参照先の位置)をコピーして自分のところに入れただけなので、objobj1obj2のどちらからか同じ参照先の中身の変更がすべて自身へ返ります。

でも、もしobjがほかの参照先へ変更したらobj1obj2も一緒に変わるのでしょうか?

obj = {
  f: function () { }
}

console.log(obj1) // { c: 5, d: 'efg' }
console.log(obj2) // { c: 5, d: 'efg' }
console.log(obj) // { f: [Function: f] }

passbyvalue2.png
そもそもobj1obj2objという変数を保存するのではなく、objの値(参照位置)をコピーして保存したわけだから、別の値を受け入れたobjはもとの参照位置から離脱し、obj1obj2に影響しません。

同じ参照位置がほしいなら、reassignするしかありません。

obj2 = obj
console.log(obj2) // { f: [Function: f] }

passbyvalue3.png

上の説明のようにShallow copyは、同じ参照位置を値として別の変数に付与、参照位置をシェアしたわけだから、どちらからでも中身に操作できるけど、どちらも変化をうけます。
それに対してDeep copyは中身を完全に複製し、新しい参照位置で保存するということです。

Method

このメソッドの作成は、JavaScriptのinheritanceについて part2でデモした関数を参考しました。

const add = function (a, b, c) {
  this.a = a
  this.b = b
  this.c = c
  this.total = function () {
    return this.a + this.b + this.c
  }
}

const greet = function (something) {
  this.name = something

  this.greeting = function () {
    this.a = '123'
    return `Hello ${this.name}`
  }
}

const obj = {
  a: 1,
  b: '2',
  c: [0, 1, 2],
  add: add,
  innerMethod: {
    greet: greet,
    g: [3, 4, 5]
  }
}

今回デモするコードは、objのようにプリミティブ、配列、関数、そして関数と配列の入ったオブジェクトがぐちゃぐちゃになった状態を想定しています。

普通のオブジェクト(ネスト無し)ならObject.assign()でちゃちゃっとすぐできるのですが、ネストした状態のオブジェクトは内部がShallow copyになるので、やっぱり自分で考えて作りましょうと。

そして、関数の設定は一般の関数ではなくコンストラクタ関数です。
どこのスコープでも通用する関数ならオブジェクト内部ではなくグローバルに置くのが合理的ではないかと、コンストラクタ関数にしました。

function copyObject(obj) {
  let copy = {}
  const keys = Object.keys(obj)
  const objLength = keys.length

  for (let i = 0; i < objLength; i++) {

    if (Array.isArray(obj[keys[i]])) {

      copy[keys[i]] = [...obj[keys[i]]]

    } else if (typeof obj[keys[i]] === 'function') {

      copy[keys[i]] = new obj[keys[i]]

    } else if (typeof obj[keys[i]] === 'object' && obj[keys[i]] !== 'null') {

      copy[keys[i]] = copyObject(obj[keys[i]])

    } else {

      copy[keys[i]] = obj[keys[i]]

    }
  }
  return copy
}

Object.keys()はオブジェクトのkeyを摘出し、配列で返すメソッドです。オブジェクトは必ずproperty key: property value配置だから、keyの摘出から長さも分かったうえ、forループで走査します。
配列ならDestructuring assignment(分割代入)、関数ならnewでインスタンスを創る、オブジェクトなら再帰処理させる。オブジェクトの条件は三番目に置いたから配列の判定と被らない。

以下は検証コードです。

const copies = copyObject(obj)
console.log(copies)
// {
//   a: 1,
//   b: '2',
//   c: [ 0, 1, 2 ],
//   add: add {
//     a: undefined,
//     b: undefined,
//     c: undefined,
//     total: [Function (anonymous)]
//   },
//   innerMethod: {
//     greet: greet {
//       name: undefined,
//       greeting: [Function (anonymous)]
//     },
//     g: [ 3, 4, 5 ]
//   }

copies.addcopies.innerMethod.greetもインスタンスだけど、値が付与されないままインスタンス化したから、返したthisオブジェクトの値はundefinedですね。

copies.acopies.bはプリミティブだからスキップ、少しcopies.cの配列を弄ってみたら

copies.c.push(3)

console.log(copies.c) // [ 0, 1, 2, 3 ]
console.log(obj.c) // [ 0, 1, 2, 3 ]

大丈夫そうです。

次はcopies.add

copies.add.a = 1
copies.add.b = 2
copies.add.c = 3
console.log(copies.add.total()) // 6
console.log(copies.add instanceof obj.add) // true
console.log(copies.add)
// add { a: 1, b: 2, c: 3, total: [Function (anonymous)] }

thisnewのおかげでcopies.addはオブジェクトになったのでcopies.add()はできないけど、内部のcopies.add.total()関数がちゃんと動けます。

そして肝心なcopies.innerMethodオブジェクトはどうなったのでしょう。

copies.innerMethod.greet.name = 'Amy'
console.log(copies.innerMethod.greet.greeting()) // Hello Amy
console.log(copies.innerMethod.greet instanceof obj.innerMethod.greet) // true
console.log(copies.innerMethod.greet)
// greet {
//   name: 'Amy',
//   greeting: [Function (anonymous)],
//   a: '123'
// }

うまくいったみたいですね。↓はobjのログです。

console.log(obj)
// {
//   a: 1,
//   b: '2',
//   c: [ 0, 1, 2 ],
//   add: [Function: add],
//   innerMethod: { greet: [Function: greet], g: [ 3, 4, 5 ] }
// }

ここからはちょっと気になるところも検証してみました。
まずはcopies.innerMethod.greet.greetingに新しい関数をreassign。

copies.innerMethod.greet.greeting = function () {
  this.a = '456'
  return `Hello ${this.name}`
}
console.log(copies.innerMethod.greet.greeting) // [Function (anonymous)]

ログしても中身が分からないですね。
インスタンス創ってみたら、

const newGreeting = new copies.innerMethod.greet.greeting()

console.log(newGreeting instanceof copies.innerMethod.greet.greeting) // true
console.log(newGreeting) // { a: '456' }
console.log(newGreeting.a) // 456

インスタンスの内部にある関数でもインスタンスにでき、新しいthisオブジェクトを返してくれました。
newインスタンスは、ある意味で複製された関数をオブジェクト化にして、thisを通してオブジェクトの中身を構成していくのではないかと。

Update

最近の参考文章のなかからとても簡潔な書き方を発見したので、ここに一緒にまとめようと思います。

元の例では配列のディープコピーはオブジェクトになってしまうので少し手を加えて変更しました。

const shallowClone = (obj) => {
  const type = Object.prototype.toString
  if (type.call(obj).includes('Array')) {
    return [...obj]
  }
  return Object.assign({}, obj);
};

function deepClone(obj) {
  const newObj = shallowClone(obj);

  Object.keys(newObj)
    .filter((key) => typeof newObj[key] === 'object')
    .forEach((key) => {
      newObj[key] = deepClone(newObj[key])
    });
  return newObj;
}

const obj = {
  level: 1,
  nest: {
    level: 2,
    arr: ['a', 'b', 'c'],
    myMethod() {
      console.log('my method')
    }
  }
};
const cloneObj = deepClone(obj);
console.log(cloneObj);
// {
//   level: 1,
//   nest: { level: 2, arr: [ 'a', 'b', 'c' ], myMethod: [Function: myMethod] }
// }
console.log(cloneObj.nest === obj.nest); // false

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0