Swift 與 Objective-C

objc_msgSend 完整解析:Objective-C 方法呼叫為何不是函式呼叫

在 Objective-C 程式碼中,用方括號包住的 [receiver message] 語法,看起來就像一般的方法呼叫。

閱讀 5 分鐘
objc_msgSend 完整解析:Objective-C 方法呼叫為何不是函式呼叫 封面圖

在 Objective-C 程式碼中,方括號包住的 [receiver message] 語法看起來就像一般的方法呼叫。

但它和 C++ 或 Java 的方法呼叫有本質上的不同。

先從結論開始。

Objective-C 的方法呼叫不是「函式呼叫」,而是「傳送訊息」。

要執行哪段程式碼,是在執行階段決定,而不是編譯時期。

了解這項差異後,方法調配、KVO(Key-Value Observing)以及當機記錄中的unrecognized selector,都能串成一條線。這也是 Effective Objective-C 2.0 第 11~12 項涵蓋的內容。


先看重點摘要(3 點)

  1. [obj foo] 編譯後會變成 objc_msgSend(obj, @selector(foo)) 函式呼叫。
  2. objc_msgSend 會在執行階段搜尋類別的方法表,找出要執行的函式(IMP)。
  3. 找不到時,不會立即當機,而是啟動稱為訊息轉送的三階段補救流程。

所有訊息都會匯集到 objc_msgSend

C++ 的虛擬函式會在編譯時期決定 vtable 索引。相對地,Objective-C 幾乎所有訊息都會通過 objc_msgSend 這個閘道。更精確地說,[super foo]會編譯成 objc_msgSendSuper;在 x86-64 時代,也曾有用於結構回傳的 objc_msgSend_stret 等同族函式(arm64 則整合到 objc_msgSend),但運作原理完全相同。

NSString *result = [greeting uppercaseString];

這段程式碼經過編譯器後,會變成如下的 C 函式呼叫。

NSString *result = objc_msgSend(greeting, @selector(uppercaseString));

objc_msgSend 做的事情很單純。

  1. 沿著 receiver 的 isa 指標找到類別
  2. 先搜尋類別的方法快取(非常快)
  3. 快取沒有時搜尋方法清單,仍沒有就往父類別查找
  4. 跳至找到的函式指標(IMP)

找到的結果會儲存在快取中,因此第二次起傳送相同訊息時,速度接近函式呼叫。這就是「動態分派很慢」在實務上幾乎不成問題的原因。

即使搜尋失敗,仍有三次補救機會
即使搜尋失敗,仍有三次補救機會

找不到時怎麼辦?訊息轉送的 3 個階段

方法搜尋一路到最上層類別仍失敗時,執行階段會在當機前提供三次機會。

第 1 階段:動態方法解析(resolveInstanceMethod:)

它會詢問類別:「現在要不要補上對應這個 selector 的方法?」Core Data 的 @dynamic 屬性會在此階段即時建立並加入存取子。

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

第 2 階段:替代接收者(forwardingTargetForSelector:)

它會問:「如果你無法處理,能不能告訴我一個可以代勞的物件?」回傳其他物件後,訊息就會轉交給它。需要在不使用繼承的情況下模擬「類似多重繼承」時,會用到這個階段。

第 3 階段:完整轉送(forwardInvocation:)

進入此階段前,執行階段會先透過 methodSignatureForSelector: 詢問方法簽章。若回傳 nil,轉送甚至不會開始,而是直接進入 doesNotRecognizeSelector: 並當機。若有簽章,整個訊息會根據它包裝成 NSInvocation 物件後傳入。此處可以修改引數、分送給多個物件,或乾脆靜默吞掉。它最有彈性,但成本也最高。

直到 3 個階段全部失敗,才會真正發生那個著名的當機。

-[MyViewController buttonTapped:]: unrecognized selector sent to instance 0x7f8a2c400c50

現在就能理解,為什麼當機記錄不是說「沒有函式」,而是「無法辨識 selector」。因為編譯時期沒有問題,結果是執行階段的搜尋與轉送全部失敗。


這種結構帶來的功能

iOS 開發中熟悉的功能,都建構在這個執行階段結構之上。

  • KVO:加入觀察者後,執行階段會悄悄把物件的 isa 替換成子類別
  • 方法調配:在執行階段替換 selector 與 IMP 的連結
  • NSProxy:建立只靠轉送運作的代理物件(也會用來解決 NSTimer 的循環參考)
  • @dynamic:不在編譯時期建立存取子,而是在第 1 階段轉送中解決

Swift 選擇靜態分派與 vtable 分派,而不是訊息分派,以速度和安全性換取這份彈性。因此在 Swift 中使用 KVO 時,必須加上@objc dynamic。這表示要把該屬性重新交回 Objective-C 執行階段的訊息世界。

現在,你會用不同方式閱讀這份當機記錄
現在,你會用不同方式閱讀這份當機記錄

總結

  • Objective-C 方法呼叫的本質,是透過 objc_msgSend 傳送訊息
  • 要執行的程式碼在執行階段決定;有快取後,不必太擔心速度
  • 搜尋失敗時,會依序經過動態解析 → 替代接收者 → 完整轉送來補救
  • unrecognized selector 當機表示所有這些階段都失敗了

即使你不會接觸 Objective-C 遺留程式碼,這個執行階段仍在 UIKit 底層運作。當你開始用不同方式讀懂當機記錄中的一句話,除錯的思路也會改變。

延伸閱讀