Swift 與 Objective-C

Objective-C 訊息轉送的 3 個階段

iOS 錯誤記錄中最常見的訊息之一,就是 unrecognized selector sent to instance。

閱讀 6 分鐘
Objective-C 訊息轉送的 3 個階段 封面圖

iOS 錯誤記錄中最常見的訊息之一,就是 unrecognized selector sent to instance

不過,這個錯誤其實不是「立即死亡」。Objective-C 執行階段在發生錯誤前,會給物件三次機會。這套補救流程就是訊息轉送(Message Forwarding)。

上一篇 objc_msgSend 文章整理到「方法搜尋失敗後會執行訊息轉送的 3 個階段」。這次會用程式碼逐一拆解這三個階段。重點是為什麼第 2 階段被稱為Fast Forwarding,以及第 3 階段為什麼需要方法簽章。


全貌:三次機會與成本

如果方法搜尋一路到達最上層類別,仍找不到 IMP(指向方法實際實作的函式指標),執行階段就會依序提出以下問題。

階段 方法 問題 成本
1. 動態方法解析 resolveInstanceMethod: 「現在要不要新增方法?」
2. 替代接收者 forwardingTargetForSelector: 「有沒有其他物件可以代為接收?」 低(Fast)
3. 完整轉送 methodSignatureForSelector: + forwardInvocation: 「我把整個訊息交給你,你要自行處理嗎?」

順序也就是成本順序。執行階段會先嘗試最便宜的方法;全部遭到拒絕後,呼叫doesNotRecognizeSelector:,接著就會發生那個著名的錯誤。


第 1 階段:resolveInstanceMethod: — 現在新增方法

第一個問題會以類別方法的形式送出。在這裡透過class_addMethod加入實作並回傳 YES,訊息傳送就會從頭重新開始,這次便能成功。

void dynamicIMP(id self, SEL _cmd) {
    NSLog(@"動態新增的實作");
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

第三個引數的"v@:"是型別編碼。回傳值是 void(v)、接收者是 id(@)、選擇子是(:)——所有 Objective-C 方法都會接收兩個隱藏引數 self 與_cmd,這件事在此便清楚呈現。

Core Data 就是這個階段的代表性使用者。以@dynamic宣告的屬性在編譯時沒有存取子,第一次呼叫時才會在這個階段即時建立。

有一點要注意:resolveInstanceMethod:即使不是轉送情境,也可能被呼叫。respondsToSelector:或 KVC(Key-Value Coding)在內部查詢方法時也會呼叫它,因此在這裡記錄日誌,頻率會遠高於預期。


第 2 階段:forwardingTargetForSelector: — Fast Forwarding 的真相

第二個問題是:「如果你無法處理,能不能告訴我哪個物件可以代為處理?」

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([self.helper respondsToSelector:aSelector]) {
        return self.helper;
    }
    return [super forwardingTargetForSelector:aSelector];
}

回傳非 nil 物件後,整個訊息會重新傳送給該物件。與第 3 階段相比,就能清楚看出這個階段為何稱為Fast Forwarding:它不建立 NSInvocation 物件,只替換接收者,因此成本幾乎和一般訊息傳送相同。

這也是實務用途很明確的階段。

  • 模擬多重繼承:依選擇子將訊息委派給多個 helper 物件,就能不靠繼承整合多個類別的能力。
  • weak proxy:用來打破 NSTimer retain cycle 的中介代理物件,便利用了這個位置(精確地說,是 NSProxy 的轉送)。
  • API 安全網:在舊版 OS 上,將僅存在於新版 OS 的方法導向替代物件的防禦性程式碼。
objc_msgSend 搜尋失敗後的訊息轉送三階段分支流程圖,從 resolveInstanceMethod 到 forwardInvocation
三個階段中只要有一個成功,就不會發生錯誤

第 3 階段:完整轉送 — 為什麼必須先取得簽章

第 2 階段也遭拒絕後,執行階段會使用最後手段。但這個階段不是覆寫一個方法,而是必須成對覆寫兩個方法

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    NSMethodSignature *sig = [super methodSignatureForSelector:aSelector];
    if (!sig) {
        sig = [self.target methodSignatureForSelector:aSelector];
    }
    return sig;
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    for (id target in self.targets) {
        if ([target respondsToSelector:invocation.selector]) {
            [invocation invokeWithTarget:target];
        }
    }
}

為什麼簽章要先取得?因為執行階段若要把訊息包裝成 NSInvocation 物件,就必須知道有幾個引數,以及每個引數佔多少位元組。如果methodSignatureForSelector:無法回傳有效簽章,forwardInvocation: 根本不會被呼叫,而是直接發生錯誤。

取得 NSInvocation 後,能做的事就多了。你可以修改引數,像上面的範例一樣把同一則訊息廣播給多個物件(多播代理),也可以記錄回應,稍後再重播。NSUndoManager 的prepareWithInvocationTarget:正是採用這種方式:先將可復原的方法呼叫擷取成 NSInvocation,再於 undo 時重播。

它最有彈性,但成本也最高。由於建立 NSInvocation 和封裝引數都需要成本,Apple 文件也明確指出它「比一般訊息傳送慢得多」。若是重視效能的路徑,在第 2 階段結束才是正確答案。


陷阱:respondsToSelector: 不知道訊息轉送

即使物件能透過轉送正常處理訊息,詢問respondsToSelector:時仍會回答 NO。選擇子查詢只查看方法清單,而轉送路徑必須實際傳送訊息才會運作。

因此,要正確建立以轉送為基礎的 proxy,也必須覆寫respondsToSelector:,配合說出「那則訊息我可以接收」的謊言。在常見委派檢查(if ([delegate respondsToSelector:...]))的 Objective-C 世界裡,漏掉這一步,就會遇到轉送已準備妥當卻完全沒有被呼叫的謎題。

NSProxy 不繼承 NSObject,而是獨立根類別的原因也與此有關。繼承的方法越多,就越多訊息會由自己處理而不會進入轉送,因此才另外建立只保留骨架的類別。這部分會在 NSProxy 文章中另行說明。

一個機器人把訊息信封交給另一個機器人的 Fast Forwarding 替代接收者插圖
第 2 階段只替換接收者就能傳遞,因此速度很快

總結

  • unrecognized selector 錯誤不是立即死亡,而是3 階段補救流程全部失敗的結果
  • 第 1 階段resolveInstanceMethod:會即時新增方法——@dynamic 與 Core Data 都在這裡運作。
  • 第 2 階段forwardingTargetForSelector:只替換接收者,也就是Fast Forwarding——不需要 NSInvocation 就能低成本完成。
  • 第 3 階段是methodSignatureForSelector:forwardInvocation:的組合——必須有簽章才能建立 NSInvocation。
  • 多播代理、NSUndoManager 和 weak proxy,都是建立在這個結構上的應用。
  • 建立轉送 proxy 時,別忘了覆寫respondsToSelector:

接著了解 Swift 為何預設捨棄這種彈性、選擇靜態分派,以及即使如此仍能透過@objc dynamic重新進入這個世界的原因,就能更立體地理解兩種語言的設計哲學。

延伸閱讀