15 марта 2016 г.

Мы не можем игнорировать особенности окружающей нас инфрастуктуры, оказывающей влияние на, казалось бы, незыблемые паттерны проектирования, описанные более 20 лет назад и несомненно ставшими классическими. Возможности, предоставляемые платформой .NET, могут видоизменять и упрощать реализацию паттернов проектирования. Вспомните, как паттерн наблюдатель среди .NET программистов стал неразрывно связан с понятием событий, но в этом посте я хочу рассказать о другом паттерне, чрезвычайно полезном для решения некоторых задач (к примеру, обход синтаксического дерева) - посетителе, открывающемся с другой стороны благодаря новой в .NET 4 DLR и ключевому слову dynamic.

Классический посетитель

Если верить классическим книгам, паттерн посетитель позволяет добавить новую функциональность в иерархию типов без изменения самой иерархии. Классический посетитель использует так называемую двойную диспетчеризацию. Не стану вдаваться в подробности паттерна, ведь целью статьи не является объяснение самого паттерна, читатель уже должен иметь некоторое представление о посетителе, приведу лишь пример иерархии шахматных фигур для будущего сравнения классического кода с dynamic подходом:
// Интерфейс посетителя
interface IFigureVisitor
{
 void Visit(Pawn pawn);

 void Visit(Knight knight);

 void Visit(Bishop bishop);
 ...
}

// Базовый класс для всех фигур. 
// Имеет метод Accept, необходимый для реализации посетителя
abstract class Figure 
{
 public abstract void Accept(IFigureVisitor visitor);
 ...
}

class Pawn : Figure
{
 public override void Accept(IFigureVisitor visitor) => visitor.Visit(this);
}

class Knight : Figure
{
 public override void Accept(IFigureVisitor visitor) => visitor.Visit(this);
}

class Bishop : Figure
{
 public override void Accept(IFigureVisitor visitor) => visitor.Visit(this);
}

// Использование:
// Во первых, реализация интерфейса посетителя,
сlass FigureVisitor : IFigureVisitor
{
 public void Visit(Pawn pawn) { ... }
 
 public void Visit(Knight knight) { ... }
 
 public void Visit(Bishop bishop) { ... }
}

// Во вторых, собственно, его использование
class ClassicVisitor
{
 public void Visit(IEnumerable<Figure> figures)
 {
  var visitor = new FigureVisitor();
  
  foreach (var figure in figures)
   figure.Accept(visitor);
 }
}
Ничего страшного, если вы не знакомы с новым C# 6.0 синтаксисом описания метода через лямбду void Accept(IFigureVisitor visitor) => visitor.Visit(this); - при первом знакомстве можно считать их просто удобным сокращением однострочных методов void Accept(IFigureVisitor visitor) { visitor.Visit(this); }

Dynamic посетитель

А сейчас предлагаю посмотреть на иной подход с использованием ключевого слова dynamic: cуть подхода заключается в том, что решение о вызове необходимого метода посетителя производится не самим объектом в виртуальном методе Accept, а внешним кодом, который приминает решение о выборе нужного метода посетителя в зависимости от типа объекта. Как следствие, отпадает необходимость наличия в иерархии типов метода Accept, что позволяет использовать такую версию посетителя в иерархиях классов (к примеру, XElement), не предоставляющих метод Accept.
// Базовый класс для всех фигур
// Теперь от иерархии не требуется наличие метода Accept
abstract class Figure { ... }

class Pawn : Figure { ... }

class Knight : Figure { ... }

class Bishop : Figure { ... }

// Реализации методов посетителя, которые ранее были в FigureVisitor
class DynamicVisitor
{
 public void Visit(IEnumerable<Figure> figures)
 {
  foreach (dynamic figure in figures)
   Visit(figure);
 }
 
 private void Visit(Pawn pawn) { ... }
 
 private void Visit(Knight knight) { ... }
 
 private void Visit(Bishop bishop) { ... }
}
Безусловно, эту же самую функциональность можно реализовать без dynamic, например, последовательной проверкой типа для каждой фигуры, однако dynamic делает код значительно чище, прячет сложность выбора метода за инфраструктурой DLR.

Предлагаю провести аналогию с виртуальными методами: выбор конкректного метода при вызове определяется типом объекта, хотя при отсутствии поддержки виртуальности и невероятном усердии возможно каждый вызов виртуального метода в программе заменить на код выбора конкретного невиртуального метода в зависимости от типа объекта, но так ведь никто не делает, верно?

Сравнение подходов

Следует иметь в виду, что, как обычно, и бывает в мире программирования, у обоих подходов есть как положительные, так и отрицательные стороны. К примеру, насколько разрушительным станет введение нового типа в иерархию типов? Классический посетитель во время компиляции потребует добавить в интерфейс посетителя новый метод, соответствующий новому добавленному типу. Отлично! Как мне кажется, найти ошибку на этапе компиляции - лучший из возможных исходов. Динамический посетитель в принципе не сможет отловить добавление нового типа во время компиляции из-за позднего связывания вызова методов посетителя. К тому же, во время исполнения, если DLR не сможет найти метод с нужной сигнатурой, она будет вынуждена бросить почти ничего не значащий RuntimeBinderException. Совсем нехорошо, но что поделать? К счастью, можно создать метод, который гарантированно вызовется при отсутвии метода с более подходящей сигнатурой:
class DynamicVisitor
{
 public void Visit(IEnumerable<Figure> figures) 
 { 
  foreach (dynamic figure in figures)
   Visit(figure);
 }
 
 private void Visit(Pawn pawn) { ... }
 
 private void Visit(Knight knight) { ... }
 
 private void Visit(Bishop bishop) { ... }
 
 private void Visit(Figure figure) 
 { 
  /* 
   * Действия по умолчанию, объекты новых типов иерархию попадут сюда, 
   * вместо выброса неприятного исключения 
   */
 }
}
Перейдем к ощутимым плюсам динамического посетителя: благодаря тому, что DLR выбирает наиболее подходящий по сигнатуре метод из всех доступных перегрузок, возможно писать вещи, которые невозможно или чрезвычайно сложно реализовать классическим подходом, но, соглашусь, такой код слишком отдален от классического шаблона:
class DynamicVisitor 
{
 public void Visit(IEnumerable<Figure> blackFigures, IEnumerable<Figure> whiteFigures) 
 { 
  foreach (dynamic blackFigure in blackFigures)
   foreach (dynamic whiteFigure in whiteFigures)
    Visit(blackFigure, whiteFigure);
 }
 
 private void Visit(Pawn pawn, Knight knight) { ... }
 
 private void Visit(Pawn pawn, Pawn pawn) { ... }
 
 private void Visit(Knight knight, Pawn pawn) { ... }
}
Классический посетитель, тем не менее, имеет свои недостатки: что думаете об интерфейсе, который знает и зависит от всех типов иерархии, что по сути создает жесткие зависимости между всеми типами иерархии, или требование метода Accept, отсутствие которого полностью ограничивает применение паттерна, а если код находится во внешней библиотеке, то добавить такой метод не получится никаким способом (если конечно вы не являетесь ее автором)?

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

Хотя производительность и не является темой статьи, настоятельно рекомендую не говорить о проблемах производительности или утечках памяти без результатов профилирования.

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