Objective-C Runtime 執行時之三:方法與訊息

南峰子的技術部落格發表於2014-11-11

前面我們討論了Runtime中對類和物件的處理,及對成員變數與屬性的處理。這一章,我們就要開始討論Runtime中最有意思的一部分:訊息處理機制。我們將詳細討論訊息的傳送及訊息的轉發。不過在討論訊息之前,我們先來了解一下與方法相關的一些內容。

基礎資料型別

SEL

SEL又叫選擇器,是表示一個方法的selector的指標,其定義如下:

typedef struct objc_selector *SEL;

objc_selector結構體的詳細定義沒有在<objc/runtime.h>標頭檔案中找到。方法的selector用於表示執行時方法的名字。Objective-C在編譯時,會依據每一個方法的名字、引數序列,生成一個唯一的整型標識(Int型別的地址),這個標識就是SEL。如下程式碼所示:

SEL sel1 = @selector(method1);
NSLog(@"sel : %p", sel1);

上面的輸出為:

2014-10-30 18:40:07.518 RuntimeTest[52734:466626] sel : 0x100002d72

兩個類之間,不管它們是父類與子類的關係,還是之間沒有這種關係,只要方法名相同,那麼方法的SEL就是一樣的。每一個方法都對應著一個SEL。所以在Objective-C同一個類(及類的繼承體系)中,不能存在2個同名的方法,即使引數型別不同也不行。相同的方法只能對應一個SEL。這也就導致Objective-C在處理相同方法名且引數個數相同但型別不同的方法方面的能力很差。如在某個類中定義以下兩個方法:

- (void)setWidth:(int)width;
- (void)setWidth:(double)width;

這樣的定義被認為是一種編譯錯誤,所以我們不能像C++, C#那樣。而是需要像下面這樣來宣告:

-(void)setWidthIntValue:(int)width;
-(void)setWidthDoubleValue:(double)width;

當然,不同的類可以擁有相同的selector,這個沒有問題。不同類的例項物件執行相同的selector時,會在各自的方法列表中去根據selector去尋找自己對應的IMP。

工程中的所有的SEL組成一個Set集合,Set的特點就是唯一,因此SEL是唯一的。因此,如果我們想到這個方法集合中查詢某個方法時,只需要去找到這個方法對應的SEL就行了,SEL實際上就是根據方法名hash化了的一個字串,而對於字串的比較僅僅需要比較他們的地址就可以了,可以說速度上無語倫比!!但是,有一個問題,就是數量增多會增大hash衝突而導致的效能下降(或是沒有衝突,因為也可能用的是perfect hash)。但是不管使用什麼樣的方法加速,如果能夠將總量減少(多個方法可能對應同一個SEL),那將是最犀利的方法。那麼,我們就不難理解,為什麼SEL僅僅是函式名了。

本質上,SEL只是一個指向方法的指標(準確的說,只是一個根據方法名hash化了的KEY值,能唯一代表一個方法),它的存在只是為了加快方法的查詢速度。這個查詢過程我們將在下面討論。

我們可以在執行時新增新的selector,也可以在執行時獲取已存在的selector,我們可以通過下面三種方法來獲取SEL:

  1. sel_registerName函式
  2. Objective-C編譯器提供的@selector()
  3. NSSelectorFromString()方法

IMP

IMP實際上是一個函式指標,指向方法實現的首地址。其定義如下:

id (*IMP)(id, SEL, ...)

這個函式使用當前CPU架構實現的標準的C呼叫約定。第一個引數是指向self的指標(如果是例項方法,則是類例項的記憶體地址;如果是類方法,則是指向元類的指標),第二個引數是方法選擇器(selector),接下來是方法的實際引數列表。

前面介紹過的SEL就是為了查詢方法的最終實現IMP的。由於每個方法對應唯一的SEL,因此我們可以通過SEL方便快速準確地獲得它所對應的IMP,查詢過程將在下面討論。取得IMP後,我們就獲得了執行這個方法程式碼的入口點,此時,我們就可以像呼叫普通的C語言函式一樣來使用這個函式指標了。

