1. 概要

ダブルディスパッチは、レシーバーと引数の両方のタイプに基づいて呼び出すメソッドを選択するプロセスを説明するための専門用語です。

多くの開発者は、ダブルディスパッチをストラテジーパターンと混同することがよくあります。

Javaはダブルディスパッチをサポートしていませんが、この制限を克服するために採用できる手法があります。

このチュートリアルでは、ドメイン駆動設計(DDD)と戦略パターンのコンテキストでのダブルディスパッチの例を示すことに焦点を当てます。

2. ダブルディスパッチ

ダブルディスパッチについて説明する前に、いくつかの基本を確認し、シングルディスパッチが実際に何であるかを説明しましょう。

2.1. シングルディスパッチ

シングルディスパッチは、レシーバーランタイムタイプに基づいてメソッドの実装を選択する方法です。 Javaでは、これは基本的にポリモーフィズムと同じです。

たとえば、この単純な割引ポリシーインターフェイスを見てみましょう。

public interface DiscountPolicy {
    double discount(Order order);
}

DiscountPolicyインターフェースには2つの実装があります。 常に同じ割引を返すフラットなもの:

public class FlatDiscountPolicy implements DiscountPolicy {
    @Override
    public double discount(Order order) {
        return 0.01;
    }
}

そして、注文の総コストに基づいて割引を返す2番目の実装:

public class AmountBasedDiscountPolicy implements DiscountPolicy {
    @Override
    public double discount(Order order) {
        if (order.totalCost()
            .isGreaterThan(Money.of(CurrencyUnit.USD, 500.00))) {
            return 0.10;
        } else {
            return 0;
        }
    }
}

この例のニーズのために、 OrderクラスにtotalCost()メソッドがあると仮定します。

現在、Javaでの単一ディスパッチは、次のテストで示される非常によく知られているポリモーフィックな動作です。

@DisplayName(
    "given two discount policies, " +
    "when use these policies, " +
    "then single dispatch chooses the implementation based on runtime type"
    )
@Test
void test() throws Exception {
    // given
    DiscountPolicy flatPolicy = new FlatDiscountPolicy();
    DiscountPolicy amountPolicy = new AmountBasedDiscountPolicy();
    Order orderWorth501Dollars = orderWorthNDollars(501);

    // when
    double flatDiscount = flatPolicy.discount(orderWorth501Dollars);
    double amountDiscount = amountPolicy.discount(orderWorth501Dollars);

    // then
    assertThat(flatDiscount).isEqualTo(0.01);
    assertThat(amountDiscount).isEqualTo(0.1);
}

これがすべて非常に簡単に思える場合は、しばらくお待ちください。 後で同じ例を使用します。

これで、ダブルディスパッチを導入する準備が整いました。

2.2. ダブルディスパッチとメソッドのオーバーロード

ダブルディスパッチは、レシーバータイプと引数タイプの両方に基づいて実行時に呼び出すメソッドを決定します

Javaはダブルディスパッチをサポートしていません。

ダブルディスパッチはメソッドのオーバーロードと混同されることが多いことに注意してください。これは同じことではありません。 メソッドのオーバーロードは、変数の宣言タイプなど、コンパイル時の情報のみに基づいて呼び出すメソッドを選択します。

次の例では、この動作について詳しく説明します。

SpecialDiscountPolicyと呼ばれる新しい割引インターフェースを紹介しましょう。

public interface SpecialDiscountPolicy extends DiscountPolicy {
    double discount(SpecialOrder order);
}

SpecialOrder は、新しい動作を追加せずにOrderを拡張するだけです。

ここで、 SpecialOrder のインスタンスを作成し、それを通常の Order として宣言すると、特別割引メソッドは使用されません。

@DisplayName(
    "given discount policy accepting special orders, " +
    "when apply the policy on special order declared as regular order, " +
    "then regular discount method is used"
    )
@Test
void test() throws Exception {
    // given
    SpecialDiscountPolicy specialPolicy = new SpecialDiscountPolicy() {
        @Override
        public double discount(Order order) {
            return 0.01;
        }

        @Override
        public double discount(SpecialOrder order) {
            return 0.10;
        }
    };
    Order specialOrder = new SpecialOrder(anyOrderLines());

    // when
    double discount = specialPolicy.discount(specialOrder);

    // then
    assertThat(discount).isEqualTo(0.01);
}

したがって、メソッドのオーバーロードはダブルディスパッチではありません。

Javaがダブルディスパッチをサポートしていない場合でも、パターンを使用して同様の動作を実現できます:Visitor

2.3. ビジターパターン

ビジターパターンを使用すると、既存のクラスを変更せずに新しい動作を追加できます。 これは、ダブルディスパッチをエミュレートする巧妙な手法のおかげで可能になります。

