Към съдържанието
wannacode

Strategy

Накратко: Strategy слага всяко правило (например начин на плащане) в отделен обект зад общ интерфейс, за да добавяш нови правила, без да пипаш кода, който ги ползва.

Проблем

Касата, която знае всеки начин на плащане

Онлайн магазинът приема пет начина на плащане. Наложеният платеж струва 2,50 € повече, ваучерът вади 10 €, банковият превод дава 2% отстъпка, а разсроченото плащане е с 5% оскъпяване. Касата смята сумата така:

static double total(String method, double amount) {
if (method.equals("карта")) {
return amount;
} else if (method.equals("наложен платеж")) {
return amount + 2.50;
} else if (method.equals("ваучер")) {
return Math.max(amount - 10, 0);
} else if (method.equals("банков превод")) {
return amount * 0.98;
} else if (method.equals("разсрочено")) {
return amount * 1.05;
}
throw new IllegalArgumentException("Непознат начин на плащане: " + method);
}
System.out.printf("Карта: %.2f €%n", total("карта", 80));
System.out.printf("Наложен платеж: %.2f €%n", total("наложен платеж", 80));
System.out.printf("Ваучер: %.2f €%n", total("ваучер", 80));
System.out.printf("Банков превод: %.2f €%n", total("банков превод", 80));
System.out.printf("Разсрочено: %.2f €%n", total("разсрочено", 80));
Изход
Карта: 80.00 €
Наложен платеж: 82.50 €
Ваучер: 70.00 €
Банков превод: 78.40 €
Разсрочено: 84.00 €

Работи. Но всеки месец идва нов начин, и всеки е нов клон в тази метод. Същата верига има и във фактурата, и в имейла с потвърждението. Някой добавя Apple Pay на две от трите места и клиентът получава имейл с „Непознат начин на плащане“.

Ваучерите вече не са само за 10 €. За да подадеш стойността, методът получава трети параметър, и се сменя всяко място, което я вика, дори за карта, където ваучер няма. Кодът, през който минава всяко плащане, се пипа при всяка промяна.

Идеята

Strategy: правилото отделно, касата пита

Каквото се сменя, е едно нещо: как се смята сумата. Каквото не се сменя, е касата: тя взима сумата, пита и закръгля.

Затова правилото отива в отделен обект. Всички начини имат една обща форма: интерфейс с един метод, който приема сума и връща колко да се плати. Касата пази референция към такъв обект и вика метода, без да знае кой точно е той.

