Java中的深浅拷贝

引子

相信点进来的同学多少对浅拷贝与深拷贝有一定的了解,这里就不再多赘述,看完这篇文章,希望可以加深你对深拷贝的理解。

数据类型

说起拷贝,就不得不提起 js 的数据类型了,因为深拷贝和浅拷贝的核心就在于不同的数据类型在内存中存储的地方不同。

ECMAScript 基本数据类型

最新的 ECMAScript 标准定义了 8 种数据类型,其中 7 中是基本数据类型,它们是:BooleanNullUndefinedNumberStringBigIntSymbol

基本数据类型都是存储在栈(stack)内存中,栈具有先进后出的特点,基本数据类型占用空间小、大小固定,通过按值来访问,属于被频繁使用的数据。

所有的基本类型值本身是无法被改变的。可能有的人会有疑问,我天天修改字符串等基本类型值,还不是发生了改变么,其实我们对字符串进行操作后,返回的都是新的字符串,并没有修改原来的数据。

let a = 1;
let b = a;
b = 2;
console.log(a, b); // 1 2
复制代码

基本数据类型的赋值,赋值后两个变量互不影响,b复制的是a的原始值,它们存储在独立的的栈空间中,因此修改了b的值,a的值不会受到影响,大家可以查看下图更清晰的了解。

Java中的深浅拷贝_第1张图片

ECMAScript 引用数据类型

引用数据类型 Object,像 ArrayFunctionDate...等都属于 Object,它们的值都是对象。

引用数据类型存放在堆内存中,可以直接进行访问和修改。

引用数据类型占据空间大、大小不固定,存放在栈中会有性能的问题。引用数据类型在栈中保存了一份指针,该指针指向对应的数据在堆中的起始地址,当解释器寻找引用值时,会首先检索其在栈中的地址,通过地址从堆中获得数据。

let obj = { name: '烟花渲染离别' };
let obj2 = obj;
obj2.name = '七宝';
console.log(obj.name); // 七宝
console.log(obj2.name); // 七宝
复制代码

引用类型的赋值,在栈中复制了一份引用类型的地址指针,两个变量指向的还是同一个对象,所以修改了obj2.nameobj.name也会发生改变,这种改变有时候并不是我们所期望的,这时候就需要拿出我们的秘技:浅拷贝和深拷贝。

Java中的深浅拷贝_第2张图片

浅拷贝

浅拷贝就是将源对象的属性拷贝一份,如果属性时基本类型值,直接拷贝基本类型值,如果属性是引用类型,则拷贝的是该引用类型在堆中的地址。下面介绍几种常用的浅拷贝方法:

展开运算符 ...

个人常用的浅拷贝就是 ...展开运算符了,展开运算符是es6的新特性,相信大家都已经很了解了,不了解的可以前往阮一峰大神的ECMAScript 6 入门查看。

let obj = { name: '烟花渲染离别' };
let obj2 = { ...obj };
obj2.name = '七宝';
console.log(obj.name); // 烟花渲染离别
console.log(obj2.name); // 七宝
复制代码

Object.assign()

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。它将返回目标对象。

我一般是在需要合并两个对象成为一个新对象时使用这个方法。

let obj = { name: '烟花渲染离别' };
let obj2 = Object.assign({}, obj);
obj2.name = '七宝';
console.log(obj.name); // 烟花渲染离别
console.log(obj2.name); // 七宝
复制代码

concat和slice

这两个方法常用来拷贝数组。

let arr = [1, 2];
let arr2 = arr.concat();
arr.push(3);
console.log(arr); // [1, 2, 3]
console.log(arr2); // [1, 2]
复制代码
let arr = [1, 2];
let arr2 = arr.slice();
arr.push(3);
console.log(arr); // [1, 2, 3]
console.log(arr2); // [1, 2]
复制代码

浅拷贝的问题

有了浅拷贝后,为什么还需要深拷贝呢?自然是因为浅拷贝是有缺陷的,如果拷贝的对象中属性有引用类型值的话,浅拷贝就不能达到预期的完全复制隔离的效果了,下面来看个例子:

let obj = { name: '烟花渲染离别', hobby: ['看动漫'] };
let obj2 = { ...obj };
obj2.name = '七宝';
console.log(obj.name); // 烟花渲染离别
console.log(obj2.name); // 七宝

obj.hobby.push('打球');
console.log(obj.hobby); // ['看动漫', '打球']
console.log(obj2.hobby); // ['看动漫', '打球']
console.log(obj.hobby === obj2.hobby); // true
复制代码

可以看到浅拷贝后,obj.hobby的修改影响到了obj2.hobby,根据我们上面引用类型的赋值,我们可以大胆推测,浅拷贝拷贝的是hobby的指针。同样画个图方便大家理解。

