java-recipes

ホーム デザインパターン › DP-23

DP-23: Visitor パターン

オブジェクト構造を走査しながら、各要素に対する処理を「訪問者(Visitor)」として外部から追加するパターンです。 要素クラスを変更せずに新しい操作を追加できます。

Visitor パターンとは

Visitor(ビジター/訪問者)パターンは、オブジェクト構造(ツリーやリストなど)の要素を 走査しながら、要素の種類に応じた処理を「訪問者」クラスに集約するパターンです。 操作を要素クラスの外に切り出すことで、要素クラスを変えずに新しい操作を追加できます。

登場する役割

  • Visitor(訪問者): 各要素への処理を定義するインターフェース。要素の種類ごとに visit() メソッドを持つ
  • ConcreteVisitor(具体訪問者): 実際の処理を実装するクラス(例: ファイル一覧表示、サイズ計算)
  • Element(要素): accept(visitor) メソッドで Visitor を受け入れるインターフェース
  • ConcreteElement(具体要素): accept() で visitor.visit(this) を呼び出す(ダブルディスパッチ)

ダブルディスパッチとは、メソッドの呼び出しが「要素の型」と「Visitor の型」の 2つで決まる仕組みです。accept() で Visitor を呼ぶことで、 実行時に「どの要素が」「どの Visitor で」処理されるかが決まります。

主なユースケースとして、コンパイラの AST(抽象構文木)走査、ファイルシステムの集計処理、 XML/JSON ドキュメントの変換などがあります。 下のサンプルでは、ファイルとディレクトリからなるツリー構造を、 「一覧表示」「合計サイズ計算」「拡張子カウント」の 3 つの Visitor で処理します。

サンプルコード

VisitorPatternSample.java
import java.util.ArrayList;
import java.util.List;

public class VisitorPatternSample {

    // Visitor インターフェース: 各要素を訪問するメソッドを定義する
    interface FileSystemVisitor {
        void visitFile(FileNode file);
        void visitDirectory(DirectoryNode directory);
    }

    // Element インターフェース: Visitor を受け入れる accept メソッドを定義する
    interface FileSystemNode {
        String getName();
        void accept(FileSystemVisitor visitor);
    }

    // 具体要素: ファイルを表すクラス
    static class FileNode implements FileSystemNode {
        private final String name;
        private final long sizeBytes;

        FileNode(String name, long sizeBytes) {
            this.name = name;
            this.sizeBytes = sizeBytes;
        }

        @Override
        public String getName() { return name; }

        long getSizeBytes() { return sizeBytes; }

        @Override
        public void accept(FileSystemVisitor visitor) {
            // ファイルへの訪問を Visitor に委譲する
            visitor.visitFile(this);
        }
    }

    // 具体要素: ディレクトリを表すクラス
    static class DirectoryNode implements FileSystemNode {
        private final String name;
        private final List<FileSystemNode> children = new ArrayList<>();

        DirectoryNode(String name) { this.name = name; }

        @Override
        public String getName() { return name; }

        void add(FileSystemNode node) { children.add(node); }

        List<FileSystemNode> getChildren() { return children; }

        @Override
        public void accept(FileSystemVisitor visitor) {
            // ディレクトリ自身への訪問
            visitor.visitDirectory(this);
            // 子要素も再帰的に訪問する
            for (FileSystemNode child : children) {
                child.accept(visitor);
            }
        }
    }

    // 具体 Visitor 1: ファイル一覧を表示する
    static class ListVisitor implements FileSystemVisitor {
        private int depth = 0;

        // インデントを手動で作成する(Java 8 では "  ".repeat() が使えないため)
        private String indent() {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < depth; i++) {
                sb.append("  ");
            }
            return sb.toString();
        }

        @Override
        public void visitFile(FileNode file) {
            System.out.println(indent() + "[FILE] " + file.getName()
                    + " (" + file.getSizeBytes() + " bytes)");
        }

