среда, 26 февраля 2014 г.

Паттерн Стратегия

Пред. запись: GoF паттерны на платформе .NET
След. запись: Паттерн Шаблонный Метод

Назначение: определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.

Другими словами: стратегия инкапсулирует некоторое поведение с возможностью его подмены.

Подробнее – Strategy Pattern on Wiki

Мотивация

Паттерн стратегия является настолько распространенным и общепринятым, что многие его используют постоянно, даже не задумываясь о том, что это хитроумный паттерн проектирования, расписанный когда-то бандой четырех.

Каждый второй раз, когда мы пользуемся наследованием, мы используем Стратегию; каждый раз, когда мы абстрагируемся от некоторого процесса, поведения или алгоритма – мы используем стратегию. Сортировка, анализ данных, валидация, разбор данных, сериализация, кодирование, получение конфигурации, все эти концепции могут и должны быть выражены в виде стратегии или политики (policy).

Стратегия является фундаментальным паттерном, поскольку она проявляется в большинстве других классических паттернов, которые поддерживают специализацию за счет наследования. Абстрактная фабрика – это стратегия создания семейства объектов; фабричный метод – стратегия создания одного объекта; строитель – стратегия построения объекта; итератор – стратегия перебора элементов и т.д.

Давайте предположим, мы хотим написать анализатор паттернов из исходного кода. В этом случае нам понадобится набор детекторов паттернов, реализация которых может отличаться: некоторые детекторы могут быть анализировать исходный код вручную, а некоторые могут использовать более сложные инструменты, такие как Roslyn.

clip_image002[4]

При этом мы не хотим, чтобы анализатор паттернов (PatternsAnalyzer) знал, какая конкретно стратегия определения паттернов используется, поэтому вместо использования конкретного типа, анализатор будет принимать детектор через аргументы конструктора. Таким образом мы не просто абстрагируемся от процесса определения паттернов, но и позволяем клиентскому коду, который использует анализатор паттернов определять стратегию самостоятельно.

Мотивация использования Стратегии: выделение поведения или алгоритма с возможностью его замены во время исполнения.

Варианты реализации в .NET

В общем случае паттерн Стратегия не определяет, какое количество операций будет у "выделенного поведения или алгоритма". Это может быть одна операция (ISorter.Sort), а может быть и семейство операций (IMessageProcessor: Encode/Decode etc).

При этом, если операция лишь одна, то вместо выделения и передачи интерфейса в современных .NET приложениях очень часто используется передача делегата. Так, в нашем случае, вместо передачи интерфейса ISingletonDetector класс PatternsAnalyzer мог бы принимать делегат вида: Func<string, SingletonPattern>, который соответствует сигнатуре единственного метода стратегии:

class PatternsAnalyzer
{
   
private readonly Func<string, SingletonPattern
> _singletonDetector;

   
public
PatternsAnalyzer(
       
Func<string, SingletonPattern
> singletonDetector)
    {
        _singletonDetector
= singletonDetector;
    }
}

Помимо замены наследования передачей "функций", других платформенно зависимых особенностей паттерна Стратегия не существует. Существуют попытки создания обобщенных решений в библиотечном виде (с интерфейсами IStrategy, IContext), но это скорее показывает непонимание фундаментальных принципов паттернов проектирования и редко приводит к нужному результату.

Примеры в .NET Framework

Стратегия является невероятно распространенным паттерном в .NET Framework. Весь LINQ (Language Integrated Query) – это набор методов расширения, принимающих "стратегии" фильтрации, получения проекции и т.д. Коллекции принимают стратегии сравнения элементов, в результате любой класс, который принимает IComparer<T> или IEqualityComparer<T> используют стратегию.

WCF просто переполнен стратегиями: IErrorHandler – стратегия обработки коммуникационных ошибок; IChannelInitializer – стратегия инициализации канала; IDispatchMessageFormatter – стратегия форматирования сообщений; MessageFilter – стратегия фильтрации сообщений и т.д.

Аналогичный (по количеству) набор стратегий есть в Windows Forms, WPF, ASP.NET и других фреймворках. Любая библиотека просто набита стратегиями, поскольку они представляют собой универсальный механизм расширения требуемого функционала пользователем.

Обсуждение паттерна Стратегия

По определению, применение стратегии обусловлено двумя причинами: (1) инкапсуляция поведения или алгоритма и (2) возможность замены поведения или алгоритма во время исполнения. Любой нормально спроектированный класс уже инкапсулирует в себе поведение или алгоритм, но не любой класс с некоторым поведением является или должен быть стратегией. Стратегия нужна тогда, когда нужно не просто спрятать алгоритм, а когда нам важно иметь возможность заменить его во время исполнения!

Другими словами, стратегия обеспечивает точку расширения системы в определенной плоскости: класс-потребитель стратегии не знает, как выполняется некоторое действие и кто именно его выполняет; об этом знают классы более высокого уровня.

Выделять интерфейс или нет?

