Objective-C 里的拷贝

Objective-C 语言里的拷贝就是对象拷贝,即创建一个跟之前的实例对象一模一样的对象。一般来说数据模型类是需要支持拷贝的。Objective-C 里拷贝又分为浅拷贝(shallow-copy)和深拷贝(deep-copy)。

# 浅拷贝和深拷贝区别

浅拷贝只是复制一下对象指针,但是拷贝前和拷贝后的对象指针均指向同一内存区域,只不过是对象的内存引用计数加一。 深拷贝为拷贝前和拷贝后的对象指针指向不同的内存区域,即会创建一个新的对象。 下图比较直观清楚。 Jietu20180305-184629

一个非常容易产生的误区就是,遵从了 NSCopying 协议的对象都会执行深拷贝。其实不然,Foundation 框架里大部分类执行的还是浅拷贝。比如 NSString 等。举个例子:

NSString *str = [NSString stringWithFormat:@"%@",@"hello"];
NSString *copyStr = [str copy];
NSLog(@"str = %p,copyStr = %p",str ,copyStr);
---
> str = 0x6f6c6c656855,copyStr = 0x6f6c6c656855
> str 在 copy 前后指向的内存地址都一模一样。

还有一点注意的是,虽然有浅拷贝和深拷贝的概念,但是并没有专门定义深拷贝的协议。这点很关键,这意味着除非有文档是写用深拷贝实现 NSCopying 协议的,否则深拷贝的实现都是需要开发者自己去手写实现,而不能依赖于系统框架的实现。

# Copy 实例方法和 NSCopying 协议

NSObject 有名为 copymutableCopy 的实例方法。

- (id)copy;
- (id)mutableCopy;

NSCopying 协议的方法为 -(id)copyWithZone:(nullable NSZone *)zone。(zone 目前没有任何意义)

@protocol NSCopying
- (id)copyWithZone:(nullable NSZone *)zone;
@end

这个实例方法和协议方法有啥关联呢?其实调用 NSObjectcopy 实例方法就是调用 NSCopying 协议约定的 copyWithZone 方法。同理的,调用 NSObjectmutableCopy 实例方法就是调用 NSMutableCopying 协议的 mutableCopyWithZone 方法。

需要注意的是,因为 NSObject 类并不支持 NSCopying 协议,所以继承 NSObject 的子类如果不实现 NSCopying 协议的 copyWithZone 方法会导致应用异常。 见下面 demo

@interface FRModel : NSObject
@end
@implementation FRModel
@end
---
FRModel *model = [[FRModel alloc] init];
[model copy];
> -[FRModel copyWithZone:]: unrecognized selector sent to instance 0x1004004c0

copy API 说明 (opens new window) Returns the object returned by copyWithZone:.

因为 NSObject 并没有实现 NSCopying 协议,即没有实现 copyWithZone: 方法,所以 FRModel 需要单独实现 copyWithZone: 方法。有一个误区是子类直接复写 copy 方法,这是不正确的操作方式,要避免。

# Copy 和 MutableCopy

上面说了 NSObjectcopymutableCopy 两个实例方法。对应的也有两个协议, NSCopying(copyWithZone:)NSMutableCopying(mutableCopyWithZone:)。虽然名字比较像但是这是两个不同的类,即如果你的类只实现了 NSCopying 协议,这时候给你发 mutableCopy 方法的话还是照样会异常,这两个也没有任何包含关系,把这两个类理解为单独的协议就好了。

FRModel *model = [[FRModel alloc] init];
model.name = @"Frank";
FRModel *copyModel = [model mutableCopy];
NSLog(@"model = %p,copymodel = %p",model,copyModel);
NSLog(@"model.name = %p,copymodel.name = %p",model.name,copyModel.name);
---
> FRCopyDemo *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[FRModel mutableCopyWithZone:]: unrecognized selector sent to instance 0x100532040'

# 什么时候用 Copy 什么时候用 MutableCopy?

我们可以看下 NSMutableCopying协议说明 (opens new window)

The NSMutableCopying protocol declares a method for providing mutable copies of an object. Only classes that define an “immutable vs. mutable” distinction should adopt this protocol. Classes that don’t define such a distinction should adopt NSCopying instead. NSMutableCopying 声明了提供可对象可变副本的方法,只有区分可变和不可变的类应该遵守这个协议,不区分可变不尅版的话就直接用 NSCopying 方法就好了。

