iOS 底層探索 - 類

在這裏插入圖片描述

我們在前面探索了 iOS 中的對象原理,面向對象編程中有一句名言:

萬物皆對象

那麼對象又是從哪來的呢?有過面向對象編程基礎的同學肯定都知道是類派生出對象的,那麼今天我們就一起來探索一下類的底層原理吧。

一、iOS 中的類到底是什麼?

我們在日常開發中大多數情況都是從 NSObject 這個基類來派生出我們需要的類。那麼在 OC 底層,我們的類 Class 到底被編譯成什麼樣子了呢?

我們新建一個 macOS 控制檯項目,然後新建一個 Animal 類出來。

// Animal.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Animal : NSObject

@end

NS_ASSUME_NONNULL_END

// Animal.m
@implementation Animal

@end

// main.m
#import <Foundation/Foundation.h>
#import "Animal.h"
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Animal *animal = [[Animal alloc] init];
        NSLog(@"%p", animal);
    }
    return 0;
}

我們在終端執行 clang 命令:

clang -rewrite-objc main.m -o main.cpp

這個命令是將我們的 main.m 重寫成 main.cpp,我們打開這個文件搜索 Animal:

我們發現有多個地方都出現了 Animal:

// 1
typedef struct objc_object Animal;

// 2
struct Animal_IMPL {
	struct NSObject_IMPL NSObject_IVARS;
};

// 3
objc_getClass("Animal")

我們先全局搜索第一個 typedef struct objc_object,發現有 843 個結果

我們通過 Command + G 快捷鍵快速翻閱一下,最終在 7626 行找到了 Class 的定義:

typedef struct objc_class *Class;

由這行代碼我們可以得出一個結論,Class 類型在底層是一個結構體類型的指針,這個結構體類型爲 objc_class
再搜索 typedef struct objc_class 發現搜不出來了,這個時候我們需要在 objc4-756 源碼中進行探索了。

我們在 objc4-756 源碼中直接搜索 struct objc_class ,然後定位到 objc-runtime-new.h 文件

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }
}

看到這裏,細心的讀者可能會發現,我們在前面探索對象原理中遇到的 objc_object 再次出現了,並且這次是作爲 objc_class 的父類。這裏再次引用那句經典名言 萬物皆對象,也就是說類其實也是一種對象

由此,我們可以簡單總結一下類和對象在 COC 中分別的定義

C OC
objc_object NSObject
objc_class NSObject(Class)

二、類的結構是什麼樣的呢?

通過上面的探索,我們已經知道了類本質上也是對象,而日常開發中常見的成員變量、屬性、方法、協議等都是在類裏面存在的,那麼我們是不是可以猜想在 iOS 底層,類其實就存儲了這些內容呢?

我們可以通過分析源碼來驗證我們的猜想。

從上一節中 objc_class 的定義處,我們可以梳理出 Class 中的 4 個屬性

  • isa 指針
  • superclass 指針
  • cache
  • bits

需要值得注意的是,這裏的 isa 指針在這裏是隱藏屬性.

2.1 isa 指針

首先是 isa 指針,我們之前已經探索過了,在對象初始化的時候,通過 isa 可以讓對象和類關聯,這一點很好理解,可是爲什麼在類結構裏面還會有 isa 呢?看過上一篇文章的同學肯定知道這個問題的答案了。沒錯,就是元類。我們的對象和類關聯起來需要 isa,同樣的,類和元類之間關聯也需要 isa

2.2 superclass 指針

顧名思義,superclass 指針表明當前類指向的是哪個父類。一般來說,類的根父類基本上都是 NSObject 類。根元類的父類也是 NSObject 類。

2.3 cache 緩存

cache 的數據結構爲 cache_t,其定義如下:

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
    
    ...省略代碼...
}

類的緩存裏面存放的是什麼呢?是屬性?是實例變量?還是方法?我們可以通過閱讀 objc-cache.mm 源文件來解答這個問題。

  • objc-cache.m
  • Method cache management
  • Cache flushing
  • Cache garbage collection
  • Cache instrumentation
  • Dedicated allocator for large caches

