【過去記事】【JavaScript】オブジェクトをDeepCopyするclone関数を書いた

*この記事は古いです。新しい投稿を参照してください。

JavaScriptのオブジェクトは参照渡しなので複製したい時は自分で同じオブジェクトを作らないといけない。そこでオブジェクトをDeepCopyするclone関数を作ってみた。

var clone = (function() {
	function createMemo() {
		return {
			'Object': [],
			'Array' : [],
			'Function': [],
			'Error': [],
			'Date': [],
			'RegExp': [],
			'Boolean': [],
			'String': [],
			'Number': [],
		};
	}
	//循環参照対策のため、すべてオブジェクトをmemoに保存;
	var memo = createMemo();
	//main関数 第一引数はcloneしたいobject 第二引数はcloneしたくないobjectのconstructorを配列で指定する;
	function clone(object, prototypes) {
		//プリミティブ型はそのまま返す;
		if(object === null || (typeof object !== 'object' && typeof object !== 'function')) {
			return object;
		}
		//cloneしたくないobjectであれば、参照で返す;
		if(typeOf(prototypes) === 'Array'){
			for(var i = 0, len = prototypes.length; i < len; i++) {
				if(Object.getPrototypeOf(object) === prototypes[i]) {
					return object;
				}
			}
		}
		//Nodeオブジェクトは自作関数cloneNodeに処理を任せる;
		if(object instanceof Node){
			return cloneNode(object);
		}
		//objectの型とcloneObjの型を同一にする;
		var cloneObj;
		var type = typeOf(object);
		switch(type) {
			case 'Object':
				//自作クラスはprototype継承される
				cloneObj = Object.create(Object.getPrototypeOf(object));
				break;
			case 'Array':
				cloneObj = [];
				break;
			case 'Function':
				//ネイティブ関数オブジェクトはcloneできないので、そのまま参照で返す;
				try {
					eval("cloneObj = " + object.toString());
				}catch(e) {
					return object;
				}
				break;
			case 'Error':
				cloneObj = new Object.getPrototypeOf(object).constructor();
			case 'Date':
				cloneObj = new Date(object.valueOf());
				break;
			case 'RegExp':
				cloneObj = new RegExp(object.valueOf());
				break;
			case 'Boolean':
			case 'String':
			case 'Number':
				cloneObj = new Object(object.valueOf());
				break;
			default:
				//ここで列挙されていない型は対応していないので、参照で返す;
				return object;
		}
		//循環参照対策 objectが既にmemoに保存されていれば内部参照なので、値渡しではなくcloneObjに参照先を切り替えたobjectを返す;
		for(var i = 0, len = memo[type].length; i < len; i++) {
			if(memo[type][i][0] === object) {
				return memo[type][i][1];
			}
		}
		//循環参照対策 objectはcloneObjとセットでmemoに追加;
		memo[type].push([object, cloneObj]);

		//objectのすべてのプロパティを再帰的にcloneする;
		var properties = Object.getOwnPropertyNames(object);
		for(var i = 0, len = properties.length; i < len; i++) {
			var prop = properties[i];
			cloneObj[prop] = clone(object[prop], prototypes);
		}
		//cloneしたオブジェクトを返す;
		return cloneObj;
	}
	function typeOf(operand) {
		return Object.prototype.toString.call(operand).slice(8, -1);
	}
	function cloneNode(node) {
		//script要素は再評価するためにcloneScriptでcloneする;
		if(node.tagName === 'SCRIPT') {
			return cloneScript(node);
		}
		//cloneNodeで要素をcloneする;
		var clone = node.cloneNode();
		//子要素があれば再帰的に追加;
		if(node.firstChild) {
			var childNodes = node.childNodes;
			for(var i = 0, len = childNodes.length; i < len; i++) {
				clone.appendChild(cloneNode(childNodes[i]));
			}
		}
		return clone;
	}
	function cloneScript(element) {
		var script = document.createElement('script');
		var attrs = element.attributes;
		for(var i = 0, len = attrs.length; i < len; i++) {
			var attr = attrs[i];
			script.setAttribute(attr.name, attr.value);
		}
		script.innerHTML = element.innerHTML;
		return script;
	}

	return function(object, prototypes) {
		memo = createMemo();
		return clone(object, prototypes);
	}
})();

使い方

clone(object, prototypes);

第一引数「object」はコピーしたいオブジェクトを指定する。第二引数「prototypes」は、コピーせずに参照で渡したいオブジェクトのprototype(Object.getPrototypeOf(object)で得られるprototype)を配列で指定する。例えば[Function.prototype, Array.prototype]と指定すると関数オブジェクトと配列はコピーされず、参照で渡されるようになる。ここにユーザーが作ったクラス(コンストラクタ)を指定することで、そのクラスから派生したインスタンスはコピーされずそのまま参照で渡されるようになる。後に詳しく説明する、

対応しているオブジェクト

