DDDのダブルディスパッチ

1. 概要

ダブルディスパッチは、受信側と引数の両方のタイプに基づいて、呼び出すメソッドを選択するプロセスを説明する専門用語です。
多くの開発者は、二重ディスパッチとhttps://en.wikipedia.org/wiki/Strategy_pattern[Strategy Pattern]を混同することがよくあります。
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が二重ディスパッチをサポートしていない場合でも、パターンを使用して同様の動作を実現できます:https://en.wikipedia.org/wiki/Visitor_pattern[Visitor]。

2.3. 訪問者パターン

  • Visitorパターンを使用すると、既存のクラスを変更せずに新しい動作を追加できます*。 これは、二重ディスパッチをエミュレートする巧妙なテクニックのおかげで可能です。

    訪問者パターンを紹介できるように、割引の例を少し残しましょう。
    *注文の種類ごとに異なるテンプレートを使用して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を認識するために訪問可能なクラスを必要とすることです。 + *
クラスが訪問者をサポートするように設計されていない場合、このパターンを適用するのは難しいかもしれません(ソースコードが利用できない場合は不可能ですらあります)。
各注文タイプは、_Visitable_インターフェースを実装し、一見同一と思われる独自の実装を提供する必要があります。これは別の欠点です。
_Order_と_SpecialOrder_に追加されたメソッドは同一であることに注意してください。
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);
}
*これまで明示的に言及していませんが、この例ではhttps://en.wikipedia.org/wiki/Strategy_pattern[Strategy pattern]を使用しています。 DDDは多くの場合、このパターンを使用してhttps://martinfowler.com/bliki/UbiquitousLanguage.html [ユビキタス言語]の原則に準拠し、低結合を実現します。 DDDの世界では、戦略パターンはしばしばポリシーと呼ばれます。*
ダブルディスパッチテクニックと割引ポリシーを組み合わせる方法を見てみましょう。

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

*ポリシーパターンを適切に使用するには、引数として渡すことをお勧めします*。 このアプローチは、より良いカプセル化をサポートするhttps://martinfowler.com/bliki/TellDontAsk.html[Tell、Do n't Ask]の原則に従います。
たとえば、_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_)パターンを使用する方法を学びました。
すべてのサンプルの完全なソースコードは、https://github.com/eugenp/tutorials/tree/master/ddd [GitHubで]から入手できます。