举个例子,我们日常使用的包括可变类型的类是 NSString(NSMutableString)NSArray(NSMutableArray)NSDictionary(NSMutableDictionary)以及NSSet(NSMutableSet) 这些类都是同时遵守了 NSCopyingNSMutableCopying 协议的。一般来说,我们自己用到的类很少有是可变类型的,所以也很少看到有自己的类实现 NSMutableCopying 协议。

如果要实现的话基本遵守如下原则:

  1. 向可变(mutable)或者不可变(immutable)对象发送 copy 消息,得到的都是不可变(immutable)对象。
  2. 向可变(mutable)或者不可变(immutable)对象发送 mutableCopy 消息,得到的都是可变(mutable)对象。 举个例子验证一下
NSString *str = [NSString stringWithFormat:@"%@",@"hello"];
NSMutableString *copyStr = [str mutableCopy]; //得到可变对象.
[copyStr appendString:@" world"];
NSLog(@"str = %p,copyStr = %p",str ,copyStr);
NSLog(@"str class = %@,copyStr class = %@",NSStringFromClass([str class]) ,NSStringFromClass([copyStr class]));
NSLog(@"str = %@,copyStr = %@",str ,copyStr);
---
> FRCopyDemo str = 0x6f6c6c656855, copyStr = 0x10044a960
> FRCopyDemo str class = NSTaggedPointerString, copyStr class = __NSCFString
> FRCopyDemo str = hello, copyStr = hello world

通过输出结果基本上验证了以上的结论。 这儿还有一点比较有意思的事儿是,关于 mutableCopy 和深拷贝的关系。我们看到 mutableCopy 之后对象的指针发生了变化,内容并没有发生变化。但其实对象指针类型已经发生了变化。所以跟我们之前说的拷贝多少还是有区别的(正常的拷贝指针类型是不会发生变化的)。只要搞清楚这些不同的概念,就能理解这些概念的区别所在。

# 怎样让我们的对象支持 copy

完整的回答应该是:让类实现 NSCopying 协议里的 copyWithZone: 方法,如果对象是可变的应该同时实现 NSMutableCopyingmutableCopyWithZone: 方法。

# copyWithZone: 方法的最佳实践

一般开发者自己实现 copyWithZone: 方法的时候通常都是实现深拷贝,而非浅拷贝。因为浅拷贝确实没啥好实现的。深拷贝的实现需要考虑其父类是否也遵守了 NSCopying 协议,实现了 copyWithZone: 方法。 比如继承自 NSObjectFRModel 类按如下方式实现 copyWithZone: 方法,这样显然是会异常的(实际上连编译都不会编译过去)。

@interface FRModel : NSObject<NSCopying>
@end
@implementation FRModel
- (id)copyWithZone:(NSZone *)zone {
    FRModel *frmodel = [[super allocWithZone:zone] init];
    return frmodel;
}
@end

稍作修改,如果父类没有实现的话子类直接重新创建一个就好了

@interface FRModel : NSObject<NSCopying>
@end
@implementation FRModel
- (id)copyWithZone:(NSZone *)zone {
    FRModel *frmodel = [[[self class] allocWithZone:zone] init];
    return frmodel;
}
@end

如果该类还有属性或者成员变量的话,可以直接对成员变量执行 copy 操作。参考如下 demo:

@interface FRModel : NSObject<NSCopying>
@property (nonatomic,strong) NSString *name;
@end
@implementation FRModel
- (id)copyWithZone:(NSZone *)zone {
    FRModel *frmodel = [[FRModel allocWithZone:zone] init];
    if (frmodel) { frmodel.name = [_name copyWithZone:zone]; }
    return frmodel;
}
@end

FRModel *model = [[FRModel alloc] init];
model.name = @"Frank";
FRModel *copyModel = [model copy];
NSLog(@"model = %p,copymodel = %p",model,copyModel);
NSLog(@"model.name = %p,copymodel.name = %p",model.name,copyModel.name);
---
> FRCopyDemo model = 0x102803520,copymodel = 0x102803530
> FRCopyDemo model.name = 0x1000010d0,copymodel.name = 0x1000010d0

注意因为 NSString 类型本身 copyWithZone: 属性为浅拷贝,所以最后输出结果两个类的 name 属性的指针指向相同。

Best practice when implementing copywithzone (opens new window)

# 集合类型拷贝

Foundation 框架中所有集合类型在默认情况下都执行浅拷贝,也就是说,只拷贝容器对象本身,而不复制其中数据。这样做的原因在于,容器内的对象未必能拷贝,而且调用者也未必想在拷贝容器时一并拷贝其中每一个对象。

