说说JS中的浅拷贝与深拷贝
outline:
- 为什么要说JS中深拷贝与浅拷贝
- JS对类型的分类
- immutable与mutable
- 简单类型检测
- 浅拷贝VS深拷贝
为什么要说JS中深拷贝与浅拷贝
近来在研读underscore
的源码,发现其中一小段代码1
2
3
4
5
6
7
8
9
10
11
12_.mixin = function(obj) {
_.each(_.functions(obj), function(name) {
var func = _[name] = obj[name];
_.prototype[name] = function() {
var args = [this._wrapped];
push.apply(args, arguments);
return result(this, func.apply(_, args));
};
});
};
....
_.mixin(_);
这段代码就是要把我们在_
上绑的很多方法浅拷贝一份到_.prototype
.这里的浅拷贝引发一些思考.那么什么是浅拷贝、什么是深拷贝?在了解深、浅拷贝之前,我们需要了解下JS中对类型的分类,因为对于不同的类型,我们选择拷贝的方式也是不一样的.
JS对类型的分类
stackoverflow有人提了一个问题:
Stoyan Stefanov in his excellent book ‘Object-Oriented JavaScript’ says:
Any value that doesn’t belong to one of the five primitive types listed above is an object.
Stoyan Stefanov说的这句话,在JS中要么就是
primitive
类型,要们就是object
类型.Primitive
A primitive (primitive value, primitive data type) is data that is not an object and has no methods. In JavaScript, there are 6 primitive data types: string, number, boolean, null, undefined, symbol (new in ECMAScript 2015).
Most of the time, a primitive value is represented directly at the lowest level of the language implementation.
All primitives are immutable (cannot be changed).
MDN上指出了JS中的primitive类型一共就是
string
number
boolean
null
undefined
symbol(ES2015)
6中类型,其余的都是object类型.MDN还说了primitive类型not an object以及has no methods.但是我们平时的使用都是这样的var str = "hello world";console.log(str.charAt(0))
.这段代码中明显str是primitive的变量,按照MDN的说法,str变量应该是not an object
并且has no methods
的,这里我们明显调用了str.charAt
方法.是我们错了还是MDN错了!!!!那我们再测试下str是不是一个object.Object.prototype.toString.call(str)
这段代码执行的结果居然是[object String]
.就是说str不仅是object
同时还has methods
.但是str确实是primitive类型的.在MDN给出primitive type定义的同时,还给出了
Primitive wrapper objects
的定义Except for null and undefined, all primitive values have object equivalents that wrap around the primitive values:
String for the string primitive. Number for the number primitive. Boolean for the Boolean primitive. Symbol for the Symbol primitive.
The wrapper’s valueOf() method returns the primitive value.
也就是说对于这些primitive的类型,确实不是object,并且也没有methods.执行
str.charAt
的时候是把string(primitive)类型转成了String(object)类型.ES5规范中这样解释:这里虽然对于一些内部方法的调用我们并不清楚,但是基本也明确当我们在调用
str.charAt
的时候,JS执行引擎把str变成了String对象,可以执行String上的方法.了解了JS中的类型分类,我们在说一说JS中mutable
和immutable
.
immutable与mutable
在上一段我们讲了JS中的类型分类,总体来说就两类就是object和primitive,判断依据就是只有string、number、boolean、null、undefined、symbol(ES2015)才是primitive的,其余均为object的.在我们引用MDN的一段话中,还提到了All primitives are immutable (cannot be changed).
那么这句话是什么意思.所有的primitive都是immutable(不可变的)
.这句话可能大家看完很不理解.var a = 1;a = 2; a= "hello world";
,这里a就是primitive的类型,不是可以修改么,那MDN的这句All primitives are immutable
是什么意思呢.MDN的这句话其实是没错误的.碰到这种问题,查内存地址是最好的办法,可惜查内存地址难度太大,在chrome和nodejs上我都尝试了,都没有找个有一个比较直观的方式去看内存地址,如果有读者了解如何看内存可以和我联系.这里我们借用JS中的原型链来做一个小实验,也可以间接达到查看内存地址的目的.
1 | <!DOCTYPE html> |
这里id从0变为1、2就是说,我们的a赋值的过程并不是给a指向的内存赋值,而是说a重新指向了一个新的值.感谢网友白巧克力z指出下面这个案例的问题.
上面这个案例是存在问题的.问题在于JavaScript中,对于primitive的变量,如果调用方法,会临时把这个primitive的变量包装成对应的object类型.上面的测试用等价于1
2
3
4
5
6var a = 1;
console.log((new Number(a)).id());//0
a = 2;
console.log((new Number(a)).id());//1
a = "hello world";
console.log((new String(a)).id());//2
由于临时把primitive包装成object,id的值会自动增加.所以就出现1
2
3
4var a = 1;
console.log(a.id());//0 相当于console.log((new Number(a)).id())
console.log(a.id());//1 相当于console.log((new Number(a)).id())
console.log(a.id());//2 相当于console.log((new Number(a)).id())
所以说这里楼主并没有想出比较好的方法去看primitive类型的变量的内存.我就直接针对上面的这个例子上一张图把
基于此,MDN所谓的primitive是immutable的,说的是primitive类型的value是immutable的,而variable是mutable的.所以说,对于primitive类型的变量,为其赋值,本质上就是让变量指向新的内存.
那么对于object类型的变量呢.我们也来做一个实验:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24(function() {
var id = 0;
function generateId() { return id++; console.log(id)};
Object.prototype.id = function() {
var newId = generateId();
this.id = function() { return newId; };
return newId;
};
})();
var o1 = {name:'o1'};
console.log(o1.id());//0
var o2 = o1;
console.log(o2.id());//0
o2.name = "o2";
console.log('o1',o1);//o1 Object {name: "o2"}
console.log('o2',o2);//o2 Object {name: "o2"}
o2 = {name:'xx'}
console.log(o2.id())//1
console.log('o1',o1);//o1 Object {name: "o2"}
console.log('o2',o2);//o2 Object {name: "xx"}
从这个例子我们可以看出,对于Object类型的变量,直接赋值过程等于说让变量指向右值内存地址.如var o2 = o1
,o2就是指向o1指向的内存空间.但是当我们修改对象的属性的时候,就会修改原来内存中对象的属性值.如果o2.name = "o2"
会令o1.name =="o2"
.这里就会引发一个深拷贝、浅拷贝的问题.比如这里的o2 = o1就是一次浅拷贝.浅拷贝的时候,由于指向的内存地址是一样的,如果直接给对象赋值是不存在任何问题的比如var o2 = o1;o2 = {name:'xx'}
此时o1.id()返回0,o2.id()返回1.但是如果修改对象上的属性时,就会触发对象指向的内存中的对象的属性修改.
我们在来看另外一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21(function() {
var id = 0;
function generateId() { return id++; console.log(id)};
Object.prototype.id = function() {
var newId = generateId();
this.id = function() { return newId; };
return newId;
};
})();
var o1 = {name:'o1'};
console.log(o1.id());//0
var o2 = {}
o2.name = o1.name;
console.log(o2.id());//1
o2.name = "o2";
console.log('o1',o1);//o1 Object {name: "o1"}
console.log('o2',o2);//o2 Object {name: "o2"}
在这个例子中我们对于o2的赋值没有采用o2 = o1;而是采用了o2={},o2.name = o1.name.那么这样够不够.结合我么之前说的immutable和mutable,由于name对应的值是string类型的,是immutable的,所以这里我们拷贝到name是完全够的,是属于深拷贝.
看到这里,相信大家后面我们要做的深、浅拷贝可能有一定的想法了.浅拷贝就是直接赋值,或者说不完全的赋值(对于对象而言,后面我们会举例),浅拷贝对于primitive类型的或者说不会直接修改属性的对象而言比如Function是无害的,但是对于浅拷贝{k1:v1}或者说是[v1,v2]的对象,会出现严重的问题,即由于指向同一个内存对象,修改属性等于修改了所有指向该内存对象的属性.
那么下面我们就需要做类型检测,对于做深拷贝需要检测的情况很简单,如果检测出来是浅拷贝有害的,我们就做深拷贝,否则直接浅拷贝.
简单类型检测
这里我们只需要做Object和Array的类型检测,对于Function、Date等类型的我们都不是很需要.类型检测我们采用Object.prototype.toString方法1
2
3
4
5
6
7
8
9var isType = function(type){
return function(obj){
return Object.prototype.toString.call(obj) === '[object '+ type +']';
}
}
var is = {
isArray : isType('Array'),
isObject : isType('Object'),
}
有了类型检测函数,下面我们就可以开心的做深拷贝了.
浅拷贝VS深拷贝
浅拷贝我们之前也说了,这里直接举个例子,说明其危害.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44(function() {
var id = 0;
function generateId() { return id++; console.log(id)};
Object.prototype.id = function() {
var newId = generateId();
this.id = function() { return newId; };
return newId;
};
})();
var o1 = {
number: 1,
string: "I am a string",
object: {
test1: "Old value"
},
arr: [
"a string",
{
test2: "Try changing me"
}
]
};
var extend = function(result, source) {
for (var key in source)
result[key] = source[key];
return result;
}
var o2 = extend({},o1);
console.log('o1',o1.number.id());//0
console.log('o1',o1.string.id());//1
console.log('o1',o1.object.id());//2
console.log('o1',o1.arr.id());//3
console.log('o2',o2.number.id());//4
console.log('o2',o2.string.id());//5
console.log('o2',o2.object.id());//2
console.log('o2',o2.arr.id());//3
从id的值上看,o2和o1的内部属性值,number、string是采用的两个副本,但是object和arr确实采用的同一个副本.这种情况下如果我们修改o2.object = {name:’o2’}是没有问题的,由于直接复制本质上上内存指向修改的问题.但是如果我们修改o2.object.test1 = “New value”,此时o1和o2会一起变!!!这种情况是我们不想看到的.对于object、array类型的最好做深拷贝(是否深拷贝看应用场景,读者需要斟酌),结合我们上面的类型检测,我们把extend函数修改一下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73(function() {
var id = 0;
function generateId() { return id++; console.log(id)};
Object.prototype.id = function() {
var newId = generateId();
this.id = function() { return newId; };
return newId;
};
})();
var isType = function(type){
return function(obj){
return Object.prototype.toString.call(obj) === '[object '+ type +']';
}
}
var is = {
isArray : isType('Array'),
isObject : isType('Object'),
}
var o1 = {
number: 1,
string: "I am a string",
object: {
test1: "Old value"
},
arr: [
"a string",
{
test2: "Try changing me"
}
]
};
var extend = function(result, source) {
for (var key in source){
var copy = source[key];
if(is.isArray(copy)){
//Array deep copy
result[key] = extend(result[key] || [], copy);
}else if(is.isObject(copy)){
//Object deep copy
result[key] = extend(result[key] || {}, copy);
}else{
result[key] = copy;
}
}
return result;
}
var o2 = extend({},o1);
console.log('o1',o1.number.id());//0
console.log('o1',o1.string.id());//1
console.log('o1',o1.object.id());//2
console.log('o1',o1.arr.id());//3
console.log('o2',o2.number.id());//4
console.log('o2',o2.string.id());//5
console.log('o2',o2.object.id());//6
console.log('o2',o2.arr.id());//7
o2.object.test1 = "new Value";
console.log(o1,JSON.stringify(o1))//o1.object.test1 == "Old value"
console.log(o2,JSON.stringify(o2))//o2.object.test1 == "new Value"
o2.arr[1].test2 = "就不改你";
console.log(o1,JSON.stringify(o1))//o1.object.test1 == "Try changing me"
console.log(o2,JSON.stringify(o2))//o2.arr[1].test2 == "就不改你"
补充
看到评论中有人提到了这个方法1
2
3
4
5
6
7
8
9
10
11
12
13(function() {
var id = 0;
function generateId() { return id++; console.log(id)};
Object.prototype.id = function() {
var newId = generateId();
this.id = function() { return newId; };
return newId;
};
})();
这里我大概画了张图解释,首先我们要明白这个方法执行完毕,当前对象的原型链上会有两个id方法,一个是当前对象上的,一个是Object上的.
Object上的id方法调用会触发id+1,而当前对象上的id方法调用会返回出来当前的id值,也就是对象内存地址的相对值.
当对某个对象调用id方法,会顺着原型链找到Object上的id方法,执行后会给当前对象增加一个id方法.这样的才能真正看到当前对象的id相对值.