Сейчас существует два противоположных лагеря в ОО мире: ярые сторонники выделения интерфейсов и ярые противники этого являения. Когда возникает вопрос о необходимости выделения интерфейса и добавления наследования мне нравится думать об этом, как о необходимости выделения стратегии. Это не всегда точно, но может быть хорошей лакмусовой бумажкой этого процесса.

Нужно ли выделять интерфейс IValidator для проверки корректности ввода пользователя? Нужен ли нам интерфейсы IFactory или IAbstractFactory, или нам подойдет один конкретный класс? Ответы на эти вопрос зависят от того, нужна ли нам стратегия (или политика) валидации или создания объектов. Хотим ли мы заменять эту стратегию во время исполнения или мы можем использовать конкретную реализацию и внести в нее изменение в случае необходимости?

Я предпочитаю отталкиваться от наиболее простого решения и переходить к наследованию и возможности замены поведения во время исполнения лишь тогда, когда в этом будет необходимость. Сейчас многие считают, что необходимость наследования есть всегда, поскольку без выделения интерфейсов невозможно покрыть код юнит-тестами. На самом же деле, нет ничего смертельного в использовании конкретных классов, до тех пока они не завязаны на внешнее окружение и не работают с файлами, временем, потоками и т.п. Другими словами, полезно выделять интерфейсы для классов, поведение которых зависит от внешнего контекста и не выделять их для классов со стабильным поведением.

ПРИМЕЧАНИЕ
Подробнее о постоянных и изменчивых зависимостях можно почитать в разделе "Стабильные и изменчивые зависимости", а пример тестируемого дизайна без обильного использования наследования рассмотрен в статье "Пример тестируемого дизайна".

У выделения интерфейса и передачи его в качестве зависимости есть еще несколько особенностей. Возможность задать детектор паттернов для класса анализатора (передача ISingletonDetector в класс PatternsAnalyzer) повышает гибкость, но повышает и сложность. С одной стороны такое решение повышает свободу, но с другой стороны усложняет код клиента.

ПРИМЕЧАНИЕ
Дополнительные рассуждения по поводу выделения интерфейсов и передачи их через конструктор, свойство или метод рассмотрены в нескольких статьях из цикла DI Patterns: “DI Паттерны. Constructor Injection”, “DI Паттерны. Property Injection” и “DI Паттерны. Method Injection”.

Интерфейс vs. Делегат

Некоторые языки поддерживают возможность создания анонимных классов, реализующих определенных интерфейс. В языке C# есть возможность создания анонимных классов, однако они могут содержать лишь свойства и не могут реализовывать интерфейсы. С другой стороны, язык C# позволяет создавать анонимные методы в виде анонимных делегатов и лямбда-функций, что может свести потребность в анонимной реализации интерфейсов к минимуму.

Поскольку некоторые стратегии содержат лишь один метод, то очень часто мы можем использовать стратегию на основе делегатов, вместо классических стратегий на основе наследования. Иногда эти подходы совмещаются, что дает возможность использовать оба подхода, позволяя клиенту выбрать наиболее удобный вариант.

Классическим примером такой ситуации является "стратегия сортировки", представленная интерфейсами IComparable<T> и делегатом Comparison<T>:

class Employee
{
   
public int Id { get; set
; }
   
public string Name { get; set
; }
   
public override string
ToString()
    {
       
return string.Format("Id = {0}, Name = {1}"
, Id, Name);
    }
}

class EmployeeByIdComparer : IComparer<Employee
>
{
   
public int Compare(Employee x, Employee
y)
    {
       
return x.Id.CompareTo(y.
Id);
    }
}


public static void
SortLists()
{
   
var list = new List<Employee
>();

   
// Используем "функтор"
    list.Sort(new EmployeeByIdComparer
());

   
// Используем делегат
    list.Sort((x, y) => x.Name.CompareTo(y.Name));
}

Реализация интерфейса и класса EmployeeByIdComparer требует больше кода и приводит к переключению контекста при чтении, чем использовании лямбда-выражения. В случае с методом List.Sort у нас есть оба варианта, но в некоторых случаях классы могут принимать только лишь стратегию на основе интерфейса, и не принимать стратегию на основе делегатов, как в случае с классами SortedList или SortedSet:

var comparer = new EmployeeByIdComparer();
// Конструктор принимает IComparable
var set = new SortedSet<Employee>(comparer);
// Нет конструктора, принимающего делегат Comparison<T>

В этом случае мы можем легко сделать небольшой "адаптерный фабричный" класс, который будет принимать делегат Comparison<T> и возвращать интерфейс IComparable<T>:

class ComparerFactory
{
   
public static IComparer<T> Create<T>(Comparison<T
> comparer)
    {
       
Contract.Requires(comparer != null
);

       
return new DelegateComparer<T
>(comparer);
    }

   
private class DelegateComparer<T> : IComparer<T
>
    {
       
private readonly Comparison<T
> _comparer;

       
public DelegateComparer(Comparison<T
> comparer)
        {
            _comparer
=
comparer;
        }

       
public int Compare(T x, T
y)
        {
           
return _comparer(x, y);
        }
    }
}

