Swift & Objective-C

objc_msgSend完全解説:Objective-Cのメソッド呼び出しが関数呼び出しではない理由

Objective-Cコードで角括弧に囲まれた[receiver message]構文は、一見すると普通のメソッド呼び出しに見えます。

読了 6 分
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. 見つからなくてもすぐにクラッシュせず、メッセージフォワーディングという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)へジャンプする

検索結果はキャッシュされるため、同じメッセージを2回目から送ると関数呼び出しに近い速度になります。「動的ディスパッチは遅い」という心配が実務でほぼ問題にならない理由です。

検索に失敗しても、3回の救済機会が残されています
検索に失敗しても、3回の救済機会が残されています

見つからない場合は? メッセージフォワーディングの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の下で今も動いています。クラッシュログの一行が違って読めるようになると、デバッグの質も変わります。

あわせて読みたい