広告

Java : Visitor パターン (図解/デザインパターン)

Visitor パターンとは、GoF によって定義されたデザインパターンの1つです。
複数の異なるクラスに対して、それらのクラスの変更なしで一連の処理を拡張するための設計です。

本記事では、Visitor パターンを Java のコード付きで解説していきます。


デザインパターン(GoF) 関連記事

概要

Visitor パターンは、オブジェクト指向プログラミング およびソフトウェア工学 において、 アルゴリズムをオブジェクトの構造から分離するためのデザインパターンである。分離による実用的な結果として、既存のオブジェクトに対する新たな操作を構造を変更せずに追加することができる。

Visitor パターンとは、

  • ある複数の異なるクラスがあり
  • それらのクラスの変更なしで一連の処理を拡張する

という目的のための設計です。
また、複数の異なるクラスに対して、横断的に (for文などで) 処理したいときに便利です。

ただし、後述しますが Visitor パターンには 注意点 もあります。

Visitor は、日本語的に発音すると「ビジター」となります。
意味は「訪問者」ですね。


【 Visitor パターンのイメージ図 】

イメージ図1

異なるクラスに対して for文などで横断的に処理したい場合、通常は、

  • instanceof
  • ダウンキャスト

といった型操作が必要になります。

ベースとなるクラスに必要なメソッドがすべてそろっていれば型操作は不要です。
しかし、それぞれのサブクラスでのみ実装されているメソッドが必要になることもあるでしょう。

クラス図1

final var list = List.of(
        new クラスA(),
        new クラスB(),
        new クラスA(),
        new クラスB()
);

for (final var base : list) {
    if (base instanceof クラスA) {
        // クラスAでしか実装されていないメソッドの呼び出し。
        final var a = (クラスA) base;
        a.aaa();
    } else if (base instanceof クラスB) {
        // クラスBでしか実装されていないメソッドの呼び出し。
        final var b = (クラスB) base;
        b.bbb();
    }
}

instanceof で型チェックをすると、当然 if文による 条件分岐 も必要になります。
条件分岐処理は、プログラムの複雑さを増す大きな要因です。

条件分岐をできるだけ少なくすると、プログラムの品質向上にもつながります。
ユニットテストもしやすくなりますしね。

Visitor パターンを使うと、instanceof やダウンキャストはもちろん、if文による条件分岐も不要になります。

クラス図2

final var visitor = new ビジター();
for (final var base : list) {
    base.accept(visitor);
}

関連記事:


クラス図とシーケンス図

クラス図3

シーケンス図1

上の図は、Visitor パターンの一般的なクラス図とシーケンス図です。

このクラス図をそのままコードにしてみましょう。

public interface Element {
    void accept(Visitor visitor);
}

public class ConcreteElementA implements Element {
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }

    public void operationA() {
        System.out.println("  OperationA!");
    }
}

public class ConcreteElementB implements Element {
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }

    public void operationB() {
        System.out.println("  OperationB!");
    }
}

Element の実装クラス、

  • ConcreteElementA
  • ConcreteElementB

accept メソッドでは、

visitor.visit(this);

というように、this で自分自身を渡して Visitor の visit メソッドを 呼び返し ます。
この手法を「ダブルディスパッチ」といいます。


ダブルディスパッチ(英: double dispatch)は、多重ディスパッチのひとつの形態で、2個のオブジェクトから、それに対応する実際の手続きが実行時に決まる、というものである。

ダブルディスパッチを使うことで、

  • instanceof による型チェック
  • if文による条件分岐

が不要になります。

もし詳細が知りたいかたは、引用にある Wikipedia の記事をご参照ください。


public interface Visitor {
    void visit(ConcreteElementA a);

    void visit(ConcreteElementB b);
}

public class ConcreteVisitor1 implements Visitor {

    @Override
    public void visit(ConcreteElementA a) {
        System.out.println("Visitor1 : ElementA");
        a.operationA();
    }

    @Override
    public void visit(ConcreteElementB b) {
        System.out.println("Visitor1 : ElementB");
        b.operationB();
    }
}

public class ConcreteVisitor2 implements Visitor {
    @Override
    public void visit(ConcreteElementA a) {
        System.out.println("Visitor2 : ElementA");
        a.operationA();
    }

    @Override
    public void visit(ConcreteElementB b) {
        System.out.println("Visitor2 : ElementB");
        b.operationB();
    }
}

Visitor では、

  • ConcreteElementA
  • ConcreteElementB

からダブルディスパッチで呼び出される visit メソッドを実装します。
ここでは、それぞれの ConcreteElement で宣言されている operationAoperationB メソッドを呼び出しています。

最後に Client による処理です。

// 異なるクラスに対して…
final var elements = List.of(
        new ConcreteElementA(),
        new ConcreteElementB()
);

// Visitor1 で横断的に処理を実行
final var visitor1 = new ConcreteVisitor1();
for (final var element : elements) {
    element.accept(visitor1);
}

// 結果
// ↓
//Visitor1 : ElementA
//  OperationA!
//Visitor1 : ElementB
//  OperationB!

