Visitorパターンとは?ダブルディスパッチとJava・C#の実装例
Visitorパターンは、データ構造のクラスを書き換えずに、そこへ後から操作だけを足すためのGoFデザインパターンです。構文木やドキュメントツリーのように「型の種類は固定だが、やりたい処理は増え続ける」構造が主戦場です。この記事では、なぜacceptとvisitの2段構えが必要になるのかをコンパイラの挙動から確認し、Java・C#・Pythonの実装を示したうえで、Java 21以降ならsealedとパターンマッチのswitchに置き換えるべき条件まで踏み込みます。
まとめ
先に結論をまとめます。
- Visitorパターンの本質はダブルディスパッチです。
acceptで要素の実行時型を、visitのオーバーロードで処理を、2段階に分けて決めます。 - 得意なのは操作の追加、苦手なのは型の追加。要素の型を1つ足すと、既存のVisitor実装をすべて直すことになります。
- この代償は実在ライブラリに現れています。Roslynの
CSharpSyntaxVisitorはVisitXxxを249メソッド持ち、JDKのElementVisitorは「将来のJavaでメソッドが増える可能性がある」とAPI Noteで警告済みです。 - Java 21以上で階層が自社管理なら、Visitorを書かず
sealedインターフェース+パターンマッチのswitchを選びます。コンパイラが網羅性を検査するため、型を足したときの漏れが自動で見つかります。 - C#に同じ保証はまだありません。網羅漏れの診断CS8509は警告レベルで、クラス階層では
_アームを外すと実行時例外になります。
以降では、この判断に至る仕組みと、言語ごとの具体的な書き方を順に見ていきます。
Visitorパターンが解く問題とダブルディスパッチの仕組み
Visitorパターンを「操作をクラスの外に出すパターン」とだけ覚えると、なぜacceptという回りくどい経由地が要るのかを説明できません。理由はオーバーロード解決のタイミングにあります。仕組みはC++で最短に示せるので、この節だけC++を使います。
C++で確かめるオーバーロード解決の限界
Java、C#、C++はいずれも単一ディスパッチの言語です。仮想関数呼び出しではレシーバの実行時型が使われますが、引数によるオーバーロードの選択はコンパイル時の静的型だけで決まります。次のコードを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としてしか見ておらず、2つの候補のどちらにも結び付けられません。ここで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用のオーバーロードへコンパイル時に結び付く形です。実行時型と処理の組み合わせを2回のディスパッチで決めるので、ダブルディスパッチと呼ばれます。新しい処理(Exporter、Validator)を足すのはVisitorを1クラス書くだけ。一方、新しい要素の型(Triangle)を足すと、既存のVisitor実装がすべてコンパイルエラーになります。
JavaでのVisitor実装と戻り値を返す設計
Javaで書く場合、戻り値を型パラメータにしておくと、評価器・整形器・集計器を同じ構造で使い回せます。対象は足し算と掛け算だけの式ツリー。ジェネリックメソッドを使うので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); }
}
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
}
}
式を文字列に戻したくなったらVisitor<String>を1クラス追加するだけで、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 Noteは2つを書き分けています。実装者には抽象Visitorクラスの継承を推奨する一方、「an API should generally use this visitor interface as the type for parameters, return type, etc. rather than one of the abstract classes(APIは引数や戻り値の型として抽象クラスではなくこのビジターインターフェースを使うべき)」。公開する型はインターフェースのままにし、継承先だけ抽象クラスへ寄せる役割分担です。
実際にメソッドは増えています。visitModuleはJava 9のモジュール、visitRecordComponentはJava 16のレコードに対応して追加され、どちらもvisitUnknown(e, p)を呼ぶdefaultメソッドとして定義されました。既存の実装がコンパイルできなくなる事態を、この形で回避しています。javax.lang.model.utilにAbstractElementVisitor6・7・8・9・14と5世代の抽象クラスが並ぶのも同じ理由です。
C#でのVisitor実装とRoslynが採用している形
C#でも構造は同じです。Javaのジェネリックメソッドに相当する書き方で、戻り値の型を呼び出し側に決めさせます。式形式メンバはC# 6以降、タプルによる分解代入はC# 7以降で使えます。
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);
}
要素クラスに付けたsealedは、そのクラスの継承を禁止する修飾子です。後述するJavaのsealed+permits(許可するサブタイプを列挙する宣言)とは別物なので、混同しないでください。ここで継承を止めているのは、派生を許すとVisitAddがどの型を受け取っているのか保証できなくなるからです。
CSharpSyntaxVisitorが示す249メソッドという現実
.NETコンパイラプラットフォームRoslynは、C#の構文木の走査にVisitorをそのまま使っています。Microsoft.CodeAnalysis.CSharp名前空間(アセンブリはMicrosoft.CodeAnalysis.CSharp.dll)のCSharpSyntaxVisitor<TResult>は、公式リファレンスで「Visitメソッドに渡された単一のCSharpSyntaxNodeだけを訪問し、TResultで指定した型の値を返すビジター」と定義された抽象クラスです。
公開メンバは、入口のVisit(SyntaxNode)、受け皿のDefaultVisit(SyntaxNode)、そして構文ノード1種類につき1つのVisitXxxです。公式APIリファレンス(既定モニカー roslyn-dotnet-5.0.0)のメソッド表は全251行で、VisitとDefaultVisitを除いたVisitXxxは249ありました(2026年8月時点の実測)。ソースコードを書き換えるCSharpSyntaxRewriterは、このクラスの派生です。
249という数字は、C#に新しい構文が入るたびにこの抽象クラスへメソッドが増えることを意味します。RoslynはVisitorのインターフェースを公開せず、抽象クラスとDefaultVisitのフォールバックで互換を取る方式。JDKはインターフェースにdefaultメソッドを足す方式で、手段は違いますが狙いは同じです。
PythonでのVisitor実装と標準ライブラリast.NodeVisitor
Pythonは実行時に属性を名前で引けるため、acceptを書かずにVisitorを実現できます。標準ライブラリのast.NodeVisitorがその実装です。
visit_クラス名による名前ベースの振り分け
公式ドキュメントはvisit()の挙動を「ノードクラス名に対応するself.visit_クラス名を呼び、そのメソッドが無ければgeneric_visit()を呼ぶ」と説明しています。CPython 3.14.6で実行した例です。
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だけでなく定数も丸ごと落ちます。ast走査が一部しか拾えない場合、原因はほぼここです。
一方visit_Constantでgeneric_visitを呼んでいないのは、ConstantのvalueがASTノードではなく素の値だからで、子を持たないノードでは省略しても結果が変わりません。node.funcをast.Nameに限定しているのも意図的です。obj.method()のような呼び出しはast.Attributeになり.idを持ちません。
ノードを書き換えたいときはNodeVisitorではなくNodeTransformerを使います。visitorメソッドの戻り値でノードを置換し、Noneを返すとそのノードが削除されます。落とし穴は2つで、子ノードは自分で変換するか先にgeneric_visit()を呼ぶ必要があること、そして位置情報を持たない新規ノードを差し込んだ場合はfix_missing_locations()を呼ばないとコンパイルできないことです。
Python 3.14で呼ばれなくなったvisit_Num系への対応
visit_Num、visit_Str、visit_Bytes、visit_NameConstant、visit_Ellipsisは3.8で非推奨となり、3.14以降は呼ばれません。手元のCPython 3.14.6でvisit_Numだけを定義したVisitorにast.parse("1")を渡したところ、メソッドは一度も呼ばれませんでした。
移行先はvisit_Constantです。数値・文字列・バイト列・None・Ellipsisがすべてこのメソッドに集約されるため、node.valueの型で分岐します。3.8から3.13までは非推奨ながら動いていたので、古いコード生成ツールや静的解析スクリプトを3.14へ上げるときは、この名前の置き換えが黙って結果を空にする落とし穴です。同じAST走査をTypeScript側で行う場合の作法はTypeScript Compiler APIとは|AST解析と型情報取得の手順とTypeScript 7対応にまとめています。
Java 21のsealedとswitchでVisitorを置き換える判断基準
SERP上位の日本語解説を実測したところ、sealedとパターンマッチによる置き換えに触れているものはありませんでした。しかしJava 21以降、要素の型が閉じているならVisitorを書く理由はほぼ消えます。
網羅性の検査をコンパイラに移す書き換え
sealedクラスとインターフェースはJava SE 17の標準機能で、permits句によって「これを実装できるのはこの型だけ」と宣言します。そしてJava 21では、パターンをcaseラベルに書けるswitchが正式機能になりました。この2つを組み合わせると、先ほどのJava版Visitorは次まで縮みます。
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の言語ガイドは、パターンやnullラベルを使うswitchについて「must be exhaustive(網羅的でなければならない)」と定めたうえで、セレクタ式の型がsealedである場合はコンパイラが許可済みサブタイプの全網羅を判定するためdefaultが不要になると説明しています。
あとからNodeにDivを追加してpermitsに加えると、その型を処理していないswitchがすべてコンパイルエラーになります。Visitorがインターフェースへのメソッド追加で実現していた漏れの検出を、accept・visitの往復なしで得られるということです。ただし守られるのは再コンパイルしたコードだけで、同じガイドは、sealed階層を変更したあとswitchを含むクラスを再コンパイルしないと実行時にMatchExceptionが投げられると明記しています。
判断は単純です。Java 21以上で、型階層を自分たちで管理していて外部にサブタイプの追加を許さないなら、Visitorは書かずにsealedとswitchを使います。階層をライブラリとして公開し利用者に処理の追加だけを許したい場合や、Java 17より前の環境に留まる場合は、従来のVisitorが現実解です。
C#のswitch式で同じ保証が得られない理由
同じ発想をC#へ持ち込むと、途中で止まります。C#のswitch式は網羅していないとCS8509を出しますが、メッセージは「The switch expression does not handle all possible values of its input type (it is not exhaustive).」であり、診断の重大度は警告です。加えて、安定版のC#にはJavaのpermitsに相当する閉じた階層の宣言がまだありません。インターフェースや基底クラスを対象にしたswitch式では、コンパイラが網羅を証明できないということです。
static int Evaluate(INode node) => node switch {
Num n => n.Value,
Add a => Evaluate(a.Left) + Evaluate(a.Right),
_ => throw new NotSupportedException(node.GetType().Name)
};
_アームは構文上は省略できますが、省略するとCS8509が出たうえで、未処理の型が来たときに実行時例外で落ちます。実質的に外せない、というのが正確なところです。ビルドで止めたいなら、プロジェクトファイルのWarningsAsErrorsにCS8509を指定します。enumも安心はできません。名前付きメンバを全網羅しても、未命名の値ぶんについて別コードのCS8524が出ます。
この状況は動き始めています。Roslynの機能ステータス表では「Closed class hierarchies」がプレビュー機能として.NET 11 preview 5とVS 18.8にマージ済み、判別共用体にあたる「Unions」は開発中と記載されています(2026年8月時点)。安定版に届くまでは、C#で要素型の集合を守る責任はVisitorインターフェース側に残ります。C#でVisitorを書く価値がJavaより長く残る理由がここにあります。
Visitorを採用すべきでない設計条件
Visitorは、条件を外すと保守コストだけが増える設計です。採用を見送るべき状況をはっきりさせておきます。
要素の型が増え続ける階層での破綻
最も分かりやすい失敗は、要素の型が今後も増える見込みの階層にVisitorを入れることです。型を1つ足すたびに、Visitorインターフェースへメソッドが1つ増え、実装済みのVisitorが全滅します。RoslynのVisitXxxが249に達し、JDKがバージョン付き抽象クラスを5世代も用意したのは、この問題に正面からぶつかった結果です。処理の種類より型の種類のほうが速く増える設計なら、Visitorは選びません。
次に多いのが、要素が2〜3種類しかないのにVisitorを持ち込むケースです。acceptとVisitorインターフェースの分だけファイルが増え、呼び出しが1段深くなる割に拡張性が使われません。この規模なら、Javaはパターンマッチのswitch、C#はswitch式で十分。要素クラスの内部状態にVisitorから触れる必要がある設計も避けます。getValue()のようなアクセサを公開して回ることになり、カプセル化を壊した見返りに操作の分離を得るという割に合わない取引です。
採用可否を分ける条件
| 条件 | Visitor | sealed+switch | 単純なポリモーフィズム |
|---|---|---|---|
| 要素の型が固定・処理が増える | 適する | 適する | 適さない |
| 要素の型が増え続ける | 適さない | 適さない | 適する |
| 階層を外部へ公開する | 適する | 適さない | 適する |
| Java 21以上・階層は自社管理 | 過剰 | 適する | 条件次第 |
| 安定版C#(閉じた階層の宣言なし) | 適する | 網羅保証なし | 条件次第 |
| 要素が2〜3種類 | 過剰 | 適する | 適する |
2行目でsealed+switchも「適さない」としているのは、型を足したときの改修量がVisitorと変わらないからです。違いは漏れがコンパイル時に見つかる点だけで、改修そのものは消えません。「単純なポリモーフィズム」は処理を要素クラス自身のメソッドとして持たせる素直な設計で、要素の型が増え続けるならこれが最も安く済みます。設計を選ぶ前に、増えるのは型か処理かを確かめてください。GoFのデザインパターンとは?23種類の一覧と代表パターンをコードで解説では、この判断軸を23パターン全体で整理しています。
Strategy・Composite・Iteratorとの役割の違い
Visitorは他の振る舞い系パターンと混同されがちですが、何を可変にするかを見れば切り分けは簡単です。
| パターン | 可変にする対象 | 切り替えの単位 | 典型的な用途 |
|---|---|---|---|
| Visitor | 要素の型ごとの処理 | 処理の集合(Visitor1個) | 構文木の評価・整形・検証 |
| Strategy | 1つのアルゴリズム | アルゴリズム1個 | ソート順・料金計算の差し替え |
| Composite | 木構造の表現 | ノードの合成 | ツリーの統一的な扱い |
| Iterator | 走査の順序 | 走査手段1個 | コレクションの巡回 |
CompositeとVisitorは競合せず、Compositeが木を作りVisitorがその木を巡る処理を受け持つ形で併用します。Iteratorは要素を順に取り出すだけで型ごとの処理を持ちません。走査順をVisitor側に書くかIteratorへ任せるかは、走査順が処理ごとに変わるかどうかで決めます。Strategyとの使い分けはStrategyパターンとは?PHP・Java・TypeScriptの実装例と使いどころで扱っています。
よくある質問
Visitorパターンとは何ですか?
オブジェクトの構造を変えずに、その構造に対する操作を後から追加するためのデザインパターンです。要素クラスにacceptを1つだけ用意し、実際の処理はVisitorという別クラスにまとめます。新しい処理が必要になってもVisitorを1クラス追加するだけで、要素クラスのソースには手を入れません。構文木やファイルシステムのように、型の種類が固定で処理だけが増える構造に向きます。
ダブルディスパッチとは何ですか?
呼び出す処理を、2つのオブジェクトの実行時の型の組み合わせで決める仕組みです。Java・C#・C++は単一ディスパッチの言語で、仮想関数はレシーバの実行時型で解決されますが、引数のオーバーロードはコンパイル時の静的型でしか選ばれません。そこでVisitorパターンでは、まずacceptで要素の実行時型に飛び、その内側でvisitを呼ぶことで2回のディスパッチを行います。1回目で要素の型、2回目で処理が確定します。
C#でVisitorパターンを使うべき場面はどこですか?
要素の型の集合が閉じていて、処理のほうが増えるときです。安定版のC#にはJavaのsealedインターフェースとpermitsに相当する閉じた階層の宣言がなく、switch式の網羅漏れは警告レベルのCS8509にとどまります。型の追加漏れをコンパイラに検出させたいなら、Visitorインターフェースにメソッドを追加する方法が今も有効。実例がRoslynのCSharpSyntaxVisitor<TResult>で、構文ノード種別ごとに249のVisitXxxを備えています。
VisitorパターンとStrategyパターンの違いは何ですか?
可変にする対象が違います。Strategyは1つのアルゴリズムを差し替えるパターンで、扱う対象の型は1種類です。Visitorは複数の要素型それぞれに対する処理をまとめて1組にし、その組ごと差し替える形です。「料金計算の方式を切り替えたい」ならStrategy、「構文木の全ノード種別に対する処理を評価用と整形用で切り替えたい」ならVisitorになります。両者を組み合わせ、Visitorの各メソッド内部でStrategyを選ぶ設計も成立します。
Visitorパターンにパフォーマンス上のオーバーヘッドはありますか?
1要素あたり仮想呼び出しが2回(acceptとvisit)発生するため、直接メソッドを呼ぶ場合より増えるのは確かです。ただしRoslynはC#のコンパイル処理そのものをこの構造で回しており、実用規模で成立することは実装が示しています。速度が問題になるかどうかは対象の木の大きさと走査回数で変わるので、気になる場合は実測してください。コストとして先に効いてくるのは実行速度ではなく、要素の型を追加したときに全Visitorへ波及する改修量のほうです。