Swift & Objective-C

[Swift応用 #5] Swiftのディスパッチ3兄弟、finalが性能キーワードである理由

メソッド呼び出しは、直接呼び出し・vtable・objc_msgSendの3方式にコンパイルされます。finalとprivateが性能に関わる仕組み、プロトコルextensionの落とし穴、プロファイラでの確認方法をまとめました。

読了 6 分
[Swift応用 #5] Swiftのディスパッチ3兄弟、finalが性能キーワードである理由のカバー画像

「クラスにfinalを付けると速くなる」と聞いたことがあるでしょう。都市伝説のように語られますが、そこには正確な仕組みがあります。

前回の記事 Swift応用 #4の続きです。

一見当たり前のメソッド呼び出しは、実際には3通りの方法でコンパイルされます。どの方式になるかが、性能と最適化の可能性を左右します。

応用シリーズ、今回はディスパッチです。

呼び出しの3つの顔 — 直呼び出し、テーブル、メッセージ

object.doSomething()のようなコードが機械語に変換される方法は3つあります。

静的ディスパッチ。 コンパイル時に関数が確定し、そのアドレスへ直接ジャンプします。

最速であり、さらに重要なのは、コンパイラがインライン化できることです。structのメソッド、グローバル関数、finalメソッドが該当します。

テーブルディスパッチ。 継承とオーバーライドが可能なクラスメソッドで使われます。

変数のコンパイル時型がAnimalでも、実体はDogかもしれません。どの実装を呼ぶかは実行時まで分かりません。

そのためクラスごとに仮想関数テーブル(vtable)を持ち、呼び出し時にインスタンスの表から関数番号を探してジャンプします。多態性の代償として間接参照が1段増えます。

プロトコルではwitness tableが同じ役割を果たします。some·anyの記事で見た表です。

メッセージディスパッチ。 Objective-Cランタイムの方式で、objc_msgSendによる名前ベースの検索を使います。

3つの中で最も遅い一方、実行時のメソッド差し替えまで可能な柔軟性を得られます。Swiftでは@objc dynamicのメソッドがこの方式になります。

objc_msgSendの内部は別の記事で扱ったので、ここでは触れません。

結局は柔軟性と速度のトレードオフです。実行時に決まるほど遅く、コンパイル時に固定されるほど速く、最適化しやすくなります。

finalとprivateが性能キーワードである理由

これで都市伝説を解剖できます。コンパイラがテーブルディスパッチを静的にデバーチャライズできる条件は何でしょうか。

「このメソッドはオーバーライドされない」と証明できることです。

finalはまさにその証明です。final classfinal funcは、サブクラスで再定義されないことを宣言します。

コンパイラはvtable検索を取り除き、直接呼び出しに変えられます。インライン化への道も開きます(Swift Optimization Tips)。

privateも似た効果を持ちます。ファイル外から見えない宣言なら、コンパイラはファイル内の利用箇所をすべて確認でき、オーバーライドがないと証明できます。

whole-module optimization(WMO、現在のリリースビルドではデフォルト)が有効なら、証明の範囲はモジュール全体に広がります。継承のないinternalクラスも自動的にfinalとして扱われます。

実務上の指針はこうです。継承する予定のないクラスにはfinalを付けます。

設計上の宣言として正しいだけでなく、コンパイラにとって最適化の証明書にもなります。

ただし「finalを付けたらアプリが速くなった」と期待しすぎないでください。呼び出しのオーバーヘッドはナノ秒単位なので、ホットループ以外では体感できません。

価値があるのは、インライン化後に連鎖する最適化です。それはコンパイラが自動で行います。私たちの仕事は証明を妨げないことです。

finalの証明で検索テーブルの料金所を撤去する最適化過程の図
finalは「オーバーライドなし」の証明書。料金所が撤去されます

プロトコルextensionの落とし穴 — 要件かどうか

ディスパッチの知識が実務で最も役立つのは、プロトコルのデフォルト実装です。クイズを見てみましょう。

protocol Greeter {
    func hello()            // 要件として宣言
}
extension Greeter {
    func hello() { print("こんにちは") }
    func bye() { print("さようなら") }   // 要件ではない
}
struct Korean: Greeter {
    func hello() { print("こんにちは") }
    func bye() { print("さようなら") }
}

let k: any Greeter = Korean()
k.hello()   // ?
k.bye()     // ?

答えは「こんにちは」と「さようなら」です。helloはプロトコル要件なのでwitness table経由で動的にディスパッチされ、Koreanの実装が表に登録されます。

一方、byeは要件にないextension専用メソッドです。プロトコル型から呼ぶと、Koreanの定義に関係なくextension実装が静的に直接呼び出されます。

この規則を知らないと、「実装したのに呼ばれない」という謎のバグになります。対処は簡単です。

採用側が差し替えられる必要のあるメソッドは、必ずプロトコル本体で要件として宣言します。extensionのデフォルト実装は残しつつ、本体に宣言して表に枠を作ります。

POP(プロトコル指向プログラミング)の記事では、プロトコルextensionを継承の代替として紹介しました。その安全規則がこれです。

計測で確認する — 感覚ではなくプロファイラ

ディスパッチの話の締めには、いつも同じ注意が必要です。これはマイクロ最適化の領域で、順序が重要です。

まずInstrumentsのTime Profilerで実際のボトルネックを探します。多くの性能問題はディスパッチではなく、アルゴリズム(O(n²)ループ)、不要な処理(毎フレームの再計算)、I/Oが原因です。

早すぎる最適化への警告は、Knuthの記事で扱ったとおりです。

プロファイルでホットループ内の動的ディスパッチが本当に見つかったら、型をfinalにする、anyをsome·ジェネリックに置き換えて特殊化を促す、プロトコル境界をループの外へ出す、といった対策を取ります。

逆に言えば、この知識の普段の用途は最適化ではなく設計の理解です。

なぜstructがデフォルトなのか(静的ディスパッチと相性がよい)、なぜanyよりsomeが推奨されるのか(特殊化できる)、なぜSwiftUIがstructのビューを使うのか。

言語の大きな決定はすべてこの層に根ざしているため、ディスパッチを知るとSwiftの設計を一枚の絵として読めます。

要件メソッドとextension専用メソッドのディスパッチ経路を比較する図
要件にないextensionメソッドは自分の実装を通り過ぎます

まとめ

  • メソッド呼び出しは静的(直接)・テーブル(vtable/witness table)・メッセージ(objc_msgSend)の3方式でコンパイルされ、柔軟性と速度は反比例します。
  • final・private・WMOは「オーバーライドなし」の証明となり、動的呼び出しを静的にデバーチャライズしてインライン化への道を開きます。継承しないクラスにはfinalが基本です。
  • プロトコルextensionメソッドは、要件として宣言されているかどうかでディスパッチが分かれます。差し替え可能にするメソッドは必ず本体に宣言します。
  • 順番はプロファイラが先です。ディスパッチ知識の普段の価値は最適化の技巧ではなく、言語設計を読み解く視点です。

次回はさらに一段掘り下げます。structのサイズの決まり方、プロパティの順序がメモリを変える理由、anyボックスの実際のサイズなど、メモリレイアウトを扱います。

あわせて読みたい

出典と確認基準

  • Swift Optimization TipsSwift 프로젝트 · 公式ドキュメント · 確認日 2026年8月17日根拠: 静的・動的ディスパッチ、final・whole-module optimizationと性能特性