ソフトウェア設計

SwiftのVisitorパターン:ダブルディスパッチが必要なとき

iOS開発では、図形やノードなど種類の多いオブジェクトに、処理を追加し続ける場面があります。

読了 5 分
SwiftのVisitorパターン:ダブルディスパッチが必要なときのカバー画像

iOS開発では、図形やノードなど種類の多いオブジェクトに、処理を追加し続ける場面があります。

if letやas?で型を一つずつ調べるコードを書きがちですが、種類が増えるたびに分岐を修正する必要があります。

この記事では、そんなときに使うSwift Visitorパターンと、その核心であるダブルディスパッチがいつ必要かを例で整理します。

Swiftはメソッドを選ぶ際、レシーバー(メッセージを受け取るオブジェクト)の動的型だけを見ます。

「どの図形か」と「どの処理か」の両方が変わる場合、単一ディスパッチだけでは不十分です。2回の呼び出しで両方の型を実行時に決めるのがダブルディスパッチです。


Swiftのメソッドディスパッチは、なぜ1つだけでは不十分なのか?

Swiftでshape.draw()を呼ぶと、shapeの実際の型に合うdraw()が実行されます。

これが単一ディスパッチです。レシーバー型****だけでメソッドを選びます。

問題は処理が複数あるときに起きます。

図形は円・長方形・三角形、処理は描画・面積計算・JSON出力です。2つの軸を掛け合わせると組み合わせが急増します。

そのため、次のようなコードを書きがちです。

// 図形ごとに処理が増えるたび、どんどん長くなります if-else
func export(_ shape: Shape) -> String {
    if let c = shape as? Circle { return drawCircle(c) }
    if let r = shape as? Rect   { return drawRect(r) }
    return "" // 新しい図形が増えるたび、ここをまた修正する必要があります
}

新しい図形を1つ追加すると、この関数を開いて分岐を追加しなければなりません。

処理が5つあれば、このような関数が5か所あることになります。

if-elseがここまで積み上がったら、私はサインだと考えます。
if-elseがここまで積み上がったら、私はサインだと考えます。

ダブルディスパッチが必要なのはいつ?

私の基準は単純です。

メソッドの動作が2つの型に同時に依存するときが、ダブルディスパッチの出番です。

先ほどの例がまさにそうです。実行するコードが「図形型 × 処理型」の両方で変わります。

Swiftの標準ディスパッチはレシーバー型しか見ないため、もう一方はas?のような型チェックで手作業になります。

この手動分岐がコードスメルです。

ダブルディスパッチでは、2つ目の型の決定も言語のオーバーロードに任せられます。型を直接調べる必要がなくなります。

逆に、処理が1つだけ、または型が増えないなら、このパターンは不要です。


Swift Visitorパターンはどう実装する?

要点は呼び出しを2回に分けることです。だからダブルディスパッチと呼ばれます。

protocol Shape { func accept(_ v: Visitor) -> String }
struct Circle: Shape { func accept(_ v: Visitor) -> String { v.visit(self) } }
struct Rect: Shape   { func accept(_ v: Visitor) -> String { v.visit(self) } }

protocol Visitor {
    func visit(_ c: Circle) -> String   // ここで図形型が決まります
    func visit(_ r: Rect) -> String      // オーバーロードで処理が決まります
}

流れを追ってみましょう。

1回目の呼び出しshape.accept(v)で、図形の実際の型が決まります。Circleのacceptか、Rectのものかをランタイムが選びます。

2回目の呼び出しv.visit(self)では、selfの静的型がすでにCircleまたはRectに確定しています。そのためオーバーロードされたvisitが正しく選ばれます。

この2回の呼び出しが重なり、両方の型が正確に決まります。

2回の呼び出しが重なる場所、それがダブルディスパッチ
2回の呼び出しが重なる場所、それがダブルディスパッチ

新しい処理を追加するなら、Visitorを採用した構造体を1つ作るだけです。既存の図形コードは変更不要です。


Visitorパターンを使うべき?メリットとデメリット

万能なパターンはありません。トレードオフを見ていきます。

区分 型分岐(as? / switch) Visitorパターン
処理の追加 各所の分岐を修正 新しいVisitorを1つ追加
型の追加 分岐を1行追加 すべてのVisitorを修正
コードの可読性 分岐が増えると複雑 処理ごとにきれいに分離
初期実装コスト 低い 比較的高い

表のとおり、Visitorパターンは処理が頻繁に増え、型が安定しているときに最も効果を発揮します。

一方、型(図形)が増え続ける構成では、すべてのVisitorを修正するため手間が増えます。

acceptしてvisitする、たった2回で完了
acceptしてvisitする、たった2回で完了

よくある質問(Q&A)

Q. Swiftにはジェネリクスがありますが、それでもVisitorは必要ですか?

ジェネリクスはコンパイル時に型が決まる場合に強力です。しかし[Shape]配列のように実行時に実際の型が混在する場合、ダブルディスパッチは有効です。

Q. enumとswitchでもよいのでは?

可能です。型が固定ならenum + switchの方が簡潔です。処理が増え続ける状況にはVisitorが適しています。


2つの型に同時に依存する動作に出会ったら、ダブルディスパッチとSwift Visitorパターンを思い出してください。

as?分岐が増え続けるコードがあるなら、今日の例を参考にリファクタリングしてみましょう。ずっと扱いやすい構成になります🙂

あわせて読みたい