«1. Обзор

Двойная отправка — это технический термин, описывающий процесс выбора метода для вызова на основе типов получателя и аргументов.

Многие разработчики часто путают двойную отправку с шаблоном стратегии.

Java не поддерживает двойную диспетчеризацию, но есть приемы, позволяющие обойти это ограничение.

В этом руководстве мы сосредоточимся на демонстрации примеров двойной диспетчеризации в контексте проектирования, управляемого предметной областью (DDD), и шаблона стратегии.

2. Двойная отправка

Прежде чем мы обсудим двойную отправку, давайте рассмотрим некоторые основы и объясним, что такое одиночная отправка.

2.1. Single Dispatch

Single Dispatch — это способ выбора реализации метода на основе типа среды выполнения приемника. В Java это в основном то же самое, что и полиморфизм.

Например, давайте взглянем на этот простой интерфейс политики скидок:

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

Интерфейс DiscountPolicy имеет две реализации. Плоская, которая всегда возвращает одну и ту же скидку:

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

И вторая реализация, которая возвращает скидку на основе общей стоимости заказа:

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:

SpecialOrder просто расширяет Order без добавления нового поведения.

public interface SpecialDiscountPolicy extends DiscountPolicy {
    double discount(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 не поддерживает двойную диспетчеризацию, мы можем использовать шаблон для достижения аналогичного поведения: Посетитель.

2.3. Шаблон посетителя

Шаблон посетителя позволяет нам добавлять новое поведение к существующим классам, не изменяя их. Это возможно благодаря умной технике имитации двойной отправки.

Давайте на мгновение оставим пример со скидкой, чтобы мы могли представить шаблон Посетитель.

Представьте, что мы хотим создавать HTML-представления, используя разные шаблоны для каждого вида заказа. Мы могли бы добавить это поведение непосредственно в классы заказов, но это не лучшая идея из-за нарушения SRP.

Вместо этого мы будем использовать шаблон Посетитель.

Во-первых, нам нужно ввести интерфейс Visitable:

Мы также будем использовать интерфейс посетителя, в нашем случае под названием OrderVisitor:

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

Однако один из недостатков шаблона Visitor заключается в том, что он требует, чтобы посещаемые классы знали о посетителе.

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

Если бы классы не были разработаны для поддержки посетителя, может быть сложно (или даже невозможно, если исходный код недоступен) применить этот шаблон.

Каждый тип заказа должен реализовать интерфейс Visitable и предоставить свою собственную реализацию, которая на первый взгляд идентична, что является еще одним недостатком.

Обратите внимание, что методы, добавленные в Order и SpecialOrder, идентичны:

Может возникнуть соблазн не реализовывать accept повторно в подклассе. Однако, если бы мы этого не сделали, то метод OrderVisitor.visit(Order) всегда использовался бы, конечно, из-за полиморфизма.

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);
    }
}

Наконец, давайте посмотрим на реализацию OrderVisitor, ответственного за создание представлений HTML:

В следующем примере демонстрируется использование HtmlOrderViewCreator:

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());
    }

}

«

@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);
}

Мы пока не упоминали об этом явно, но в этом примере используется шаблон Strategy. DDD часто использует этот шаблон, чтобы соответствовать принципу Ubiquitous Language и добиться низкой связанности. В мире DDD шаблон стратегии часто называют политикой.

Давайте посмотрим, как совместить технику двойной отправки и политику скидок.

3.2. Двойная политика отправки и скидок

Чтобы правильно использовать шаблон политики, рекомендуется передать его в качестве аргумента. Этот подход следует принципу «говори, не спрашивай», который поддерживает лучшую инкапсуляцию.

Например, класс Order может реализовать totalCost следующим образом:

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

Теперь предположим, что мы хотим обрабатывать каждый тип заказа по-разному.

Например, при расчете скидки по спецзаказам существуют другие правила, требующие информацию, уникальную для класса SpecialOrder. Мы хотим избежать приведения и отражения и в то же время иметь возможность рассчитать общие затраты для каждого Заказа с правильно примененной скидкой.

Мы уже знаем, что перегрузка методов происходит во время компиляции. Итак, возникает естественный вопрос: как мы можем динамически направить логику скидки заказа в правильный метод, основанный на типе выполнения заказа?

Ответ? Нам нужно немного изменить классы заказов.

Корневой класс 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);
    }
   // ...
}

Благодаря такому дизайну мы избегаем дублирования бизнес-логики в методе totalCost в подклассах Order.

Давайте продемонстрируем использование:

@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));
}

В этом примере по-прежнему используется шаблон Посетитель, но в несколько измененной версии. Классы заказов знают, что SpecialDiscountPolicy (Посетитель) имеет некоторое значение, и вычисляют скидку.

Как упоминалось ранее, мы хотим иметь возможность применять различные правила скидок в зависимости от типа заказа во время выполнения. Поэтому нам нужно переопределить защищенный метод 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. Заключение

В этой статье мы узнали, как использовать метод двойной диспетчеризации и шаблон стратегии (или политики) в проектировании, управляемом доменом.

Полный исходный код всех примеров доступен на GitHub.