// Visitor2 で横断的に処理を実行
final var visitor2 = new ConcreteVisitor2();
for (final var element : elements) {
    element.accept(visitor2);
}

// 結果
// ↓
//Visitor2 : ElementA
//  OperationA!
//Visitor2 : ElementB
//  OperationB!

異なるクラス

  • ConcreteElementA
  • ConcreteElementB

に対して、各 Visitor を使うことで、for文で横断的に処理を実行できました。

instanceof による型チェックや、ダウンキャスト、if文による条件分岐もないため、ユニットテストもしやすいでしょう。

もし処理を拡張したい場合は、ConcreteVisitor3, ConcreteVisitor4 ... と Visitor を追加していけば OK です。
ConcreteElementA や ConcreteElementB といった Element の修正は必要ありません。

これは 開放閉鎖の原則

  • ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。

にも則していますね。


コード例

もう少し具体的な例も見てみましょう。

例として、書籍や食品といった品物があるとします。
そして、それらの品物の一覧をテキストで作成したいとします。

クラス図4

※こちらのコード例では「レコードクラス」を使います。

public record Book(String title, int pages) {
}

final var book = new Book("ももたろう", 30);
System.out.println(book.title()); // ももたろう
System.out.println(book.pages()); // 30

Book クラスは書籍を表します。

  • 本のタイトル
  • ページ数

を属性として持ちます。

public record Food(String name, int kiloCalories) {
}

final var food = new Food("焼きそば", 600);
System.out.println(food.name()); // 焼きそば
System.out.println(food.kiloCalories()); // 600

Food クラスは食品を表します。

  • 食品の名前
  • 熱量(キロカロリー)

を属性として持ちます。

Visitor パターンを使わないケース

まずは、品物の一覧を 単純なテキスト として作成してみましょう。

テキスト取得用のインタフェースとして TextGetter を追加することにします。
そして、Book クラス、Food クラスで TextGetter を実装します。

クラス図5

TextGetter インタフェースの getPlainText メソッドで、品物の内容をテキストとして取得します。

public interface TextGetter {
    String getPlainText();
}

Book クラスと、Food クラスで、TextGetter インタフェースを実装(implements) します。

public record Book(String title, int pages) implements TextGetter {
    @Override
    public String getPlainText() {
        return "書籍:%s (%d ページ)".formatted(title, pages);
    }
}

public record Food(String name, int kiloCalories) implements TextGetter {
    @Override
    public String getPlainText() {
        return "食品:%s (%d キロカロリー)".formatted(name, kiloCalories);
    }
}

それでは品物一覧のテキストを作成してみましょう。
getPlainText メソッドで品物の内容を取得して、それを StringBuilder に追加していきます。

final var list = List.of(
        new Book("ももたろう", 30),
        new Food("焼きそば", 600),
        new Book("英和辞典", 2500),
        new Food("お団子", 290)
);

final var sb = new StringBuilder();
for (final var v : list) {
    final var text = v.getPlainText();
    sb.append(text).append("\n");
}

System.out.print(sb);

// 結果
// ↓
//書籍:ももたろう (30 ページ)
//食品:焼きそば (600 キロカロリー)
//書籍:英和辞典 (2500 ページ)
//食品:お団子 (290 キロカロリー)

問題なく機能していますね。

さて、しばらくして機能追加の依頼がありました。
XML形式でも出力したい、とのことです。

そこで、TextGetter インタフェースに getXmlText メソッドを追加しました。

public interface TextGetter {
    String getPlainText();

    String getXmlText();
}

また、それにともない Book クラス、Food クラスを修正しました。

public record Book(String title, int pages) implements TextGetter {
    @Override
    public String getPlainText() {
        return "書籍:%s (%d ページ)".formatted(title, pages);
    }

    @Override
    public String getXmlText() {
        return """
                <book pages="%d">%s</book>""".formatted(pages, title);
    }
}

public record Food(String name, int kiloCalories) implements TextGetter {
    @Override
    public String getPlainText() {
        return "食品:%s (%d キロカロリー)".formatted(name, kiloCalories);
    }

    @Override
    public String getXmlText() {
        return """
                <food kcal="%d">%s</food>""".formatted(kiloCalories, name);
    }
}

結果は次のようになりました。

final var list = List.of(
        new Book("ももたろう", 30),
        new Food("焼きそば", 600),
        new Book("英和辞典", 2500),
        new Food("お団子", 290)
);

final var sb = new StringBuilder();
sb.append("<root>\n");

for (final var v : list) {
    final var text = v.getXmlText();
    sb.append("  ").append(text).append("\n");
}

sb.append("</root>\n");
System.out.print(sb);

// 結果
// ↓
//<root>
//  <book pages="30">ももたろう</book>
//  <food kcal="600">焼きそば</food>
//  <book pages="2500">英和辞典</book>
//  <food kcal="290">お団子</food>
//</root>

無事に、XMLでも品物の一覧を作成できました。
しかし、機能追加のたびに Book クラスと Food クラスを修正するのは、あまりよい設計ではありません。

開放閉鎖の原則

  • ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。

にも反しています。

Book クラスと Food クラスは 修正に対して閉じていない ということですね。