        @Override
        public void visitDirectory(DirectoryNode directory) {
            System.out.println(indent() + "[DIR]  " + directory.getName() + "/");
            depth++;
        }
    }

    // 具体 Visitor 2: ファイルの合計サイズを計算する
    static class SizeCalculatorVisitor implements FileSystemVisitor {
        private long totalSize = 0;

        @Override
        public void visitFile(FileNode file) {
            totalSize += file.getSizeBytes();
        }

        @Override
        public void visitDirectory(DirectoryNode directory) {
            // ディレクトリ自体はサイズを持たない
        }

        long getTotalSize() { return totalSize; }
    }

    // 具体 Visitor 3: 指定した拡張子のファイルを数える
    static class FileCountVisitor implements FileSystemVisitor {
        private final String extension;
        private int count = 0;

        FileCountVisitor(String extension) {
            this.extension = extension.toLowerCase();
        }

        @Override
        public void visitFile(FileNode file) {
            if (file.getName().toLowerCase().endsWith(extension)) {
                count++;
            }
        }

        @Override
        public void visitDirectory(DirectoryNode directory) {
            // ディレクトリ自体はカウントしない
        }

        int getCount() { return count; }
    }

    public static void main(String[] args) {
        // ファイルシステム構造を構築する
        DirectoryNode root = new DirectoryNode("project");
        DirectoryNode src = new DirectoryNode("src");
        DirectoryNode docs = new DirectoryNode("docs");

        src.add(new FileNode("Main.java", 2048));
        src.add(new FileNode("Utils.java", 1024));
        docs.add(new FileNode("README.md", 256));
        docs.add(new FileNode("design.pdf", 10240));

        root.add(src);
        root.add(docs);
        root.add(new FileNode("pom.xml", 384));

        // Visitor 1: ファイル一覧
        System.out.println("=== ファイル一覧 ===");
        root.accept(new ListVisitor());

        // Visitor 2: 合計サイズ
        System.out.println("\n=== 合計サイズ ===");
        SizeCalculatorVisitor sizeCalc = new SizeCalculatorVisitor();
        root.accept(sizeCalc);
        System.out.println("合計: " + sizeCalc.getTotalSize() + " bytes");

        // Visitor 3: .java ファイル数
        System.out.println("\n=== .java ファイル数 ===");
        FileCountVisitor javaCount = new FileCountVisitor(".java");
        root.accept(javaCount);
        System.out.println(".java ファイル数: " + javaCount.getCount());
    }
}

Java 8 では Visitor インターフェースと Element インターフェースを定義し、accept() メソッドで訪問を委譲します。新しい操作(Visitor)を追加しても、FileNode や DirectoryNode のコードは一切変更しなくて済みます。

よくあるミス・注意点

⚠️ 新しい要素クラスを追加すると全 Visitor を修正する必要がある

Visitor パターンの弱点は「要素の種類を増やすこと」が難しい点です。 新しい要素(例: SymlinkNode)を追加すると、全ての Visitor インターフェースと 具体 Visitor クラスに新しい visitSymlink() メソッドを追加する必要があります。 要素の種類が頻繁に変わる設計には向いていません。

⚠️ accept() の実装を間違えると Visitor が呼ばれない

accept() の中でvisitor.visitFile(this)this を正しく渡さないと、 ダブルディスパッチが機能しません。 親クラスの accept() をそのまま継承するだけでは、visit() に渡される型が意図した型にならないことがあります。

⚠️ ListVisitor の depth カウンターがずれる

サンプルの ListVisitorvisitDirectory()depth++ を行い、 子要素の走査が終わった後も depth を戻していません。 同一 Visitor を複数のツリーに適用する場合は、depth の管理に注意が必要です。 再利用する場合はツリーを抜けたタイミングで depth-- する処理を追加しましょう。

⚠️ Visitor がステートフル(状態を持つ)な場合は使い捨てにする

SizeCalculatorVisitortotalSize のように、 Visitor が内部状態を持つ場合は同じインスタンスを複数のツリーに適用しないでください。 前回の走査結果が残ったまま加算されてしまいます。 1回の走査ごとに新しい Visitor インスタンスを生成しましょう。

テストする観点

  • ファイルのみのディレクトリに対して SizeCalculatorVisitor を適用すると、全ファイルのサイズ合計が返ること
  • 空のディレクトリ(子要素が 0 件)に対して SizeCalculatorVisitor を適用すると 0 が返ること(境界値)
  • ネストしたディレクトリ構造(root → src → Main.java など)を走査したとき、全ファイルが集計されること
  • FileCountVisitor(".java") が大文字小文字を区別せずに拡張子を一致させること(例: ".JAVA" も一致)
  • 同一の Visitor インスタンスを再利用せず、新しいインスタンスで走査したとき前回の集計結果が引き継がれないこと
  • 新しい Visitor を追加しても、FileNode・DirectoryNode のコードを変更しなくて済むことを確認すること(拡張性の検証)

GitHub でソースコードを見る →