Java中的深浅拷贝_第3张图片

既然浅拷贝有这种问题,那我们肯定想要避免这个问题,怎么去避免这个问题呢?这就要用到我下面要讲的深拷贝了。

深拷贝

深拷贝,顾名思义就是比浅拷贝能够更深层级的拷贝,它能够将拷贝过程中遇到的引用类型都新开辟一块地址拷贝对应的数据,这样就能避免子对象共享同一份内存的问题了。

JSON.parse(JSON.stringify())

let obj = { name: '烟花渲染离别', hobby: ['看动漫'] };
let obj2 = JSON.parse(JSON.stringify(obj));
obj.hobby.push('打球');
console.log(obj.hobby); // ['看动漫', '打球']
console.log(obj2.hobby); // ['看动漫']
复制代码

基于JSON.stringify将对象先转成字符串,再通过JSON.parse将字符串转成对象,此时对象中每个层级的堆内存都是新开辟的。

这种方法虽然简单,但它还有几个缺陷:

  1. 不能解决循环引用的问题

  2. 无法拷贝特殊对象,比如:RegExpBigIntDateSetMap

手写深拷贝

既然利用js内置的JSON.parse + JSON.stringify方法进行深拷贝有缺陷的话,那我们就自己动手实现一个深拷贝吧。

实现深拷贝之前思考下我们思考下应该怎么去实现,其实核心就是:浅拷贝 + 递归。

  • 对于基本数据类型,我们直接拷贝即可

  • 对于引用数据类型,则需要进行递归拷贝。

我们先动手实现一个功能类似JSON.parse(JSON.stringify())的简单深拷贝,能对对象和数组进行深拷贝

// 获取对象
function isObject(target) {
    const type = typeof target;
    return target !== null && (type === 'object' || type === 'function');
}

function deepClone(target) {
    if (!isObject(target)) return target; // 拷贝基本类型值

    let cloneTarget = Array.isArray(target) ? [] : {}; // 判断拷贝的是否是数组
    Object.keys(target).forEach(key => {
        cloneTarget[key] = deepClone(target[key]); // 递归拷贝属性
    });
    return cloneTarget;
}

let obj = { name: '烟花渲染离别', hobby: ['看动漫'] };
let obj2 = deepClone(obj);
obj2.name = '七宝';
console.log(obj.name); // 烟花渲染离别
console.log(obj2.name); // 七宝

obj.hobby.push('打球');
console.log(obj.hobby); // ['看动漫', '打球']
console.log(obj2.hobby); // ['看动漫']
复制代码

Java中的深浅拷贝_第4张图片

可以看到基本实现了JSON.parse(JSON.stringify())的深拷贝功能,但是我们都知道这种方法的缺陷,那我们继续完善深拷贝方法。

处理循环引用

什么是循环引用呢?简单来说就是自己内部引用了自已,和递归的自己调用自己有点像,来看个例子吧:

let obj = { name: '烟花渲染离别' };
obj.info = obj;
console.log(obj);
复制代码

Java中的深浅拷贝_第5张图片

如果使用上面的深拷贝的话,因为没有处理循环引用,就会导致info属性一直递归拷贝,递归死循环导致栈内存溢出。

如何处理循环引用呢?我们可以开辟一个空间存储要拷贝过的对象,当拷贝当前对象时,先去存储空间查找该对象是否被拷贝过,如果拷贝过,直接返回该对象,如果没有拷贝过就继续拷贝。

function deepClone(target, cache = new WeakSet()) { 
    if (!isObject(target)) return target; // 拷贝基本类型值
    if (cache.has(target)) return target; // 如果之前已经拷贝过该对象,直接返回该对象
    cache.add(target); // 将对象添加缓存

    let cloneTarget = Array.isArray(target) ? [] : {}; // 判断拷贝的是否是数组
    Object.keys(target).forEach(key => {
        cloneTarget[key] = deepClone(target[key], cache); // 递归拷贝属性,将缓存传递
    });
    return cloneTarget;
}
复制代码

这里采用了WeakSet收集拷贝对象,WeakSet中的对象都是弱引用的,垃圾回收机制不考虑WeakSet对该对象的引用。如果我们拷贝的对象很大的时候,使用Set会导致很大的内存消耗,需要我们手动清除Set中的数据才能释放内存,而WeakSet则不会有这样的问题。

总结

深拷贝作为面试常考的题目,里面确实涉及到了很多细节:

  • 考察你的递归能力

  • 考察处理循环引用,还可以深入挖掘对weakSetweakMap弱引用的了解程度

  • 考察各种引用类型的处理,对数据类型的掌握的程度

你可能感兴趣的:(开发问题,前端开发总结,java,javascript,前端)