ビジターパターンを紹介できるように、割引の例を少し残しておきましょう。

注文の種類ごとに異なるテンプレートを使用してHTMLビューを作成したいとします。 この動作を注文クラスに直接追加することもできますが、SRP違反であるため、これは最善の方法ではありません。

代わりに、Visitorパターンを使用します。

まず、Visitableインターフェースを導入する必要があります。

public interface Visitable<V> {
    void accept(V visitor);
}

OrderVisitor という名前のケースでは、ビジターインターフェイスも使用します。

public interface OrderVisitor {
    void visit(Order order);
    void visit(SpecialOrder order);
}

ただし、Visitorパターンの欠点の1つは、VisitorクラスがVisitorを認識する必要があることです。

クラスがVisitorをサポートするように設計されていない場合、このパターンを適用するのは難しい(または、ソースコードが利用できない場合は不可能でさえある)可能性があります。

各注文タイプは、 Visitable インターフェースを実装し、一見同じように見える独自の実装を提供する必要があります。これは別の欠点です。

OrderSpecialOrderに追加されたメソッドは同じであることに注意してください。

public class Order implements Visitable<OrderVisitor> {
    @Override
    public void accept(OrderVisitor visitor) {
        visitor.visit(this);        
    }
}

public class SpecialOrder extends Order {
    @Override
    public void accept(OrderVisitor visitor) {
        visitor.visit(this);
    }
}

サブクラスでacceptを再実装しないようにしたくなるかもしれません。 ただし、そうでない場合は、もちろん、ポリモーフィズムのために、 OrderVisitor.visit(Order)メソッドが常に使用されます。

最後に、HTMLビューの作成を担当するOrderVisitorの実装を見てみましょう。

public class HtmlOrderViewCreator implements OrderVisitor {
    
    private String html;
    
    public String getHtml() {
        return html;
    }

    @Override
    public void visit(Order order) {
        html = String.format("<p>Regular order total cost: %s</p>", order.totalCost());
    }

    @Override
    public void visit(SpecialOrder order) {
        html = String.format("<h1>Special Order</h1><p>total cost: %s</p>", order.totalCost());
    }

}

次の例は、HtmlOrderViewCreatorの使用法を示しています。

@DisplayName(
        "given collection of regular and special orders, " +
        "when create HTML view using visitor for each order, " +
        "then the dedicated view is created for each order"   
    )
@Test
void test() throws Exception {
    // given
    List<OrderLine> anyOrderLines = OrderFixtureUtils.anyOrderLines();
    List<Order> orders = Arrays.asList(new Order(anyOrderLines), new SpecialOrder(anyOrderLines));
    HtmlOrderViewCreator htmlOrderViewCreator = new HtmlOrderViewCreator();

    // when
    orders.get(0)
        .accept(htmlOrderViewCreator);
    String regularOrderHtml = htmlOrderViewCreator.getHtml();
    orders.get(1)
        .accept(htmlOrderViewCreator);
    String specialOrderHtml = htmlOrderViewCreator.getHtml();

    // then
    assertThat(regularOrderHtml).containsPattern("<p>Regular order total cost: .*</p>");
    assertThat(specialOrderHtml).containsPattern("<h1>Special Order</h1><p>total cost: .*</p>");
}

3. DDDでのダブルディスパッチ

前のセクションでは、ダブルディスパッチとビジターパターンについて説明しました。

これで、DDDでこれらの手法を使用する方法を示す準備ができました。

注文と割引ポリシーの例に戻りましょう。

3.1. 戦略パターンとしての割引政策

以前、 Order クラスと、すべての注文品目の合計を計算するその totalCost()メソッドを導入しました。

public class Order {
    public Money totalCost() {
        // ...
    }
}

注文の割引を計算するためのDiscountPolicyインターフェースもあります。 このインターフェースは、さまざまな割引ポリシーを使用し、実行時にそれらを変更できるようにするために導入されました。

この設計は、Orderクラスで可能なすべての割引ポリシーを単にハードコーディングするよりもはるかに柔軟です。

public interface DiscountPolicy {
    double discount(Order order);
}

これまで明示的に言及していませんが、この例ではストラテジーパターンを使用しています。 DDDは、このパターンを使用してユビキタス言語の原則に準拠し、低結合を実現することがよくあります。 DDDの世界では、戦略パターンはしばしばポリシーと呼ばれます。

ダブルディスパッチ手法と割引ポリシーを組み合わせる方法を見てみましょう。

3.2. ダブルディスパッチと割引ポリシー

ポリシーパターンを適切に使用するには、引数として渡すことをお勧めします。 このアプローチは、より優れたカプセル化をサポートする Tell、Do n’tAskの原則に従います。