clone関数が対応しているオブジェクトを以下に書いていく。オブジェクトの種類は基本的にObject.prototype.toString()で判別している。

  • Object

    ユーザーが「new Object()」や「{}」リテラルで作成したObjectや自作クラスからnewでインスタンス化したオブジェクト、Object.create()で継承したオブジェクト全てに対応している。

    			var a = {a: {}, b: [{}]};
    			var b = clone(a);
    
    			console.log(a.a === b.a); //false;
    			console.log(a.b === b.b); //false;
    			console.log(a.b[0] === b.b[0]); //false;
    			
  • Array

    Arrayオブジェクトも同様。ユーザーが作ったArrayオブジェクトはコピーされる。

    			var a = [[{}], {a: []}];
    			a.a = {};
    			var b = clone(a);
    
    			console.log(a[0] === b[0]); //false;
    			console.log(a[0][0] === b[0][0]); //false;
    			console.log(a[1] === b[1]); //false;
    			console.log(a[1].a === b[1].a); //false;
    			console.log(a.a === b.a); //false;
    			
  • Function

    ユーザーが作成したfunctionオブジェクトはコピーされる。

    			var a = function() {};
    			a.a = {a: function f() {}};
    			var b = clone(a);
    
    			console.log(a === b); //false;
    			console.log(a.a === b.a); //false;
    			console.log(a.a.a === b.a.a); //false;
    			

    ビルトイン関数やビルトインメソッドはコピーされず参照で渡される。

  • Error, Date, RegExp, Boolean, String, Number

    いずれもコピーされる

  • Node

    NodeはcloneNodeを使ってコピーされる。しかし、通常のcloneNode(true)ではNodeを入れ替えた時にscript要素が実行されないのでscript要素はcloneScriptを使ってコピーしている。つまり、cloneされたNodeを代入するとscript要素内のスクリプトも再実行される。

対応していないオブジェクト

基本的に対応しているオブジェクト以外はコピーされず参照で渡される。Math、JSON、arguments、Event、screen、navigator、window、「Node以外のブラウザオブジェクト」などなどは参照で渡される。

また、ビルトイン関数、標準で用意されているメソッドも参照で渡される。setTimeout、eval、Array.prototype.sliceなどは参照で渡される。

その他

  • 循環参照、内部参照

    循環参照や内部参照はcloneで作成した新たなオブジェクトでの循環参照や内部参照に切り替えられる。循環参照のオブジェクトが渡されてもスタックオーバーフローはしない。

    			var a = {};
    			a.a = a;
    			var b = clone(a); //スタックオーバーフローしない;
    
    			console.log(a === b) //false;
    			console.log(a.a === b.a) //false; →循環参照先が適切に切り替えられる;
    			console.log(b === b.a) //true → b内で循環参照している;
    			

    実は循環参照対策のためにcloneに渡されるコピーされるオブジェクトは毎度メモされて一々線形探索で既に出てきたオブジェクトではないか確かめている。そのため、循環参照関係なしに深ければ深いオブジェクト程時間がかかる。コピーされるオブジェクトの個数をnとすると最悪計算量はO(n^2)、最悪の計算式は「1/2(n^2-n)」である。深ければ深いほど時間がかかってしまう。

  • 自作クラス(コンストラクタ)などからprototype継承しているインスタンスオブジェクト

    コピーされる。コピーされるクローンオブジェクトはコピー元のインスタンスオブジェクト同様、同じ親からprototype継承をする。正確に言えば、Object.getPrototypeOf()でコピー元のオブジェクトのprototypeを取得し、それを継承する。また、Object.getOwnPropertyNamesで列挙属性関係なく自分で持つプロパティはすべてコピーされる。

    			//newでインスタンスを作る例;
    			var Class = function() {};
    			var instance = new Class();
    			instance.prop = 1;
    			var cloneIns = clone(instance);
    
    			console.log(cloneIns === instance); //false → インスタンスは別;
    			console.log(cloneIns.__proto__ === instance.__proto__); //true → 両方共Class.prototypeを参照している;
    			console.log(cloneIns.prop); //1 → インスタンスプロパティは通常どおりコピーされる;
    
    			//Object.createでインスタンスを作る例;
    			var Person = {};
    			var child = Object.create(Person);
    			child.age = 12;
    			var brother = clone(child);
    
    			console.log(brother === child); //false;
    			console.log(brother.__proto__ === child.__proto__); //true → 両方共Personを参照している;
    			console.log(brother.age); //12 → インスタンスプロパティは通常どおりコピーされる;
    			

    以上のようにほぼ同じインスタンスが作成される。ほぼと書いたのはコンストラクタの隠蔽されている変数にはアクセスできないからで全く同じインスタンスが作成されるわけではないのだ。もしインスタンスをコピーせずに参照で渡したいのであれば、使い方のセクションにも書いたように、cloneの第二引数に配列で継承しているprototypeを指定する。

    			var Class = function() {};
    			var instance = new Class();
    
    			var cloneIns = clone(instance, [Class.prototype]);
    			console.log(cloneIns === instance); //true → コピーされず参照で渡された;
    			

    もちろん自作クラスのprototypeだけでなく、ビルドインオブジェクトのprototypeも指定できる。例えば、Function.ptorotypeを配列で指定した場合、functionオブジェクトは参照で渡されるようになる。Array.prototypeを指定した場合、new Array()や[]リテラルで作成したオブジェクトはコピーされず参照で渡されるようになる。

    			var a = {a: function() {}, b: [], c: {}};
    			var b = clone(a, [Function.prototype, Array.prototype]);
    
    			console.log(b.a === a.a); //true → 参照で渡された;
    			console.log(b.b === a.b); //true → 参照で渡された;
    			console.log(b.c === a.c); //false → 第二引数で指定していないObjectオブジェクトはコピーされた;
    			

    この第二引数は、主に自作クラスのインスタンスを個別で扱うために用意したが、Functionオブジェクトなど大抵の場合コピーする必要性があまりないオブジェクトも指定できるようになっている。

既に調べれば色々とライブラリがあるけど勉強がてら作ってみた。再帰に対する苦手意識が少し緩和された。基本的にJSON.parse(JSON.stringify(object))が可能であればそれでコピーした方がよさそうだ。このclone関数の特徴は自作クラスのprototype継承をしているオブジェクトを継承含め真似る点、Functionオブジェクトまでコピーできる点と、script要素を含んだNodeもcloneできるという点と循環参照対策されている点。もし、バグがあれば教えて下さい。