Javaを鍛える|デザインパターン全23回 #24

Visitorパターン|階層構造を変えずに新しい集計処理を追加する

  • Java
  • デザインパターン
  • Visitor
  • Javaを鍛える

📋 この記事の目次
  1. このパターンは何のためにあるか
  2. Before
  3. After
  4. こんな場面で使う/使わない
  5. まとめ
  6. 🎓 理解度テスト

TicketCraftには、プロジェクト配下に複数のサブプロジェクトがあり、各サブプロジェクトの下にチケットが格納された階層構造があります。ある日、チームは集計レポートの自動生成とCSVエクスポートという2つの機能を追加したいと判断し、既存のProjectSubProjectTicketクラスそれぞれに処理メソッドを追加する方針で修正を進めていました。しかし、集計ロジックが各クラスの内部に分散してしまい、レポートの出力形式を1行変更するために3つのファイルを書き換える羽目になりました。

このパターンは何のためにあるか

Visitorパターンは、データ構造と処理を分離し、既存のクラス階層を変更せずに新しい操作を追加できるようにするデザインパターンです。家族全員の名前を各メンバーに個別に教えるのではなく、一斉に「名前を教えてください」と呼び掛けるだけで全員の応答を得られるような仕組みをイメージしてください。

Before

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

public class BeforeMain {

    static abstract class Item {
        protected String name;
        protected long cost;
        public Item(String name, long cost) { this.name = name; this.cost = cost; }
        public String getName() { return name; }
        public long getCost() { return cost; }
        // 集計レポート作成(各クラスに重複して追加される)
        abstract void generateReport();
    }

    static class Project extends Item {
        private List<Item> children = new ArrayList<>();
        public Project(String name, long cost) { super(name, cost); }
        public void addChild(Item child) { children.add(child); }
        @Override
        void generateReport() {
            System.out.println("[集計] プロジェクト: " + name + ", 費用: " + cost);
            for (Item c : children) {
                if (c instanceof Project) { ((Project) c).generateReport(); }
                else if (c instanceof SubProject) { ((SubProject) c).generateReport(); }
                else { ((Ticket) c).generateReport(); }
            }
        }
    }

    static class SubProject extends Item {
        private List<Item> children = new ArrayList<>();
        public SubProject(String name, long cost) { super(name, cost); }
        public void addChild(Item child) { children.add(child); }
        @Override
        void generateReport() {
            System.out.println("[集計] サブプロジェクト: " + name + ", 費用: " + cost);
            for (Item c : children) {
                if (c instanceof Project) { ((Project) c).generateReport(); }
                else if (c instanceof SubProject) { ((SubProject) c).generateReport(); }
                else { ((Ticket) c).generateReport(); }
            }
        }
    }

    static class Ticket extends Item {
        private String status;
        public Ticket(String name, long cost, String status) { super(name, cost); this.status = status; }
        @Override
        void generateReport() {
            System.out.println("[集計] チケット: " + name + ", 費用: " + cost + ", ステータス: " + status);
        }
    }

    public static void main(String[] args) {
        Item project = new Project("TicketCraft 改修", 50000L);
        Item subA = new SubProject("UI改善", 20000L);
        Item ticket1 = new Ticket("ログイン画面のバグ修正", 8000L, "進行中");
        Item ticket2 = new Ticket("検索機能の実装", 12000L, "完了");
        ((SubProject) subA).addChild(ticket1);
        ((SubProject) subA).addChild(ticket2);
        ((Project) project).addChild(subA);

        System.out.println("=== 集計レポート ===");
        project.generateReport();

        // CSVエクスポートを後から追加する場合、各クラスにメソッドを追加する必要があり、既存コードが汚れる
    }
}

実行結果は以下の通りです。

=== 集計レポート ===
[集計] プロジェクト: TicketCraft 改修, 費用: 50000
[集計] サブプロジェクト: UI改善, 費用: 20000
[集計] チケット: ログイン画面のバグ修正, 費用: 8000, ステータス: 進行中
[集計] チケット: 検索機能の実装, 費用: 12000, ステータス: 完了

ProjectSubProjectTicketの各クラスがgenerateReport()を実装しており、さらに再帰的に子供を辿るロジックも各クラスにコピーされています。この構造では集計レポートの出力形式を変更するたびに3つのファイルを書き換えなければならず、「CSVエクスポート」のような新しい処理を追加するにも同様に3箇所へ修正を入れる必要があります。

After

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

public class AfterMain {

    // 訪問される側のインターフェース
    interface Visitor {
        void visit(Project project);
        void visit(SubProject subProject);
        void visit(Ticket ticket);
    }

    static abstract class Item {
        protected String name;
        protected long cost;
        public Item(String name, long cost) { this.name = name; this.cost = cost; }
        public String getName() { return name; }
        public long getCost() { return cost; }
        // 訪問者を受け入れる抽象メソッド
        abstract void accept(Visitor visitor);
    }