Това е шаблонът Strategy (стратегия). Има три роли:

  • касата държи стратегията и я вика. В книгите това се казва контекст;
  • общата форма: интерфейсът PaymentMethod (в C# IPaymentMethod);
  • конкретните стратегии: по един клас за всеки начин.

Нов начин е нова стратегия. Касата не се пипа.

Виж го

Касата и три начина

Общата форма:

interface PaymentMethod {
double total(double amount);
}
Три начина, всеки в свой клас. Ваучерът има поле със стойността си, затова стойността идва в конструктора, а не като параметър на всеки:
class Card implements PaymentMethod {
@Override
public double total(double amount) {
return amount;
}
}
class CashOnDelivery implements PaymentMethod {
@Override
public double total(double amount) {
return amount + 2.50;
}
}
class Voucher implements PaymentMethod {
private double value;
public Voucher(double value) {
this.value = value;
}
@Override
public double total(double amount) {
return Math.max(amount - value, 0);
}
}
Касата пази стратегията в поле и смята с нея. setMethod я сменя:
class Checkout {
private PaymentMethod method;
public Checkout(PaymentMethod method) {
this.method = method;
}
public void setMethod(PaymentMethod method) {
this.method = method;
}
public double total(double amount) {
return Math.round(method.total(amount) * 100) / 100.0;
}
}
Checkout checkout = new Checkout(new Card());
System.out.printf("Карта: %.2f €%n", checkout.total(80));
checkout.setMethod(new CashOnDelivery());
System.out.printf("Наложен платеж: %.2f €%n", checkout.total(80));
checkout.setMethod(new Voucher(10));
System.out.printf("Ваучер: %.2f €%n", checkout.total(80));
Изход
Карта: 80.00 €
Наложен платеж: 82.50 €
Ваучер: 70.00 €

Checkout checkout = new Checkout(new Card());
System.out.printf("Карта: %.2f €%n", checkout.total(80));
checkout.setMethod(new CashOnDelivery());
System.out.printf("Наложен платеж: %.2f €%n", checkout.total(80));
checkout.setMethod(new Voucher(10));
System.out.printf("Ваучер: %.2f €%n", checkout.total(80));
Стъпка по стъпка, с думи
  1. Касата получава карта: полето method сочи обекта Card.
  2. total(80): касата не знае кой е начинът. Само пита стратегията колко е сумата и получава 80.00.
  3. Нов начин. setMethod насочва полето към обекта CashOnDelivery. В Checkout не се пипа нито ред.
  4. Същото total(80), друг отговор: 82.50.
  5. Ваучер за 10: обект със свое поле value. Старите стратегии вече никой не ги сочи.
  6. Ваучерът вади 10: 70.00. Три начина, а касата е същият обект със същия код през цялото време.

Касата е един обект с един код през цялата програма. Сменя се само това, към което сочи полето.

Виж го

Нов начин, без да пипаш касата

Банковият превод и разсроченото плащане идват като два нови класа:

class BankTransfer implements PaymentMethod {
@Override
public double total(double amount) {
return amount * 0.98;
}
}
class Installments implements PaymentMethod {
@Override
public double total(double amount) {
return amount * 1.05;
}
}
Checkout checkout = new Checkout(new BankTransfer());
System.out.printf("Банков превод: %.2f €%n", checkout.total(80));
checkout.setMethod(new Installments());
System.out.printf("Разсрочено: %.2f €%n", checkout.total(80));
Изход
Банков превод: 78.40 €
Разсрочено: 84.00 €

В касата не е сменен нито ред. Не може да счупиш картата, докато добавяш превод, защото кодът на картата не е пред теб.

Има и още нещо. Ако новият клас забрави метода или го напише с грешно име, програмата не се компилира:

class Installments implements PaymentMethod {
public double totl(double amount) {
return amount * 1.05;
}
}
Грешка
error: Installments is not abstract and does not override abstract method total(double) in PaymentMethod

При веригата от if грешно име на начина се вижда чак когато клиент го избере.

Виж го

Кой начин избира клиентът

Клиентът пише начина като текст. Веригата от if, която избираше правилото, става таблица: име → стратегия.

Map<String, PaymentMethod> methods = Map.of(
"карта", new Card(),
"наложен платеж", new CashOnDelivery(),
"ваучер", new Voucher(10));
Scanner scanner = new Scanner(System.in);
String choice = scanner.nextLine();
PaymentMethod method = methods.get(choice);
if (method == null) {
System.out.println("Няма такъв начин на плащане: " + choice);
} else {
Checkout checkout = new Checkout(method);
System.out.printf("%s: %.2f €%n", choice, checkout.total(80));
}
Вход
наложен платеж
Изход
наложен платеж: 82.50 €

Непознат начин не стига до касата:

Вход
PayPal
Изход
Няма такъв начин на плащане: PayPal

Изборът пак е на едно място, но вече е данни. Нов начин е нов ред в таблицата и нов клас.

По-точно, за любопитните

PaymentMethod има един метод, затова е функционален интерфейс и стратегия може да е и ламбда:

Checkout checkout = new Checkout(amount -> amount + 2.50);
System.out.printf("Наложен платеж: %.2f €%n", checkout.total(80));
Изход
Наложен платеж: 82.50 €

Ламбда стига за правило от един ред. Клас си заслужава, когато стратегията има свое поле (ваучерът), име или повече от един метод.

Пробвай

Предвиди изхода

Поръчката е за 8 €, а ваучерът е за 10 €. После касата минава на наложен платеж. Какво печата програмата?

Checkout checkout = new Checkout(new Voucher(10));
System.out.printf("%.2f €%n", checkout.total(8));
checkout.setMethod(new CashOnDelivery());
System.out.printf("%.2f €%n", checkout.total(8));
Отговор

Пробвай

Намери грешката

Ваучерът трябва да се използва веднъж. Някой го „изхабява“ още в стратегията:

class Voucher implements PaymentMethod {
private double value;
public Voucher(double value) {
this.value = value;
}
@Override
public double total(double amount) {
double result = Math.max(amount - value, 0);
value = 0;
return result;
}
}
Checkout checkout = new Checkout(new Voucher(10));
System.out.printf("Преглед на поръчката: %.2f €%n", checkout.total(80));
System.out.printf("Плащане: %.2f €%n", checkout.total(80));
Изход
Преглед на поръчката: 70.00 €
Плащане: 80.00 €

Прегледът показа 70 €, а плащането поиска 80 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога не ти трябва

Strategy има цена: интерфейс и по клас за всеки вариант, вместо един if. Не си струва, когато:

  • вариантите са два и не се сменят. „С ДДС или без“ е един if;
  • правилото се ползва на едно място и никой не го пипа;
  • има само една стратегия „за всеки случай“. Интерфейс с една имплементация е код, който трябва да четеш, без да ти дава нищо.

Струва си, когато вариантите растат, сменят се по време на работа (клиентът избира) или различни части от програмата искат различни правила.

Упражнение

Apple Pay, без да пипаш касата

Реши задачата в редактора си.

Apple Pay. Магазинът добавя Apple Pay с такса 1% от сумата. Добави клас ApplePay и плати с него поръчка за 80 €. Касата и другите начини не се пипат.

Изход
Apple Pay: 80.80 €

Още задачи

  • Безплатен наложен платеж над 100 €. Направи стратегия за наложен платеж, която не взима 2,50 € при поръчка над 100 €. Пусни я с 80 € и със 120 €.
  • Начинът от входа. Добави банковия превод и разсроченото плащане в таблицата от картата „Кой начин избира клиентът“. Прочети и сумата от входа.
  • Подаръчна карта с баланс. Картата има баланс 50 €: плаща колкото може, а останалото е с карта. Помисли къде се записва новият баланс. Стратегията ли го сменя, или поръчката след плащането?

Тест

Провери се

1. Магазинът добавя плащане с Apple Pay. Какво пишеш при Strategy?
2. Какво пази касата при Strategy?
3. Стратегията за ваучер нулира стойността си при всяко извикване на total. Касата пита веднъж за прегледа и веднъж за плащането. Какво става?
4. Кога Strategy не ти трябва?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·