Objective-Cコードでは、角括弧で囲まれた[receiver message]構文が普通のメソッド呼び出しのように見えます。
しかし、C++やJavaのメソッド呼び出しとは根本的に異なります。
まず結論から説明します。
Objective-Cのメソッド呼び出しは「関数呼び出し」ではなく「メッセージ送信」です。
どのコードを実行するかはコンパイル時ではなく、ランタイムに決まります。
この違いが分かると、メソッドスウィズリング、KVO(Key-Value Observing)、クラッシュログのunrecognized selectorまでが一本につながります。Effective Objective-C 2.0の11〜12項で扱われる内容でもあります。
まずは要点(3つ)
[obj foo]はコンパイル時に**objc_msgSend(obj, @selector(foo))**という関数呼び出しに変換されます。- 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の動作は単純です。
- receiverのisaポインタをたどってクラスを見つける
- まずクラスのメソッドキャッシュを検索する(非常に高速)
- キャッシュになければメソッドリストを検索し、なければ親クラスへ移る
- 見つかった関数ポインタ(IMP)へジャンプする
検索結果はキャッシュされるため、同じメッセージを2回目から送ると関数呼び出しに近い速度になります。「動的ディスパッチは遅い」という心配が実務でほぼ問題にならない理由です。
見つからない場合は? メッセージフォワーディングの3段階
メソッド検索が最上位クラスまで進んでも失敗すると、ランタイムはクラッシュする前に3回の機会を与えます。
第1段階:動的メソッド解決(resolveInstanceMethod:)
クラスに「このセレクタに対応するメソッドを、今から追加しますか?」と尋ねます。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
クラッシュログが「関数がない」ではなく「セレクタを認識できない」と示す理由が、これで明確になります。コンパイル時には問題がなく、ランタイムの検索とフォワーディングがすべて失敗した結果だからです。
この構造が生み出したもの
iOS開発でおなじみの機能は、このランタイム構造の上で動作しています。
- KVO:オブザーバーを追加すると、ランタイムが対象オブジェクトのisaを密かにサブクラスへ置き換えます
- メソッドスウィズリング:セレクタとIMPの対応関係をランタイムに置き換えます
- NSProxy:フォワーディングだけで動作する代理オブジェクトを作ります(NSTimerの循環参照解決にも登場します)
- @dynamic:アクセサをコンパイル時に作らず、第1段階のフォワーディングで解決します
Swiftはメッセージディスパッチではなく静的ディスパッチとvtableディスパッチを選び、この柔軟性の代わりに速度と安全性を得ました。そのためSwiftでKVOを使うには@objc dynamicを付ける必要があります。プロパティをObjective-Cランタイムのメッセージング世界へ戻す印です。
まとめ
- Objective-Cのメソッド呼び出しの正体は、objc_msgSendによるメッセージ送信です
- 実行するコードはランタイムに決まり、キャッシュがあるため速度を心配する必要はありません
- 検索に失敗した場合は、動的解決 → 代替の受信者 → 完全なフォワーディングの順に救済されます
- unrecognized selectorクラッシュは、これらすべての段階に失敗したことを意味します
Objective-Cのレガシーコードに触れる機会がなくても、このランタイムはUIKitの下で今も動いています。クラッシュログの一行が違って読めるようになると、デバッグの質も変わります。

