從 runtime 原始碼解析物件傳送訊息的動態性

Mitsui_發表於2018-01-04

寫在前面的話

本文不是對runtime的使用的簡單的闡述,而是我對runtime中訊息傳送的一些更深層的理解。

不要相信任何部落格或者文章,apple 的 opensource 原始碼會告訴我們想知道的一切,所以善用原始碼可能會事半功倍。

一、結構體 vs 類

我們知道,OC 是 C 語言的超集,是對 C 和 C++ 的進一步封裝,一開始學習 OC 這門語言的時候,我們就被灌輸過一句話:物件儲存在堆記憶體,變數儲存在棧記憶體,而 runtime 告訴我們類是對 C 和 C++ 中結構體的封裝,而結構體是值型別(值型別 vs 引用型別),肯定是儲存在棧上的,這不是自相矛盾嗎?另外,OC1.0 是完全對 C 語言的封裝,C 語言的結構體是不能宣告和實現函式的,到底是怎麼回事呢?現在我們用結構體實現一個簡單的類:

// 父類
struct SuperFoo {
    int val;
};

struct Foo {
    int val;
    // 指向自己的指標
    struct Foo *self;
    // 指向父類的指標
    struct SuperFoo *superFoo;
    // 宣告一個指標變數 sum,它的型別為具有一個 int 型別返回值,兩個 int 型別引數的函式。
    int(*sum)(int,int);
};

typedef struct Foo* PFoo; // PFoo 為一個指向 Foo 結構體的指標型別

int sum(int a, int b)
{
    return a + b;
}

int main(int argc, const char * argv[]) {
    // 宣告一個指標指向 Foo 結構體,PFoo就是引用型別,pFoo 就是分配在棧記憶體的變數
    PFoo pFoo; 
    // 相當於 OC 中的alloc,將例項存入堆記憶體,現在 pFoo 就指向(引用)一個堆內
    // 存的例項
    pFoo = (PFoo)malloc(sizeof(PFoo));
    // init 初始化操作
    pFoo -> val = 4;
    pFoo -> self = pFoo;
    // 將函式 sum() 賦值給 pFoo 的成員變數 sum
    pFoo -> sum = sum;
    // 可以建立一個父類物件,讓 superFoo 指向父類物件
    // pFoo -> superFoo = superPFoo;
    // use
    // 通過函式指標呼叫函式,pFoo -> sum 是一個指向函式sum的指標
    int result = (pFoo -> sum)(4, 5);
    printf("result = %d\n", result);
    // print "result = 9"
    
    // 釋放記憶體
    free(pFoo);
    // 將 pFoo 設定為空指標
    pFoo = NULL;
    
    return 0;
}

複製程式碼

上述的程式碼就是用結構體實現一個簡單的類,其實真正的runtime對類的實現比這個要複雜的多的多,函式的呼叫也不是簡單的通過函式指標的成員變數呼叫,說這個只是想引入一下函式指標對類的意義以及值型別和引用型別的關係。

二、OC 訊息傳送的動態性

1. 動態性

提及 OC 及 runtime,我們聽到最多的一句話就是 OC 是一門動態型別的語言,所謂的動態和靜態的區分主要是指程式的執行是依賴於編譯期還是執行期。

如果一段程式的執行在編譯結束後就決定了它的記憶體分配,那麼我們就可以說它是個靜態型別的語言,而 OC 的動態性在於,它在編譯期只是進行簡單的語義語法檢查,而不會分配記憶體。它在編譯期只關心某個型別的某個物件能不能呼叫某個方法,而不會關心這個物件是不是 nil,也不會關心這個方法的實現細節,甚至不關心到底有沒有這個方法,這些事都是執行期才會去做的事。

這就決定了我們可以在執行期對我們的程式做更多的更改,當然也存在很多弊端,有句話說得好:“動態型別一時爽,程式碼重構火葬場”,執行期分配記憶體確實會讓我們的程式出現很多執行時的錯誤,比如,訪問了野指標、記憶體洩漏等等,確實會給程式帶來很多災難性的bug,甚至於必須重構程式碼才能解決。