Теперь мы можем использовать этот фабричный метод таким образом:

var comparer = ComparerFactory.Create<Employee>(
    (x, y)
=> x.Id.CompareTo(x.Id));
var set = new SortedSet<Employee>(comparer);

ПРИМЕЧАНИЕ
Аналогично, можно пойти еще дальше, и вместо метода "императивного" подхода на основе делегата Comparison<T>, можно получить более "декларативное" решение аналогичное тому, что используется в методе Enumerable.OrderBy: на основе "селектора" свойств для сравнения.

Классическая стратегия

clip_image002[6]

Обратите внимание, что классический паттерн Стратегии весьма абстрактен.

  • Паттерн Стратегия не специфицирует интерфейсы контекста (PatternsAnalyzer) и стратегии (ISingletonDetector).
  • Паттерн Стратегия не определяет, как стратегия получит данные, необходимые для выполнения своей работы. Они могут передаваться в аргументах метода стратегии, или стратегия может получать ссылку на сам контекст и получать требуемые данные самостоятельно.
  • Паттерн Стратегия не определяет, каким образом контекст получает экземпляр стратегии. Контекст может получать ее в аргументах конструктора, через метод или свойство или получать ее у третьей стороны.

Применимость

Применимость стратегии полностью определяется ее назначением: стратегия используется тогда, когда нужно абстрагироваться от некоторого процесса и дать возможность заменять его во время исполнения.

Нужно четко понимать, что любой класс за счет инкапсуляции уже ограничивает своих клиентов от деталей реализации. Даже не выделяя интерфейса мы можем изменять поведение одних классов независимо путем изменения кода и в большинстве случаев этой гибкости достаточно.

При выделении стратегии, мы перекладываем получение экземпляра на внешний уровень, что упрощает код этого класса (нам не нужно задумываться, откуда взять нужный экземпляр), но усложняет код верхнего уровня (теперь об этом нужно задумываться ему). Гибкость не бывает бесплатной, поэтому выделять стратегии стоит тогда, когда нам действительно нужна возможность замены поведения во время исполнения.

Дополнительные ссылки

11 комментариев:

  1. >>Стратегия нужна тогда, когда нужно не просто спрятать алгоритм, а когда нам важно иметь возможность заменить его во время исполнения!
    Мелкая придирка: _singletonDetector определен как readonly поле (что есть хорошо, на мой взгляд), т.е. заменить его нельзя. Я бы расширил "область определения" стратегии до "когда нам важно иметь возможность выбирать/указывать конкретную реализацию во время исполнения".

    ОтветитьУдалить
    Ответы
    1. Тут очень интересный момент. Обычно для конкретного клиента стратегии, стратегию менять нельзя, поэтому поле должно быть readonly. Если клиент хочет заменить стратегию на другую, то ему (клиенту) нужно создать контекст еще раз и передать в него новую стратегию.
      Иногда (смотри пост DI Паттерны. Property Injection) изменение во время исполнения стратегии именно внутри контекста тоже бывает полезным, но такая гибкость нужна довольно редко.

      Спасибо.

      Удалить
  2. Этот комментарий был удален автором.

    ОтветитьУдалить
  3. Спасибо за статью. Манера изложения материала легка и интуитвно понятна. С удавольствием читаю ваш блог. Хочу заметить, что у вас в статье перепутаны IComparable<Т>, Comparer<Т> и Comparison<Т>. Comparer<Т> - это абстрактный базовый класс для реализации интерфейса IComparer<Т> со свойством Default возвращающий дефолтный компарер для дженерик аргумента. IComparable<Т> задает порядок сортировки обектов реализующий этот интерфейс. Comparison<Т> это делегат которым задаеться стратегия сортировки.

    Спасибо за отличную статью и за отличный блог.

    ОтветитьУдалить
    Ответы
    1. Спасибо за комментарий.
      Кажется у меня тут перепутано лишь Comparer и Comparison (и то не в коде, а в описании). При этом IComparer отличается от IComparable в том, что IComparer задает порядок сортировки "извне", а IIComparable задает порядок сортировки текущего объекта.

      Удалить
  4. Огромное спасибо за статью и блог. Очень нравится манера изложения и правильная (на мой взгляд) расстановка акцентов в статьях.

    З.Ы.: Опечатка в последнем абзаце (Гибксоть)

    ОтветитьУдалить
  5. Спасибо, хорошая статья!

    Раздел "Обсуждение паттерна Стратегия", первый абзац, третья строка - опечатка: алгориМТ

    ОтветитьУдалить
  6. Этот комментарий был удален автором.

    ОтветитьУдалить
  7. var comparer = new EmployeeByIdComparer();
    // Конструктор принимает IComparable
    var set = new SortedSet<Employee>(comparer);

    comparer - экземляр класса EmployeeByIdComparer, который реализует интерфейс IComparer, а не IComparable. В книге эта же ошибка.

    ОтветитьУдалить