集合类型的浅拷贝和深拷贝的概念和普通对象的深浅拷贝略有不同。

  1. 集合类型的浅拷贝是指,当执行浅拷贝的时候,原始集合类型里的对象都收到一个 retain 消息,对象指针被复制到新的集合类型里。
  2. 集合类型的深拷贝是指,当执行深拷贝的时候,原始集合里的对象都会收到一个 copyWithZone:,即集合里的对象需要实现 NSCopying 协议来实现深拷贝。如果集合里的对象并没有实现 NSCopying 则程序会异常。 如图所示 15203089948766 浅拷贝 demo:
NSObject *obj = [[NSObject alloc] init];
NSArray *arr = [NSArray arrayWithObject:obj];
NSArray *copyArr = [[NSArray alloc] initWithArray:arr copyItems:NO];
NSLog(@"arr = %p,copyArr = %p",arr ,copyArr);
NSLog(@"arr model = %p,copyArr model = %p",[arr objectAtIndex:0],[copyArr objectAtIndex:0]);
---
> FRCopyDemo arr = 0x100462b40,copyArr = 0x100461400
> FRCopyDemo arr model = 0x100400640,copyArr model = 0x100400640

里面使用了 initWithArray:copyItems: 这个方法,传入 NO 即为浅拷贝。通过打印日志可以看到集合对象指针虽然发生了变化,但是集合内部元素的对象指针还是指向同样的内存区域。

深拷贝 demo: (将 initWithArray:copyItems: 方法第二个参数传入 YES 即为深拷贝)

NSObject *obj = [[NSObject alloc] init];
NSArray *arr = [NSArray arrayWithObject:obj];
NSArray *copyArr = [[NSArray alloc] initWithArray:arr copyItems:YES];
NSLog(@"arr = %p,copyArr = %p",arr ,copyArr);
NSLog(@"arr model = %p,copyArr model = %p",[arr objectAtIndex:0],[copyArr objectAtIndex:0]);
---
> FRCopyDemo -[NSObject copyWithZone:]: unrecognized selector sent to instance 0x100688490
> FRCopyDemo *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSObject copyWithZone:]: unrecognized selector sent to instance 0x100688490'

因为 NSObject 没有实现 NSCopying 协议,所以集合类型深拷贝时,集合元素 NSObject 对象收到 copyWithZone: 之后异常了。我们将 NSObject 类型修改为 NSMutableString 再次运行

NSMutableString *obj = [NSMutableString stringWithFormat:@"hello world"];
NSArray *arr = [NSArray arrayWithObject:obj];
NSArray *copyArr = [[NSArray alloc] initWithArray:arr copyItems:YES];
NSLog(@"arr = %p,copyArr = %p",arr ,copyArr);
NSLog(@"arr model = %p,copyArr model = %p",[arr objectAtIndex:0],[copyArr objectAtIndex:0]);
---
> FRCopyDemo arr = 0x100406dc0,copyArr = 0x100403fb0
> FRCopyDemo arr model = 0x100406780,copyArr model = 0x100405260

可以看到集合内的元素执行了不同的内存地址,因为 NSMutableString 收到 copyWithZone: 消息会生成一个不可变的 NSString 对象。 initWithArray:copyItems: API 说明 (opens new window)

copyItems: 参数 flag If YES, each object in array receives a copyWithZone: message to create a copy of the object—objects must conform to the NSCopying protocol. In a managed memory environment, this is instead of the retain message the object would otherwise receive. The object copy is then added to the returned array. If NO, then in a managed memory environment each object in array simply receives a retain message when it is added to the returned array.

# 单层复制和完全复制?

思考这样一种情况,即数组套数组,这种深拷贝是怎么做? 我们刚使用 initWithArray:copyItems: 方法进行的拷贝只是元素拷贝。即如果是两层数组的话,内层数组里的元素其实并没有机会执行 copyWithZone: 方法。苹果官方称这种拷贝为 one-level-deep copy,即单层复制。

完全复制是指,不管嵌套多少层集合,每层的的元素都有机会执行 copyWithZone: 方法。

# Copy 和 NSCoding

如何实现完全复制呢? 让对象实现 NSCoding 协议,然后将对象归档到文件里再从文件中归档出来,即需要进行两次 I/O 操作。 举个例子:

