Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS中的深拷贝 #12

Open
phenomLi opened this issue Dec 17, 2017 · 1 comment
Open

JS中的深拷贝 #12

phenomLi opened this issue Dec 17, 2017 · 1 comment

Comments

@phenomLi
Copy link
Owner

phenomLi commented Dec 17, 2017

深拷贝(deep copy) 算是js里面比较久经不衰的话题,论坛爱讨论,面试也爱考。何为深拷贝?其实就是实现对一个引用类型的完整复制。


什么是引用类型?
js中有值类型引用类型两种类型,基本类型就是像numberboolean这些。值类型在定义的时候,会在栈内存为其分配一个固定的空间。
而引用类型比较特殊,它的大小是不固定的,所以在定义引用类型的时候,解析器会在堆内存为其分配空间,然后再在栈内存分配一个指向堆内存里该内存空间的指针。

const obj = {},
      arr = [];

在内存中地址分配如图:



js中的引用类型有三种:

  • Array

  • Object

  • String


其实严格来说 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
Copy link

xiaoweiQ commented Apr 9, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants