55

Category的本质 关联对象

 5 years ago
source link: http://www.cocoachina.com/ios/20180813/24538.html?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Category的本质 一>

Category的本质 load,initialize方法 二>

面试题:Category能否添加成员变量?如果可以,如何给Category添加成员变量?

我们首先创建一个类Person类继承自NSObject,给这个类声明一个属性name:

@property (nonatomic, strong)NSString *name;

我们声明了这句话之后,实际是做了三件事:

  • 1.声明了一个成员变量_name。

NSString *_name;
  • 2.声明了set和get方法:

- (void)setName:(NSString *)name;
- (NSString *)name;
  • 3.在.m文件中实现set和get方法:

- (void)setName:(NSString *)name{
    
    _name = name;
}

- (NSString *)name{
    
    return _name;
}

以上是给一个类添加属性。下面给一个分类添加属性:

我们创建一个Person类的分类Test1,然后给这个分类添加一个height属性:

@property (nonatomic, assign)int height;

这样只会申明set和get方法,而不会申明成员变量和实现set,get方法:

- (void)setHeight:(int)height;
- (int)height;

既然系统没有帮我们声明成员变量和实现set和get方法,那么我们能不能自己去声明一下呢?我们尝试一下:

b2Izee6.png!web

出现了报错Instance variables may not be placed in categories,意思就是成员变量不能声明在分类中。所以我们得出结论,分类中不能添加成员变量。

我们从分类的结构的角度来考虑一下分类中为什么不能添加成员变量:

imq6Zzm.png!web

通过分类的底层结构我们可以看到,分类中可以存放实例方法,类方法,协议,属性,但是没有存放成员变量的地方。

既然分类中不能添加成员变量,那么我们给分类添加属性时,它的功能不是完整的,比如说我们分别给Person类的name和height这两个属性赋值,然后打印读出这两个属性:

Person *person = [[Person alloc] init];
person.name = @"dongdong";
person.height = 180;
    
NSLog(@"name: %@, height : %d", person.name, person.height);

程序崩溃了,崩溃原因是:-[Person setHeight:]: unrecognized selector sent to instance 0x60400020a0d0,意思就是给这个person对象发送了没有实现的消息:setHeight:,这应该是在我们的预料之中,为什么呢?因为我们在分类中声明age这个属性的时候,不像在类中声明属性一样,系统只会声明set和get方法,而不会在.m中去实现set和get方法,因此导致了程序崩溃。因此我们在分类的.m文件中去实现set和get方法:

//Person+Test1.m文件
- (void)setHeight:(int)height{
    
}

- (int)height{
    
    return 0;
}

再次运行代码,这次程序不崩溃了,打印结果是:

Category[9030:308848] name: dongdong, height : 0

我们看到name属性赋值成功了,而height属性显然没有赋值成功。

person.height = 180;

这句代码显然是调用了set方法,但是在分类中的set方法什么也没有实现,没有存储下这个设置的值180。

person.height

实则是调用了get方法,由于不能保存传递过来的height值,所以上面的代码中我们返回固定值0。

而name属性能够赋值和读取成功,是因为在其set方法中用_name这个成员变量保存的赋的值:

- (void)setName:(NSString *)name{
    
    _name = name;
}

在其get方法中利用_name成员变量返回存储的值:

- (NSString *)name{
    
    return _name;
}

所以如果我们在分类的.m文件中保存传递过来的值,然后在取值的时候返回存储的值,那么应该也能实现属性的完整功能。

方法一 全局变量

第一种方法是使用全局变量来存储传递进来的值:

int height_;

- (void)setHeight:(int)height{
    height_ = height;
}
- (int)height{
    
    return height_;
}

然后我们运行一下程序:

Category[9497:328996] name: dongdong, height : 180

这次好像是赋值成功了,返回也对。我们再把height改成190试试:

Category[9533:330381] name: dongdong, height : 190

这次打印的也是对的,那么这样是不是就真的可以完全实现属性的功能呢?

问题在于,height_是全局变量,所有的对象共用这一个全局变量,如果有个对象的height值变了,其他的对象的height值也会跟着改变,也是不符合我们的需求的,我们可以测试一下:

Person *person1 = [[Person alloc] init];
person1.height = 180;
Person *person2 = [[Person alloc] init];
person2.height = 190;
    
 NSLog(@"person1: %d, person2 : %d", person1.height, person2.height);

打印结果:

Category[9648:335004] person1: 190, person2 : 190

所以这种方法就被pass掉了。

方法二 字典

第一种方法全局变量失败的原因就是不能做到每个对象和自己的height值一一对应。这就让我们想到了一个数据结构-字典。假如我们通过键值对的形式存放height值,这样是否可以呢?我们使用person对象指向的地址作为键,将height值作为值存储在字典中:

NSMutableDictionary *heights_;
//由于load方法只初始化一次,所以我们可以在这个方法里做一些初始化操作
+ (void)load{
    
    heights_ = [NSMutableDictionary dictionary];
}
- (void)setHeight:(int)height{
    NSString *key = [NSString stringWithFormat:@"%p", self];
    heights_[key] = @(height);
}
- (int)height{
    
    NSString *key = [NSString stringWithFormat:@"%p", self];
    return [heights_[key] intValue];
}
Person *person1 = [[Person alloc] init];
person1.height = 180;
Person *person2 = [[Person alloc] init];
person2.height = 190;
    
NSLog(@"person1: %d, person2 : %d", person1.height, person2.height);

打印结果:

Category[10166:350395] person1: 180, person2 : 190

所以采用字典这种方式是完全可行的。

使用字典存在的问题:

  • 1.非线程安全

    由于这个字典是全局的,所有的对象的height属性值都是存储在这个全局字典里面,当不同的对象在不同的线程同时访问这个全局字典时,这个时候就容易产生线程安全问题,需要去加线程锁,有些复杂。

  • 2.需要创建多个全局字典

    刚才已经看到了,我们需要为分类中的每一个属性值创建一个全局字典,这是非常麻烦又复杂的事。

方法三 关联对象

关联对象使用的是runtime的API:

/****
//这个方法是在set方法中使用,目的是把传递进来的value值和object这个对象关联起来
@object:这个参数是要关联的对象
@key:在这里设置了key值,那么在get方法里面就可以根据这个key取得值
@value:传递进来的值
@policy:它是个一个枚举值,用来修饰value
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,          
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, 
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,  
    OBJC_ASSOCIATION_RETAIN = 01401,      
    OBJC_ASSOCIATION_COPY = 01403         
};
***/
objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,
                         id _Nullable value, objc_AssociationPolicy policy)
/***
//这个方法是在get方法中使用,获得关联对象的值。
@object:关联的对象
@key:set方法中设置的key值
***/
objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)

我们再给Person类的分类声明一个属性:

@property (nonatomic, copy)NSString *sex;

然后我们使用关联对象的方法给sex这个属性赋值和取值:

//由于key的类型是`void *`类型,也就是一个指针类型,所以这里声明了一个指针类型的sexKey
const void *sexKey;
- (void)setSex:(NSString *)sex{
    
    objc_setAssociatedObject(self, sexKey, sex, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)sex{
    
    return objc_getAssociatedObject(self, sexKey);
}
Person *person1 = [[Person alloc] init];
person1.sex = @"man";
Person *person2 = [[Person alloc] init];
person2.sex = @"women";
    
NSLog(@"person1: %@, person2 : %@", person1.sex, person2.sex);

打印结果:

Category[11243:396207] person1: man, person2 : women

我们发现打印结果是正确的。

但是这里存在一个问题就是我们设置的key没有赋值,也即是sexKey相当于NULL,假如我们再给height属性设置一个key为heightKey,那么这个heightKey也是NULL,那么在get方法中通过key值来取得值时,由于属性的key都是一样的,所以就很容易出错。

  • 方法一

因此我们需要给这个sexKey赋值一个独一无二的值:

const void *sexKey = &sexKey;

这句话就是直接将sexKey这个指针的地址值赋给自己。对于height:

const void *heightKey = &heightKey;

由于这两个指针分类在不同的内存地址中,所以heightKey和sexKey可以保证是不相同的,这样就能在get方法中取出正确的值。

  • 方法二

上面这种方式实在是非常啰嗦又累赘,我们要声明指针,初始化指针,下面介绍一种更简单的方法:

- (void)setSex:(NSString *)sex{
    
    objc_setAssociatedObject(self, @"sex", sex, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)sex{
    
    return objc_getAssociatedObject(self, @"sex");
}

我们直接把@"sex"这个字符串传进去作为key,这样就不用声明指针又初始化了。有人就有疑问了,这里的key明明要求是指针类型的,我们传进一个字符串可以吗?我们分析一下下面这句代码:

NSString *name = @"dongdong";

这里name变量是一个指针变量。那么我们为什么能用一个字符串去初始化一个指针变量呢?原因就是这里传进去的是@"dongdong"这个字符串的地址。这样我们就能明白,上面@"sex"其实传进去的也是这个字符串的地址。

为了防止误写,我们还可以把字符串抽成宏:

#define SEX @"sex"

- (void)setSex:(NSString *)sex{
    
    objc_setAssociatedObject(self, SEX, sex, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)sex{
    
    return objc_getAssociatedObject(self, SEX);
}
  • 方法三

第二种方法已经非常简便了,但是为了方便准确我们还要把字符串抽成宏。有没有更加简便的方法呢?我们可以尝试传进一个方法的地址作为key,比如说set或get方法:

- (void)setSex:(NSString *)sex{
    
    objc_setAssociatedObject(self, @selector(sex), sex, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)sex{
    
    return objc_getAssociatedObject(self, @selector(sex));
}

这里传进去的key是@selector(sex),也就是sex这个get方法的地址。当然我们也可以传进set方法的地址作为key。最后我们还可以更进一步的简化:

- (void)setSex:(NSString *)sex{
    
    objc_setAssociatedObject(self, @selector(sex), sex, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (NSString *)sex{
    
    return objc_getAssociatedObject(self, _cmd);
}

这里在get方法里把@selector(sex)换成了_cmd,这是因为我们使用的key是sex这个方法的地址,在这个方法内部,我们可以直接使用_cmd获取本方法。那这样就非常方便简洁了。

关联对象的原理

set方法

objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)方法

我们直接去runtime的源码中去查看关联对象的具体实现,直接搜索objc_setA,

aaIFZze.png!web

  • 1.选择objc-runtime.mm这个文件中的实现:

void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy) {
    _object_set_associative_reference(object, (void *)key, value, policy);
}
  • 2.点进_object_set_associative_reference(object, (void *)key, value, policy);这个真实的实现函数:

void _object_set_associative_reference(id object, void *key, id value, uintptr_t policy) {
    // retain the new value (if any) outside the lock.
    ObjcAssociation old_association(0, nil);
    id new_value = value ? acquireValue(value, policy) : nil;
    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.associations());
        disguised_ptr_t disguised_object = DISGUISE(object);
        if (new_value) {
            // break any existing association.
            AssociationsHashMap::iterator i = associations.find(disguised_object);
            if (i != associations.end()) {
                // secondary table exists
                ObjectAssociationMap *refs = i->second;
                ObjectAssociationMap::iterator j = refs->find(key);
                if (j != refs->end()) {
                    old_association = j->second;
                    j->second = ObjcAssociation(policy, new_value);
                } else {
                    (*refs)[key] = ObjcAssociation(policy, new_value);
                }
            } else {
                // create the new association (first time).
                ObjectAssociationMap *refs = new ObjectAssociationMap;
                associations[disguised_object] = refs;
                (*refs)[key] = ObjcAssociation(policy, new_value);
                object->setHasAssociatedObjects();
            }
        } else {
            // setting the association to nil breaks the association.
            AssociationsHashMap::iterator i = associations.find(disguised_object);
            if (i !=  associations.end()) {
                ObjectAssociationMap *refs = i->second;
                ObjectAssociationMap::iterator j = refs->find(key);
                if (j != refs->end()) {
                    old_association = j->second;
                    refs->erase(j);
                }
            }
        }
    }
    // release the old value (outside of the lock).
    if (old_association.hasValue()) ReleaseValue()(old_association);
}

这个函数的实现看起来非常复杂,都是C++的语法,对于不了解C++的人来说非常困难,不过没关系,即便我们看不懂上面的代码,通过下面的分析,我们也能明白关联对象的原理:

实现关联对象技术的核心对象有:

  • AssociationsManager

  • AssociationsHashMap

  • ObjectAssociationMap

  • ObjectAssociation

这里面经常出现Map这个东西,这其实和我们Objective-c中的字典是一样的,我们可以把它当字典来看待。在第二种方法里面我们是用字典去实现的,这里又出现了和字典相似的结构,那它们的实现会不会相似呢?

在上面的一大段源码中,我们在开头的位置找到这一句:

AssociationsManager manager;

我们点进AssociationsManager查看其结构:

quU7FbF.png!web

前面讲了Map类型是字典,那么什么是key,什么是value呢?然后我们继续点进AssociationsHashMap:

AzQVjaQ.png!web

我们前面也讲了,ObjectAssociationMap这个结构也是字典,那么这个字典里面装的是什么呢?我们点进去看看:

ErEZvmZ.png!web

那这个ObjcAssociation又是什么东西呢?我们进去看看:

ZZjEJvn.png!web

总结一下上面四个核心对象的结构:

niieQri.png!web

下面这张图总结的是这四个核心对象之间的联系:

eYz22mN.png!web

那么问题来了,objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)中的四个参数分别对应上面结构中的哪个结构呢?

下图就展示了它们的对应关系:

Nvq2Mvy.png!web

拿我们之前写的作为例子:

objc_setAssociatedObject(self, @selector(sex), sex, OBJC_ASSOCIATION_COPY_NONATOMIC);

这句代码中,self也就是person对象被赋给了AssociationHashMap的key,而@selector(sex)的地址被赋给了AssociationMap的key,策略OBJC_ASSOCIATION_COPY_NONATOMIC被赋值给了ObjectAssociation的policy,传递进来的值sex被赋值给了ObjectAssociation的value。

这种设计的巧妙之处就在于:

当一个person对象不光有一个属性值要关联时,比如我们要关联height和sex这两个属性时,我们以person对象作为key,然后值是AssociationMap这个字典类型,在这个字典类型中,分别使用@selector(sex)和@selector(height)作为key,然后分别利用sex属性的policy和传递进来的value和height属性的policy和传递进来的value生成ObjectAssociation作为value。而如果有多个person对象需要关联时,我们只需要在AssociationHashMap中创造更多的键值对就可以解决这个问题。

通过这个过程我们也能明白:

关联对象的值它不是存储在自己的实例对象的结构中,而是维护了一个全局的结构AssociationManager

get方法

objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)方法

经过了上面的分析,基本上就对set方法的原理比较清楚了,下面我们直接看一下get方法的源码:

  • 1.在runtime的源码中找到这个函数:

id objc_getAssociatedObject(id object, const void *key) {
    return _object_get_associative_reference(object, (void *)key);
}
  • 2.点进_object_get_associative_reference(object, (void *)key);

U7bi226.png!web

回答面试题

Category能否添加成员变量?如果可以,如何给Category添加成员变量?

答:不能直接给Category添加成员变量,但是可以间接实现Category有成员变量的效果。我们可以使用runtime的API,objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)和objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)这两个来实现。

作者:雪山飞狐_91ae

链接: https://www.jianshu.com/p/4b463169a84a


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK