「Visitor」パターンとは?サンプルを踏まえてわかりやすく解説!【Java】

  • LINEで送る
「Visitor」パターンとは?サンプルを踏まえてわかりやすく解説!

重要度:★★☆☆☆

 本記事ではGoFのデザインパターンのプログラムの振る舞いに関するパターンの一つである「Visitor」パターンを解説します。このパターンを一言で説明するならば、「データ構造とそのデータに対する処理を分割し、Visitorオブジェクトに処理を追加することで、データ構造に変更を加えることなく処理アルゴリズムの追加ができる方法」と言えるでしょう。「Visitor」パターンをサンプルを踏まえて解説します。

Visitorパターンとは

 Visitorパターンは、複数の異なる型のオブジェクト(例:冷蔵庫・電子レンジ・洗濯機)に対して、
それぞれ異なる処理を行いたいが、その処理を一箇所に集めたいときに利用します。特に、既存のクラス(構造)に手を加えずに処理を追加したいときに便利です。

 オブジェクト指向では「各クラスが自分自身の振る舞い(メソッド)を持つ」というのが基本ですが、この基本構造にも欠点があります。

  • 同じような処理を複数クラスに重複して書く必要が出てくる
  • 処理を変えたいとき、全クラスを修正しなければならない
  • 別の処理を追加するたびに、同様の苦労が必要

 要は同じ処理が、いろいろな場所に点在してしまう問題があります。このような場合に威力を発揮するのがVisitorパターンです。

 このパターンでは処理を各クラスに書かず、「処理専用のクラス(Visitor)」を用意します。そのVisitorクラスが訪れたオブジェクトに対して、そのオブジェクトの種類に応じた処理を実行します。

Visitorパターンのイメージ図

 家電の点検業者の例で考えてみましょう。点検業者あなたの家にやってきて、家電の点検を行います。点検は下記の作業を行います。

  • 冷蔵庫を見て「温度センサーを点検しました」
  • 電子レンジを見て「出力電力を確認しました」
  • 洗濯機を見て「排水機能を確認しました」

ここで重要なのは…

  • 家電自体は点検方法を知らない
  • 点検業者が家電の種類に応じた適切な処理を行っている
  • 点検業者(Visitor)を変えれば他の処理も実行可能
    • 例)「メンテナンス業者」や「ログ記録者」等

 このように、処理を訪問者に集中させて、対象には責任を持たせないという構造が、Visitorパターンの本質です。

Visitorパターンの構成要素

要素役割
Element(要素)・「処理される対象」全体の共通インターフェース
・外部から何らかの操作(Visitor)を受け入れる「受け口」を提供する
ConcreteElement(具体要素)・個々のデータ構造や実体
・外部からの処理要求(訪問)を受け取り、自分自身をVisitorに渡すことで処理の委譲を行う
Visitor(訪問者)・操作対象(要素)の型ごとに異なる処理を定義するインターフェース
・訪れる対象に応じた「振る舞いの切り替え」を担う
ConcreteVisitor(具体訪問者)・実際に処理を実行するクラス群
・各要素に応じた具体的な処理ロジック(例:表示、計算、変換など)を提供する
ObjectStructure(対象集合)・要素(Element)の集合を管理し、Visitorによる一括処理を可能にする
・巡回や全体操作の窓口となる

Visitorパターンの書き方

 実際に上記で触れた「点検業者が家の家電を点検する処理」の例でVisitorパターンを実装します。

Visitorを使わない場合

 まずはVisitorパターンを利用しない場合、どのような実装になるでしょうか。それは家電自身が自分で点検できるような仕組みを全ての家電に追加する必要があります。

// 家電のインターフェース
public interface Appliance {
    void inspect();  // 各自が点検処理を持つ
}

// 冷蔵庫
public class Fridge implements Appliance {
    @Override
    public void inspect() {
        System.out.println("Fridge: 温度センサーを点検しました。");
    }
}

// 電子レンジ
public class Microwave implements Appliance {
    @Override
    public void inspect() {
        System.out.println("Microwave: 出力電力を確認しました。");
    }
}

// 洗濯機
public class WashingMachine implements Appliance {
    @Override
    public void inspect() {
        System.out.println("WashingMachine: 排水機能を点検しました。");
    }
}
public class Main {
    public static void main(String[] args) {
        List<Appliance> appliances = List.of(
            new Fridge(),
            new Microwave(),
            new WashingMachine()
        );

        for (Appliance appliance : appliances) {
            appliance.inspect(); // 各家電が自分自身で処理を行う
        }
    }
}

 この方法でも家電の点検処理は実装できます。ではこの実装にはどのような問題点があるのでしょうか。下記の表でまとめます。

