種類の異なるデータから操作を分離し、操作側へ型ごとの処理をまとめる設計がVisitorです。データ型の一覧を同じモジュール内で閉じられるなら、古典的な accept と visit は、sealed interface とパターンマッチング switch に置き換えられます。
ただし、型の階層を外部へ開く、操作を別モジュールから追加する、木構造を巡回するといった要件では Visitor を残す意味があります。題材はアップロードファイルです。同じ保証を保ったまま、コードの構造がどう変わるかを比較対象にします。
1. Visitorはデータから操作を分けるパターン
Visitorは、種類の異なるデータに対する処理を、データ側のクラスから分離するデザインパターンです。たとえばテキスト・画像・動画に対して、表示用の要約、ファイルサイズの集計、監査ログの生成といった操作を後から追加する場面で使います。
古典的なVisitorの構成要素は次の4つです。
- Element: Visitorを受け取る
acceptを定義する共通の型 - ConcreteElement: テキストや画像など、実際のデータ型
- Visitor: データ型ごとの
visitを定義する操作の型 - ConcreteVisitor: 要約や集計など、1つの操作をまとめた実装
新しい操作はConcreteVisitorとして追加できるため、既存のデータ型へ操作メソッドを増やさずに済みます。代わりに新しいデータ型を増やすと、すべてのVisitorへ対応する visit が必要です。この記事の焦点は、この特徴を保ったまま accept と visit を外せる条件です。
この記事では、Upload がElement、TextFile と ImageFile がConcreteElement、UploadVisitor がVisitor、SummaryVisitor がConcreteVisitorに当たります。upload.accept(visitor) が実体に合う accept を選び、その中の visitor.visit(this) が要素型に合う処理を選ぶ2段階の呼び出しを、二重ディスパッチと呼びます。
2. 二重ディスパッチを外せる条件
選び分けは次のとおりです。
| 条件 | 向いている書き方 |
|---|---|
| 型の一覧を閉じられ、操作をアプリケーション内で管理する | sealed interface と switch |
| 操作を独立したモジュールとして次々に追加する | 古典 Visitor |
| 再帰的な木を同じ手順で巡回する | 古典 Visitor、または専用の走査処理 |
| 2つの値の実行時型の組み合わせで処理を選ぶ | 二重ディスパッチを含む専用設計 |
switch へ置き換える利点は、Visitor の能力が増えることではありません。同じ網羅性を、要素側の accept と Visitor 側の visit の組み合わせなしで表せることです。
3. 古典Visitorがacceptとvisitを使う理由
Windows のターミナルから WSL2 に入り、作業ディレクトリを作成してください。
wsl
mkdir -p ~/projects/java-visitor-pattern-demo
cd ~/projects/java-visitor-pattern-demo
code .
まず VisitorClassic.java を作成します。
public class VisitorClassic {
public static void main(String[] args) {
UploadVisitor<String> summary = new SummaryVisitor();
for (Upload upload : new Upload[] {
new TextFile("readme.txt", 120),
new ImageFile("cover.jpg", 1_200, 800)
}) {
System.out.println(upload.accept(summary));
}
}
}
interface Upload {
<R> R accept(UploadVisitor<R> visitor);
}
interface UploadVisitor<R> {
R visit(TextFile file);
R visit(ImageFile file);
}
record TextFile(String name, int bytes) implements Upload {
public <R> R accept(UploadVisitor<R> visitor) { return visitor.visit(this); }
}
record ImageFile(String name, int width, int height) implements Upload {
public <R> R accept(UploadVisitor<R> visitor) { return visitor.visit(this); }
}
final class SummaryVisitor implements UploadVisitor<String> {
public String visit(TextFile file) {
return file.name() + ": text (" + file.bytes() + " bytes)";
}
public String visit(ImageFile file) {
return file.name() + ": landscape image";
}
}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java VisitorClassic.java
readme.txt: text (120 bytes)
cover.jpg: landscape image
呼び出し側が知っている変数の型は Upload です。実体に合う visit(TextFile) または visit(ImageFile) を選ぶため、各要素の accept が visitor.visit(this) を呼び直します。この2段階の選択が二重ディスパッチです。
新しい操作を足すときは、UploadVisitor の実装を1つ増やします。既存の TextFile と ImageFile は変更不要です。これが Visitor の強みです。
4. sealedとrecordパターンで同じ結果を得る
型の一覧を閉じられるなら、accept と visit を外して1つの switch にできます。比較用の VisitorSealed.java を作成してください。
public class VisitorSealed {
public static void main(String[] args) {
for (Upload upload : new Upload[] {
new TextFile("readme.txt", 120),
new ImageFile("cover.jpg", 1_200, 800)
}) {
System.out.println(summary(upload));
}
}
static String summary(Upload upload) {
return switch (upload) {
case TextFile(var name, var bytes) ->
name + ": text (" + bytes + " bytes)";
case ImageFile(var name, var width, var height) when width > height ->
name + ": landscape image";
case ImageFile(var name, var width, var height) ->
name + ": portrait or square image";
};
}
}
sealed interface Upload permits TextFile, ImageFile {}
record TextFile(String name, int bytes) implements Upload {}
record ImageFile(String name, int width, int height) implements Upload {}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java VisitorSealed.java
readme.txt: text (120 bytes)
cover.jpg: landscape image
record パターンは TextFile や ImageFile の成分を case で取り出します。when は、型が同じ ImageFile の中で横長かどうかを分ける条件に当たります。要素側は、操作のためのメソッドを持たない形です。
record パターンと switch のパターンマッチングは JDK 21 で正式導入され、この記事のコードも JDK 21 以降が対象です。背景仕様は JEP 440: Record Patterns と JEP 441: Pattern Matching for switch を参照してください。
5. 型を足すとどちらもコンパイルで止まる
VideoFile を追加すると、古典 Visitor は visit(VideoFile) を実装していない Visitor を検出します。次の VisitorClassicAdded.java は、SummaryVisitor だけを直し忘れた最小例です。
public class VisitorClassicAdded {
public static void main(String[] args) {}
}
interface Upload {
<R> R accept(UploadVisitor<R> visitor);
}
interface UploadVisitor<R> {
R visit(TextFile file);
R visit(ImageFile file);
R visit(VideoFile file);
}
record TextFile(String name) implements Upload {
public <R> R accept(UploadVisitor<R> visitor) { return visitor.visit(this); }
}
record ImageFile(String name) implements Upload {
public <R> R accept(UploadVisitor<R> visitor) { return visitor.visit(this); }
}
record VideoFile(String name) implements Upload {
public <R> R accept(UploadVisitor<R> visitor) { return visitor.visit(this); }
}
final class SummaryVisitor implements UploadVisitor<String> {
public String visit(TextFile file) { return "text"; }
public String visit(ImageFile file) { return "image"; }
}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java VisitorClassicAdded.java
VisitorClassicAdded.java:27: error: SummaryVisitor is not abstract and does not override abstract method visit(VideoFile) in UploadVisitor
final class SummaryVisitor implements UploadVisitor<String> {
^
1 error
error: compilation failed
sealed 側では、permits に VideoFile を足した時点で未対応の switch が止まります。次の VisitorSealedAdded.java が最小例です。
public class VisitorSealedAdded {
public static void main(String[] args) {}
static String summary(Upload upload) {
return switch (upload) {
case TextFile(var name) -> "text: " + name;
case ImageFile(var name) -> "image: " + name;
};
}
}
sealed interface Upload permits TextFile, ImageFile, VideoFile {}
record TextFile(String name) implements Upload {}
record ImageFile(String name) implements Upload {}
record VideoFile(String name) implements Upload {}
docker run --rm -v "$(pwd):/app" -w /app eclipse-temurin:25-jdk java VisitorSealedAdded.java
VisitorSealedAdded.java:5: error: the switch expression does not cover all possible input values
return switch (upload) {
^
1 error
error: compilation failed
保証は同じでも、修正箇所の形は異なります。古典 Visitor では各 Visitor 実装へ visit(VideoFile) を追加し、sealed 側では各操作の switch へ case VideoFile を追加する形です。
default や case Upload other を置くと、新しい型をまとめて受け止めるため、この検査は働きません。未知の型を意図的に無視する操作だけで使い、網羅したい操作では避けるのが原則です。
6. 操作を足すコストと、Visitorを残す条件
型を増やさず、新しい操作だけを足す場合、どちらも要素型は変更不要です。
| 追加するもの | 古典 Visitor | sealed + switch |
|---|---|---|
| 新しい操作 | UploadVisitor の実装を追加 | switch を持つメソッドを追加 |
| 新しい型 | Visitor 実装をすべて更新 | 操作の switch をすべて更新 |
操作がアプリケーション内の数個の関数なら、switch のほうが仕掛けは少なくなります。一方、Visitor を残す価値があるのは次の条件です。
- 操作を別モジュールとして追加し、要素側から独立させたい
- 木構造を巡回する共通手順を Visitor に持たせたい
- Visitor が集計状態や外部サービスを保持する
- 要素型を
sealedで閉じられない - 2つの値の実行時型の組み合わせで処理を選ぶ
Visitor が古くなったから消す、という結論ではありません。型を閉じられ、操作が単純な関数として収まる範囲では、Java 21 以降の型検査へ二重ディスパッチの役割を渡せる、という判断です。
前の記事: Javaのデザインパターン「State」とsealed・switchを使い分ける。同じ網羅性を、状態追加の側から扱っています。
7. 片付けと、対処
残るものは、作業ディレクトリと Docker イメージです。コンテナには --rm を付けているため、停止後のコンテナは残りません。
削除する前に対象を確認します。
ls -d ~/projects/java-visitor-pattern-demo
docker image ls eclipse-temurin
表示されたディレクトリがこの記事の作業用であり、JDK 25 のイメージをほかの作業で使わない場合だけ削除してください。続けて同シリーズの記事を試す場合、イメージの削除は不要です。
cd ~
rm -rf ~/projects/java-visitor-pattern-demo
docker image rm eclipse-temurin:25-jdk
does not override abstract method visit(...) が出たら、追加した型に対応する visit を各 Visitor 実装へ足します。the switch expression does not cover all possible input values が出たら、追加した型の case を各操作へ足します。