因此,對執行時的充分了解能使我們盡最大可能的規避這些錯誤,從而減少我們踩坑的機率和填坑的時間。

2. 訊息傳送的動態性

舉個例子:

void hello() {
    printf("Hello, world!");
}

void bye() {
    printf("Goodbye, world!");
}

void doSomeThing(int anyState) {
    // 函式的呼叫由編譯時決定,函式的彙編指令是硬編碼
    if (anyState) {
        hello();
    } else {
        bye();
    }
}

複製程式碼

上述程式碼是一段簡單的C語言程式碼,不管會不會 C 語言,應該都能看得懂,當呼叫 doSomeThing() 的時候,不管 if 條件是不是成立,程式都會將 hello()bye() 這兩個函式的彙編指令硬編碼進彙編指令集。

從 runtime 原始碼解析物件傳送訊息的動態性

假設 hello()bye() 這兩個函式在程式碼區中的儲存為上圖,則在 doSomeThing() 中,編譯器會在編譯期,在 ifelse 中都會將這兩塊記憶體生成的彙編指令硬編碼進彙編指令集。類似於:

從 runtime 原始碼解析物件傳送訊息的動態性
這就是一種靜態的呼叫函式的方式,而動態的呼叫方法為:

void hello() {
    printf("Hello, world!");
}

void bye() {
    printf("Goodbye, world!");
}

void doSomeThing(int anyState) {
    // 編譯時只獲取函式的地址,執行時才發出指令,執行函式
    void (*func)();
    if (anyState) {
        func = hello;
    } else {
        func = bye;
    }
}
複製程式碼

這段程式碼和上述程式碼的差異為,在 if 條件語句中呼叫函式的方式變成了函式指標而不是簡單的函式呼叫。它的動態性體現在,編譯器在編譯期僅僅獲取函式的首地址,將指向函式的首地址硬編碼進彙編指令集,而不是將整個函式的指令全部硬編碼,到執行時再去決定呼叫那個函式(訪問哪個函式的記憶體)。如果你在執行時強制將這個本來指向某個函式的指標指向另一個函式,那麼這就是所謂的方法交換。

從 runtime 原始碼解析物件傳送訊息的動態性

這就是所謂的呼叫函式的動態性。OC 這門語言就是採用這種函式指標的方式實現訊息傳送的動態性。當然也不可能實現的這麼簡單。

真正的彙編指令集肯定不可能這麼簡單,只是簡單畫了一下,更容易理解一點。

三、將方法儲存到類

OC 的動態性並不僅僅體現在訊息傳送方面,還有其他的,比如,執行時新增屬性、新增成員變數、訊息轉發等等,其實對屬性、變數和方法的封裝大同小異,這裡僅分析了 runtime 對訊息的儲存和獲取。

大家都知道的一件事就是,OC 中類的實質是結構體,結構體中儲存了所有的成員方法列表、屬性列表、協議列表等等。儲存結構如下圖:

從 runtime 原始碼解析物件傳送訊息的動態性

可以看到一個 method_array_t 型別的變數 methods,這就是類中的方法列表,method_array_t 是一個類,所以 methods 指向一個類例項,它在runtime中的組成為:

從 runtime 原始碼解析物件傳送訊息的動態性

可以看到,方法列表最終儲存的東西為 method_t 結構體,它有三個成員變數,一個 name,可以理解為方法的簽名,OC 會通過方法簽名去列表中查詢某個方法的實現,runtime 對它的定義為:

/// An opaque type that represents a method selector.
typedef struct objc_selector *SEL;
複製程式碼

可以看出這是一個指標型別,指向 objc_selector 結構體。另一個成員為:const char *types 常量為 OC 執行時方法的 typeEncoding 集合,它指定了方法的引數型別以及在函式呼叫時引數入棧所要的記憶體空間,沒有這個標識就無法動態的壓入引數 Type Encoding

