Страницы

вторник, 4 марта 2014 г.

Паттерн Шаблонный Метод

Пред. запись: Паттерн Стратегия
След. запись: RAII в C#. Локальный Метод Шаблона vs. IDisposable

Назначение: Шаблонный Метод определяет основу алгоритма и позволяет подклассам переопределять некоторые шаги алгоритма, не изменяя его структуру в целом.

Другими словами: шаблонный метод – это каркас, в который наследники могут подставить свои реализации.

Подробнее – Template Method Pattern on Wiki

Общие сведения

На заре своего становления, наследование считалось ключевой концепцией ООП для расширения и повторного использования кода. Однако со временем, многим разработчикам стало очевидно, что наследование не такой уж и простой инструмент, использование которого приводит к сильной связности (tight coupling) между базовым классом и его наследником. Эта связность приводит к сложности понимания иерархии классов, а отсутствие формализации отношений между базовым классом и наследниками не позволяет четко понять, как именно разделены обязанности между классами Base и Derived, что можно делать наследнику, а что – нет.

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

Формальное определение звучит так:

...если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом (subtype) для T.

Менее формальное определение этого принципа дал Боб Мартин дал в статье "Liskov Substitution Principle", а затем повторил в книге "Принципы, паттерны и методики гибкой разработки на языке C#":

Должна быть возможность вместо базового типа подставить любое его подтип.

Оба определения в некоторой степени полезны, но ни одно из них не достаточно практично. Так, в первом случае все еще остается открытым вопрос о том, как формализовать поведение "в терминах T" и в каких пределах наследнику все же можно варьировать поведение переопределяемых методов. Второе определение слишком упрощенное и скорее может применяться в качестве "лакмусовой бумажки" для проверки корректности наследования, но не как не помогает при создании корректной иерархии наследования с нуля.

На практике, решение проблемы взаимоотношений "родителей" и "детей" лежит в более четкой формализации отношений между ними с помощью Контрактного программирования (Design by Contract) или с помощью паттерна Шаблонный Метод.

ПРИМЕЧАНИЕ
Подробнее о взаимоотношении принципа LSP и контрактов можно почитать в статье "Принцип замещения Лисков и контракты", а общие сведения о контрактах можно получить из серии "Проектирование по контракту".

Мотивация

Давайте вернемся к теме разработки приложения по анализу паттернов проектирования из исходного кода. Очевидно, что паттерны проектирования могут и должны быть представлены в виде иерархии классов: базовым классом может быть класс DesignPattern, а наследниками – конкретные паттерны проектирования, такие как SingletonPattern, FactoryMethodPattern и другие.

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

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

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

clip_image002

public abstract class DesignPattern
{
   
private readonly StringBuilder _patternStringBuilder =
        new StringBuilder
();

   
public string Name { get; private set
; }

   
protected DesignPattern(string
name)
    {
        Name
=
name;
    }

   
public string
Print()
    {
        _patternStringBuilder
.
Clear();
        DoPrint(
"Name"
, Name);
        DoPrint(
"Motivation"
, Motivation);
        DoPrint(
"Sample"
, Sample);
        DoPrint(
"Conclusion"
, Conclusion);
       
return _patternStringBuilder.
ToString();
    }

   
protected void DoPrint(string name, string
data)
    {
        _patternStringBuilder
.AppendFormat("{0}:\n{1}\n"
, name, data);
    }

   
public abstract string Motivation { get
; }
   
public abstract string Sample { get
; }
   
public abstract string Conclusion { get; }
}

Теперь для корректного получения строкового представления паттерна нужно всего лишь переопределить нужные методы (в данном случае свойства): Motivation, Sample и Conclusion, а неполиморфный метод Print позаботится о том, чтобы результат всегда был согласованным.

Аналогичный подход очень часто применяется в современных приложениях. Шаблонный Метод позволяет создать небольшой каркас (framework) для решения определенной задачи, когда базовый класс описывает основные шаги решения, заставляя наследников предоставить недостающие куски головоломки.

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

abstract class BackendProcessor
{
   
public void Process(Data
data)
    {
       
var parsedData =
ParseData(data);
       
try
        {
           
var processResults =
DoProcess(parsedData);
            SendResponce(processResults);
        }
       
catch (Exception
e)
        {
            SendFailure(CreateFailureMessage(e));
        }
    }



   
protected abstract ProcessResults DoProcess(ParsedData
parsedData);

   
// Остальные методы пропущены
}

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

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

