Strategy: меняем поведение без переписывания кода
Strategy: меняем поведение без переписывания кода
Скидки, сортировки, тарифы - везде есть «правило, которое меняется». Strategy позволяет менять поведение, не трогая код вокруг.
Проблема
У тебя есть расчёт скидки. Сначала была одна - студенческая. Потом добавили VIP. Потом промокод. Потом «чёрная пятница». Код превращается в:
func CalcDiscount(sum int, kind string) int {
switch kind {
case "student": return sum * 10 / 100
case "vip": return sum * 20 / 100
case "promo": return sum * 15 / 100
case "friday": return sum * 30 / 100
// ...ещё 5 типов через месяц
default: return 0
}
}
<?php
declare(strict_types=1);
function calcDiscount(int $sum, string $kind): int
{
return match ($kind) {
'student' => intdiv($sum * 10, 100),
'vip' => intdiv($sum * 20, 100),
'promo' => intdiv($sum * 15, 100),
'friday' => intdiv($sum * 30, 100),
// ...ещё 5 типов через месяц
default => 0,
};
}
function calcDiscount(sum, kind) {
switch (kind) {
case 'student': return Math.floor(sum * 10 / 100);
case 'vip': return Math.floor(sum * 20 / 100);
case 'promo': return Math.floor(sum * 15 / 100);
case 'friday': return Math.floor(sum * 30 / 100);
// ...ещё 5 типов через месяц
default: return 0;
}
}
Каждый новый тип - правка в switch/match. Это нарушает OCP (Open/Closed Principle): код открыт для модификации вместо расширения.
Решение: Strategy
Strategy - это «поведение как объект». Вместо switch по типам ты передаёшь нужную стратегию снаружи. Новый тип поведения = новая структура, старый код не трогается.
Разные правила скидок
type Discount interface {
Apply(sum int) int
}
type StudentDiscount struct{}
func (StudentDiscount) Apply(sum int) int { return sum * 10 / 100 }
type VipDiscount struct{}
func (VipDiscount) Apply(sum int) int { return sum * 20 / 100 }
type PromoDiscount struct{ percent int }
func (d PromoDiscount) Apply(sum int) int { return sum * d.percent / 100 }
func Total(sum int, d Discount) int {
return sum - d.Apply(sum)
}
Добавить «чёрную пятницу»? Новая структура - и всё:
type BlackFridayDiscount struct{}
func (BlackFridayDiscount) Apply(sum int) int { return sum * 30 / 100 }
// вызов - ничего не менялось
total := Total(1000, BlackFridayDiscount{}) // 700
interface Discount
{
public function apply(int $sum): int;
}
final class StudentDiscount implements Discount
{
public function apply(int $sum): int
{
return intdiv($sum * 10, 100);
}
}
final class VipDiscount implements Discount
{
public function apply(int $sum): int
{
return intdiv($sum * 20, 100);
}
}
final class PromoDiscount implements Discount
{
public function __construct(
private readonly int $percent,
) {}
public function apply(int $sum): int
{
return intdiv($sum * $this->percent, 100);
}
}
function total(int $sum, Discount $d): int
{
return $sum - $d->apply($sum);
}
Добавить «чёрную пятницу»? Новый класс - и всё:
final class BlackFridayDiscount implements Discount
{
public function apply(int $sum): int
{
return intdiv($sum * 30, 100);
}
}
// вызов - ничего не менялось
$total = total(1000, new BlackFridayDiscount()); // 700
class StudentDiscount {
apply(sum) { return Math.floor(sum * 10 / 100); }
}
class VipDiscount {
apply(sum) { return Math.floor(sum * 20 / 100); }
}
class PromoDiscount {
#percent;
constructor(percent) { this.#percent = percent; }
apply(sum) { return Math.floor(sum * this.#percent / 100); }
}
function total(sum, discount) {
return sum - discount.apply(sum);
}
Добавить «чёрную пятницу»? Новый класс - и всё:
class BlackFridayDiscount {
apply(sum) { return Math.floor(sum * 30 / 100); }
}
// вызов - ничего не менялось
total(1000, new BlackFridayDiscount()); // 700
В JS контракт «у объекта есть метод apply(sum)» проверяется в рантайме. Это удобно для прототипирования, но в больших проектах часто берут TypeScript, чтобы зафиксировать интерфейс на этапе сборки.
Strategy в Go stdlib
Go активно использует Strategy через интерфейсы:
sort.Interface - стратегия сравнения для сортировки
Len(), Less(), Swap()
http.Handler - стратегия обработки HTTP-запроса
ServeHTTP(w, r)
io.Reader / io.Writer - стратегия чтения/записи
Read(p []byte) (n int, err error)
hash.Hash - стратегия хеширования
Write(), Sum(), Reset()
Когда ты передаёшь http.Handler в роутер - это Strategy. Роутер не знает, что делает обработчик. Он просто вызывает ServeHTTP.
// http.ListenAndServe принимает любой Handler - это Strategy
http.ListenAndServe(":8080", myHandler)
Strategy vs State: в чём разница
Strategy и State структурно одинаковы - оба подменяют поведение через интерфейс. Разница в намерении:
Если поведение выбирается один раз при создании - это Strategy. Если объект сам переключает поведение по мере жизни - это State.
Тестируемость
Strategy делает код тестируемым - ты можешь подставить любую реализацию (это прямой результат Dependency Inversion):
func TestTotal_StudentDiscount(t *testing.T) {
got := Total(1000, StudentDiscount{})
assert.Equal(t, 900, got)
}
func TestTotal_CustomDiscount(t *testing.T) {
// мок-стратегия для теста
mock := PromoDiscount{percent: 50}
got := Total(1000, mock)
assert.Equal(t, 500, got)
}
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class TotalTest extends TestCase
{
public function testStudentDiscount(): void
{
self::assertSame(900, total(1000, new StudentDiscount()));
}
public function testCustomDiscount(): void
{
// мок-стратегия для теста
$mock = new PromoDiscount(50);
self::assertSame(500, total(1000, $mock));
}
}
import { describe, it, expect } from 'vitest';
describe('total', () => {
it('student discount', () => {
expect(total(1000, new StudentDiscount())).toBe(900);
});
it('custom discount', () => {
// мок-стратегия для теста
const mock = new PromoDiscount(50);
expect(total(1000, mock)).toBe(500);
});
});
Без Strategy пришлось бы тестировать весь switch в одной функции - сложнее изолировать и дольше отлаживать.
Когда НЕ использовать
Один вариант поведения - если скидка одна и новые не планируются, switch из одного case не нужно заворачивать в интерфейс.
Поведение не меняется в runtime - если алгоритм захардкожен и всегда один, Strategy добавляет лишний уровень абстракции.
Слишком простая логика - если «стратегия» это одна строка (return sum * percent / 100), можно передать функцию вместо интерфейса:
// функция как стратегия - легче чем интерфейс
type DiscountFunc func(sum int) int
func Total(sum int, d DiscountFunc) int {
return sum - d(sum)
}
total := Total(1000, func(sum int) int { return sum * 10 / 100 })
// замыкание как стратегия - без интерфейса и класса
function total(int $sum, Closure $d): int
{
return $sum - $d($sum);
}
$total = total(1000, fn (int $sum) => intdiv($sum * 10, 100));
// стрелочная функция как стратегия - без класса
const total = (sum, discount) => sum - discount(sum);
const studentDiscount = (sum) => Math.floor(sum * 10 / 100);
total(1000, studentDiscount); // 900
total(1000, (sum) => Math.floor(sum * 30 / 100)); // 700
В JS функции - first-class объекты, и часто это даже идиоматичнее класса: стратегия без состояния = просто (sum) => .... Класс берут, когда стратегия хранит конфигурацию (percent) или имеет несколько связанных методов.
Связь с другими паттернами
Factory + Strategy - фабрика создаёт нужную стратегию по конфигу
Strategy + Template - Template Method задаёт скелет, а шаги - стратегии
Strategy + DI - стратегия инжектится через конструктор
Мини-задание
- Замени
switch kindна интерфейс Strategy + реализации в своём коде - Напиши тест, который проверяет 3 разных стратегии через один и тот же вызов
- Найди
sort.Interfaceв Go stdlib - какsort.Sort()использует Strategy? - Попробуй вариант с
funcвместо интерфейса - когда это удобнее?