問題点説明
処理が分散する・点検処理がすべての家電クラスに分かれて実装されているため一元管理できない
保守性が低い・点検の出力形式等を変更したい場合、全クラスのinspect()メソッドを修正する必要がある
拡張性に欠ける・点検以外の処理(例:ログ記録、保守管理など)を追加するには、また全家電クラスにメソッドを追加する必要がある。
クラスの責務が曖昧になる・家電の本来の責務(機能を提供すること)に加えて、点検の処理ロジックまで背負ってしまっている

 このように、Visitorを使わないとクラスの責務が曖昧になったり、処理が分散する恐れがあります。

 Observerを使う場合

 上記の問題点を解決できるのがVisitorパターンです。では実際にVisitorパターンを利用して「点検業者が家の家電を点検する処理」の仕組みを実装していきます。

 まずは家電のインターフェースを作っていきます。家電には本来、自分自身を点検する動作は行わないものです。(※最近の家電は点検してくれたりもしますが、今回はわかりやすく点検処理は持っていないものとします。)ただし、点検モードは持っているとします。点検業者はこの点検モードを使って点検します。(点検モードは、点検業者を受け入れるためのメソッドと捉える)そのため、Visitor(点検業者)を受け入れるaccept()メソッドを用意します。

// 家電のインターフェース
public interface Appliance {
    // 訪問者を受け入れるメソッド
    // 点検するメソッドは持っていない
    void accept(Visitor visitor);
}

 このインターフェースを実装してそれぞれの家電を作ります。

// 冷蔵庫クラス
public class Fridge implements Appliance {
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

// 電子レンジクラス
public class Microwave implements Appliance {
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

// 洗濯機クラス
public class WashingMachine implements Appliance {
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

 実装したaccept()メソッドは全て同じ処理を行なっているように見えますが、visitor.visit(this)のthisで自分自身のインスタンスを入れています。visit()メソッドに関しては後述します。

 次に、点検業者のインターフェースを作ります。この例では点検業者としていますが、修理業者だったり、家電の営業担当だったり、家電関連で家に訪問する人はいくつか種類があるかと思います。そのため訪問者のインターフェースを定義して、そのインターフェースを実装する形で点検業者を作ります。

// 訪問者インターフェース
public interface Visitor {
    void visit(Fridge fridge);
    void visit(Microwave microwave);
    void visit(WashingMachine washingMachine);
}

// 点検業者のクラス
public class ApplianceInspector implements Visitor {
    @Override
    public void visit(Fridge fridge) {
        System.out.println("Fridge: 温度センサーを点検しました。");
    }

    @Override
    public void visit(Microwave microwave) {
        System.out.println("Microwave: 出力電力を確認しました。");
    }

    @Override
    public void visit(WashingMachine washingMachine) {
        System.out.println("WashingMachine: 排水機能を点検しました。");
    }
}

 点検業者はそれぞれの家電のインスタンスを受け取って、点検作業を行います。visit()メソッドは、引数は全て1つですが、型が全て違います。この型の違いでそれぞれの家電の点検作業を実施します。実際にはいくつか手順を踏んで点検作業するかと思いますが、今回は単純に標準出力のみを行います。

 次にObjectStructure(対象集合)に該当する、家電を管理するHomeのクラスを実装します。

// 家電を管理するクラス(家)
public class Home {
    private final List<Appliance> appliances = new ArrayList<>();

    // 家電を追加する
    public void addAppliance(Appliance appliance) {
        appliances.add(appliance);
    }

    // Visitorによる処理の実行
    public void runInspection(Visitor visitor) {
        for (Appliance appliance : appliances) {
            appliance.accept(visitor);
        }
    }
}

 このHomeクラスはListでApplianceを複数保持できます。全ての家電はApplianceインタフェースを実装しているため、全ての家電を同じList内に格納できます。これらの家電はaddAppliance()メソッドでListに追加できます。

 さらに訪問者を受け入れるrunInspection()メソッドも用意し、訪問者を受け入れた後に訪問者が行う動作を全ての家電に対して実行します。

