phenomLi / Blog

Comments, Thoughts, Conclusions, Ideas, and the progress.
219 stars 17 forks source link

JS中的深拷贝 #12

Open phenomLi opened 6 years ago

phenomLi commented 6 years ago

深拷贝(deep copy) 算是js里面比较久经不衰的话题,论坛爱讨论,面试也爱考。何为深拷贝?其实就是实现对一个引用类型的完整复制。
什么是引用类型? js中有值类型引用类型两种类型,基本类型就是像numberboolean这些。值类型在定义的时候,会在栈内存为其分配一个固定的空间。 而引用类型比较特殊,它的大小是不固定的,所以在定义引用类型的时候,解析器会在堆内存为其分配空间,然后再在栈内存分配一个指向堆内存里该内存空间的指针。

const obj = {},
      arr = [];

在内存中地址分配如图:
js中的引用类型有三种:

其实严格来说 String(字符串) 也算其中一种,但是比较特殊,因为字符串具有可变的大小,所以显然它不能被直接存储在具有固定大小的变量中。由于效率的原因,我们希望JS只复制对字符串的引用,而不是字符串的内容。但是另一方面,字符串在许多方面都和基本类型的表现相似,而字符串是不可变的这一事实(即没法改变一个字符串值的内容),因此可以将字符串看成行为与基本类型相似的不可变引用类型。
也就是说,我们平时在引用字符串的时候引用的是地址,而修改字符串的时候得到的是字符串的拷贝。这一篇文章不会讨论字符串的拷贝。
所以!问题的核心就来了,当我们对值类型进行拷贝的时候,解析器可以直接在栈内存再分配一个新空间存放新的值,而我们对引用类型进行拷贝的时候,解析器只会拷贝该引用类型的引用(也就是指针),也就是拷贝前后的值都是指向同一片内存空间:


const a = {
  name: 'myname'
},
b = a;

/*
a, b都是指向同一片堆内存,所以a或b发生修改时,都会到影响对方
*/

console.log(b.name); //myname

a.name = 'phenom';

console.log(b.name); //phenom

这种直接拷贝指针的方法通常叫做浅拷贝(shallow copy),接下来要讲的就是实现引用类型的深拷贝

Array的深拷贝

对Array类型实现深拷贝还算是比较简单的,最常用的方法是用slice

const a = [1, 2, 3],
      b = a.slice(0);

a[0] = 0;

/*
slice方法返回了一个新的Array对象,a的修改对b没有影响,说明a,b都有独立的内存空间
*/

console.log(b); //[1, 2, 3]

当然如果Array里面嵌套了Array,slice肯定不行了,但是可以换种思路,利用递归解决:

const a = [1, 2, [3, 4, [5, 6]], 7, [8, 9, [10, 11]]];

const arrayDeepCopy = arr => arr.map(x => Array.isArray(x)? arrayDeepCopy(x): x);

console.log(arrayDeepCopy(a)); //[1, 2, [3, 4, [5, 6]], 7, [8, 9, [10, 11]]]

漂亮的functional programming。

Object的深拷贝

真正的重点在Object类型的深拷贝。

1.最hack的方法:JSON.parse(JSON.stringify())

为什么说是最hack的方法呢,因为这种方法把一个object当成json对待,先转字符串,再又转回json,从而生成一个新object。 先抛开性能问题不说(对json进行转换的操作都非常耗时),这种方法有一个很大的缺点,就是要求转换的object一定要是标准的json格式,也就是说当object中含有undefinednullfunction类型都无法进行转换。

2.浅的深拷贝:Object.assign()

Object.assign()方法是es6中提供的原生方法,用于对象的合并:
Object.assign<T, U>(target: T, source: U): T & U
可以看到Object.assign接受两个参数,一个是目的对象,一个是源对象。利用Object.assign合并两个对象:

const a = {
    name: 'myname'
};

const b = {
    age: 20
};

//将b合并到a
console.log(Object.assign(a, b));  //{ name: 'myname', age: 20 }


按照这种思路,我们可以用一个对象和一个空对象合并模拟拷贝的效果:

const a = {
    name: 'myname',
    age: 20
};