上面是 objc-cache.mm 源文件的註釋信息,我們可以看到 Method cache management 的出現,翻譯過來就是方法緩存管理。那麼是不是就是說 cache 屬性就是緩存的方法呢?而 OC 中的方法我們現在還沒有進行探索,先假設我們已經掌握了相關的底層原理,這裏先簡單提一下。

我們在類裏面編寫的方法,在底層其實是以 SEL + IMP 的形式存在。SEL 就是方法的選擇器,而 IMP 則是具體的方法實現。這裏可以以書籍的目錄以及內容來類比,我們查找一篇文章的時候,需要先知道其標題(SEL),然後在目錄中看有沒有對應的標題,如果有那麼就翻到對應的頁,最後我們就找到了我們想要的內容。當然,iOS 中方法要比書籍的例子複雜一些,不過暫時可以這麼簡單的理解,後面我們會深入方法的底層進行探索。

2.4 bits 屬性

bits 的數據結構類型是 class_data_bits_t,同時也是一個結構體類型。而我們閱讀 objc_class 源碼的時候,會發現很多地方都有 bits 的身影,比如:

class_rw_t *data() { 
    return bits.data();
}

bool hasCustomRR() {
    return ! bits.hasDefaultRR();
}    

bool canAllocFast() {
    assert(!isFuture());
    return bits.canAllocFast();
}

這裏值得我們注意的是,objc_classdata() 方法其實是返回的 bitsdata() 方法,而通過這個 data() 方法,我們發現諸如類的字節對齊、ARC、元類等特性都有 data() 的出現,這間接說明 bits 屬性其實是個大容器,有關於內存管理、C++ 析構等內容在其中有定義。

這裏我們會遇到一個十分重要的知識點: class_rw_tdata() 方法的返回值就是 class_rw_t 類型的指針對象。我們在本文後面會重點介紹。

三、類的屬性存在哪?

上一節我們對 OC 中類結構有了基本的瞭解,但是我們平時最常打交道的內容-屬性,我們還不知道它究竟是存在哪個地方。接下來我們要做一件事情,就是在 objc4-756 的源碼中新建一個 Target,爲什麼不直接用上面的 macOS 命令行項目呢?因爲我們要開始結合 LLDB 打印一些類的內部信息,所以只能是新建一個依靠於 objc4-756 源碼 projecttarget 出來。同樣的,我們還是選擇 macOS 的命令行作爲我們的 target

接着我們新建一個類 Person,然後添加一些實例變量和屬性出來。

// Person.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Person : NSObject
{
    NSString *hobby;
}
@property (nonatomic, copy) NSString *nickName;
@end

NS_ASSUME_NONNULL_END

// main.m
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import "Person.h"
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        Person *p = [[Person alloc] init];
        Class pClass = object_getClass(p);
        NSLog(@"%s", p);
    }
    return 0;
}

我們打一個斷點到 main.m 文件中的 NSLog 語句處,然後運行剛纔新建的 target

target 跑起來之後,我們在控制檯先打印輸出一下 pClass 的內容:

3.1 類的內存結構

我們這個時候需要藉助指針平移來探索,而對於類的內存結構我們先看下面這張表格:

類的內存結構 大小(字節)
isa 8
superclass 8
cache 16

前兩個大小很好理解,因爲 isasuperclass 都是結構體指針,而在 arm64 環境下,一個結構體指針的內存佔用大小爲 8 字節。而第三個屬性 cache 則需要我們進行抽絲剝繭了。

cache_t cache;

struct cache_t {
    struct bucket_t *_buckets; // 8
    mask_t _mask;  // 4
    mask_t _occupied; // 4
}

typedef uint32_t mask_t; 

從上面的代碼我們可以看出,cache 屬性其實是 cache_t 類型的結構體,其內部有一個 8 字節的結構體指針,有 2 個各爲 4 字節的 mask_t。所以加起來就是 16 個字節。也就是說前三個屬性總共的內存偏移量爲 8 + 8 + 16 = 32 個字節,32 是 10 進制的表示,在 16 進制下就是 20。

3.2 探索 bits 屬性

我們剛纔在控制檯打印輸出了 pClass 類對象的內容,我們簡單畫個圖如下所示:

那麼,類的 bits 屬性的內存地址順理成章的就是在 isa 的初始偏移量地址處進行 16 進制下的 20 遞增。也就是

