Category的本質<三>關聯物件

雪山飛狐1發表於2018-08-07

面試題: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方法,那麼我們能不能自己去宣告一下呢?我們嘗試一下:

F971DF04-F0BD-4B7C-8108-376F0DECDF3C.png
出現了報錯Instance variables may not be placed in categories,意思就是成員變數不能宣告在分類中。所以我們得出結論,分類中不能新增成員變數。

我們從分類的結構的角度來考慮一下分類中為什麼不能新增成員變數:

image.png
通過分類的底層結構我們可以看到,分類中可以存放例項方法,類方法,協議,屬性,但是沒有存放成員變數的地方。 既然分類中不能新增成員變數,那麼我們給分類新增屬性時,它的功能不是完整的,比如說我們分別給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,

24E2331B-598F-4F7B-A012-B03564C99C88.png

  • 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檢視其結構:

759A21C4-A407-490A-9F90-AE740E138B4D.png
前面講了Map型別是字典,那麼什麼是key,什麼是value呢?然後我們繼續點進AssociationsHashMap
D72D1231-FFD8-40ED-8CE0-E9D2146A4ECD.png
我們前面也講了,ObjectAssociationMap這個結構也是字典,那麼這個字典裡面裝的是什麼呢?我們點進去看看:
893E11B0-3813-441F-9833-5839CC45FD7C.png
那這個ObjcAssociation又是什麼東西呢?我們進去看看:
35314FE8-4390-4BEA-9619-6FF40D3FB9F2.png
總結一下上面四個核心物件的結構:
9C8C4B75-E1B0-425C-8E91-C7B833181741.png
下面這張圖總結的是這四個核心物件之間的聯絡:
D8080A40-5585-474E-A224-B5BBE573DB22.png
那麼問題來了,objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)中的四個引數分別對應上面結構中的哪個結構呢? 下圖就展示了它們的對應關係:
0423B811-3976-46A5-95CC-FCDFDB9FE236.png
拿我們之前寫的作為例子:

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);
    3EFE0EEF-7D66-44FF-AC39-55386E6AE3BB.png

回答面試題

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)這兩個來實現。

相關文章