Visitorパターンは、データ構造のクラスを書き換えずに、そこへ後から操作だけを足すためのGoFデザインパターンです。構文木やドキュメントツリーのように「型の種類は固定だが、やりたい処理は増え続ける」構造が主戦場になります。この記事では5つの登場人物の役割から入り、なぜacceptとvisitの2段構えが必要なのかをコンパイラの挙動で確かめます。そのうえでJava・C#・Pythonの実装を実際の処理系で動かし、Java 21以降ならsealedとパターンマッチのswitchへ置き換えるべき条件まで示します。
まとめ
- Visitorパターンの本質はダブルディスパッチです。
acceptで要素の実行時型を、visitの仮想呼び出しでVisitorの実行時型に応じた処理を、2段階に分けて決めます。 - 得意なのは操作の追加、苦手なのは型の追加。要素の型を1つ足すと、既存のVisitor実装をすべて直すことになります。
- この代償は実在ライブラリに表れています。Roslynの
CSharpSyntaxVisitorはVisitXxxが7年で215から250に増え、JDKのElementVisitorはAPI Noteで「メソッドが追加される可能性がある」と警告しています。 - Java 21以上で閉じた型階層を扱い、走査の共通基盤や状態を持つVisitorが要らないなら、
sealedとパターンマッチのswitchを選びます。型の追加漏れはコンパイルエラーになります。 - C# 14(.NET 10)までは同じ保証がなく、未処理の型は実行時に
SwitchExpressionExceptionになります。11月10日に正式版予定のC# 15(.NET 11)は、closed階層で漏れた型を警告CS8509で名指しします。
Visitorパターンの構造と5つの登場人物
GoFの『Design Patterns』(1994年)は、Visitorの目的を「オブジェクト構造の要素に対して行う操作を表現し、要素のクラスを変えずに新しい操作を定義できるようにする」と定めています(英語版Wikipediaに原文の引用があります)。登場人物は5つで、この記事の式ツリーでは次のクラスが担います。
登場人物と式ツリーの例での対応
| 役割 | 持つもの | 式ツリーの例 |
|---|---|---|
| Visitor | 要素の型ごとのvisit |
Visitor<R> |
| ConcreteVisitor | 1つの操作の全型分の実装 | Eval・Show |
| Element | accept(Visitor) |
Node |
| ConcreteElement | acceptの中で自分用のvisitを呼ぶ |
Num・Add・Mul |
| ObjectStructure | 要素の集まりと走査の入口 | 式ツリーの根 |
ConcreteVisitorは「操作1つ=クラス1つ」で、中に全要素型ぶんのメソッドを並べます。評価のロジックを直すときに開くのはEvalだけで済む一方、要素型(Divなど)を足すとVisitorと全ConcreteVisitorに1メソッドずつ増えます。この非対称がVisitorの性格そのものです。ConcreteVisitorは走査中の状態も持てるので、要素クラスに集計用のフィールドを足さずに、呼び出した関数名や最大の深さをVisitorのフィールドへ溜められます。後述するPythonのCallCollectorがその形です。
走査責任の配置先(構造側・Visitor側・Iterator)
GoFは、構造を巡る責任の置き場所として3つを挙げています。
- 構造側:
acceptの中で子のacceptも呼ぶ。走査順が1通りで足りるなら、各Visitorは目の前のノードの処理だけを書けば済みます。 - Visitor側:
visitAddの中でleft.accept(this)のように子を呼ぶ。処理ごとに順序や打ち切り条件を変えたいときはこちらで、この記事のJava例もこの形です。 - Iterator:平らなコレクションを順に取り出し、取り出した要素に
acceptさせる。木の形に意味がない集計に向きます。
Visitor側に書くと同じ再帰コードが全ConcreteVisitorに重複します。そのためライブラリは子を全部巡る既定の実装を用意しています。RoslynならCSharpSyntaxWalker、Pythonならast.NodeVisitorを継承し、必要な訪問メソッドだけを上書きします。Pythonで上書きしたメソッドから子も訪問するには、その中でgeneric_visit()を明示的に呼びます。
ダブルディスパッチの仕組みとacceptが必要な理由
なぜacceptという回りくどい経由地が要るのか。理由はオーバーロード解決のタイミングにあり、C++のコンパイルエラーで最短に示せるので、この節だけC++を使います。
C++で確かめるオーバーロード解決の限界
Java・C#・C++はいずれも単一ディスパッチの言語です。通常の静的に型付けされた呼び出しでは、仮想関数の実装はレシーバの実行時型で、オーバーロードはコンパイル時の型情報で選ばれます。ただしC#のdynamicを使う呼び出しでは、オーバーロード解決も実行時に行われます。次のコードをApple clang 21.0.0(g++ -std=c++17)でコンパイルすると、実行する前にエラーになります。
#include <cstdio>
struct Element { virtual ~Element() = default; };
struct Circle : Element {};
struct Square : Element {};
struct Renderer {
void draw(Circle&) { std::puts("circle"); }
void draw(Square&) { std::puts("square"); }
};
int main() {
Circle c;
Element& e = c; // 実体は Circle、静的型は Element
Renderer r;
r.draw(e); // ここでオーバーロードが選べない
}
コンパイラの出力です。
error: no matching member function for call to 'draw'
note: candidate function not viable: cannot bind base class object of type
'Element' to derived class reference 'Circle &' for 1st argument
note: candidate function not viable: cannot bind base class object of type
'Element' to derived class reference 'Square &' for 1st argument
1 error generated.
実体はCircleでも、コンパイラはeをElementとしてしか見ていません。dynamic_castやinstanceofの連鎖で逃げると、型が増えるたびに分岐の追記が要ります。Visitorは、この分岐を仮想関数テーブルに肩代わりさせる手段です。
acceptとvisitに分けた2段階の型解決
解決策は、型を知っている場所から呼び返すことです。要素側にacceptという仮想関数を1つ置き、その内側からvisitを呼びます。acceptの中では*thisの静的型がCircleに確定しているので、オーバーロード解決が正しく効きます。次のコードは同じ環境でコンパイルが通り、出力はcircleです。
#include <cstdio>
struct Circle; struct Square;
struct Visitor {
virtual void visit(Circle&) = 0;
virtual void visit(Square&) = 0;
virtual ~Visitor() = default;
};
struct Element {
virtual void accept(Visitor&) = 0;
virtual ~Element() = default;
};
struct Circle : Element { void accept(Visitor& v) override { v.visit(*this); } };
struct Square : Element { void accept(Visitor& v) override { v.visit(*this); } };
struct Renderer : Visitor {
void visit(Circle&) override { std::puts("circle"); }
void visit(Square&) override { std::puts("square"); }
};
int main() {
Circle c;
Element& e = c;
Renderer r;
e.accept(r); // 出力: circle
}
1段目のe.accept(r)は仮想関数なので、実行時型のCircleの実装へ飛びます。2段目のv.visit(*this)は、飛んだ先で*thisがCircleと分かっているため、Circle用のオーバーロードがコンパイル時に選ばれます。そのうえで仮想呼び出しにより、Visitorの実行時型であるRendererの実装が選ばれます。要素とVisitorそれぞれの実行時型の組み合わせを2回のディスパッチで決めるので、ダブルディスパッチと呼ばれます。新しい処理(Exporter、Validator)を足すのはVisitorを1クラス書くだけ。新しい要素の型(Triangle)を足すと、既存のVisitor実装がすべてコンパイルエラーになります。
JavaでVisitorを実装する手順と戻り値の型パラメータ
Javaでは、戻り値を型パラメータにしておくと、評価器・整形器・集計器を同じ構造で使い回せます。対象は数値・足し算・掛け算だけの式ツリーです。次のコードはjavac/java 25.0.4.1(Temurin、LTS)でコンパイル・実行しました。ジェネリックメソッドしか使っていないので、Java 5以降なら同じように動きます。
式ツリーを評価・整形する最小構成
interface Node {
<R> R accept(Visitor<R> v);
}
interface Visitor<R> {
R visitNum(Num n);
R visitAdd(Add a);
R visitMul(Mul m);
}
final class Num implements Node {
final int value;
Num(int value) { this.value = value; }
public <R> R accept(Visitor<R> v) { return v.visitNum(this); }
}
final class Add implements Node {
final Node left, right;
Add(Node left, Node right) { this.left = left; this.right = right; }
public <R> R accept(Visitor<R> v) { return v.visitAdd(this); }
}
final class Mul implements Node {
final Node left, right;
Mul(Node left, Node right) { this.left = left; this.right = right; }
public <R> R accept(Visitor<R> v) { return v.visitMul(this); }
}
final class Eval implements Visitor<Integer> {
public Integer visitNum(Num n) { return n.value; }
public Integer visitAdd(Add a) { return a.left.accept(this) + a.right.accept(this); }
public Integer visitMul(Mul m) { return m.left.accept(this) * m.right.accept(this); }
}
final class Show implements Visitor<String> {
public String visitNum(Num n) { return Integer.toString(n.value); }
public String visitAdd(Add a) { return "(" + a.left.accept(this) + " + " + a.right.accept(this) + ")"; }
public String visitMul(Mul m) { return m.left.accept(this) + " * " + m.right.accept(this); }
}
public class Main {
public static void main(String[] args) {
Node ast = new Add(new Num(2), new Mul(new Num(3), new Num(4)));
System.out.println(ast.accept(new Eval())); // 14
System.out.println(ast.accept(new Show())); // (2 + 3 * 4)
}
}
出力は14と(2 + 3 * 4)の2行です。文字列化のShowは後から足したConcreteVisitorで、Num・Add・Mulのソースには触れていません。これがVisitorの見返りです。子のacceptをVisitor側で呼んでいるので、括弧の付け方のような走査の細部も処理ごとに変えられます。
JDK標準のElementVisitorが示す型追加のコスト
Visitorの弱点はJDK自身が公式ドキュメントで注意書きにしています。アノテーションプロセッサ用のjavax.lang.model.element.ElementVisitorのAPI Noteは「WARNING: It is possible that methods will be added to this interface(このインターフェースにはメソッドが追加される可能性があります)」と書き、直接実装したクラスは将来のJavaとソース互換でなくなる恐れがあると続けます。対処は書き分けられていて、実装する側には抽象Visitorクラスの継承を勧め、APIを設計する側には「an API should generally use this visitor interface as the type for parameters, return type, etc. rather than one of the abstract classes(引数や戻り値の型には抽象クラスではなくこのインターフェースを使うべき)」と求めています。
実際にメソッドは増えてきました。visitModule(@since 9)とvisitRecordComponent(@since 16)は、モジュールとレコードに合わせてvisitUnknown(e, p)を呼ぶdefaultメソッドとして追加され、既存の実装をコンパイルエラーにしない形を取っています。javax.lang.model.utilにはソースバージョンごとのAbstractElementVisitor6・7・8・9・14の5世代が並び、2026年9月リリースのJDK 27でも同じ5世代です(ほかにJDK 23からプレビュー機能用のAbstractElementVisitorPreviewがあります)。
C#でVisitorを実装する手順とRoslynの採用例
C#でも構造は同じで、ジェネリックメソッドで戻り値の型を呼び出し側に決めさせます。次のコードは.NET SDK 10.0.401(net10.0、C# 14)でビルドし、2 + (3 + 4)の評価結果9が出ることを確かめました。
AcceptとVisitの最小実装
public interface INode {
T Accept<T>(INodeVisitor<T> visitor);
}
public interface INodeVisitor<T> {
T VisitNum(Num node);
T VisitAdd(Add node);
}
public sealed class Num : INode {
public int Value { get; }
public Num(int value) => Value = value;
public T Accept<T>(INodeVisitor<T> visitor) => visitor.VisitNum(this);
}
public sealed class Add : INode {
public INode Left { get; }
public INode Right { get; }
public Add(INode left, INode right) => (Left, Right) = (left, right);
public T Accept<T>(INodeVisitor<T> visitor) => visitor.VisitAdd(this);
}
public sealed class Eval : INodeVisitor<int> {
public int VisitNum(Num node) => node.Value;
public int VisitAdd(Add node) => node.Left.Accept(this) + node.Right.Accept(this);
}
// 呼び出し側
// var ast = new Add(new Num(2), new Add(new Num(3), new Num(4)));
// Console.WriteLine(ast.Accept(new Eval())); // 9
要素クラスに付けたsealedは、そのクラスの継承を禁止する修飾子です。後述するJavaのsealed+permits(実装できるサブタイプを列挙する宣言)とは別物です。ここで継承を止めているのは、派生クラスがAcceptを上書きせずに残ると、派生型なのにVisitAddへ振り分けられてしまうからです。
CSharpSyntaxVisitorのメソッド数が示す型追加のコスト
.NETコンパイラプラットフォームRoslynは、C#の構文木の走査にVisitorをそのまま使っています。抽象クラスCSharpSyntaxVisitor<TResult>の公開メンバは、入口のVisit(SyntaxNode)、受け皿のDefaultVisit(SyntaxNode)、そして構文ノード1種類につき1つのVisitXxxです。NuGetのMicrosoft.CodeAnalysis.CSharpの各版を.NET 10のリフレクションで読み込み、このクラス自身が宣言するpublicなVisitXxxを数えると次のとおりでした(.NET 11 RC3のSDKに同梱されたコンパイラも250)。
| Microsoft.CodeAnalysis.CSharp | NuGet公開日 | VisitXxxの数 |
|---|---|---|
| 3.0.0 | 2019-04-02 | 215 |
| 4.0.1 | 2021-11-09 | 235 |
| 4.8.0 | 2023-11-22 | 241 |
| 4.14.0 | 2025-05-15 | 246 |
| 5.0.0 | 2025-11-18 | 247 |
| 5.9.0 | 2026-08-17 | 250 |
7年で35個増えました。DefaultVisitの既定の戻り値はdefault(TResult)なので、新しい構文に対応していない派生Visitorは例外を出さずに素通りします。VisitXxxを1つも上書きしないCSharpSyntaxVisitor<string>にclass C { }の構文木を渡すと、戻り値はnullでした。RoslynはVisitorのインターフェースを公開せず、この抽象クラスとDefaultVisitのフォールバックで互換を保っています。JDKのdefaultメソッドと手段は違っても狙いは同じです。なお書き換え用のCSharpSyntaxRewriterはCSharpSyntaxVisitor<SyntaxNode>、子を全部巡るCSharpSyntaxWalkerは非ジェネリック版CSharpSyntaxVisitorの派生です。
PythonでVisitorを実装する2つの方法
Pythonは実行時に属性を名前で引けるため、acceptを書かずにVisitorを実現できます。標準ライブラリには、メソッド名で振り分けるast.NodeVisitorと、引数の型で振り分けるfunctools.singledispatchmethodの2つの土台があります。以下のコードはCPython 3.14.6と3.15.0の両方で実行し、同じ出力になりました。
ast.NodeVisitorのvisit_クラス名による振り分け
公式ドキュメントはvisit()の挙動を「ノードクラス名に対応するself.visit_クラス名を呼び、そのメソッドが無ければgeneric_visit()を呼ぶ」と説明しています。呼び出した関数名と定数を集めるVisitorです。
import ast
class CallCollector(ast.NodeVisitor):
def __init__(self):
self.calls = []
self.consts = []
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
self.calls.append(node.func.id)
self.generic_visit(node)
def visit_Constant(self, node):
self.consts.append(node.value)
tree = ast.parse("print(len('abc') + int('7'))")
v = CallCollector()
v.visit(tree)
print("calls:", v.calls) # calls: ['print', 'len', 'int']
print("consts:", v.consts) # consts: ['abc', '7']
visit_Callの末尾のself.generic_visit(node)が要です。ドキュメントは「独自のvisitorメソッドを持つノードの子は、visitorがgeneric_visit()を呼ぶか自分で訪問しない限り訪問されない」と明記しています。この1行を消して実行すると、出力はcalls: ['print']とconsts: []になり、内側のlenとintだけでなく定数も丸ごと落ちます。この例で内側の呼び出しや定数を拾えない場合は、まずvisit_Callからgeneric_visit()を呼んでいるか確認します。
visit_Constantでgeneric_visitを呼ばないのは、ConstantのvalueがASTノードではなく素の値で、子を持たないからです。node.funcをast.Nameに限っているのは、obj.method()のような呼び出しがast.Attributeになり.idを持たないためです。ノードを書き換えるならNodeTransformerを使い、位置情報の無い新規ノードを差し込んだらfix_missing_locations()を呼びます。
singledispatchmethodによる型別ディスパッチ
自前のデータクラスにVisitorを当てたいなら、Python 3.8で入ったfunctools.singledispatchmethodが使えます。ドキュメントのとおり、振り分けはselfを除いた最初の引数の型で決まります。型ヒントを付けた関数をregisterで登録していくと、型ごとのvisitを並べたConcreteVisitorになります。
from dataclasses import dataclass
from functools import singledispatchmethod
@dataclass
class Num:
value: int
@dataclass
class Add:
left: object
right: object
class Eval:
@singledispatchmethod
def visit(self, node):
raise TypeError(f"未対応のノード: {type(node).__name__}")
@visit.register
def _(self, node: Num):
return node.value
@visit.register
def _(self, node: Add):
return self.visit(node.left) + self.visit(node.right)
print(Eval().visit(Add(Num(2), Add(Num(3), Num(4))))) # 9
try:
Eval().visit("x")
except TypeError as e:
print(e) # 未対応のノード: str
要素側のNumとAddにはacceptがありません。デコレータが引数の型を見て振り分けるので、ダブルディスパッチの1段目が要らなくなります。代わりに網羅性の検査も無く、Divを足してregisterを忘れると実行して初めて既定の実装に落ちます。既定の実装をTypeErrorにしているのは、黙ってNoneを返すより早く気付けるからです。
Python 3.14以降呼ばれないvisit_Num系への対応
visit_Num・visit_Str・visit_Bytes・visit_NameConstant・visit_Ellipsisは3.8で非推奨になり、ドキュメントには「Python 3.14+では呼ばれない」と書かれています。visit_Numだけを定義したVisitorにast.parse("1")を渡すと、3.14.6でも2026年10月9日リリースの3.15.0でもメソッドは一度も呼ばれず、ast.Numというクラス自体も存在しませんでした。
移行先はvisit_Constantで、数値・文字列・Noneなどはnode.valueの型で分岐します。3.13まではDeprecationWarningを出しながら呼ばれていたため、古い静的解析スクリプトを3.14以降へ上げると、エラーにならずに結果だけが空になります。TypeScriptで同じAST走査をする方法はTypeScript Compiler APIの使い方|AST解析・型取得のコードとTypeScript 7.1の新APIで扱っています。
Java 21のsealedとswitchでVisitorを置き換える判断基準
Java 21以降で閉じた型階層を扱い、操作ごとの走査基盤や状態管理を共有する必要がないなら、まずsealedとswitchを選びます。ここでは、同じ式ツリーをsealedとswitchで実装し、Visitor版とのコード量と型追加時の検出方法を比較します。
網羅性の検査をコンパイラに移す書き換え
sealedクラス・インターフェースはJava SE 17で標準機能になり、permits句で「これを実装できるのはこの型だけ」と宣言します。Java 21ではパターンをcaseラベルに書けるswitchが正式機能になりました。組み合わせると、先ほどのJava版Visitorは次まで縮みます(JDK 25での出力は14)。
sealed interface Node permits Num, Add, Mul {}
record Num(int value) implements Node {}
record Add(Node left, Node right) implements Node {}
record Mul(Node left, Node right) implements Node {}
final class Evaluator {
static int eval(Node n) {
return switch (n) {
case Num v -> v.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
};
}
}
defaultラベルがありません。Oracleの言語ガイドによれば、パターンを使うswitchは網羅的でなければならず、セレクタ式の型がsealedなら許可済みサブタイプを全部扱っているかをコンパイラが判定するのでdefaultは要りません。Divをpermitsに加えると、JDK 25のjavacはerror: the switch expression does not cover all possible input valuesで止まりました。Visitorがメソッド追加で得ていた漏れの検出を、accept・visitの往復なしで得られるわけです。
守られるのは再コンパイルしたコードだけです。NodeとDivだけを再コンパイルし、switchを含むEvaluatorを古いクラスファイルのままDivを渡すと、JDK 25ではjava.lang.MatchExceptionで止まりました。言語ガイドの記述どおりで、sealed階層を変えたら利用側も一緒にビルドし直す運用が前提です。また--release 17ではpatterns in switch statements are not supported in -source 17となります。プレビュー設定なしで使えるのはJava 21以降で、Java 17〜20では対応するJDKとプレビュー設定が必要です。
Java 21以上で、外部にサブタイプの追加を許さない閉じた階層なら、網羅性の検査のためにVisitorを書く必要はありません。従来のVisitorが向くのは、子を巡る共通の走査基盤や走査中の状態を処理ごとのクラスにまとめたい場合と、公開済みの階層に将来型を足しても利用者のコードを壊したくない場合です。後者では、JDKのElementVisitorのようにdefaultメソッドや抽象クラスのフォールバックで互換を保てますが、網羅的なswitchは型を足した時点で利用者側がコンパイルエラーかMatchExceptionになります。Java 21未満に留まる場合もVisitorが現実解です。sealedの文法とnon-sealedを含む3修飾子の違いはJavaのシールクラス(sealed class)とは?permitsと3修飾子・switch網羅・エラー例をJDK 25で実測で扱っています。
C#のswitch式で同じ保証が得られない理由
同じ発想をC#へ持ち込むと途中で止まります。先ほどのC#版に_アームの無い次のswitch式を加えると、.NET SDK 10.0.401は警告CS8509を出しつつビルドを通しました。
static int Evaluate(INode node) => node switch {
Num n => n.Value,
Add a => Evaluate(a.Left) + Evaluate(a.Right),
};
C# 14までには、Javaのpermitsに相当する「この型の派生はこれで全部」という宣言が無く、インターフェースや基底クラスを対象にしたswitch式の網羅をコンパイラは証明できません。Num・Add以外のINode実装を渡すとSystem.Runtime.CompilerServices.SwitchExpressionExceptionが投げられました(switch式のリファレンスの説明どおり)。<WarningsAsErrors>にCS8509を入れれば(コマンドラインなら-p:WarningsAsErrors=CS8509)ビルドは失敗になりますが、基底型が相手では全派生型を書いても警告が消えません。警告を解消するには_アームを追加できます。そのアームで例外を投げれば未対応の型を実行時に検出できますが、型追加時のコンパイル時検出はできません。
この状況は.NET 11で変わります。C# 15には、宣言したアセンブリの中でしか派生できないclosed修飾子と、型の集合を宣言するunion型が入り、どちらも網羅性検査の対象になります。.NET 11は2026年10月25日にRC3(本番利用を認めるgo-live)が出ており、正式版は11月10日の予定です。RC3のSDK 11.0.100-rc.3.26522.148(net11.0の既定LangVersionは15.0)では、次のコードは_アーム無しでも警告が出ず、結果は9でした。
public closed record Node;
public sealed record Num(int Value) : Node;
public sealed record Add(Node Left, Node Right) : Node;
static class Calc {
public static int Evaluate(Node node) => node switch {
Num n => n.Value,
Add a => Evaluate(a.Left) + Evaluate(a.Right),
};
}
ここにpublic sealed record Neg(Node X) : Node;を足すと、CS8509が「パターン ‘Neg’ がカバーされていません」と漏れた型を名指ししました。重大度は警告のままなのでビルドを止めるにはWarningsAsErrorsが要り、closedは派生先へ引き継がれないので中間の階層にも付ける必要があります。それでも.NET 11へ上げられるなら、C#でもVisitorを書く理由はJavaと同程度まで減ります。.NET 10(LTS)に留まる間は、要素型の集合を守る役目がVisitorインターフェース側に残ります。
Visitorを採用すべきでない設計条件
Visitorは、前提を外すと保守コストだけが増える設計です。見送るべき状況をはっきりさせておきます。
要素の型が増え続ける階層での破綻
最も分かりやすい失敗は、要素の型が今後も増える階層にVisitorを入れることです。型を1つ足すたびにVisitorインターフェースへメソッドが増え、実装済みのVisitorが全部コンパイルエラーになります。RoslynのVisitXxxが250に達し、JDKがバージョン付きの抽象クラスを何世代も用意してきたのはこの問題の表れです。Visitorがオープンクローズドの原則(OCP)とは?意味と違反コードの直し方、メイヤー版とマーチン版の違いでいう「拡張に開いている」状態を提供するのは処理の側だけなので、型のほうが速く増えるなら選びません。
要素が2〜3種類で、操作も少なく今後の追加予定がないなら、Visitorを持ち込む利点は小さくなります。acceptとVisitorインターフェースの分だけ型が増え、拡張性は使われません。この規模ならJavaはパターンマッチのswitch、C#はswitch式で足ります。要素の内部状態にVisitorから触れる必要がある設計も避けます。アクセサを次々に公開することになり、カプセル化を壊して操作の分離を得る割に合わない取引になります。
採用可否を分ける条件
| 条件 | Visitor | sealed+switch | 単純なポリモーフィズム |
|---|---|---|---|
| 要素の型が固定・処理が増える | 適する | 適する | 適さない |
| 要素の型が増え続ける | 適さない | 適さない | 適する |
| 閉じた階層を外部へ公開する | 適する | 適する | 処理追加は要改修 |
| 公開後に型を足しても利用者を壊さない | 適する(default等) | 適さない | 適する |
| Java 21以上・階層は自社管理 | 網羅目的なら過剰 | 適する | 条件次第 |
| C# 14以前(閉じた階層の宣言なし) | 適する | 網羅保証なし | 条件次第 |
| C# 15(closed・union) | 網羅目的なら過剰 | 適する(警告) | 条件次第 |
| 要素が2〜3種類・操作の追加予定なし | 過剰 | 適する | 適する |
2行目でsealed+switchも「適さない」としたのは、型を足したときの改修量がVisitorと変わらないからです(どちらも未対応箇所をコンパイル時に検出できますが、sealed+switchではVisitorインターフェースとacceptの実装が不要になります)。「単純なポリモーフィズム」は処理を要素クラス自身のメソッドとして持たせる設計で、型が増え続けるならこれが最も安く済みます。選ぶ前に、増えるのは型か処理かを確かめてください。GoFデザインパターン23種類の一覧|3分類と見分け方をJavaコードで解説では、この見分け方を23パターン全体で整理しています。
Strategy・Composite・Iteratorとの役割の違い
Visitorは他の振る舞い系パターンと混同されがちですが、何を可変にするかを見れば切り分けられます。
| パターン | 可変にする対象 | 切り替えの単位 | 典型的な用途 |
|---|---|---|---|
| Visitor | 要素の型ごとの処理 | 処理の集合(Visitor1個) | 構文木の評価・整形・検証 |
| Strategy | 1つのアルゴリズム | アルゴリズム1個 | ソート順・料金計算の差し替え |
| Composite | 木構造の表現 | ノードの合成 | ツリーの統一的な扱い |
| Iterator | 走査の順序 | 走査手段1個 | コレクションの巡回 |
CompositeとVisitorは競合せず、Compositeが木を作りVisitorがその木を巡る処理を受け持つ形で併用します。Iteratorは要素を順に取り出すだけで、型ごとの処理は持ちません。走査をどちらに任せるかは、前述の「走査を誰が担うか」の基準で決めます。Strategyとの使い分けはStrategyパターンとは?if文の分岐を置き換える仕組みとPHP・Java・Python・TypeScriptの実装例で扱っています。
よくある質問
Visitorパターンとは何ですか?
オブジェクトの構造を変えずに、その構造に対する操作を後から追加するためのGoFデザインパターンです。要素クラスにはacceptを1つだけ置き、処理はVisitorという別クラスにまとめます。新しい処理はVisitorを1クラス足すだけで済むので、構文木のように型の種類が固定で処理だけが増える構造に向いています。
ダブルディスパッチとは何ですか?
呼び出す処理を、2つのオブジェクトの実行時の型の組み合わせで決める仕組みです。Java・C#・C++の通常の呼び出しでは、仮想関数はレシーバの実行時型で解決されますが、引数のオーバーロードはコンパイル時の型で選ばれます(C#のdynamicを使う呼び出しは例外)。そこでVisitorはacceptで要素の実行時型に飛び、その内側でvisitを仮想呼び出しすることで、1回目に要素の型、2回目にVisitorの型を確定させます。
C#でVisitorパターンを使うべき場面はどこですか?
.NET 10(C# 14)以前で、要素の型の集合が閉じていて処理のほうが増えるときです。C# 14までは基底型を対象にしたswitch式で型の追加漏れを検出できないため、Visitorインターフェースへメソッドを足す方法が有効です。実例はRoslynのCSharpSyntaxVisitor<TResult>です。C# 15(.NET 11)のclosed階層を使えるなら、switch式で足ります。
VisitorパターンとStrategyパターンの違いは何ですか?
可変にする対象が違います。Strategyは共通のインターフェースを通じてアルゴリズムを差し替えます。処理対象の型が1種類に限定されるわけではありません。Visitorは複数の要素型それぞれに対する処理を1組にまとめ、組ごと差し替えます。「料金計算の方式を切り替えたい」ならStrategy、「構文木の全ノード種別に対する処理を評価用と整形用で切り替えたい」ならVisitorです。
Visitorパターンにパフォーマンス上のオーバーヘッドはありますか?
1要素あたり仮想呼び出しが2回(acceptとvisit)になるので、直接呼ぶより呼び出しは増えます。ただしRoslynはC#のコンパイル処理そのものをこの構造で回しており、実用規模で成り立つことは実装が示しています。設計時には型追加時の改修範囲を確認し、実行速度が要件になる場合は実際のデータ量と処理系で計測します。