//将a合并到一个空对象
const b = Object.assign({}, a);  

a.age = 21;

console.log(b);  //{ name: 'myname', age: 20 }

看起来目的是达到了,对象a的属性的修改并没有影响到对象b。但是事情并没有这么简单。 查阅一下MDN,发现对Object.assign有这样一段描述:

The Object.assign() method only copies enumerable and own properties from a source object to a target object.

里面说到了一些关键的地方:only copies own properties,也就是只拷贝对象自身的属性。这意味着什么呢?假如有这样一个对象:

const student = {
    name: 'myname',
    age: 20,
    grade: {
        humanity: 90,
        science: 80
    }
};

那么该对象在内存中的存放情况是这样子的: 可以看到,gradestudent对象其实是两个不同的对象,他们拥有属于自己的内存空间,只不过student里面保留着对grade的引用。也就是说,grade对象并不是student自身的属性,属于student自身的属性的只有nameage,和grade的指针。那么到这里应该很容易就能想到:
Object.assign()只能拷贝源对象的首层属性,对于源对象里面嵌套的引用类型并不能复制。
Talk is cheap, show me the code:

const student = {
    name: 'myname',
    age: 20,
    grade: {
        humanity: 90,
        science: 80
    }
};

const b = Object.assign({}, student);  

student.grade.humanity = 100;

console.log(b.grade.humanity);  //100

果然MDN没有骗我。

3.最接近完美的方法:传统递归

既然用以上的方法都不完美,那么是否可以回归淳朴,直接手写递归解决?
答案当然是可以的,而且递归是最接近完美的方法,一层一层深入拷贝,思路跟深拷贝数组基本一样:

//递归深拷贝
const deepCopy = function(obj) {
    const tmp = {};

    for(let prop in obj) {
        //若属性为数组
        if(Array.isArray(obj[prop])) {
            tmp[prop] = arrayDeepCopy(obj[prop]);
        }
        //若属性为对象
        else if(!Array.isArray(obj[prop]) && obj[prop] instanceof Object) {
            tmp[prop] = deepCopy(obj[prop]);
        }
        //若为其他类型,直接复制
        else {
            tmp[prop] = obj[prop];
        }
    }

    return tmp;
}

这种递归拷贝,应该是最接近完美的方法了。但是,事情还是没有这么简单,因为我说了这是最接近完美,而不是最完美。
因为这种方法没有考虑引用环的情况。
什么鬼!?什么是引用环?请看下面的情况:

const a = {
    b: {}    
}

//循环引用
a.b.a = a;

内存情况如图所示: 可以看到,a中的属性b中的属性a又引用了a自身,形成了一个环,这种就叫引用环,但是这种逻辑完全又是合法的(参考数据结构中的循环链表)。如果对一个含有引用环的对象进行递归拷贝,就会出现栈溢出的现象(因为递归没法终止)。
当然,在实际开发中,出现引用环的情况其实很少很少,而且也要尽量避免出现,所以说递归拷贝足够应对大多数场景了。

一些思考

故事到这里基本就结束了,但是有一些有意思的问题还是可以思考一下。之前刷知乎,看见有人在讨论:
深拷贝一个对象究竟要不要拷贝对象的方法和对象的proto
额,我自己认真想了一下,我的答案(不一定是对的,只是个人认为)是:两个都不需要,理由如下:
对于function:首先,你根本没有方法深拷贝一个function,其次,也根本不需要深拷贝一个function。什么是function,就是对某些逻辑集合的抽象嘛,为什么要有function?就是为了代码复用。说到底,function不是数据,只是一个处理数据的工具。数据需要copy,工具不需要copy。
对于_proto_:一个对象被创造出来后,其实已经跟他的_proto_关系不大了。我们在日常开发当中,基本不需要操作一个对象的_proto_,而且无论是es6还是typescript,很明显js的发展也是朝着去prototype拥抱class这个方向在走,对象的_proto_的概念已经被弱化。而且,这样浪费内存真的好吗。

---EFO---

xiaoweiQ commented 4 months ago

不过递归拷贝的性能太拉垮了。。。