WalkerZ's Blog

关于OC中的深拷贝和浅拷贝问题

文章的由来

之前和好友讨论属性特性copy的时候引出的关于什么是深拷贝和浅拷贝的问题。在网上查阅了很多关于OC的深拷贝和浅拷贝介绍的文章,很多文章对深浅拷贝都有这样的误解:copy就是浅拷贝,mutableCopy就是深拷贝。

所以写这篇文章是为了阐述我个人对深浅拷贝的理解。如果您有不同的见解,还请不吝赐教。

先说结论

我认为在OC中,copy不一定就是浅拷贝,mutableCopy不一定就是深拷贝。(是不是感觉跟没说一样= =)

论述

深拷贝和浅拷贝

假设存在A类对象a1,现在欲拷贝a1到A类对象a2。
所谓拷贝,就是创建新的内存空间,并将a1的属性的值拷贝到a2对应的属性。在这里,属性的类型可能是值类型也可能是引用类型(指针类型)。深浅拷贝的区别就在于对引用类型属性值的拷贝方式上。

所谓浅拷贝,就是拷贝引用类型属性x的时候,仅仅将x的引用从a1拷贝到a2,在OC里就是将x的地址从a1拷贝到a2,a1和a2的x都指向同一个对象。这时候如果修改了a1.x.field的值,那么a2.x.field的值也会修改到。

所谓深拷贝,就是拷贝x的时候,把a1.x所指的对象也拷贝一份,并将新拷贝的对象的引用赋值给a2.x。这样修改了a1.x.field的值,也不会使a2.x.field的值发生变化。

OC的拷贝

非容器类的拷贝

浅拷贝:
我定义了两个类:A和X,其中A类实现NSCoping协议,A类有个X类型的属性,X类有个NSInteger类型的属性valueField。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface A : NSObject <NSCopying>
@property (nonatomic, strong) X *fieldX;
// 为了直观一点,fieldX的初始化没写在init方法里,而写在测试代码里面了
@end
@implementation A
- (id)copyWithZone:(NSZone *)zone {
A *copyA = [[self class] allocWithZone:zone];
copyA.fieldX = self.fieldX;
return copyA;
}
@end
@interface X : NSObject
@property (nonatomic, assign) NSInteger valueField;
@end

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
A *a1 = [[A alloc] init];
a1.fieldX = [[X alloc] init];
a1.fieldX.valueField = 1;
A *a2 = [a1 copy];
NSLog(@"a1.fieldX 的地址为 %p", a1.fieldX);
NSLog(@"a2.fieldX 的地址为 %p", a2.fieldX);
NSLog(@"修改a1.fieldX.valueField的值之前");
NSLog(@"a1.fieldX.valueField: %ld", a1.fieldX.valueField);
NSLog(@"a2.fieldX.valueField: %ld", a2.fieldX.valueField);
a1.fieldX.valueField = 3;
NSLog(@"修改a1.fieldX.valueField的值之后");
NSLog(@"a1.fieldX.valueField: %ld", a1.fieldX.valueField);
NSLog(@"a2.fieldX.valueField: %ld", a2.fieldX.valueField);

输出的结果为:

a1.fieldX 的地址为 0x600000001230
a2.fieldX 的地址为 0x600000001230
修改a1.fieldX.valueField的值之前
a1.fieldX.valueField: 1
a2.fieldX.valueField: 1
修改a1.fieldX.valueField的值之后
a1.fieldX.valueField: 3
a2.fieldX.valueField: 3

这种情况就是浅拷贝,拷贝的时候a1仅仅是将fieldX的地址拷贝给a2的fieldX,所以a1、a2的fieldX是同一个对象,所以修改了a1.fieldX.valueField的值之后,a2.fieldX.valueField也会改变。

下面改成深拷贝试试:

在A类里将fieldX的属性特性strong修改为copy,X类实现NSCoping协议

1
2
3
4
5
6
7
@implementation X
- (id)copyWithZone:(NSZone *)zone {
X *copyX = [[self class] allocWithZone:zone];
copyX.valueField = self.valueField;
return copyX;
}
@end

测试代码不变,输出结果:

a1.fieldX 的地址为 0x600000008ec0
a2.fieldX 的地址为 0x600000008ed0
修改a1.fieldX.valueField的值之前
a1.fieldX.valueField: 1
a2.fieldX.valueField: 1
修改a1.fieldX.valueField的值之后
a1.fieldX.valueField: 3
a2.fieldX.valueField: 1

可以看到a1和a2的fieldX已经不是同一个对象了,所以修改a1.fieldX.valueField的值不会影响到a2.fieldX.valueField的值。

这就是最简单的非容器类的拷贝了。

容器类的拷贝

OC的容器类的拷贝严格上来讲,都是浅拷贝。不管是怎么拷贝(copy or mutableCopy),新数组和原始数组的元素都是一样的。

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
A *a1 = [[A alloc] init];
a1.fieldX = [[X alloc] init];
a1.fieldX.valueField = 1;
A *a2 = [a1 copy];
NSArray<A *> *array = @[a1, a2];
NSLog(@"最原始的数组的地址");
NSLog(@"%p", array);
NSLog(@"最原始的数组元素的地址");
for (A *a in array) {
NSLog(@"- %p", a);
}
NSLog(@"\n");
NSArray<A *> *arrayCopy = [array copy];
NSLog(@"NSArray -> NSArray 拷贝后新数组的地址");
NSLog(@"%p", arrayCopy);
NSLog(@"NSArray -> NSArray 拷贝后新数组的元素的地址");
for (A *a in arrayCopy) {
NSLog(@"- %p", a);
}
NSLog(@"\n");
NSMutableArray<A *> *arrayMutableCopy = [NSMutableArray arrayWithArray:array];
NSLog(@"NSArray -> NSMutableArray 拷贝后新数组的地址");
NSLog(@"%p", arrayMutableCopy);
NSLog(@"NSArray -> NSMutableArray 拷贝后新数组的元素的地址");
for (A *a in arrayMutableCopy) {
NSLog(@"- %p", a);
}
NSLog(@"\n");

输出如下:

最原始的数组的地址
0x60800003f420
最原始的数组元素的地址
0x60800001dc60
0x60800001dc70

NSArray -> NSArray 拷贝后新数组的地址
0x60800003f420
NSArray -> NSArray 拷贝后新数组的元素的地址
0x60800001dc60
0x60800001dc70

NSArray -> NSMutableArray 拷贝后新数组的地址
0x600000055d80
NSArray -> NSMutableArray 拷贝后新数组的元素的地址
0x60800001dc60
0x60800001dc70

可以看到,NSArray -> NSArray的copy其实连copy都算不上,而NSArray -> NSMutableArray其实只是浅拷贝,元素其实都是同样那些元素。如果是NSMutableArray -> NSMutableArray 或者 NSMutableArray -> NSArray,也是同样的情况,这里就没有写出来了。字典和集合也是一样的情况。

所以如果拷贝出一个新数组,但是修改了其中一个数组中某个元素的属性,那么另一个数组的这个元素的属性也会受到影响。所以在开发中将数组作为参数来传值的时候需要特别注意这个问题。

当然,苹果这样做也是合理的,毕竟不是每个类都实现了拷贝协议,而且深拷贝付出的代价也是昂贵的。

字符串的拷贝

编译器对常量字符串的存储是经过编译器优化的:

1
2
3
4
5
6
> NSString *string1 = @"Hello";
> NSString *string2 = @"Hello";
> if (string1 == string2) {
> NSLog(@"They are same address");
> }
>

>

由于常量会占用一块特殊的代码段,加载到内存时,就会映射到一块常量存储区,以加快访问速度。编译器在编译时会发现,string1和string2的内存是相同的常量字符串,会把他们都指向相同的一个区域,而不是开辟出一个额外的内存空间。因此他们的地址是相同的。

引用自:iOS清楚常量字符串和一般字符串的区别

而由常量字符串拷贝出来的常量字符串和上述的情况是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
NSString *str1 = @"str";
NSLog(@"原始常量字符串地址 %p", str1);
NSString *strCopy1 = [str1 copy];
NSLog(@"NSString -> NSString 拷贝后新字符串的地址 %p", strCopy1);
NSMutableString *strMutableCopy = [str1 mutableCopy];
NSLog(@"NSString -> NSMutableString 拷贝后新字符串的地址 %p", strMutableCopy);
NSLog(@"\n");
NSMutableString *mutableStr = [NSMutableString stringWithString:@"str"];
NSLog(@"原始可变字符串地址 %p", mutableStr);
NSString *mutableStrCopy = [mutableStr copy];
NSLog(@"NSMutableString -> NSString 拷贝后新字符串的地址 %p", mutableStrCopy);
NSMutableString *mutableStrMutableCopy = [mutableStr mutableCopy];
NSLog(@"NSMutableString -> NSMutableString 拷贝后新字符串的地址 %p", mutableStrMutableCopy);
NSLog(@"\n");

输出结果:

原始常量字符串地址 0x11630b418
NSString -> NSString 拷贝后新字符串的地址 0x11630b418
NSString -> NSMutableString 拷贝后新字符串的地址 0x60000026c600

原始可变字符串地址 0x60000026c640
NSMutableString -> NSString 拷贝后新字符串的地址 0xa000000007274733
NSMutableString -> NSMutableString 拷贝后新字符串的地址 0x60000026c680

可以看到NSString -> NSString 其实也不算是拷贝,而与可变字符串相关的拷贝都是真正意义上的拷贝,至于是深拷贝还是浅拷贝,因为我不了解字符串中字符的存储方式,所以也无法下结论。

当然,我们也有办法由常量字符串真实意义上地拷贝出常量字符串:

1
2
3
4
5
NSString *str1 = @"str";
NSLog(@"原始常量字符串地址 %p", str1);
NSString *strCopy1 = [[NSString alloc] initWithFormat:@"%@", str1];
NSLog(@"NSString -> NSString 拷贝后新字符串的地址 %p", strCopy1);

输出结果:

原始常量字符串地址 0x11cf6b418
NSString -> NSString 拷贝后新字符串的地址 0xa000000007274733

当然了,大部分情况下这种做法是没必要的囧。

总结

在上面的例子中,尤其是容器类的拷贝的例子中可以看出,copy不一定是浅拷贝,有时甚至没发生拷贝行为,而mutableCopy也未必是深拷贝。

其实比深浅拷贝的概念更重要的是,设想这样一种场景,我们需要向某个方法或界面传递一个数组参数时,我们希望在这个方法或界面中对数组数据的修改不影响原数组的数据,我们如果选择拷贝出数组的副本作为参数,这时候清楚地知道数组的拷贝方式就非常重要了。