    static class Project extends Item {
        private List<Item> children = new ArrayList<>();
        public Project(String name, long cost) { super(name, cost); }
        public void addChild(Item child) { children.add(child); }
        @Override
        void accept(Visitor visitor) {
            visitor.visit(this);
            for (Item c : children) { c.accept(visitor); }
        }
    }

    static class SubProject extends Item {
        private List<Item> children = new ArrayList<>();
        public SubProject(String name, long cost) { super(name, cost); }
        public void addChild(Item child) { children.add(child); }
        @Override
        void accept(Visitor visitor) {
            visitor.visit(this);
            for (Item c : children) { c.accept(visitor); }
        }
    }

    static class Ticket extends Item {
        private String status;
        public Ticket(String name, long cost, String status) { super(name, cost); this.status = status; }
        public String getStatus() { return status; }
        @Override
        void accept(Visitor visitor) { visitor.visit(this); }
    }

    // 集計レポート作成の処理を分離
    static class ReportGenerator implements Visitor {
        @Override
        public void visit(Project project) {
            System.out.println("[集計] プロジェクト: " + project.getName() + ", 費用: " + project.getCost());
        }
        @Override
        public void visit(SubProject subProject) {
            System.out.println("[集計] サブプロジェクト: " + subProject.getName() + ", 費用: " + subProject.getCost());
        }
        @Override
        public void visit(Ticket ticket) {
            System.out.println("[集計] チケット: " + ticket.getName() + ", 費用: " + ticket.getCost() + ", ステータス: " + ticket.getStatus());
        }
    }

    // CSVエクスポートの処理も分離(既存クラスを一切変更せずに追加可能)
    static class CsvExporter implements Visitor {
        @Override
        public void visit(Project project) {
            System.out.println("CSV行: PROJECT," + project.getName() + "," + project.getCost());
        }
        @Override
        public void visit(SubProject subProject) {
            System.out.println("CSV行: SUBPROJECT," + subProject.getName() + "," + subProject.getCost());
        }
        @Override
        public void visit(Ticket ticket) {
            System.out.println("CSV行: TICKET," + ticket.getName() + "," + ticket.getCost() + "," + ticket.getStatus());
        }
    }

    public static void main(String[] args) {
        Project project = new Project("TicketCraft 改修", 50000L);
        SubProject subA = new SubProject("UI改善", 20000L);
        Ticket ticket1 = new Ticket("ログイン画面のバグ修正", 8000L, "進行中");
        Ticket ticket2 = new Ticket("検索機能の実装", 12000L, "完了");
        subA.addChild(ticket1);
        subA.addChild(ticket2);
        project.addChild(subA);

        System.out.println("=== 集計レポート ===");
        project.accept(new ReportGenerator());

        System.out.println();
        System.out.println("=== CSVエクスポート ===");
        project.accept(new CsvExporter());
    }
}

実行結果は以下の通りです。

=== 集計レポート ===
[集計] プロジェクト: TicketCraft 改修, 費用: 50000
[集計] サブプロジェクト: UI改善, 費用: 20000
[集計] チケット: ログイン画面のバグ修正, 費用: 8000, ステータス: 進行中
[集計] チケット: 検索機能の実装, 費用: 12000, ステータス: 完了

=== CSVエクスポート ===
CSV行: PROJECT,TicketCraft 改修,50000
CSV行: SUBPROJECT,UI改善,20000
CSV行: TICKET,ログイン画面のバグ修正,8000,進行中
CSV行: TICKET,検索機能の実装,12000,完了

accept()メソッドが階層を再帰的に辿り、各ノードで対応するvisit()メソッドに処理を委譲しています。これにより、集計レポートとCSV出力という2つの異なる操作が別々のクラスとして独立し、既存のProjectSubProjectTicketのコードは1行も変更されていません。

🧓 ベテランの現場コラム Visitorパターンで追加する処理が「同じ階層をたどって表示するだけ」なら、再帰メソッドを1つ書けば済む話なので、あえてこのパターンを導入する必要はありません。「同じ構造に対して、種類の異なる複数の操作を将来追加したくなりそうか」が導入判断の分かれ目です。

こんな場面で使う/使わない

  • 使う: データ構造が安定しているが、その構造に対して後から頻繁に新しい処理(集計・変換・エクスポートなど)を追加したい場合
  • 使わない: 既存のクラス階層自体が不安定で、メソッドやフィールドの追加削除が絶えず行われる開発途中の場合

まとめ

  • Visitorパターンはデータ構造と操作を分離し、既存クラスを変更せずに新しい処理を後から追加できる
  • 再帰的な階層構造に対して、各ノードでaccept()visit()という委譲によるディスパッチを実現する
  • 「操作を追加したいが構造は変えたくない」という場面で真価を発揮する

🎓 理解度テスト

学んだ内容を確認しましょう。全3問。