通過取得IMP,我們可以跳過Runtime的訊息傳遞機制,直接執行IMP指向的函式實現,這樣省去了Runtime訊息傳遞過程中所做的一系列查詢操作,會比直接向物件傳送訊息高效一些。

Method

介紹完SEL和IMP,我們就可以來講講Method了。Method用於表示類定義中的方法,則定義如下:

typedef struct objc_method *Method;

struct objc_method {
    SEL method_name                 OBJC2_UNAVAILABLE;  // 方法名
    char *method_types                  OBJC2_UNAVAILABLE;
    IMP method_imp                      OBJC2_UNAVAILABLE;  // 方法實現
}

我們可以看到該結構體中包含一個SEL和IMP,實際上相當於在SEL和IMP之間作了一個對映。有了SEL,我們便可以找到對應的IMP,從而呼叫方法的實現程式碼。具體操作流程我們將在下面討論。

objc_method_description

objc_method_description定義了一個Objective-C方法,其定義如下:

struct objc_method_description { SEL name; char *types; };

方法相關操作函式

Runtime提供了一系列的方法來處理與方法相關的操作。包括方法本身及SEL。本節我們介紹一下這些函式。

方法

方法操作相關函式包括下以:

// 呼叫指定方法的實現
id method_invoke ( id receiver, Method m, ... );

// 呼叫返回一個資料結構的方法的實現
void method_invoke_stret ( id receiver, Method m, ... );

// 獲取方法名
SEL method_getName ( Method m );

// 返回方法的實現
IMP method_getImplementation ( Method m );

// 獲取描述方法引數和返回值型別的字串
const char * method_getTypeEncoding ( Method m );

// 獲取方法的返回值型別的字串
char * method_copyReturnType ( Method m );

// 獲取方法的指定位置引數的型別字串
char * method_copyArgumentType ( Method m, unsigned int index );

// 通過引用返回方法的返回值型別字串
void method_getReturnType ( Method m, char *dst, size_t dst_len );

// 返回方法的引數的個數
unsigned int method_getNumberOfArguments ( Method m );

// 通過引用返回方法指定位置引數的型別字串
void method_getArgumentType ( Method m, unsigned int index, char *dst, size_t dst_len );

// 返回指定方法的方法描述結構體
struct objc_method_description * method_getDescription ( Method m );

// 設定方法的實現
IMP method_setImplementation ( Method m, IMP imp );

// 交換兩個方法的實現
void method_exchangeImplementations ( Method m1, Method m2 );
  • method_invoke函式,返回的是實際實現的返回值。引數receiver不能為空。這個方法的效率會比method_getImplementation和method_getName更快。
  • method_getName函式,返回的是一個SEL。如果想獲取方法名的C字串,可以使用sel_getName(method_getName(method))。
  • method_getReturnType函式,型別字串會被拷貝到dst中。
  • method_setImplementation函式,注意該函式返回值是方法之前的實現。

方法選擇器

選擇器相關的操作函式包括:

// 返回給定選擇器指定的方法的名稱
const char * sel_getName ( SEL sel );

// 在Objective-C Runtime系統中註冊一個方法,將方法名對映到一個選擇器,並返回這個選擇器
SEL sel_registerName ( const char *str );

// 在Objective-C Runtime系統中註冊一個方法
SEL sel_getUid ( const char *str );

// 比較兩個選擇器
BOOL sel_isEqual ( SEL lhs, SEL rhs );
  • sel_registerName函式:在我們將一個方法新增到類定義時,我們必須在Objective-C Runtime系統中註冊一個方法名以獲取方法的選擇器。

方法呼叫流程

在Objective-C中,訊息直到執行時才繫結到方法實現上。編譯器會將訊息表示式[receiver message]轉化為一個訊息函式的呼叫,即objc_msgSend。這個函式將訊息接收者和方法名作為其基礎引數,如以下所示:

objc_msgSend(receiver, selector)

如果訊息中還有其它引數,則該方法的形式如下所示:

objc_msgSend(receiver, selector, arg1, arg2, ...)

