美文网首页
iOS - 内存管理(二)之Copy

iOS - 内存管理(二)之Copy

作者: FKSky | 来源:发表于2020-07-07 17:48 被阅读0次

    1.前言

    阅读本文前请先阅读第一篇《iOS - 内存管理(一)之MRR》,因为部分内容有涉及之前的知识点。
    本来copy这个东西不完全属于是内存管理的内容,不过由于内存管理的规则涉及到copy这个方法,同时copy的实现也有着许多内存管理的注意点,所以有必要专门在这里将其介绍清楚。本文的内容都基于MRR模式

    2.copy的目的

    copy的目的就是为了不同的数据使用方使用或修改数据的时候不会影响到另一方正在使用的数据。
    copy方法就是为了复制出来一个对象的副本,副本内的数据跟原对象一样,这样不管之后修改原对象或者副本对象的内容,都不会影响到另外一方的数据。
    就好比我有一本书,我要借给某人,我又不想这个人在我书上乱画,我就去复印了一本,给他了复印本。这样不管以后我怎么画,或者他怎么画,都不会影响到我们彼此手中的书里的内容。
    这个目的非常简单,也非常重要,这个目的要是没有想清楚,之后有些点的东西在理解上会有困扰。

    3.不同的copy

    copy一个对象正常来说结果都是创建了一个新的对象,新对象和旧对象数据是一样的。但是有的时候并没有创建新对象,而直接返回了就对象的引用。针对这两种情况,就有了不同的名称,分别是 深拷贝浅拷贝

    3.1浅拷贝

    如果细心的人可能会问了,你上面不是刚说了copy的目的就是要创建一个新的副本,这样两边使用自己的对象就不会影响到别人了么?为什么还会有浅拷贝的情况出现?如果旧的和新的都是一个对象那还怎么能达到copy的目的?
    这个问题其实很好,首先正常来说copy都是深拷贝,只有当某个类是不可变类型的时候,才会出现浅拷贝的情况。
    什么叫不可变的类型?即这个类型的对象里面放的数据,一旦初始化了,之后就不能改变了。比如我们都熟悉的NSString,NSArray或者NSDictionary。

    NSString *str = @"123";
    //copyStr 和 str其实是指向内存里同一个字符串对象,这个copy就是一个浅拷贝
    NSString *copyStr = [str copy];
    

    上面的代码中,虽然这个copy一个浅拷贝,但是它并没有违背我们copy的目的。因为NSString本身就是不可变的,也就是说不管是拿着旧的引用str还是副本引用copyStr,你都没法改变它内部的数据,也就是这个字符串永远都是123。所以为了节省资源,这个copy也就没有必要返回一个新的对象了。这便是浅拷贝的意义所在。
    有人可能想说,我就是想对一个不可变的类型对象,拷贝一个真正的新的对象出来,行不行?行!还有另一个拷贝方法,mutableCopy,

    NSString *str = @"123";
    //因为是使用了mutableCopy,明确说明我是要可变的拷贝,所以拷贝出来的对象是一个新的对象
    //并且这个对象的类型也变了,变成了它的可变类型NSMutableString
    //这就是一个深拷贝
    NSMutableString *copyStr = [str mutableCopy];
    

    3.2深拷贝

    深拷贝就很简单了,copy之后的对象是一个新的对象,比如上面的例子对一个不可变的字符串调用了mutableCopy这就是一个深拷贝。
    假如对一个可变的类型进行copy会怎样?很简单,对一个可变的类型进行不管copy还是mutableCopy,结果都是深拷贝。想想上面最开始说的copy的目的就能马上明白,本身就是可变类型,表示可以改变对象保存的数据内容,如果还直接返回一个旧对象,那就没法避免互相影响了。

    3.3内存管理的角度分析

    下面是一个小总结,如果理解了copy的目的和浅拷贝深拷贝,就不用背这种东西都能自己写出来。

    不可变类型 -> copy -> 不可变类型(浅拷贝,新旧指针指向同一个对象,该对象引用计数+1)
    不可变类型 -> mutableCopy -> 可变类型 (深拷贝,旧对象引用计数不变,新对象引用计数=1)
    可变类型 -> copy -> 可变类型 (深拷贝,旧对象引用计数不变,新对象引用计数=1)
    可变类型 -> mutableCopy -> 可变类型 (深拷贝,旧对象引用计数不变,新对象引用计数=1)

    只有当一个拷贝是浅拷贝的时候,新旧指针指向同一个对象,该对象引用计数+1。如果一个拷贝是深拷贝的时候旧对象引用计数不变,新对象引用计数=1。
    如果再细心的人在这里就能明白为什么我在上一篇《iOS - 内存管理(一)之MRR》中说“用copy和mutableCopy获得的新对象retainCount等于1”这一句话不严谨了。包括苹果官方文档的那个配图,也不严谨了。

    苹果官方文档示例图
    因为这种不严谨的说法都是深拷贝的情况,浅拷贝的时候retainCount就不是如上所说了。

    但是!但是!这个但是很重要,虽然说不严谨,但是并不是不正确,并且在某种层面上来说是很正确的。如果站在底层真实retainCount的角度来说不严谨。但是如果站在使用层面,或者说只要遵守了MRR的内存管理原则,你怎么去理解这个copy都不会造成内存泄漏问题,你把它全部都当成深拷贝来管理都没关系。下面用代码举例说明

    //从真实retainCount角度来看
    {
      //新建的一个不可变的数组,数组的retainCount等于1
      NSArray *arr = [[NSArray alloc] initWithObjects:obj, nil];
      //这是一个浅拷贝,这个数组的retainCount = 2
      NSArray * arr2 = [arr copy];
      
      //因为arr和arr2都是指向同一个内存对象,所以我调用arr release和arr2 release效果一样的
      //release两次就对象销毁了
      [arr release];
      [arr release];//或者[arr2 release]效果相同
    }
    

    可以看到这种纯考虑retainCount的想法不是太可取,而且也不应该对一个变量调用两次release,站在使用方的层面也不应该去考虑这个考虑是浅的还是深的。所以我们换一个思维来管理,就是上一篇说的只要是alloc/new/copy/mutableCopy返回的,都当成一个新的对象,注意看下面代码的注释:

    //使用方角度来看
    {
      //新建的一个不可变的数组,因为是用alloc创建的,所以持有他,有责任释放
      NSArray *arr = [[NSArray alloc] initWithObjects:obj, nil];
      //copy了一个新数组出来,不要管是不是浅拷贝,因为是copy的,所以持有他,有责任释放
      NSArray * arr2 = [arr1 copy];
      
      //arr和arr2都是持有的,所以都有责任释放
      [arr release];
      [arr2 release];
    }
    

    可以看到两份代码一样的,所以内存管理的结果也是一样的,但是由于思想不同,在写这两个代码时候管理内存的思路是不同的,也可以看出就将copy看成是获得了一个独立的副本会更加有利于去内存管理。

    理解一个东西很重要的一点是要基于这个东西所在的层的原则去理解,有时候你要用下一层实现的思维去使用上一层的方法往往会给理解造成困扰。

    4.NSCopying和NSMutableCopying

    NSCopying是一个protocol协议,我们平时自定义的类默认是没有实现这个协议的,也就是说我们自定义的类时没有copy方法可以调用的,如果想要有copy方法,就要让你的自定义类遵守NSCopying这个协议,并实现协议规定的方法。

    4.1 copyWithZone

    NSCopying协议只规定了一个方法要实现就是:

    - (id)copyWithZone:(NSZone *)zone

    在这个方法里,返回一个新的副本对象的引用。
    对于前面说的不可变类型浅拷贝的情况,就是在里面直接返回当前对象的引用:

    - (id)copyWithZone:(NSZone *)zone
    {
        return [self retain];
    }
    

    4.2 实例变量的copy方式

    对于我们的自定义的类,通常都不会是不可变的类型,也就是说我们自定义的类创建的对象里面保存的数据通常都是可以被不断重设的,这种情况下copyWithZone方法的实现就不是简单的返回self retain了。

    比如我有如下的一个自定义类,它想要实现copy方法:

    @interface Book : NSObject <NSCopying>
    {
        NSString *_bookName;
        Person *_author;
    }
    @end
    

    这个类可能有很多个实例变量,当我创建了一个新的副本对象,这些原有实例变量内容需要都被赋值过去,那么这些实例变量它具体是要被深拷贝还是浅拷贝到新的对象里,这就要看这个实例变量的setter方法是怎么写的。

    (1)如果setter方法里是copy了再赋值给实例变量,那么就要深拷贝它

    意思就是,比如_author的setter方法如下

    - (void)setAuthor:(Person *)author
    {
      [_author autorelease];
      // 这里对新设置进来的author进行了copy
      // 说明这里本意就是对设置的author持有它的副本,从而外接改变了原有author的内容也不影响当前实例变量的_author值
      // 更直接的说就是,是希望不同的Book对象持有不同的author对象
      _author = [author copy];
    }
    

    所以根据注释说的,每设置一个新的值进来的时候,setter都会copy一个副本出来再传给实例变量。所以再copy整个Book类的时候,也要深拷贝这个实例变量。

    (2)如果setter方法里是retain新值再赋值,那么就要浅拷贝它

    加入author的setter方法如下,则在copy Book的时候只需要浅拷贝这个变量:

    - (void)setAuthor:(Person *)author
    {
      [_author autorelease];
      // 这里对新设置进来的author进行了retain
      // 说明这里本意就是持有这个新值,而不是新值的副本
      _author = [author retain];
    }
    

    也很容易理解,就是它只想持有原始对象,没有想持有一个不一样的副本。

    (3)如果setter方法里是直接赋值的,那么也是浅拷贝它

    - (void)setAuthor:(Person *)author
    {
      // 这里对新设置进来的author直接赋值
      // 说明这里本意就是使用这个新值,而不是持有,允许它虽然被销毁
      // 更不是要新值的副本
      _author = author;
    }
    

    这种情况也是浅拷贝即可,它只想要原始对象的使用权,没有想持有一个不一样的副本。

    4.3 父类没有实现copy方法

    当一个类的父类没有实现copy方法的时候,在这个类的copyWithZone里,最好是使用alloc,initXXX方法来创建新的副本对象。因为那些继承的实例变量的细节通常都被封装在里面了。

    - (void)copyWithZone:(NSZone *)zone
    {
       Book *copy = [[Book alloc] initWithName:[self bookName] author:[self author]];
       return copy;
    }
    

    只要父类没有实现copy,子类就有义务将父类中定义的变量和子类中定义的变量都拷贝给副本对象里。

    4.3 父类实现了copy方法

    当父类实现了copy方法,我们可以直接super copyWithZone来获得副本对象,然后再将子类自己定义的实例变量复制给新的副本对象。

    //假设父类实现了copy
    - (void)copyWithZone:(NSZone *)zone
    {
       Book *copy = [super copyWithZone:zone];
       [copy setAuthor:[self author]];
       return copy;
    }
    

    假如父类的copyWithZone的实现里面可能或者用了NSCopyObject()这个方法来创建副本,那么在子类里调用完了父类的copyWithZone之后,还要将父类里定义的retain或者copy的实例变量,重新赋值一次。

    注意:因为NSCopyObject()只会浅拷贝所有实例变量,也就是说通过NSCopyObject()拷贝返回出来的对象是一个新的对象,但是对象里面的所有实例变量都是指向原本对象的实例变量,并且这些实力变量指向内容的retainCount是没有变化的。并且赋值的时候一定要注意先将实例变量设为nil,否则会实例变量被提前销毁。上代码解释更清楚:

    //假设我的TestObj类有两个实例变量
    //随便取的类型的名字,不要太纠结
    @interface TestObj : NSObject
    {
      NSMutableString *_name;
      NSError *_error;
    }
    
    //_name的set方法里,旧值release,copy 了新值然后保存
    - (void)setName:(NSMutableString *)name
    {
      [_name release];
      _name = [name mutableCopy];
    }
    
    //error的set方法里,旧值release,retain了新值然后保存
    - (void)setError:(NSError *)error
    {
      [_error release];
      _error = [error retain];
    }
    
    //TestObj类的copy方法
    - (id)copyWithZone:(NSZone *)zone
    {
      /*
        使用NSCopyObject()方法copy出来的新的副本对象copy
      */
      TestObj *copy = NSCopyObject(self, 0, zone);
      /*
        走完上面这句以后self和copy是两个TestObj对象,它们的实例变量里的值是一样的
        由于NSCopyObject()方法拷贝出来对象里的实例变量值都是浅拷贝
        也就是说copy里面的_name和_error指向的对象值就是self的_name和_error里的,并且retainCount都没变
         这就到之后最后销毁的时候很可能会被多release一次,导致程序异常或崩溃
      */
    
      /*
       所以我们必须在NSCopyObject之后,重新设值一次原对象“持有”的那些实例变量。
       至于为什么要先设置为nil,可以这样理解:
       假设当前self的_name的retainCount=1,copy的_name因为是浅拷贝,一样的值retainCount也是1
       但是set方法里会先autorelease旧的值,所以还没等赋值,_name的retainCount就归零了
       所以要先将这个浅拷贝的“弱指针”指向nil。
      */ 
      copy->_name = nil;
      [copy setName:self.name];
      copy->_error = nil;
      [copy setError:self.error];
      
      return copy;
    }
    
    
    

    5.NSMutableCopying

    以上讲的都是NSCopying协议,NSMutableCopying协议同理,只有一个方法要实现:

    -(id)mutableCopyWithZone:(nullable NSZone *)zone;

    但是通常来说,我们自己的自定义的类要实现copy方法,只需要遵守NSCopying协议即可,因为一般的自定义类都是可变的。除非有特殊情况,你要实现想NSString和NSMutableString这种配对的可变和不可变的类,那么你才需要遵守这个协议。然后针对可变不可变来实现不同的内容。

    6. 结语

    至此copy相关的内容就介绍完了,主要还是为了填补上一篇中遗留的一些内容,可以继续阅读下一篇《iOS - 内存管理(三)之ARC》

    相关文章

      网友评论

          本文标题:iOS - 内存管理(二)之Copy

          本文链接:https://www.haomeiwen.com/subject/oopaqktx.html