handbookНаписать

patterns/behavioral/strategy/README.md · тема · 4 языка · 23 теста

Стратегия (Strategy) ​

Выносит семейство взаимозаменяемых алгоритмов в отдельные объекты, чтобы менять их без правки кода, который их использует.

Проблема ​

Оформление заказа считает итоговую сумму со скидкой. Видов скидок становится больше: без скидки, процентная, фиксированная, праздничная… Если держать их в Checkout через if/switch, каждый новый вид скидки — правка и повторное тестирование Checkout, а сам класс разрастается и знает обо всех акциях магазина.

Решение ​

Описать общий контракт «посчитай сумму со скидкой» (DiscountStrategy) и вынести каждый вид скидки в свою реализацию. Checkout получает стратегию извне и просто делегирует ей расчёт. Новая скидка — новый класс, Checkout не меняется (принцип открытости/закрытости).

Чем уже контракт, тем больше реализаций под него подходит и тем проще заменить класс функцией, как в Go и Python. Имя метода описывает результат, а не способ: apply, а не applyPercent.

Структура ​

Checkout

-discount: DiscountStrategy

+total(amountCents) : int

«interface»

DiscountStrategy

+apply(amountCents) : int

NoDiscount

PercentDiscount

-percent: int

FixedDiscount

-offCents: int

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__
TypeScriptinterface 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.

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS