Swift & Objective-C

Objective-Cメッセージフォワーディングの3段階

iOSのクラッシュログで最もよく見かける文言の一つが、unrecognized selector sent to instanceです。

読了 7 分
Objective-Cメッセージフォワーディングの3段階のカバー画像

iOSのクラッシュログで最もよく見かける文言の一つが、unrecognized selector sent to instanceです。

しかし、このクラッシュは実は「即死」ではありません。Objective-Cランタイムはクラッシュする前に、そのオブジェクトへ3回のチャンスを与えます。この救済処理がメッセージフォワーディング(Message Forwarding)です。

前回のobjc_msgSendの記事では、「メソッド探索に失敗するとフォワーディングの3段階が実行される」ことまで整理しました。今回は、その3段階をコードで一つずつ掘り下げます。特に、なぜ2段階目がFast Forwardingと呼ばれるのか、そして3段階目でなぜメソッドシグネチャが必要なのかが核心です。


全体像: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];
}

第3引数の"v@:"は型エンコーディングです。戻り値はvoid(v)、レシーバーはid(@)、セレクターは(:)。ここから、すべてのObjective-Cメソッドが隠し引数selfと_cmdを受け取ることが分かります。

この段階の代表的な利用者がCore Dataです。@dynamicで宣言されたプロパティにはコンパイル時点でアクセサーがなく、最初に呼び出された瞬間、この段階で実行時に生成されます。

注意点が一つあります。resolveInstanceMethod:はフォワーディングでなくても呼び出されます。respondsToSelector:やKVC(Key-Value Coding)が内部でメソッドを検索するときにも呼ばれるため、ここでログを出すと想定以上に頻繁に記録されます。


2段階目:forwardingTargetForSelector: — Fast Forwardingの正体

2つ目の質問は、「自分で処理できないなら、代わりに処理するオブジェクトを教えてくれますか?」です。

- (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段階のフロー図
3段階のうち一つでも成功すればクラッシュは起きません

3段階目:完全なフォワーディング — 先にシグネチャが必要な理由

2段階目も拒否されると、ランタイムは最後の手段を使います。この段階では、1つではなく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と答えます。セレクター検索はメソッドリストだけを確認し、フォワーディング経路は実際にメッセージを送って初めて動作するためです。

そのため、フォワーディングベースのプロキシを正しく作るには、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はすべてこの構造の応用です。
  • フォワーディングプロキシを作るときは、respondsToSelector:のオーバーライドを忘れないでください。

Swiftがこの柔軟性を捨てて基本的に静的ディスパッチを選んだ理由と、それでも@objc dynamicによってこの世界へ戻れる理由まで続けて読むと、2つの言語の設計思想をより立体的に理解できます。

あわせて読みたい