IMP imp 就是一個指向函式的函式指標,就是一個指向方法的首地址的指標。IMP 型別被定義為:

/// A pointer to the function of a method implementation. 
#if !OBJC_OLD_DISPATCH_PROTOTYPES
typedef void (*IMP)(void /* id, SEL, ... */ ); 
#else
typedef id (*IMP)(id, SEL, ...); 
#endif

複製程式碼

可以看出這也是一個指標型別,指向一個函式,即函式指標。當我們向物件的方法列表新增方法的時候,會呼叫:

BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types)
{
    if (!cls) return NO;

    rwlock_writer_t lock(runtimeLock);
    return ! addMethod(cls, name, imp, types ?: "", NO);
}
複製程式碼

addMethod() 會返回一個 IMP 型別的函式指標,這個函式會將傳入的 imp 新增進類的函式列表,並且更新快取,最後返回這個 imp。如果 addMethod() 方法返回為空指標,則新增失敗,返回 falseaddMethod() 方法的具體實現細節為:

static IMP 
addMethod(Class cls, SEL name, IMP imp, const char *types, bool replace)
{
    IMP result = nil;
    // 1
    runtimeLock.assertWriting();
    
    // 2
    assert(types);
    assert(cls->isRealized());
    
    // 3
    method_t *m;
    if ((m = getMethodNoSuper_nolock(cls, name))) {
        // already exists  
        // 4
        if (!replace) {
            result = m->imp;
        } else {
            result = _method_setImplementation(cls, m, imp);
        }
    } else {
        // fixme optimize
        // 5
        method_list_t *newlist;
        newlist = (method_list_t *)calloc(sizeof(*newlist), 1);
        newlist->entsizeAndFlags = 
            (uint32_t)sizeof(method_t) | fixed_up_method_list;
        newlist->count = 1;
        newlist->first.name = name;
        newlist->first.types = strdupIfMutable(types);
        newlist->first.imp = imp;

        prepareMethodLists(cls, &newlist, 1, NO, NO);
        cls->data()->methods.attachLists(&newlist, 1);
        flushCaches(cls);

        result = nil;
    }

    return result;
}
複製程式碼

根據註釋順序:

1、加寫入鎖。

2、檢查型別,檢查類是否實現。

3、宣告一個指標變數,指向 method_t 結構體,判斷方法是否已經存在。

4、如果方法已經存在,判斷是替換方法還是新增方法,如果不是替換,直接返回已經存在的方法的實現,如果是替換,則直接覆蓋原方法。

5、如果方法不存在,則將其新增進入方法列表。

更具體的實現:runtime,可以下載最新的 runtime 原始碼檢視。

四、從類中查詢方法

當我們向物件傳送訊息的時候:

id returnValue = [obj doSomeThingWithParams:params];
複製程式碼

編譯器會將它編譯成原型為:

void objc_msgSend(id self, SEL cmd, ...);
複製程式碼

的 C 函式。所以上面的函式會被翻譯成:

id returnValue = objc_msgSend(obj, @selector(doSomeThingWithParams:), params);
複製程式碼

這是一個標準的 C 函式,而且知道執行時的 iOS 開發者大部分都對它有所瞭解。我們來看一下,runtime 如何通過這個函式實現 doSomeThingWithParams 這個方法的呼叫。

當我們使用 objc_msgSend() 呼叫函式時,函式的呼叫棧為:

0 lookUpImpOrForward
1 _class_lookupMethodAndLoadCache3
2 objc_msgSend
3 main
4 start
複製程式碼

可以看到在呼叫了 objc_msgSend 之後,呼叫了 class_lookupMethodAndLoadCache3 這個函式,這個函式名的字面意思為:從類中查詢方法並且載入快取。這個函式的實現為:

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

複製程式碼

就呼叫了一個函式 lookUpImpOrForward(),這個函式名的字面意思是:查詢 imp 或者轉發,可以看出來,這個方法應該就是從方法列表中查詢函式指標的那個方法了。它的實現為:

/***********************************************************************
* lookUpImpOrForward.
* The standard IMP lookup. 
* initialize==NO tries to avoid +initialize (but sometimes fails)
* cache==NO skips optimistic unlocked lookup (but uses cache elsewhere)
* Most callers should use initialize==YES and cache==YES.
* inst is an instance of cls or a subclass thereof, or nil if none is known. 
*   If cls is an un-initialized metaclass then a non-nil inst is faster.
* May return _objc_msgForward_impcache. IMPs destined for external use 
*   must be converted to _objc_msgForward or _objc_msgForward_stret.
*   If you don't want forwarding at all, use lookUpImpOrNil() instead.
**********************************************************************/
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver)
{
    Class curClass;
    IMP imp = nil;
    Method meth;
    bool triedResolver = NO;

    runtimeLock.assertUnlocked();

    // Optimistic cache lookup
    if (cache) {
        imp = cache_getImp(cls, sel);
        if (imp) return imp;
    }

    if (!cls->isRealized()) {
        rwlock_writer_t lock(runtimeLock);
        realizeClass(cls);
    }

    if (initialize  &&  !cls->isInitialized()) {
        _class_initialize (_class_getNonMetaClass(cls, inst));
        // If sel == initialize, _class_initialize will send +initialize and 
        // then the messenger will send +initialize again after this 
        // procedure finishes. Of course, if this is not being called 
        // from the messenger then it won't happen. 2778172
    }

    // The lock is held to make method-lookup + cache-fill atomic 
    // with respect to method addition. Otherwise, a category could 
    // be added but ignored indefinitely because the cache was re-filled 
    // with the old value after the cache flush on behalf of the category.
 retry:
    runtimeLock.read();

    // Try this class's cache.

    imp = cache_getImp(cls, sel);
    if (imp) goto done;

    // Try this class's method lists.

    meth = getMethodNoSuper_nolock(cls, sel);
    if (meth) {
        log_and_fill_cache(cls, meth->imp, sel, inst, cls);
        imp = meth->imp;
        goto done;
    }

    // Try superclass caches and method lists.

    curClass = cls;
    while ((curClass = curClass->superclass)) {
        // Superclass cache.
        imp = cache_getImp(curClass, sel);
        if (imp) {
            if (imp != (IMP)_objc_msgForward_impcache) {
                // Found the method in a superclass. Cache it in this class.
                log_and_fill_cache(cls, imp, sel, inst, curClass);
                goto done;
            }
            else {
                // Found a forward:: entry in a superclass.
                // Stop searching, but don't cache yet; call method 
                // resolver for this class first.
                break;
            }
        }

        // Superclass method list.
        meth = getMethodNoSuper_nolock(curClass, sel);
        if (meth) {
            log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
            imp = meth->imp;
            goto done;
        }
    }

    // No implementation found. Try method resolver once.

    if (resolver  &&  !triedResolver) {
        runtimeLock.unlockRead();
        _class_resolveMethod(cls, sel, inst);
        // Don't cache the result; we don't hold the lock so it may have 
        // changed already. Re-do the search from scratch instead.
        triedResolver = YES;
        goto retry;
    }

    // No implementation found, and method resolver didn't help. 
    // Use forwarding.

    imp = (IMP)_objc_msgForward_impcache;
    cache_fill(cls, sel, imp, inst);

 done:
    runtimeLock.unlockRead();

    return imp;
}
複製程式碼

原始碼中給的註釋很清楚,先從優化快取中查詢 imp,如果有直接返回,如果沒有,先判斷類是否實現,如果沒有就去實現類,然後判斷類是否初始化,如果沒有就去初始化,再然後去類中的快取列表中查詢,找到就返回,如果沒找到,再去父類的快取和父類的方法列表中查詢,找到就返回,如果還是沒有,則允許一次 resolve,如果還是沒有,則進入訊息轉發。

然後就可以使用返回的 imp 和彙編指令完成方法的呼叫了。對彙編精通的可以參考原始碼中的 objc-msg 模組檢視彙編指令對 imp 的使用。

相關文章