//NSArray 和 NSString 都支持 NSCoding 协议
NSString *path = @"/Users/xiushan.fan/Desktop/arrfile";
NSArray *subArray1 = @[[NSMutableString stringWithString:@"1"]];
NSArray *subArray2 = @[[NSMutableString stringWithString:@"2"]];
NSArray *wholeArr = @[subArray1,subArray2];
[NSKeyedArchiver archiveRootObject:wholeArr toFile:path];
NSLog(@"wholeArr = %@,wholeArr = %p,subArr1 = %p,elemement = %p",wholeArr,wholeArr,[wholeArr objectAtIndex:0],[[wholeArr objectAtIndex:0] objectAtIndex:0]);
NSArray *unarchivedArray = [NSKeyedUnarchiver unarchiveObjectWithFile:path];
//NSArray *unarchivedArray = [[NSArray alloc] initWithArray:wholeArr copyItems:YES];
NSLog(@"unarchivedArray = %@, unarchivedArray = %p ,subArr1 = %p,elemement = %p",unarchivedArray,unarchivedArray,[unarchivedArray objectAtIndex:0],[[unarchivedArray objectAtIndex:0] objectAtIndex:0]);
---
> FRCopyDemo wholeArr = ( ( 1 ),( 2 ) ),wholeArr = 0x100422c60,subArr1 = 0x100406930,elemement = 0x100422580
> FRCopyDemo unarchivedArray = (
(1 ), ( 2 )), unarchivedArray = 0x1004273a0 ,subArr1 = 0x1004039c0,elemement = 0x100424230

可以看到 NSCoding 进行转化之后所有的元素的内存地址均不相同。

# Copy 和属性

copy attribute 修饰的属性,在被赋值的时候,新值其实是会收到一个 copyWithZone: 的消息

@interface FRObj : NSObject<NSCopying>
@end
@implementation FRObj
- (id)copyWithZone:(NSZone *)zone {
    FRObj *obj = [[FRObj allocWithZone:zone] init];
    NSLog(@"FRObj copy with zone self %@",self);
    return obj;
}
@end
@interface FRModel : NSObject<NSCopying>
@property (nonatomic,copy) FRObj *name;
@end

FRModel *model = [[FRModel alloc] init];
FRObj *obj = [[FRObj alloc] init];
NSLog(@"obj %@",obj);
model.name = obj;
---
> FRCopyDemo obj <FRObj: 0x1004001c0>
> FRCopyDemo FRObj copy with zone self <FRObj: 0x1004001c0>

可以看到 FRObj 赋值的时候自己收到了一条 copyWithZone: 的消息。 具体的底层实现可以参考 属性 attribute 总结 (opens new window) 里的 copy attribute 部分

# 一些 QA

Q: copy 关键字一般在哪些场景下使用? A: 1.NSString/NSArray/NSDictionary 这些类使用,因为这些类都有对应的可变类型。 2. MRC 下修饰 block 属性需要使用 copy。ARC 下可以使用 copy/strong 去修饰 block,一般也使用 copy,给人感觉比较直观。

这个的对应的问题是,NSString 使用什么修饰符修饰?为什么?答案是使用 copy 修饰符,原因是 NSStringstrong 修饰符的时候如果指向自己的可变类型,当可变类型内容修改的时候,NSString 属性也会跟着一起修改,这不是我们希望看到的。同时根据刚才的分析,使用 copy 修饰并不会给 NSString 造成额外的负担,因为仅仅是浅拷贝而已。

Q: 这个写法会出什么问题: @property (copy) NSMutableArray *array; A: 当给 array 赋值的时候可变对象会变为不可变对象,其实是向被赋值的对象发送了一个 copy 消息, copy 的默认实现就是将可变对象变为不可变对象。属性里也没有 mutablecopy 这种修饰符,所以只能手动发送 mutableCopy 消息达到目的。 Property of mutable type 'NSMutableDictionary' has 'copy' attribute; an immutable object will be stored instead (opens new window)

Q: 如何让自己的类用 copy 修饰符?如何重写带 copy 关键字的 setter? A: 实现 NSCopying 协议,实现 copyWithZone: 方法. 直接发送 copy 消息就好了

# 参考地址

Collections Programming Topics - Copying Collections (opens new window) Cocoa Core Competencies - Object copying (opens new window) Effective Objective-C 2.0 - 理解 NSCopying 协议 Advanced Memory Management Programming Guide - About Memory Management (opens new window) iOS 集合的深复制与浅复制 (opens new window) Objective-C copy,看我就够了 (opens new window) Objective-C copy 那些事儿 (opens new window)