這個函式完成了動態繫結的所有事情:

  1. 首先它找到selector對應的方法實現。因為同一個方法可能在不同的類中有不同的實現,所以我們需要依賴於接收者的類來找到的確切的實現。
  2. 它呼叫方法實現,並將接收者物件及方法的所有引數傳給它。
  3. 最後,它將實現返回的值作為它自己的返回值。

訊息的關鍵在於我們前面章節討論過的結構體objc_class,這個結構體有兩個欄位是我們在分發訊息的關注的:

  1. 指向父類的指標
  2. 一個類的方法分發表,即methodLists。

當我們建立一個新物件時,先為其分配記憶體,並初始化其成員變數。其中isa指標也會被初始化,讓物件可以訪問類及類的繼承體系。

下圖演示了這樣一個訊息的基本框架:

messaging1

當訊息傳送給一個物件時,objc_msgSend通過物件的isa指標獲取到類的結構體,然後在方法分發表裡面查詢方法的selector。如果沒有找到selector,則通過objc_msgSend結構體中的指向父類的指標找到其父類,並在父類的分發表裡面查詢方法的selector。依此,會一直沿著類的繼承體系到達NSObject類。一旦定位到selector,函式會就獲取到了實現的入口點,並傳入相應的引數來執行方法的具體實現。如果最後沒有定位到selector,則會走訊息轉發流程,這個我們在後面討論。

為了加速訊息的處理,執行時系統快取使用過的selector及對應的方法的地址。這點我們在前面討論過,不再重複。

隱藏引數

objc_msgSend有兩個隱藏引數:

  1. 訊息接收物件
  2. 方法的selector

這兩個引數為方法的實現提供了呼叫者的資訊。之所以說是隱藏的,是因為它們在定義方法的原始碼中沒有宣告。它們是在編譯期被插入實現程式碼的。

雖然這些引數沒有顯示宣告,但在程式碼中仍然可以引用它們。我們可以使用self來引用接收者物件,使用_cmd來引用選擇器。如下程式碼所示:

- strange
{
    id  target = getTheReceiver();
    SEL method = getTheMethod();

    if ( target == self || method == _cmd )
        return nil;
    return [target performSelector:method];
}

當然,這兩個引數我們用得比較多的是self,_cmd在實際中用得比較少。

獲取方法地址

Runtime中方法的動態繫結讓我們寫程式碼時更具靈活性,如我們可以把訊息轉發給我們想要的物件,或者隨意交換一個方法的實現等。不過靈活性的提升也帶來了效能上的一些損耗。畢竟我們需要去查詢方法的實現,而不像函式呼叫來得那麼直接。當然,方法的快取一定程度上解決了這一問題。

我們上面提到過,如果想要避開這種動態繫結方式,我們可以獲取方法實現的地址,然後像呼叫函式一樣來直接呼叫它。特別是當我們需要在一個迴圈內頻繁地呼叫一個特定的方法時,通過這種方式可以提高程式的效能。

NSObject類提供了methodForSelector:方法,讓我們可以獲取到方法的指標,然後通過這個指標來呼叫實現程式碼。我們需要將methodForSelector:返回的指標轉換為合適的函式型別,函式引數和返回值都需要匹配上。

我們通過以下程式碼來看看methodForSelector:的使用:

void (*setter)(id, SEL, BOOL);
int i;

setter = (void (*)(id, SEL, BOOL))[target
    methodForSelector:@selector(setFilled:)];
for ( i = 0 ; i < 1000 ; i++ )
    setter(targetList[i], @selector(setFilled:), YES);

這裡需要注意的就是函式指標的前兩個引數必須是id和SEL。

當然這種方式只適合於在類似於for迴圈這種情況下頻繁呼叫同一方法,以提高效能的情況。另外,methodForSelector:是由Cocoa執行時提供的;它不是Objective-C語言的特性。

訊息轉發

當一個物件能接收一個訊息時,就會走正常的方法呼叫流程。但如果一個物件無法接收指定訊息時,又會發生什麼事呢?預設情況下,如果是以[object message]的方式呼叫方法,如果object無法響應message訊息時,編譯器會報錯。但如果是以perform…的形式來呼叫,則需要等到執行時才能確定object是否能接收message訊息。如果不能,則程式崩潰。

