Стратегия (Strategy)
Выносит семейство взаимозаменяемых алгоритмов в отдельные объекты, чтобы менять их без правки кода, который их использует.
Проблема
Оформление заказа считает итоговую сумму со скидкой. Видов скидок становится больше: без скидки, процентная, фиксированная, праздничная… Если держать их в Checkout через if/switch, каждый новый вид скидки — правка и повторное тестирование Checkout, а сам класс разрастается и знает обо всех акциях магазина.
Решение
Описать общий контракт «посчитай сумму со скидкой» (DiscountStrategy) и вынести каждый вид скидки в свою реализацию. Checkout получает стратегию извне и просто делегирует ей расчёт. Новая скидка — новый класс, Checkout не меняется (принцип открытости/закрытости).
Чем уже контракт, тем больше реализаций под него подходит и тем проще заменить класс функцией, как в Go и Python. Имя метода описывает результат, а не способ: apply, а не applyPercent.
Структура
Checkout не создаёт стратегию сам, а получает извне: это инверсия зависимостей, и именно поэтому тест может подставить любую. Один метод total, одна строка делегирования. Суммы хранятся в копейках/центах (int), чтобы не ловить ошибки округления float.
Как это собирается
Стратегию выбирает не Checkout, а тот, кто его создаёт. В примере это карта промокодов: код WELCOME10 даёт PercentDiscount(10), MINUS300 даёт FixedDiscount(300), нет кода - NoDiscount. Дальше Checkout получает готовую стратегию и вызывает её, ничего не зная о промокодах. В приложении карта живёт в конфиге или таблице, а сборка происходит в контроллере или DI-контейнере: так новый промокод добавляется строкой данных, без правки кассы.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Каждая реализация держит свои параметры (percent, offCents) и проверяет их при создании, поэтому к моменту расчёта объект всегда валиден. NoDiscount — «нулевой объект»: Checkout не проверяет null. Деление целых везде округляет к нулю (intdiv, //, Math.trunc), чтобы результат совпадал во всех языках.
| Язык | Особенность |
|---|---|
| PHP | Классический вариант: interface DiscountStrategy + final readonly классы |
| Go | Стратегия — функциональный тип Discount func(int) int; параметры захватываются замыканием |
| Python | Стратегия — любой callable: функция no_discount, фабрика percent_discount, объект FixedDiscount с __call__ |
| TypeScript | interface DiscountStrategy; благодаря структурной типизации подходит и объектный литерал noDiscount, и класс |
Запуск: make test T=patterns/behavioral/strategy
Плюсы и минусы
Тесты гоняют одни и те же четыре случая во всех языках: без скидки, 10%, фиксированная 300, фиксированная больше суммы. Последний проверяет границу max(0, …). Второй тест: невалидный процент отвергается при создании стратегии, а не при расчёте.
- ✅ Новые алгоритмы добавляются без изменения контекста (
Checkout). - ✅ Каждый алгоритм тестируется изолированно; контекст тестируется с любой стратегией.
- ✅ Алгоритм можно выбрать во время выполнения (по настройкам, промокоду, типу клиента).
- ❌ Больше типов/файлов, чем при простом
switch. - ❌ Клиент должен знать о существовании стратегий, чтобы выбрать нужную (обычно это прячут за фабрикой или DI-контейнером).
Когда не применять
- Вариантов два и новые не ожидаются —
ifпроще и честнее. - Алгоритм не меняется во время работы программы и не нужен в тестах в другом виде.
- В языке достаточно передать функцию — отдельная иерархия классов будет церемонией (см. ниже).
Отличия между языками
- PHP — без интерфейса нельзя гарантировать контракт на уровне типов (
callableне описывает сигнатуру), поэтому классический вариант с интерфейсом здесь уместнее всего. - Go — интерфейс с одним методом почти всегда заменяется функциональным типом: стратегия — это просто функция. Конструктор с валидацией (
PercentDiscount) возвращает(Discount, error), а не бросает исключение. - Python — паттерн «растворяется» в языке: функции — объекты первого класса, поэтому отдельный базовый класс не нужен. Если стратегии нужно состояние, подойдёт объект с
__call__(FixedDiscount) — дляCheckoutразницы нет. - TypeScript — структурная типизация: любой объект с методом
apply(number): numberуже являетсяDiscountStrategy, безimplements. Можно было бы взять и тип-функцию, как в Go; интерфейс оставлен, чтобы показать контраст.
Практика
Учебный пример обходит главный вопрос: кто выбирает стратегию. Здесь её кладёт в Checkout тест руками, в проекте так не бывает.
- Практика: расчёт доставки показывает тот же паттерн в условиях, близких к реальным: способ доставки выбирается правилами из конфига по стране, весу и сумме заказа, стратегии регистрируются по коду, а новая служба доставки добавляется без правки кассы.
- Практика: цены из прайса поставщика - наценка выбирается по категории товара, а её вид и параметры (процент, фиксированная, шкала по закупке) целиком описаны в конфиге; общие правила магазина, округление и минимальная наценка, применяются после любой стратегии и не дают ей их нарушить.
Связанные темы
- Практика: расчёт доставки — стратегия, выбранная правилами из конфига
- Практика: цены из прайса поставщика - стратегия, собранная из конфига, и правила поверх неё
Появятся по мере наполнения: State, Template Method, Command.