0x1000021c8 + 0x20 = 0x1000021e8

我們嘗試打印這個地址,注意這裏需要強轉一下:

這裏報錯了,問題其實是出在我們的 target 沒有關聯上 libobjc.A.dylib 這個動態庫,我們關聯上重新運行項目

我們重複一遍上面的流程:

這一次成功了。在 objc_class 源碼中有:

class_rw_t *data() { 
    return bits.data();
}

我們不妨打印一下里面的內容:

返回了一個 class_rw_t 指針對象。我們在 objc4-756 源碼中搜索 class_rw_t:

struct class_rw_t {
    // Be warned that Symbolication knows the layout of this structure.
    uint32_t flags;
    uint32_t version;

    const class_ro_t *ro;

    method_array_t methods;
    property_array_t properties;
    protocol_array_t protocols;

    Class firstSubclass;
    Class nextSiblingClass;
    
    ...省略代碼...    
}

顯然的,class_rw_t 也是一個結構體類型,其內部有 methodspropertiesprotocols 等我們十分熟悉的內容。我們先猜想一下,我們的屬性應該存放在 class_rw_tproperties 裏面。爲了驗證我們的猜想,我們接着進行 LLDB 打印:

我們再接着打印 properties:

properties 居然是空的,難道是 bug?其實不然,這裏我們還漏掉了一個非常重要的屬性 ro。我們來到它的定義:

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;
    
    const char * name;
    method_list_t * baseMethodList;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;

    ...隱藏代碼...    
}

ro 的類型是 class_ro_t 結構體,它包含了 baseMethodListbaseProtocolsivarsbaseProperties 等屬性。我們剛纔在 class_rw_t 中沒有找到我們聲明在 Person 類中的實例變量 hobby 和屬性 nickName,那麼希望就在 class_ro_t 身上了,我們打印看看它的內容:

根據名稱我們猜測屬性應該在 baseProperties 裏面,我們打印看看:

Bingo! 我們的屬性 nickName 被找到了,那麼我們的實例變量 hobby 呢?我們從 $8 的 count 爲 1 可以得知肯定不在 baseProperites 裏面。根據名稱我們猜測應該是在 ivars 裏面。

哈哈,hobby 實例變量也被我們找到了,不過這裏的 count 爲什麼是 2 呢?我們打印第二個元素看看:

結果爲 _nickName。這一結果證實了編譯器會幫助我們給屬性 nickName 生成一個帶下劃線前綴的實例變量 _nickName

至此,我們可以得出以下結論:

class_ro_t 是在編譯時就已經確定了的,存儲的是類的成員變量、屬性、方法和協議等內容。
class_rw_t 是可以在運行時來拓展類的一些屬性、方法和協議等內容。

四、類的方法存在哪?

研究完了類的屬性是怎麼存儲的,我們再來看看類的方法。

我們先給我們的 Person 類增加一個 sayHello 的實例方法和一個 sayHappy 的類方法。

// Person.h
- (void)sayHello;
+ (void)sayHappy;

// Person.m
- (void)sayHello
{
    NSLog(@"%s", __func__);
}

+ (void)sayHappy
{
    NSLog(@"%s", __func__);
}

按照上面的思路,我們直接讀取 class_ro_t 中的 baseMethodList 的內容:

sayHello 被打印出來了,說明 baseMethodList 就是存儲實例方法的地方。我們接着打印剩下的內容:

可以看到 baseMethodList 中除了我們的實例方法 sayHello 外,還有屬性 nickNamegettersetter 方法以及一個 C++ 析構方法。但是我們的類方法 sayHappy 並沒有被打印出來。

五、類的類方法存在哪?

我們上面已經得到了屬性,實例方法的是怎麼樣存儲,還留下了一個疑問點,就是類方法是怎麼存儲的,接下來我們用 Runtime 的 API 來實際測試一下。

// main.m
void testInstanceMethod_classToMetaclass(Class pClass){
    
    const char *className = class_getName(pClass);
    Class metaClass = objc_getMetaClass(className);
    
    Method method1 = class_getInstanceMethod(pClass, @selector(sayHello));
    Method method2 = class_getInstanceMethod(metaClass, @selector(sayHello));

    Method method3 = class_getInstanceMethod(pClass, @selector(sayHappy));
    Method method4 = class_getInstanceMethod(metaClass, @selector(sayHappy));
    
    NSLog(@"%p-%p-%p-%p",method1,method2,method3,method4);
    NSLog(@"%s",__func__);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        Person *p = [[Person alloc] init];
        Class pClass = object_getClass(p);
        
        testInstanceMethod_classToMetaclass(pClass);
        NSLog(@"%p", p);
    }
    return 0;
}

運行後打印結果如下:

首先 testInstanceMethod_classToMetaclass 方法測試的是分別從類和元類去獲取實例方法、類方法的結果。由打印結果我們可以知道:

  • 對於類對象來說,sayHello 是實例方法,存儲於類對象的內存中,不存在於元類對象中。而 sayHappy 是類方法,存儲於元類對象的內存中,不存在於類對象中。
  • 對於元類對象來說,sayHello 是類對象的實例方法,跟元類沒關係;sayHappy 是元類對象的實例方法,所以存在元類中。

我們再接着測試:

// main.m
void testClassMethod_classToMetaclass(Class pClass){
    
    const char *className = class_getName(pClass);
    Class metaClass = objc_getMetaClass(className);
    
    Method method1 = class_getClassMethod(pClass, @selector(sayHello));
    Method method2 = class_getClassMethod(metaClass, @selector(sayHello));

    Method method3 = class_getClassMethod(pClass, @selector(sayHappy));
    Method method4 = class_getClassMethod(metaClass, @selector(sayHappy));
    
    NSLog(@"%p-%p-%p-%p",method1,method2,method3,method4);
    NSLog(@"%s",__func__);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        Person *p = [[Person alloc] init];
        Class pClass = object_getClass(p);
        
        testClassMethod_classToMetaclass(pClass);
        NSLog(@"%p", p);
    }
    return 0;
}

運行後打印結果如下:

從結果我們可以看出,對於類對象來說,通過 class_getClassMethod 獲取 sayHappy 是有值的,而獲取 sayHello 是沒有值的;對於元類對象來說,通過 class_getClassMethod 獲取 sayHappy 也是有值的,而獲取 sayHello 是沒有值的。這裏第一點很好理解,但是第二點會有點讓人糊塗,不是說類方法在元類中是體現爲對象方法的嗎?怎麼通過 class_getClassMethod 從元類中也能拿到 sayHappy,我們進入到 class_getClassMethod 方法內部可以解開這個疑惑:

Method class_getClassMethod(Class cls, SEL sel)
{
    if (!cls  ||  !sel) return nil;

    return class_getInstanceMethod(cls->getMeta(), sel);
}

Class getMeta() {
    if (isMetaClass()) return (Class)this;
    else return this->ISA();
}    

可以很清楚的看到,class_getClassMethod 方法底層其實調用的是 class_getInstanceMethod,而 cls->getMeta() 方法底層的判斷邏輯是如果已經是元類就返回,如果不是就返回類的 isa。這也就解釋了上面的 sayHappy 爲什麼會出現在最後的打印中了。

除了上面的 LLDB 打印,我們還可以通過 isa 的方式來驗證類方法存放在元類中。

  • 通過 isa 在類對象中找到元類
  • 打印元類的 baseMethodsList

具體的過程筆者不再贅述。

六、類和元類的創建時機

我們在探索類和元類的時候,對於其創建時機還不是很清楚,這裏我們先拋出結論:

  • 類和元類是在編譯期創建的,即在進行 alloc 操作之前,類和元類就已經被編譯器創建出來了。

那麼如何來證明呢,我們有兩種方式可以來證明:

  • LLDB 打印類和元類的指針

  • 編譯項目後,使用 MachoView 打開程序二進制可執行文件查看:

七、總結

  • 類和元類創建於編譯時,可以通過 LLDB 來打印類和元類的指針,或者 MachOView 查看二進制可執行文件
  • 萬物皆對象:類的本質就是對象
  • 類在 class_ro_t 結構中存儲了編譯時確定的屬性、成員變量、方法和協議等內容。
  • 實例方法存放在類中
  • 類方法存放在元類中

我們完成了對 iOS 中類的底層探索,下一章我們將對類的緩存進行深一步探索,敬請期待~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章