通常,當我們不能確定一個物件是否能接收某個訊息時,會先呼叫respondsToSelector:來判斷一下。如下程式碼所示:

if ([self respondsToSelector:@selector(method)]) {
    [self performSelector:@selector(method)];
}

不過,我們這邊想討論下不使用respondsToSelector:判斷的情況。這才是我們這一節的重點。

當一個物件無法接收某一訊息時,就會啟動所謂”訊息轉發(message forwarding)“機制,通過這一機制,我們可以告訴物件如何處理未知的訊息。預設情況下,物件接收到未知的訊息,會導致程式崩潰,通過控制檯,我們可以看到以下異常資訊:

-[SUTRuntimeMethod method]: unrecognized selector sent to instance 0x100111940
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[SUTRuntimeMethod method]: unrecognized selector sent to instance 0x100111940'

這段異常資訊實際上是由NSObject的”doesNotRecognizeSelector”方法丟擲的。不過,我們可以採取一些措施,讓我們的程式執行特定的邏輯,而避免程式的崩潰。

訊息轉發機制基本上分為三個步驟:

  1. 動態方法解析
  2. 備用接收者
  3. 完整轉發

下面我們詳細討論一下這三個步驟。

動態方法解析

物件在接收到未知的訊息時,首先會呼叫所屬類的類方法+resolveInstanceMethod:(例項方法)或者+resolveClassMethod:(類方法)。在這個方法中,我們有機會為該未知訊息新增一個”處理方法”“。不過使用該方法的前提是我們已經實現了該”處理方法”,只需要在執行時通過class_addMethod函式動態新增到類裡面就可以了。如下程式碼所示:

void functionForMethod1(id self, SEL _cmd) {
   NSLog(@"%@, %p", self, _cmd);
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {

    NSString *selectorString = NSStringFromSelector(sel);

    if ([selectorString isEqualToString:@"method1"]) {
        class_addMethod(self.class, @selector(method1), (IMP)functionForMethod1, "@:");
    }

    return [super resolveInstanceMethod:sel];
}

不過這種方案更多的是為了實現@dynamic屬性。

備用接收者

如果在上一步無法處理訊息,則Runtime會繼續調以下方法:

- (id)forwardingTargetForSelector:(SEL)aSelector

如果一個物件實現了這個方法,並返回一個非nil的結果,則這個物件會作為訊息的新接收者,且訊息會被分發到這個物件。當然這個物件不能是self自身,否則就是出現無限迴圈。當然,如果我們沒有指定相應的物件來處理aSelector,則應該呼叫父類的實現來返回結果。

使用這個方法通常是在物件內部,可能還有一系列其它物件能處理該訊息,我們便可借這些物件來處理訊息並返回,這樣在物件外部看來,還是由該物件親自處理了這一訊息。如下程式碼所示:

@interface SUTRuntimeMethodHelper : NSObject

- (void)method2;

@end

@implementation SUTRuntimeMethodHelper

- (void)method2 {
    NSLog(@"%@, %p", self, _cmd);
}

@end

#pragma mark -

@interface SUTRuntimeMethod () {
    SUTRuntimeMethodHelper *_helper;
}

@end

@implementation SUTRuntimeMethod

+ (instancetype)object {
    return [[self alloc] init];
}

- (instancetype)init {
    self = [super init];
    if (self != nil) {
        _helper = [[SUTRuntimeMethodHelper alloc] init];
    }

    return self;
}

- (void)test {
    [self performSelector:@selector(method2)];
}

- (id)forwardingTargetForSelector:(SEL)aSelector {

    NSLog(@"forwardingTargetForSelector");

    NSString *selectorString = NSStringFromSelector(aSelector);

    // 將訊息轉發給_helper來處理
    if ([selectorString isEqualToString:@"method2"]) {
        return _helper;
    }

    return [super forwardingTargetForSelector:aSelector];
}

@end

這一步合適於我們只想將訊息轉發到另一個能處理該訊息的物件上。但這一步無法對訊息進行處理,如操作訊息的引數和返回值。

完整訊息轉發

如果在上一步還不能處理未知訊息,則唯一能做的就是啟用完整的訊息轉發機制了。此時會呼叫以下方法:

- (void)forwardInvocation:(NSInvocation *)anInvocation

執行時系統會在這一步給訊息接收者最後一次機會將訊息轉發給其它物件。物件會建立一個表示訊息的NSInvocation物件,把與尚未處理的訊息有關的全部細節都封裝在anInvocation中,包括selector,目標(target)和引數。我們可以在forwardInvocation方法中選擇將訊息轉發給其它物件。

forwardInvocation:方法的實現有兩個任務:

  1. 定位可以響應封裝在anInvocation中的訊息的物件。這個物件不需要能處理所有未知訊息。
  2. 使用anInvocation作為引數,將訊息傳送到選中的物件。anInvocation將會保留呼叫結果,執行時系統會提取這一結果並將其傳送到訊息的原始傳送者。

不過,在這個方法中我們可以實現一些更復雜的功能,我們可以對訊息的內容進行修改,比如追回一個引數等,然後再去觸發訊息。另外,若發現某個訊息不應由本類處理,則應呼叫父類的同名方法,以便繼承體系中的每個類都有機會處理此呼叫請求。

還有一個很重要的問題,我們必須重寫以下方法:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector

訊息轉發機制使用從這個方法中獲取的資訊來建立NSInvocation物件。因此我們必須重寫這個方法,為給定的selector提供一個合適的方法簽名。

完整的示例如下所示:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *signature = [super methodSignatureForSelector:aSelector];

    if (!signature) {
        if ([SUTRuntimeMethodHelper instancesRespondToSelector:aSelector]) {
            signature = [SUTRuntimeMethodHelper instanceMethodSignatureForSelector:aSelector];
        }
    }

    return signature;
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    if ([SUTRuntimeMethodHelper instancesRespondToSelector:anInvocation.selector]) {
        [anInvocation invokeWithTarget:_helper];
    }
}

