SwiftProgrammingSwiftデベロッパー

Swiftは、拡張に定義されたプロトコルメソッドが、ウィットネステーブルを通じて動的にディスパッチされるメソッドと、コンパイル時に静的に解決されるメソッドをどのように区別しますか?また、これらのメソッドを存在的型を通じて呼び出す際に生じる振る舞いの違いは何ですか?

Hintsage AIアシスタントで面接を突破

質問への回答

質問の歴史

Swiftは、**C++**のゼロコストの抽象化と、Objective-Cの動的柔軟性のギャップを埋めるために設計されました。初期のバージョンは、クラスの継承と仮想メソッドテーブルに大きく依存していましたが、Swift 2.0でプロトコル指向プログラミングの導入が必要となり、より微妙なディスパッチモデルが求められました。コンパイラチームは、プロトコルの要件(プロトコル本体で宣言されたメソッド)が実行時多態性のためにウィットネステーブルを利用し、拡張にのみ定義されたメソッドは静的に解決されるハイブリッドアプローチを選択しました。この設計決定は、レトロアクティブモデリングと値型をサポートする必要性に起因しており、静的ディスパッチの性能特性を犠牲にすることなく実現されています。

問題

開発者は、プロトコル拡張にメソッド実装を提供することで、準拠する型が多態的にオーバーライドできる「デフォルト」動作が作成されるとしばしば仮定します。しかし、Swiftは拡張メソッドを、参照のコンパイル時型に基づいて静的にディスパッチします。これにより、実際のインスタンスの実行時型ではなく、存在的ボックス(any Protocol)を使用する際に、コンパイル時型が存在的コンテナそのものであるため、呼び出しが拡張の実装に解決され、具体的な型のオーバーライドが無視されます。このため、異種コレクションのサブクラスや構造体内のカスタム実装が静かにバイパスされるという厄介なバグが発生します。

解決策

真の動的多態性を実現するには、メソッドをプロトコル宣言内でプロトコルの要件として宣言する必要があります。これにより、コンパイラがメソッドのためにウィットネステーブルエントリを割り当てることが強制され、ランタイムが型のウィットネステーブルを介して正しい実装を検索できるようになります。多態性が不要なパフォーマンスクリティカルなアルゴリズムでは、メソッドを拡張に維持し、コンパイラがそれらをインライン化したり、他の静的最適化を行うことを許可する必要があります。**Swift 5.6+**では、存在的型の消去をより明示化するために、anyキーワードの構文が導入され、型情報が失われることを思い出させる役割を果たします。

protocol Drawable { func draw() // 要件: ウィットネステーブルによる動的ディスパッチ } extension Drawable { func draw() { print("Default") } func render() { print("Static render") } // 拡張: 静的ディスパッチのみ } struct Circle: Drawable { func draw() { print("Circle") } func render() { print("Circle render") } } let shape: any Drawable = Circle() shape.draw() // "Circle"と表示 (動的ディスパッチ) shape.render() // "Static render"と表示 (静的ディスパッチ - Circleのバージョンを無視!)

実生活の例

私たちは、さまざまな形状がRenderCommandプロトコルに準拠するベクトルグラフィックスエンジンを開発していました。最初は、すべての形状にデフォルトのラスタライズサムネイルを提供するために、プロトコル拡張内にのみgeneratePreview()メソッドを追加しました。具体的な型であるBezierCurvePolygonは、鋭いレンダリングのために特定の幾何学的特性を使用した最適化されたgeneratePreview()メソッドを実装しました。これらの形状を[any RenderCommand]配列に格納してレンダリングパイプラインを処理した際に、各要素でgeneratePreview()を呼び出すと、カスタムの高品質なプレビューではなく、同じぼやけたデフォルト画像が生成されることを発見しました。

私たちは3つの異なる解決策を検討しました。第一に、generatePreview()RenderCommandプロトコル宣言内に正式な要件として移動することができました。このアプローチでは、ウィットネステーブルを介して動的ディスパッチを保証し、ランタイムで正しいメソッドの解決を確保します。しかし、これにより、すべての形状型がその準拠を明示的に宣言する必要が生じましたが、カスタマイズが不要な型のデフォルト実装を拡張に保持することでボイラープレートを軽減できます。

第二に、パイプラインをリファクタリングし、func process<T: RenderCommand>(commands: [T])のような関数シグネチャを使用することで、存在的な[any RenderCommand]を使用しない方法です。これにより、Swiftがコンパイル時にジェネリックを単相化するため、正しい実装に対する静的ディスパッチが保持されます。ただし、これにより、異種の形状型(BezierCurvePolygonの混合)を単一の配列に格納することができなくなり、型消去ラッパーを実装する必要があり、コードの複雑性が大幅に増加します。

第三に、ビジターパターンを実装して、メソッド呼び出しを適切な具体型に手動でルーティングすることができました。これにより、プロトコル定義を完全に変更することなく、多態的な振る舞いを実現できましたが、新しい形状型がシステムに追加されるたびにメンテナンス負担を作成し、かなりのボイラープレートコードを生成しました。

私たちは最終的に最初の解決策を選択しました。なぜなら、プロトコルがモジュール内に内部的であり、多態的な振る舞いの明確さがレンダリングエンジンの正確性にとって重要であったからです。この要件を追加してもバイナリサイズへの影響は非常に小さく、ウィットネステーブル間接呼び出しのわずかなオーバーヘッドは、レンダリング計算と比較してほとんど感じられませんでした。この変更を実装した後、プレビュー生成は各形状の最適化された実装を正しく利用し、UIの視覚的アーティファクトを排除しました。

候補者がしばしば見逃すこと

なぜサブクラスはプロトコル拡張のみに定義されたメソッドをオーバーライドできないのか?

メソッドがプロトコルそのものには宣言されずにプロトコル拡張でのみ定義されている場合、Swiftはそのためのウィットネステーブルエントリを割り当てません。ディスパッチは参照型に基づいてコンパイル時に静的に解決されます。クラスがプロトコルに準拠し、同じシグネチャのメソッドを定義した場合、それは拡張メソッドをオーバーライドするのではなく、拡張メソッドを隠す新しい無関係なメソッドを作成します。つまり、プロトコル存在(any Protocol)を介してアクセスされると、プロトコル拡張の実装が常に呼び出され、クラスのバージョンが無視されます。多態的な振る舞いを実現するには、メソッドをプロトコル宣言に宣言し、動的ディスパッチの要件となる必要があります。

some(不透明な結果型)をanyの代わりに使用すると、プロトコル拡張メソッドのディスパッチにどのように影響しますか?

some Drawableを使用すると、具体的な型はコンパイル時に知られるため、Swiftがジェネリックを単相化します。不透明型で拡張メソッドを呼び出すと、コンパイラは具体的な型の実装に静的にディスパッチできます。これは、型情報が内部で保持されているため、呼び出し元からは隠されている場合でも同様です。それに対して、any Drawableは具体的な型を消去する存在的ボックスであり、コンパイラは非要件メソッドに対してプロトコル拡張のデフォルト実装を使用せざるを得ません。重要な違いは、someは静的多態性を維持し、コンパイラが正しいメソッドにインライン化したり直接バインドできるのに対し、anyは要件に対してのみランタイムvtableルックアップを強制し、他のすべてのメソッドに対して拡張デフォルトに戻ることです。

拡張メソッドをプロトコル要件に変換することのバイナリサイズとパフォーマンスへの影響はどのようなものか?

拡張メソッドをプロトコル要件に変換すると、プロトコルのウィットネステーブルにエントリが追加され、64ビットアーキテクチャでの準拠ごとに約8バイトのバイナリサイズが増加します。各準拠型は、ウィットネステーブル内のこのスロットを埋める必要があり、型ごとにわずかなメモリオーバーヘッドが追加されます。パフォーマンス的には、要件はウィットネステーブルを介した間接呼び出しのオーバーヘッドを伴います(追加のポインタ逆参照とジャンプが1回必要)。対照的に、拡張メソッドはインライン化されたり、オーバーヘッドゼロで直接呼び出されます。ただし、要件のインライン化の喪失は、CPUの分岐予測器によってしばしば相殺され、正しい多態的な振る舞いの利点は、ほとんどのアプリケーションコードで間接呼び出しのナノ秒単位のコストを上回ることが多いです。