たとえば、OrderクラスはtotalCostを次のように実装する場合があります。

public class Order /* ... */ {
    // ...
    public Money totalCost(SpecialDiscountPolicy discountPolicy) {
        return totalCost().multipliedBy(1 - discountPolicy.discount(this), RoundingMode.HALF_UP);
    }
    // ...
}

ここで、各タイプの注文を異なる方法で処理したいとします。

たとえば、特別注文の割引を計算する場合、SpecialOrderクラスに固有の情報を必要とする他のルールがいくつかあります。 キャスティングとリフレクションを避け、同時に、正しく適用された割引を使用して、各Orderの合計コストを計算できるようにします。

メソッドのオーバーロードがコンパイル時に発生することはすでにわかっています。 したがって、自然な疑問が生じます。注文の実行時タイプに基づいて、注文割引ロジックを適切なメソッドに動的にディスパッチするにはどうすればよいでしょうか。

答え? 注文クラスを少し変更する必要があります。

ルートOrderクラスは、実行時に割引ポリシー引数にディスパッチする必要があります。 これを実現する最も簡単な方法は、保護されたapplyDiscountPolicyメソッドを追加することです。

public class Order /* ... */ {
    // ...
    public Money totalCost(SpecialDiscountPolicy discountPolicy) {
        return totalCost().multipliedBy(1 - applyDiscountPolicy(discountPolicy), RoundingMode.HALF_UP);
    }

    protected double applyDiscountPolicy(SpecialDiscountPolicy discountPolicy) {
        return discountPolicy.discount(this);
    }
   // ...
}

この設計のおかげで、OrderサブクラスのtotalCostメソッドでビジネスロジックが重複することを回避できます。

使用法のデモを見せましょう:

@DisplayName(
    "given regular order with items worth $100 total, " +
    "when apply 10% discount policy, " +
    "then cost after discount is $90"
    )
@Test
void test() throws Exception {
    // given
    Order order = new Order(OrderFixtureUtils.orderLineItemsWorthNDollars(100));
    SpecialDiscountPolicy discountPolicy = new SpecialDiscountPolicy() {

        @Override
        public double discount(Order order) {
            return 0.10;
        }

        @Override
        public double discount(SpecialOrder order) {
            return 0;
        }
    };

    // when
    Money totalCostAfterDiscount = order.totalCost(discountPolicy);

    // then
    assertThat(totalCostAfterDiscount).isEqualTo(Money.of(CurrencyUnit.USD, 90));
}

この例では、Visitorパターンを引き続き使用していますが、バージョンが少し変更されています。 注文クラスは、 SpecialDiscountPolicy (訪問者)に何らかの意味があることを認識し、割引を計算します。

前述のように、Orderのランタイムタイプに基づいてさまざまな割引ルールを適用できるようにする必要があります。 したがって、すべての子クラスで保護されたapplyDiscountPolicyメソッドをオーバーライドする必要があります。

SpecialOrderクラスでこのメソッドをオーバーライドしてみましょう。

public class SpecialOrder extends Order {
    // ...
    @Override
    protected double applyDiscountPolicy(SpecialDiscountPolicy discountPolicy) {
        return discountPolicy.discount(this);
    }
   // ...
}

割引ポリシーでSpecialOrderに関する追加情報を使用して、適切な割引を計算できるようになりました。

@DisplayName(
    "given special order eligible for extra discount with items worth $100 total, " +
    "when apply 20% discount policy for extra discount orders, " +
    "then cost after discount is $80"
    )
@Test
void test() throws Exception {
    // given
    boolean eligibleForExtraDiscount = true;
    Order order = new SpecialOrder(OrderFixtureUtils.orderLineItemsWorthNDollars(100), 
      eligibleForExtraDiscount);
    SpecialDiscountPolicy discountPolicy = new SpecialDiscountPolicy() {

        @Override
        public double discount(Order order) {
            return 0;
        }

        @Override
        public double discount(SpecialOrder order) {
            if (order.isEligibleForExtraDiscount())
                return 0.20;
            return 0.10;
        }
    };

    // when
    Money totalCostAfterDiscount = order.totalCost(discountPolicy);

    // then
    assertThat(totalCostAfterDiscount).isEqualTo(Money.of(CurrencyUnit.USD, 80.00));
}

さらに、注文クラスでポリモーフィックな動作を使用しているため、総コストの計算方法を簡単に変更できます。

4. 結論

この記事では、ドメイン駆動設計でダブルディスパッチ手法と Strategy (別名 Policy )パターンを使用する方法を学びました。

すべての例の完全なソースコードは、GitHubから入手できます。