公開日 2026-08-25

Javaのデザインパターン「Visitor」の二重ディスパッチはいつ外せるか

Visitorのacceptとvisitをsealed interfaceとパターンマッチングswitchへ置き換える。同じ網羅性を保てる条件と、Visitorを残す条件を比較する。

目次

  1. 1. Visitorはデータから操作を分けるパターン
  2. 2. 二重ディスパッチを外せる条件
  3. 3. 古典Visitorがacceptとvisitを使う理由
  4. 4. sealedとrecordパターンで同じ結果を得る
  5. 5. 型を足すとどちらもコンパイルで止まる
  6. 6. 操作を足すコストと、Visitorを残す条件
  7. 7. 片付けと、対処

種類の異なるデータから操作を分離し、操作側へ型ごとの処理をまとめる設計がVisitorです。データ型の一覧を同じモジュール内で閉じられるなら、古典的な acceptvisit は、sealed interface とパターンマッチング switch に置き換えられます。

ただし、型の階層を外部へ開く、操作を別モジュールから追加する、木構造を巡回するといった要件では Visitor を残す意味があります。題材はアップロードファイルです。同じ保証を保ったまま、コードの構造がどう変わるかを比較対象にします。

1. Visitorはデータから操作を分けるパターン

Visitorは、種類の異なるデータに対する処理を、データ側のクラスから分離するデザインパターンです。たとえばテキスト・画像・動画に対して、表示用の要約、ファイルサイズの集計、監査ログの生成といった操作を後から追加する場面で使います。

古典的なVisitorの構成要素は次の4つです。

  • Element: Visitorを受け取る accept を定義する共通の型
  • ConcreteElement: テキストや画像など、実際のデータ型
  • Visitor: データ型ごとの visit を定義する操作の型
  • ConcreteVisitor: 要約や集計など、1つの操作をまとめた実装

新しい操作はConcreteVisitorとして追加できるため、既存のデータ型へ操作メソッドを増やさずに済みます。代わりに新しいデータ型を増やすと、すべてのVisitorへ対応する visit が必要です。この記事の焦点は、この特徴を保ったまま acceptvisit を外せる条件です。

この記事では、Upload がElement、TextFileImageFile がConcreteElement、UploadVisitor がVisitor、SummaryVisitor がConcreteVisitorに当たります。upload.accept(visitor) が実体に合う accept を選び、その中の visitor.visit(this) が要素型に合う処理を選ぶ2段階の呼び出しを、二重ディスパッチと呼びます。

2. 二重ディスパッチを外せる条件

選び分けは次のとおりです。

条件向いている書き方
型の一覧を閉じられ、操作をアプリケーション内で管理するsealed interfaceswitch
操作を独立したモジュールとして次々に追加する古典 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) を選ぶため、各要素の acceptvisitor.visit(this) を呼び直します。この2段階の選択が二重ディスパッチです。

新しい操作を足すときは、UploadVisitor の実装を1つ増やします。既存の TextFileImageFile は変更不要です。これが Visitor の強みです。

4. sealedとrecordパターンで同じ結果を得る

型の一覧を閉じられるなら、acceptvisit を外して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 パターンは TextFileImageFile の成分を case で取り出します。when は、型が同じ ImageFile の中で横長かどうかを分ける条件に当たります。要素側は、操作のためのメソッドを持たない形です。

record パターンと switch のパターンマッチングは JDK 21 で正式導入され、この記事のコードも JDK 21 以降が対象です。背景仕様は JEP 440: Record PatternsJEP 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 側では、permitsVideoFile を足した時点で未対応の 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 側では各操作の switchcase VideoFile を追加する形です。

defaultcase Upload other を置くと、新しい型をまとめて受け止めるため、この検査は働きません。未知の型を意図的に無視する操作だけで使い、網羅したい操作では避けるのが原則です。

6. 操作を足すコストと、Visitorを残す条件

型を増やさず、新しい操作だけを足す場合、どちらも要素型は変更不要です。

追加するもの古典 Visitorsealed + 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 を各操作へ足します。

シリーズ 3/3

このシリーズ

デザインパターンを新しいJavaで書き直す

  1. 1. Javaのデザインパターン「Strategy」はラムダでどこまで置き換えられるか
  2. 2. Javaのデザインパターン「State」とsealed・switchを使い分ける
  3. 3. Javaのデザインパターン「Visitor」の二重ディスパッチはいつ外せるか 現在の記事