Visitor パターンを使うケース

次は、Visitor パターン を使う例を見てみましょう。
テキストを生成する処理を、Visitor として 分離 します。

クラス図6

まずは Visitable インタフェースと、Book クラス、Food クラスです。

public interface Visitable {

    void accept(Visitor visitor);
}

public record Book(String title, int pages) implements Visitable {

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

public record Food(String name, int kiloCalories) implements Visitable {

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

accept メソッドでは ダブルディスパッチ を行います。
this で自分自身を渡して Visitor.visit メソッドを呼び返します。

【プレーンテキスト版】

それでは、単純なテキストで品物の一覧を作成する TextVisitor を作りましょう。

public class TextVisitor implements Visitor {

    private final StringBuilder sb = new StringBuilder();

    @Override
    public void visit(Book book) {
        final var line = "書籍:%s (%d ページ)".formatted(
                book.title(), book.pages());
        sb.append(line).append("\n");
    }

    @Override
    public void visit(Food food) {
        final var line = "食品:%s (%d キロカロリー)".formatted(
                food.name(), food.kiloCalories());
        sb.append(line).append("\n");
    }

    public String getResult() {
        return sb.toString();
    }
}

visit メソッドでは、品物の1行分のテキストを作成します。

  • visit(Book book) : Book の1行分のテキストを作成
  • visit(Food food) : Food の1行分のテキストを作成

そして、フィールド変数の sb (StringBuilder) に追加していきます。

最後に、getResult メソッドで品物一覧のテキストを取得します。

final var list = List.of(
        new Book("ももたろう", 30),
        new Food("焼きそば", 600),
        new Book("英和辞典", 2500),
        new Food("お団子", 290)
);

final var visitor = new TextVisitor();
for (final var v : list) {
    v.accept(visitor);
}

final var result = visitor.getResult();
System.out.print(result);

// 結果
// ↓
//書籍:ももたろう (30 ページ)
//食品:焼きそば (600 キロカロリー)
//書籍:英和辞典 (2500 ページ)
//食品:お団子 (290 キロカロリー)

結果も問題なしですね。

【XML版】

XML版の XmlVisitor も同じように実装します。

public class XmlVisitor implements Visitor {

    private final StringBuilder sb = new StringBuilder();

    @Override
    public void visit(Book book) {
        final var line = """
                  <book pages="%d">%s</book>
                """.formatted(book.pages(), book.title());
        sb.append(line);
    }

    @Override
    public void visit(Food food) {
        final var line = """
                  <food kcal="%d">%s</food>
                """.formatted(food.kiloCalories(), food.name());
        sb.append(line);
    }

    public String getResult() {
        return """
                <root>
                %s\
                </root>
                """.formatted(sb.toString());
    }
}
final var list = List.of(
        new Book("ももたろう", 30),
        new Food("焼きそば", 600),
        new Book("英和辞典", 2500),
        new Food("お団子", 290)
);

final var visitor = new XmlVisitor();
for (final var v : list) {
    v.accept(visitor);
}

final var result = visitor.getResult();
System.out.print(result);

// 結果
// ↓
//<root>
//  <book pages="30">ももたろう</book>
//  <food kcal="600">焼きそば</food>
//  <book pages="2500">英和辞典</book>
//  <food kcal="290">お団子</food>
//</root>

無事に XML版の品物一覧も作成できました。

Visitor パターンを使うと、Book クラスと Food クラスの 修正なし で、どんどん機能が拡張できるのが分かりますでしょうか。

開放閉鎖の原則

  • ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。

になっていますね。


注意点

クラス図6

Visitor パターンでは、振る舞いである Visitor を拡張していく分には問題ありません。
しかし、accept を実装する側 (Book クラスや Food クラス) を追加していくのには向きません。

例えば品物の1つである Phone クラスを追加してみましょう。
すると、Visitor にも 修正が必要 になります。

public interface Visitor {
    void visit(Book book);

    void visit(Food food);

    void visit(Phone phone); // <--- 追加
}

もちろん、Visitor の実装クラスである TextVisitor と XmlVisitor にも 修正が必要 になります。

public class TextVisitor implements Visitor {
    ...

    @Override
    public void visit(Phone phone) {
        // 追加
    }
}

これは 開放閉鎖の原則 にも反していますね。

よって、Visitor パターンでは

  • Visitor 側のクラスを追加(拡張) するのには向いている
  • Visitable 側のクラスを追加(拡張) するのには向かない

という特徴があります。

まとめ

Visitor パターンとは、

  • ある複数の異なるクラスがあり
  • それらのクラスの変更なしで一連の処理を拡張する

という目的のための設計です。
また、複数の異なるクラスに対して、横断的に (for文などで) 処理したいときに便利です。

ただし、注意点もあります。

Visitor パターンは、

  • Visitor 側を拡張するのは OK
  • Visitable (acceptメソッドを実装する) 側を拡張するのには向かない

という特徴を持ちます。

個人的には、Strategy パターン よりかは使いどころが難しい印象です。
パターンの特徴をよく理解して、有効に活用していきたいですね。


関連記事

ページの先頭へ