NSObject的forwardInvocation:方法實現只是簡單呼叫了doesNotRecognizeSelector:方法,它不會轉發任何訊息。這樣,如果不在以上所述的三個步驟中處理未知訊息,則會引發一個異常。

從某種意義上來講,forwardInvocation:就像一個未知訊息的分發中心,將這些未知的訊息轉發給其它物件。或者也可以像一個運輸站一樣將所有未知訊息都傳送給同一個接收物件。這取決於具體的實現。

訊息轉發與多重繼承

回過頭來看第二和第三步,通過這兩個方法我們可以允許一個物件與其它物件建立關係,以處理某些未知訊息,而表面上看仍然是該物件在處理訊息。通過這種關係,我們可以模擬“多重繼承”的某些特性,讓物件可以“繼承”其它物件的特性來處理一些事情。不過,這兩者間有一個重要的區別:多重繼承將不同的功能整合到一個物件中,它會讓物件變得過大,涉及的東西過多;而訊息轉發將功能分解到獨立的小的物件中,並通過某種方式將這些物件連線起來,並做相應的訊息轉發。

不過訊息轉發雖然類似於繼承,但NSObject的一些方法還是能區分兩者。如respondsToSelector:和isKindOfClass:只能用於繼承體系,而不能用於轉發鏈。便如果我們想讓這種訊息轉發看起來像是繼承,則可以重寫這些方法,如以下程式碼所示:

- (BOOL)respondsToSelector:(SEL)aSelector   {       if ( [super respondsToSelector:aSelector] )         return YES;     else {          /* Here, test whether the aSelector message can     *            * be forwarded to another object and whether that  *            * object can respond to it. Return YES if it can.  */      }
        return NO;  }

小結

在此,我們已經瞭解了Runtime中訊息傳送和轉發的基本機制。這也是Runtime的強大之處,通過它,我們可以為程式增加很多動態的行為,雖然我們在實際開發中很少直接使用這些機制(如直接呼叫objc_msgSend),但瞭解它們有助於我們更多地去了解底層的實現。其實在實際的編碼過程中,我們也可以靈活地使用這些機制,去實現一些特殊的功能,如hook操作等。

相關文章