 最後にこれらのクラスを使って動作確認を行います。

public class VisitorSample {
    public static void main(String[] args) {
        // 家電を家で管理するクラス
        Home home = new Home();

        // 家に家電を追加
        home.addAppliance(new Fridge());
        home.addAppliance(new Microwave());
        home.addAppliance(new WashingMachine());

        // 点検業者を適用
        Visitor inspector = new ApplianceInspector();
        // 点検業者に家電を点検してもらう
        home.runInspection(inspector);
    }
}
>> 実行結果
Fridge: 温度センサーを点検しました。
Microwave: 出力電力を確認しました。
WashingMachine: 排水機能を点検しました。

 これで家電に点検作業を実装するのではなく、点検業者に点検作業を実装し、家電に対する処理をVisitorクラスで一元管理することが可能になりました。

メリットとデメリット

メリット

メリット説明
処理の追加が容易処理を追加する際に、既存のElementクラスに手を加える必要がない
単一責任の原則に従えるデータ構造と処理を分離できるため、クラスの責務が明確になる
オブジェクト構造の巡回が統一的に行えるVisitorが処理を一元的に制御できる

デメリット

デメリット説明
新しいElement(要素)の追加が困難すべてのVisitorに対応メソッドを追加する必要があるため手間がかかる
ElementとVisitorの依存が強くなる双方が具体的な型を知っている必要があるため、疎結合とは言いづらい

 Visitorパターンには上記のメリットとデメリットがあります。これらのメリットとデメリットから、Visitorパターンが有効なシチュエーションには下記のような場合があります。

  • 処理だけを柔軟に追加したいとき
    • クラス構造はそのままで、処理(操作)を後から増やしたい場合
  • 型ごとに異なる処理を一箇所でまとめたいとき
    • クラスごとの分岐処理をバラバラに書かず、Visitorで集中管理したい場合
  • オブジェクトの集合を巡回して処理したいとき
    • リストやツリー構造の要素を走査して、型ごとの処理を実行したい
  • データ構造と処理を分けたいとき
    • データはそのまま、処理を別クラスにして責務を明確にしたい

Visitorパターンの重大な欠陥

 これまでVisitorパターンを解説してきましたが、このパターンには大きすぎる欠陥があると思っています。それはデメリットでも記載した「ElementとVisitorの依存が強くなる」ところにあります。疎結合なプログラミングを目指して、保守やテストがしやすくするように実装する実務では慎重に採用を検討すべきです。

  • 各ElementはVisitorの具体的な存在を知らなければならない
    • accept(Visitor visitor) を実装し、中で visitor.visit(this) を呼ぶ必要がある
  • Visitor側も具体的なElementの型を知らなければならない
    • visit(Fridge fridge), visit(Microwave microwave) などのメソッドを持つため型を明示する必要がある

 このように、Elementを追加するたびにVisitorを修正する必要があります。家電が家に追加される度に、その家にいく点検者は何の家電が追加されたか知る必要があり、その家電に対しての処理を覚える(実装する)必要があります。

 このパターンは「処理」が追加しやすい代わりに、「要素」を追加しにくいという致命的なトレードオフの関係があります。そのため、「処理を切り替えるが、要素と疎結合を保てるStrategyパターン」や、「処理をオブジェクト化し、実行を委譲できるCommandパターン」の検討も同時に行うべきでしょう。StrategyパターンCommandパターンは下記の記事で紹介しています。ぜひこちらのパターンも検討してみてください。

まとめ

 Visitorパターンは、「構造を変えずに処理を柔軟に追加したい」という明確なニーズがあるときにだけ、慎重に採用すべきパターンです。それ以外の場面では、StrategyパターンCommandパターンの方が保守性に優れるケースが多いパターンになります。

 本記事で利用したサンプルケースのクラス図は下記になります。

 最後に「Visitor」パターンとは「データ構造とそのデータに対する処理を分割し、Visitorオブジェクトに処理を追加することで、データ構造に変更を加えることなく処理アルゴリズムの追加ができる方法」です。うまく使っていきましょう。

 本記事で利用したコードは下記のgit上で公開しております。

https://github.com/tamotech1028/visitor

デザインパターン講座 目次

第1章 導入

第2章 オブジェクトの「生成」に関するパターン

第3章 プログラムの「構造」に関するパターン

第4章 オブジェクトの「振る舞い」に関するパターン

最新の投稿

SNSでもご購読できます。

コメント

コメントを残す