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, чтобы зафиксировать интерфейс на этапе сборки.

Новый тип скидки = новый файл. Старый код не трогаем - меньше багов ([OCP](../solid/03-ocp.md) привет). А ещё каждую стратегию можно тестировать [изолированно](../go/15-testing.md).

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 vs 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) или имеет несколько связанных методов.

В Go если [интерфейс](../go/09-interfaces.md) содержит один метод - подумай, нужен ли интерфейс. Часто `func(...)` проще и достаточно. Интерфейс оправдан когда стратегия несёт состояние или имеет несколько методов. Это же - основная идея [ISP](../solid/05-isp.md): не делай интерфейс шире, чем нужно потребителю.

Связь с другими паттернами

Factory + Strategy - фабрика создаёт нужную стратегию по конфигу
Strategy + Template - Template Method задаёт скелет, а шаги - стратегии
Strategy + DI - стратегия инжектится через конструктор

Мини-задание

  • Замени switch kind на интерфейс Strategy + реализации в своём коде
  • Напиши тест, который проверяет 3 разных стратегии через один и тот же вызов
  • Найди sort.Interface в Go stdlib - как sort.Sort() использует Strategy?
  • Попробуй вариант с func вместо интерфейса - когда это удобнее?

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.