Шаблон проектирования или паттерн — повторимая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста.
Кажется, это определение мы слышали тысячу раз… Помимо знания терминов и паттернов интересно знать, как они применяются в реальных проектах.
В статье я рассмотрю несколько наиболее популярных паттернов используемых в .NET. Некоторые из них глубоко интегрированы в инфраструктуру .NET, в то время как другие просто применяются при проектировании базовых классов в BCL.
Паттернам проектирования посвящен не один десяток книг, но одна книга стоит особняком и это знаменитая книга «Банды четырех». Поэтому для большего понимания ситуации я буду приводить небольшое описание из этой книги.
Мы рассмотрим следующие шесть паттернов:
- наблюдатель;
- итератор;
- декоратор;
- адаптер;
- фабрика;
- стратегия.
Итак, начнем.
Наблюдатель
Пожалуй, наиболее известный паттерн, который применен в .NET — это наблюдатель. Известен он потому, что заложен в основу делегатов, которые являются неотъемлемой частью .NET с самого его появления. Тяжело не оценить их вклад в объектную модель .NET, особенно учитывая то, во что они вылились в более поздних версиях (лямбды, замыкания, и т.д.).
Назначение паттерна наблюдатель из GOF: определяет зависимость типа «один ко многим» между объектами таким образом, что при изменении состояния одного объекта все зависящие от него оповещаются об этом и автоматически обновляются.
Простейшая реализация данного паттерна на языке C# может выглядеть примерно так:
public interface IObserver // Интерфейс наблюдателя
{
void Notify();
}
public abstract class Subject // Субъект
{
private List<IObserver> observers = new List<IObserver>();
public void Add(IObserver o)
{
observers.Add(o);
}
public void Remove(IObserver o)
{
observers.Remove(o);
}
public void Notify() // Оповестить всех наблюдателей
{
foreach (IObserver o in observers)
o.Notify();
}
}
Как я уже сказал в платформе .NET абстракцию наблюдатель реализуют делегаты. Для более удобной работы с делегатами в C# используются события. В следующем примере будем их использовать:
public delegate void MyEventHandler(); // Пользовательский тип делегата
public class Subject
{
public Subject() { }
public MyEventHandler MyEvent; // Событие
public void RaiseEvent() // Вызвать все методы ассоциированные с событием
{
MyEventHandler ev = MyEvent;
if (ev != null)
ev();
}
}
static void Main(string[] args)
{
Subject s = new Subject();
s.MyEvent += () => Console.WriteLine("Hello habrahabr"); // присоединить делегат
s.MyEvent += () => Console.WriteLine("Hello world"); // присоединить делегат
s.RaiseEvent(); // вызвать все делегаты
Console.ReadKey();
}
Аналогия с паттерном наблюдатель на лицо. Событие выступает в роли субъекта, в то время как делегаты в роли наблюдателей.
На заметку
У кода описанного выше есть один недостаток: при использовании лямбда выражений не получится отсоединить делегат, то есть код
s.MyEvent -= () => Console.WriteLine("Hello habrahabr");
не удалит данный делегат, поскольку он является другим экземпляром. Для дальнейшего отсоединения делегата придется сохранить делегат (лямбда выражение) в переменной.
static void Main(string[] args)
{
Subject s = new Subject();
MyEventHandler myEv1 = () => Console.WriteLine("Hello habarhabr");
MyEventHandler myEv2 = () => Console.WriteLine("Hello world");
s.MyEvent += myEv1;
s.MyEvent += myEv2;
s.MyEvent -= myEv1;
s.RaiseEvent(); // будет вызван только один метод
Console.ReadKey();
}
В .NET 4.0 появились интерфейсы, позволяющие напрямую реализовать паттерн наблюдатель. Они выглядят так:
public interface IObservable<out T>
{
IDisposable Subscribe(IObserver<T> observer);
}
public interface IObserver<in T>
{
void OnCompleted();
void OnError(Exception error);
void OnNext(T value);
}
Как вы видите, интерфейс IObservable реализован немного иначе, нежели наш наблюдатель. Вместо того, чтобы иметь методы по добавлению и удалению наблюдателей он имеет только метод который регистрирует наблюдателя. Метод возвращает реализацию IDisposable, позволяющую наблюдателям отменять уведомления в любое время до того, как поставщик перестанет их отправлять. Теперь можно использовать лямбда выражения, и затем отсоединять их вызвав метод Dispose.
На заметку
Итератор
Следующий не менее популярный по использованию в .NET паттерн — итератор.
Назначение паттерна итератор из GOF: предоставляет способ последовательного доступа ко всем элементам составного объекта, не раскрывая его внутреннего представления.
На платформе .NET за реализацию паттерна итератор отвечают два интерфейса: IEnumerable и IEnumerator
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}
а так же их обобщенные аналоги:
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
T Current { get; }
}
Для того чтобы иметь возможность итерировать некоторую сущность с помощью цикла foreach в ней необходимо реализовать интерфейс IEnumerable<>, а так же создать сам итератор — класс /структуру реализующую интерфейс IEnumerator<>.
С учетом того, что в C# 2.0 появились блоки итераторов, используя ключевое слово yield, создание пользовательских типов реализующих интерфейс IEnumerator<> теперь возникает редко (компилятор делает это за нас).
Цикл foreach работает бок о бок с паттерном итератор. Следующий код
foreach (var item in Enumerable.Range(0, 10))
{
Console.WriteLine(item);
}
это просто синтаксический сахар для кода:
IEnumerator<int> enumerator = Enumerable.Range(0, 10).GetEnumerator();
try
{
while (enumerator.MoveNext())
{
int item = enumerator.Current;
Console.WriteLine(item);
}
}
finally
{
if (enumerator != null)
{
enumerator.Dispose();
}
}
Таким образом, паттерн итератор заложен в основу языка C#, поскольку его языковая конструкция (foreach) использует его.
На заметку
О том, что цикл foreach на самом деле не требует, чтобы итерируемая сущность реализовывала интерфейс IEnumerable (а требует лишь наличие определенных методов с заданными сигнатурами) писали многие, поэтому я говорить об этом не буду. У Сергея Теплякова SergeyT есть хороший пост, посвященный именно работе цикла foreach.
В .NET существуют несколько оптимизаций, касающихся цикла foreach. Поскольку при каждой итерации создается объект итератор, то это может негативно сказаться на сборке мусора, особенно если будет несколько вложенных циклов foreach, поэтому при итерировании массивов и строк (типов глубоко интегрированных в CLR) цикл foreach разворачивается в обычный цикл for.
На заметку
Декоратор
Паттерн №3 — декоратор объектов.
Назначение паттерна итератор из GOF: динамически добавляет объекту новые обязанности. Является гибкой альтернативой порождению подклассов с целью расширения функциональности.
Абстракцию декоратор в .NET представляет класс System.IO.Stream и его наследники. Рассмотрим следующий код:
public static void WriteBytes(Stream stream)
{
int oneByte;
while ((oneByte = stream.ReadByte()) >= 0)
{
Console.WriteLine(oneByte);
}
}
static void Main(string[] args)
{
var memStream = new MemoryStream(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 });
WriteBytes(memStream);
var buffStream = new BufferedStream(memStream);
WriteBytes(buffStream);
Console.ReadLine();
}
Метод WriteBytes работает с любым потоком, но делает это не всегда эффективно. Читать с диска по одному байту не очень хорошо, поэтому мы можем использовать класс BufferedStream, который считывает данные блоком, а потом быстро их возвращает. Класс BufferedStream в конструкторе принимает тип Stream (любой поток), тем самым оборачивая (декорируя) его в более эффективную оболочку. BufferedStream переопределяет основные методы класса Stream, такие как Read и Write, чтобы обеспечить более широкую функциональность.
Класс System.Security.Cryptography.CryptoStream позволяет шифровать и расшифровывать потоки на лету. Он так же принимает в конструкторе параметр с типом Stream, оборачивая его в свою оболочку.
Для любого потока, мы можем добавить возможность эффективного считывания, окружив его BufferedStream, без изменения интерфейса доступа к данным. Поскольку мы не наследуем функциональность, а лишь «украшаем» ее, то можем это сделать во время выполнения, а не при компиляции как это было бы, если мы использовали наследование.
Адаптер
Назначение паттерна адаптер из GOF: преобразует интерфейс одного класса в интерфейс другого, который ожидают клиенты. Адаптер обеспечивает совместную работу классов с несовместимыми интерфейсами, которая без него была бы невозможна.
COM и .NET имеют различную внутреннюю архитектуру. Поэтому необходимо бывает адаптировать один интерфейс к другому. Среда CLR предоставляет доступ к COM-объектам через посредник, называемый вызываемой оболочкой времени выполнения (RCW).
Среда выполнения создает по одной вызываемой оболочке времени выполнения для каждого COM-объекта, независимо от числа существующих ссылок на этот объект.
Среди прочих операций, вызываемая оболочка времени выполнения осуществляет маршалинг данных между управляемым и неуправляемым кодом от имени, упакованного в оболочку объекта. В частности, вызываемая оболочка времени выполнения выполняет маршалинг аргументов и возвращаемых значений метода, если данные, которыми обмениваются клиент и сервер, представлены в них по-разному.
Например, когда клиент .NET передает в неуправляемый объект как часть аргумента тип String, оболочка преобразует эту строку в тип BSTR (пост, в котором я описывал особенности строк в .NET). Если COM-объект возвращает управляемому вызывающему объекту данные типа BSTR, вызывающий объект получит данные типа String. И клиент, и сервер отправляют и получают данные в понятном им представлении.
Фактически, RCW — это адаптер, преобразующий один интерфейс к другому.
Фабрика
Пятый паттерн — это своего рода фабрика.
Назначение паттерна адаптер из GOF: предоставляет интерфейс для создания семейств взаимосвязанных или взаимозависимых объектов, не специфицируя их конкретных классов.
Класс System.Convert содержит набор статических методов, которые позволяют создавать объекты без явного вызова их конструкторов, и работает как фабрика. Для преобразования целого числа в логическое, например, мы можем вызвать и передать в метод Convert.ToBoolean в качестве параметра целое число. Возвращаемое значение этого метода будет истина, если число было ненулевым и ложь в противном случае. Другие методы преобразования типов работают аналогично.
Такая стратегия для создания новых экземпляров объектов известна как шаблон Фабрика. Мы можем не вызывая конструктора попросить у фабрики создать нужный объект. Таким образом, паттерн Фабрика может скрыть сложность создания объекта. Если мы хотим изменить детали создания объекта, нужно всего лишь изменить саму фабрику, нам не придется менять каждое место в коде, в котором вызывается конструктор.
Метод Activator.CreateInstance в сочетании с информацией о типах реализует абстрактную фабрику классов, т.е. такую, которая умеет создавать экземпляры любых типов.
Стратегия
Назначение паттерна итератор из GOF: определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.
Хорошим примером применения этого простенького паттерна является сортировка массивов. Одна из перегрузок метода Array.Sort принимает параметр типа IComparable. Используя этот интерфейс, мы можем создать целую серию алгоритмов сортировки так называемых стратегий, которые не зависят друг от друга, и которые легко заменимы.
public interface IComparable<T>
{
int CompareTo(T other);
}
Код сортировки фактически не зависит от алгоритмов сравнения элементов и может оставаться неизменным.
Сюда так же можно отнести методы Array.BinarySearch, который так же принимает интерфейс IComparable и метод Array.Find, который принимает делегат Predicate. Тем самым, варьируя различные делегаты (стратегии) мы можем менять поведение метода и получать необходимый нам результат.
В общем, паттерн стратегия применяется сплошь и рядом. До написания этой статья я особо и не задумывался, что использую паттерн стратегия при сортировки массивов разными компараторами.
Заключение
Теперь, когда мы рассмотрели некоторые паттерны, используемые в .NET Framework, я думаю вам должно стать еще легче распознать их в коде, с которым вы работаете. Тяжело не оценить вклад событий и итераторов в язык C# и инфраструктуру .NET вообще, поэтому знать, что это реализация классических шаблонов проектирования просто необходимо.
Спасибо за прочтение. Надеюсь, статья оказалась полезной.
Автор: timyrik20