"Локальный Шаблонный Метод" на основе делегатов

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

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

public sealed class PatternsAnalyzer
{
   
public ICollection<SingletonDesignPattern
> DiscoverSingletons()
    {
       
return DisoverPatterns(FindSingletons, "Looking for singletons"
);
    }

   
public ICollection<FactoryMethodDesignPattern
> DiscoverFatoryMethods()
    {
       
return
DisoverPatterns(FindFactoryMethods,
           
"Looking for factory methods"
);
    }

   
// "Локальный шаблонный метод". Принимает переменный шаг в виде делегата
    private T DisoverPatterns<T>(Func<T
> discoveryMethod,
       
string
discoveryName)
    {
       
var stopwatch = Stopwatch.
StartNew();

       
var result =
discoveryMethod();

       
Trace.WriteLine(string.Format("{0} took {1}ms"
,
            discoveryName, stopwatch
.
ElapsedMilliseconds));
       
return
result;
    }

   
private ICollection<FactoryMethodDesignPattern
> FindFactoryMethods() { }

   
private ICollection<SingletonDesignPattern> FindSingletons() { }
}

Данный подход применяется очень часто в современных .NET приложениях. Он идеально подходит для устранения дублирования кода, когда класс выполняет множество операций, отличающиеся лишь одним шагом. Шаблонный Метод на основе делегатов постоянно используется при работе с WCF сервисами, поскольку "протокол работы" с прокси объектами довольно сложен, и отличается лишь конкретным методом сервиса:

// Интерфейс сервиса анализа паттернов проектирования
interface IPatternAnalyzer
{
   
DesignPatterns AnalyzePatterns(string
url);
}

// Прокси класс, инкапсулирующий в себе особенности работы
// с WCF инфраструктурой

class PatternAnalyzerProxy : IPatternAnalyzer
{
   
class PatternAnalyzerClient : ClientBase<IPatternAnalyzer
>
    {
       
public IPatternAnalyzer
ChannelAnalyzer
        {
           
get { return
Channel; }
        }
    }

   
public DesignPatterns AnalyzePatterns(string
url)
    {
       
return UseProxyClient(pa => pa.
AnalyzePatterns(url));
    }

   
private T UseProxyClient<T>(Func<IPatternAnalyzer, T
> accessor)
    {
       
var client = new PatternAnalyzerClient
();

       
try
        {
           
var result = accessor(client.
ChannelAnalyzer);
            client
.
Close();
           
return
result;
        }
       
catch (CommunicationException
e)
        {
            client
.
Abort();
           
throw new OperationFailedException(e);
        }
    }
}

Теперь для добавления нового метода сервиса достаточно добавить лишь одну строку кода, при этом протокол работы с сервисом будет соблюден.

ПРИМЕЧАНИЕ
Подробнее о том, почему с WCF прокси нужно работать именно таким образом можно прочитать в разделе “Closing the proxy and using statement” книги “Programming WCF Services” by Juval Lowy.

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

Подход на основе делегатов может применяться не только для "локально", но и передаваться извне другому объекту в аргументах конструктора. В этом случае грань между Шаблонным Методом и Стратегией стирается практически полностью, разница остается лишь на логическом уровне: Стратегия, даже представленная в виде делегата, обычно подразумевает более законченное действие, в то время, как "переменный шаг" Шаблонного Метода обычно является более контекстно-зависимой операцией.

Шаблонный метод на основе методов расширения

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

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

public abstract class DesignPattern
{
   
public string Name { get; private set
; }
 
   
protected DesignPattern(string
name)
    {
        Name
=
name;
    }
 
   
public abstract string Motivation { get
; }
   
public abstract string Sample { get
; }
   
public abstract string Conclusion { get
; }
}
   

public static class DesignPatternEx
{
   
public static string Print(this DesignPattern
designPattern)
    {
       
Contract.Requires(designPattern != null
);
           
       
var sb = new StringBuilder
();
        DoPrint(sb,
"Name", designPattern.
Name);
        DoPrint(sb,
"Motivation", designPattern.
Motivation);
        DoPrint(sb,
"Sample", designPattern.
Sample);
        DoPrint(sb,
"Conclusion", designPattern.
Conclusion);
       
return sb.
ToString();
    }
 
   
private static void DoPrint(StringBuilder sb, string name, string
data)
    {
        sb
.AppendFormat("{0}:\n{1}\n", name, data);
    }
}

У этого подхода есть свои преимущества и недостатки. Главным недостатком является то, что "переменные шаги алгоритма" должны определяться открытыми методами, т.е. интерфейсом расширяемого класса. Главным же достоинством является то, что мы можем выделить "необязательные" ответственности в отдельные методы расширения, делая исходный класс более простым. Так, в нашем случае должны ли классы паттернов проектирования уметь получать свое текстовое представление? Ведь очевидно, что эта возможность будет полезна лишь в ограниченном контексте и не требоваться многим клиентам.

Примеры в .NET Framework

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

Так, если вернуться к WCF, то он просто пропитан этим паттерном. Одним из примеров является класс CommunicationObject, методы Open, Close, Abort и т.д. которого «закрыты» (sealed), но при этом они вызывают виртуальные или абстрактные методы OnClosed, OnAbort и т.д.

Другими примерами этого паттерна только в составе WCF могут служить: ChannelBase, ChannelFactoryBase, MessageHeader, ServiceHostBase, BodyWriter.

Другие примеры: SafeHandle (и его наследники) с абстрактным методом ReleaseHandle; класс TaskScheduler, с его внутренним QueueTask и открытым TryExecuteTaskInline; класс HashAlgorithm с его HashCore; класс DbCommandBuilder и многие другие.

Обсуждение паттерна Шаблонный Метод

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

Изменение уровня абстракции

Помимо определения шаблона некоторого алгоритма, Шаблонный Метод может применяться для повышения уровня абстракции и для повторного использования кода.

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

clip_image004

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

public abstract class PatternAnalyzer
{
   
public ICollection<DesignPattern> ProcessFile(string
fileName)
    {
       
using (var file = File.
OpenRead(fileName))
        {
           
using (TextReader tr = new StreamReader
(file))
            {
               
return
Process(tr);
            }
        }
    }

   
public abstract ICollection<DesignPattern
> Process(
       
TextReader
textReader);
}

public abstract class RoslynAnalyzer : PatternAnalyzer
{
   
public override sealed ICollection<DesignPattern
> Process(
       
TextReader
textReader)
    {
       
// Уличная магия по получению синтаксического дерева
        SyntaxTree sytaxTree =
GetSyntaxTree(textReader);
       
return
ProcessSyntaxTree(sytaxTree);
    }

   
protected abstract ICollection<DesignPattern
> ProcessSyntaxTree(
       
SyntaxTree
syntaxTree);

   
private SyntaxTree GetSyntaxTree(TextReader
textReader) {}
}

Во-первых, наследники класса PatternAnalyzer уже "зажаты" рамками, определенными базовым классом: метод ProcessFile(fileName) является невиртуальным и в качестве точки расширения используется метод Process(TextReader). Такой подход обеспечивает требуемую гибкость с точки зрения пользователя и позволяет легко протестировать всех наследников класса PatternAnalyzer путем создания экземпляра TextReader по заданной строке.

Во-вторых, классы RoslynPatternAnalyzer и PegPatternAnalyzer поднимают уровень абстракции, поскольку все их наследники работают не просто с экземпляром TextReader, а уже с проанализированным синтаксическим деревом тем или иным способом. Мы, таким образом, используем повторно код классов RoslynAnalyzer и PerPatternAnalyzer, и упрощаем реализацию всех их наследников.

clip_image006

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

Non-Virtual Interface Pattern

В языке С++ существует довольно популярная идиома под названием NVI – Non-Virtual Interface. Основная ее идея заключается в том, что класс должен четко разделять два типа своих клиентов: внешние клиенты (используют открытые члены) и клиенты-наследники (используют и переопределяют открытые или защищенные члены). Поскольку каждый из этих типов клиентов достаточно важен и думаем мы о них обычно изолировано, то разработчик класса должен четко разделить эти обязанности в его дизайне.

В результате использования этого подхода мы приходим к дизайну, когда все открытые методы являются невиртуальными, а все виртуальные и абстрактные методы являются защищенными. (Поскольку в С++ возможно переопределение закрытых (private) методов, то там используются закрытые виртуальные методы.). Таким образом, следуя этой идиоме, наш базовый класс PatternAnalyzer будет выглядеть так:

public abstract class NviPatternAnalyzer
{
   
public ICollection<DesignPattern> ProcessFile(string
fileName)
    {
       
using (var file = File.
OpenRead(fileName))
        {
           
using (TextReader tr = new StreamReader
(file))
            {
               
return
ProcessTextReader(tr);
            }
        }
    }

   
// Открытые методы невиртуальные
    public ICollection<DesignPattern> ProcessTextReader(TextReader
textReader)
    {
       
// Теперь мы можем добавить дополнительные шаги в этот метод
        return
DoProcessTextReader(textReader);
    }

   
protected abstract ICollection<DesignPattern> DoProcessTextReader(TextReader textReader);
}

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

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

ПРИМЕЧАНИЕ
Все же этот паттерн имеет свои ограничения. Так, например, он не доступен в языке F#, поскольку в нем отсутствуют защищенные (protected) члены.

А что делать, если класс реализует интерфейс?

Поскольку в языке C# реализация интерфейса неявно является запечатанной (sealed), то даже наличие интерфейсов не будет помехой для использования этой идиомы.

Насколько эта идиома применима в C#?

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

Подробнее о NVI можно почитать в статье Герба Саттера Virtuality, а также в статье Вячеслава Иванова "NVI и C#".

Классическая диаграмма Шаблонного метода

clip_image008

  • Абстрактный класс определяет невиртуальный метод TemplateMethod, который вызывает внутри примитивные операции: PrimitiveOperation1(), PrimitiveOperation2() и т.д.
  • Конкретный класс (или классы) реализует примитивные шаги алгоритма.

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

Практически всегда, когда у вас в голове появляется мысль о повторном использовании кода с помощью наследования, стоит подумать о том, как можно выразить отношения между базовым классом и его наследником максимально четко. При этом нужно помнить о своих клиентах: как о внешних классах, так и о наследниках: насколько просто создать наследника вашего класса, какие методы нужно переопределить, что в них можно делать, а что – нельзя? Когда и от каких классов иерархии наследования нужно наследоваться? Насколько легко добавить еще одного наследника, не вдаваясь в детали реализации базового класса?

Формализация отношений между базовым классом и наследником с помощью контрактов и Шаблонного Метода сделает жизнь разработчиков наследников проще и понятнее. Шаблонный Метод задает некоторый каркас, которые четко говорит пользователю, что он может сделать и в каком контексте.

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

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

  1. Как всегда на уровне. Вот только фраза "Поскольку в языке C# реализация интерфейса неявно является закрытой" так и не смог понять. Может с русской терминологией у меня проблемы? Хотья особых неустоящих терминов вроде и не использовано.
    Спасибо за статью.

    ОтветитьУдалить
    Ответы
    1. Речь о том, что при метод, реализующий интерфейс неявно является sealed.
      Пример:

      interface IFoo { void Foo();}
      class FooImpl : IFoo {
      public void Foo() {}
      }

      В классе FooImpl метод Foo является "закрытым" (sealed) и не может быть переопределен наследником.
      Тут есть несколько обходных путей, но поведение по умолчанию именно такое.

      Удалить
    2. Соасибо за ответ. Теперь все встало на свои места :) Просто в тексте, для термина "sealed" использовался термин "запечатанный" (что интуитивно более понятный и устоявщий), вот и вылезла путаница.

      Удалить
    3. Я обновлю статью, чтобы сделать этот момент более ясным.

      Удалить
  2. В первом листинге (class DesignPattern) я бы сделал абстрактные члены защищёнными (как это предлагается сделать в разделе Non-Virtual Interface Pattern).
    Это бы помогло легче понять фразу о недостатке реализации "через метод расширения".

    (С): Главным недостатком является то, что "переменные шаги алгоритма" должны определяться открытыми методами, т. е. интерфейсом расширяемого класса.

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

      Удалить
  3. Добрый день!
    (С): "Примеров использования паттерна Фабричный Метод в .NET Framework довольно много. ... примером паттерна Фабричный Метод."
    "Фабричный Метод" - это опечатка? :)

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