初めに
今回はJavaScriptのinheritanceについて part2から考えた、オブジェクトのディープコピーメソッドの記録です。
まだ予想外もの(undefined, null)の排除の仕方は考えていませんが、これから思い付くときまた更新します。
今回はオブジェクトの中、プリミティブ、配列、関数、オブジェクトが乱立しているのを想定して、ディープコピーをどうやっていくか、試してみたいと思います。
まずはJavaScriptで、Shallow copyとDeep 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' }
上のようにobj1
とobj2
は、obj
に保存している値(Ref: 0x01、つまり参照先の位置)をコピーして自分のところに入れただけなので、obj
かobj1
かobj2
のどちらからか同じ参照先の中身の変更がすべて自身へ返ります。
でも、もしobj
がほかの参照先へ変更したらobj1
、obj2
も一緒に変わるのでしょうか?
obj = {
f: function () { }
}
console.log(obj1) // { c: 5, d: 'efg' }
console.log(obj2) // { c: 5, d: 'efg' }
console.log(obj) // { f: [Function: f] }
そもそもobj1
もobj2
もobj
という変数を保存するのではなく、obj
の値(参照位置)をコピーして保存したわけだから、別の値を受け入れたobj
はもとの参照位置から離脱し、obj1
、obj2
に影響しません。
同じ参照位置がほしいなら、reassignするしかありません。
obj2 = obj
console.log(obj2) // { f: [Function: f] }
上の説明のように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.add
もcopies.innerMethod.greet
もインスタンスだけど、値が付与されないままインスタンス化したから、返したthis
オブジェクトの値はundefined
ですね。
copies.a
とcopies.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)] }
this
とnew
のおかげで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