Совсем недавно один из читателей моего блога спросил моего мнения о применении приемов функционального программирования в таких изначально объектно-ориентированных языках как C#, а также попросил прокомментировать два варианта дизайна кода: одного – чисто объектно-ориентированного, а второго – по его мнению, с уклоном в функциональное программирование. Но поскольку один лишь анализ дизайна потянул на небольшую статейку, то обсуждение интереснейшей темы мультипарадигменного программирования в языке C# мы отложим на следующий раз, а сейчас сосредоточимся исключительно на дизайне.
Постановка задачи
Итак, можно представить, что мы с вами проводим что-то вроде дизайн ревью, входными данными которого является не подробная спецификация с кучей диаграмм, а всего лишь несколько фрагментов кода, по которому нам с вами нужно принять решение, каким путем идти. Основная суть задачи, о которой идет здесь речь, заключается в проектировании иерархии команд (паттерн «Команда»), при этом каждое действие команды состоит из двух составляющей: непосредственной бизнес-логики команды и некоторого завершающего действия. При этом известно, что для одного конкретного вида бизнес-логики может существовать несколько разных завершающих действий.
Команда разработчиков представила два варианта дизайна: первое решение сводится к тому, что завершающее действие в виде делегата передается классу конкретной команды через конструктор, а во втором решении для этого создается соответствующий подкласс. При этом сторонники второго подхода придерживаются мнения, что использование делегатов не является чистым подходом с точки зрения ООП, сторонники же первого подхода считают такое поведение нормальным. Теперь давайте посмотрим на предоставленные нам фрагменты кода.
Первый вариант:
// Базовый класс команды
public abstract class AbstractCommand
{
public virtual void Execute()
{
// При выполнении любой команды нужно выполнить некоторые
// завершающие действия
CompleteAction();
}
// завершающие действия переопределяется наследником
protected abstract void CompleteAction();
}
// Конкретная команда, реализует основную бизнес логику команды
// и принимает завершающее действие в конструкторе
class ConcreteCommand : AbstractCommand
{
private Action completeAction;
public ConcreteCommand(Action completeAction)
{
this.completeAction = completeAction;
}
public override void Execute()
{
// Некоторые действия команды
Console.WriteLine("ConcreteCommand.Execute");
// Не забываем вызвать базовую версию
base.Execute();
}
protected override void CompleteAction()
{
// Вызываем делегат, полученный в конструкторе
completeAction();
}
}
Второй вариант:
// Базовый класс команды
public abstract class AbstractCommand
{
public virtual void Execute()
{
// При выполнении любой команды нужно выполнить некоторые
// завершающие действия
CompletedAction();
}
protected abstract void CompletedAction();
}
// Конкретная команда, только лишь реализует основную логику команды.
// Требуется наследник, который реализует завершающее действие
class ConcreteCommand1 : AbstractCommand
{
protected override void CompletedAction()
{
// весь код завершающей процедуры определен в команде.
}
public override void Execute()
{
// Некоторые действия команды
Console.WriteLine("ConcreteCommand.Execute");
// Не забываем вызвать базовую версию
base.Execute();
}
}
class ConcreteSubCommand1 : ConcreteCommand1
{
protected override void CompletedAction()
{
// Переопределяем завершающие действия
}
}
class ConcreteSubCommand2 : ConcreteCommand1
{
protected override void CompletedAction()
{
// Снова переопределяем завершающие действия
// не изменяя при этом основное поведение команды
}
}
Использование паттерна «Метод шаблона»
Первое, что бросается в глаза, это необходимость при переопределении метода Execute в наследниках вызывать функцию базового класса. Нужно признать, что это достаточно типовая идиома в языке C#, но я этот подход не люблю, как раз в силу необходимости следования каким-то неформальным правилам. Еще добрый десяток лет назад Скотт Мейерс в одной из своих книг по эффективному использованию С++ предложил один простой способ создания надежного дизайна: проектируйте свои классы так, чтобы их было легко использовать правильно и сложно использовать неправильно. В данном случае это сводится к использованию паттерна «Метод шаблона», который как раз и предназначен для создания «скелета» алгоритма из нескольких полиморфных составляющих: в данном случае это сводится к созданию невиртуального метода Execute, который вызывает виртуальный метод CommandAction (действия команды) и CompleteAction (завершающие действия команды)
Таким образом, не зависимо от того, каким вариантом дизайна воспользоваться, нам просто необходимо изменить наш базовый класс команды следующим образом:
// Базовый класс команды
public abstract class AbstractCommand
{
public void Execute()
{
// Теперь метод Execute стал невиртуальным, вместо него наследник
// должен переопределить два абстрактных метода CommandAction
// и CompleteAction
CommandAction();
CompleteAction();
}
protected abstract void CommandAction();
protected abstract void CompleteAction();
}
Агрегация vs Наследование
Теперь давайте внимательнее посмотрим на два предложенных варианта. Еще раз напомню, что основное различие между ними следующее: в первом случае завершающее действие команды предлагается передавать в виде внешнего делегата, а во втором случае это завершающее действие переопределяется конкретным наследником. При этом защитники второго способа утверждают, что их способ более «объектно-ориентированный», в то время как первый способ нарушает некоторые каноны ООП.
Во-первых, сам паттерн «Команда» иногда подвергается критике за то, что он сам по себе не является «чистым» с точки зрения канонов ООП, поскольку он воздвигает «действие» выше «объекта». Однако, несмотря на это, он очень здорово прижился в арсенале многих разработчиков и просто замечательно справляется с задачей уменьшения зависимостей между кодом, вызывающим команду и между действием, выполняемым конкретной командой.
Во-вторых, если внимательно посмотреть на примеры реализации паттерна «Команда» в небезызвестной книге «банды четырех», то там можно увидеть следующую реализацию «простой команды»:
MyClass* receiver = new MyClass;
Command* aCommand = new SimpleCommand<MyClass>(receiver, &MyClass::Action);
Возможно чителям, не особенно знакомым со старым добрым С++ этот код может показаться не слишком понятным, но по сути, это ни что иное, как обыкновенный callback-метод, который передается в конструкторе команды. Замените С++, на C# и вместо этого страшного синтаксиса воспользуйтесь делегатом типа Action, и вы получите абсолютно аналогичный код (и ведь именно этот подход и применяется в первом случае). Все это я веду к тому, что паттерн «команда» никак не регламентирует то, как некоторое действие будет получено и, каким волшебным образом оно окажется в этом объекте; этот паттерн говорит лишь о том, что он содержит в себе некоторое действие, которое будет вызвано клиентом этой команды, при этом клиент команды не будет знать, что за действие будет выполнено.
В-третьих, оба эти варианта показывают классический компромисс между наследованием и агрегацией. Так, первый случай, по сути, использует еще один паттерн проектирования по имени «Стратегия» и принимает конкретное действие через параметр конструктора, в то время как второй вариант использует наследование для переопределения конкретного действия завершения.
В начале девяностых годов, когда ООП только входило в моду многие разработчики начали применять его не совсем по назначению. Наследование тогда считалось идеальной формной повторного использования, ведь для этого нужно всего-лишь создать класс наследник и переопределить пару методов. Но уже спустя несколько лет, многие представители нашей отрасли начали приходить к выводу, что наследование – это далеко не панацея, и при злоупотреблении приводит к более сложному, связному и запутанному коду. Не зря, спустя всего несколько лет, в середине 90-х, представители «банды четырех» в своей знаменитой книге советуют использовать агрегацию вместо наследования.
Кроме того, если немного перефразировать Боба Мартина, то можно сказать, что ООП – это чудесная вещь. Оно способно помочь в решении многих задач проектирования. Но из того, что оно существует вовсе не следует, что его нужно употреблять к месту и не к месту (*). Если вы посмотрите на подход разработчиков компилятора языка C#, то обратите внимание, что они значительно более прагматичны, нежели, например, авторы Java и не следуют букве ООП настолько буквально. Насколько это хорошо или плохо, судить сложно, но лично мне такой прагматичный подход и отсутствие крайностей более симпатичен (**).
На самом деле, ни один из вариантов дизайна не нарушает никаких канонов, нам всего лишь нужно выбирать между двумя паттернами проектирования: между классической реализацией паттерна «Метода шаблона» или несколько модифицированной версией, когда одно из переопределяемых действий заменяется стратегией. Поскольку сам я чрезмерно использовать наследование не люблю, то первый вариант мне кажется более предпочтительным как раз ввиду своей простоты. Да и вообще, ведь никого не удивляет тот факт, что мы не наследуемся от класса List<MyClass> для реализации сортировки, вместо этого мы спокойно используем метод Sort (***), который принимает предикат (т.е. по сути, стратегию); так почему нас это должно беспокоить в данном случае?
Прячь все, что может измениться
Все мы знаем об инкапсуляции, как о сокрытии деталей реализации некоторого класса, когда поля класса делаются закрытыми, предотвращая их от непосредственного доступа со стороны клиентов класса. Эта разновидность инкапсуляции называется «сокрытием данных», однако это не единственный контекст, в котором этот термин употребляется. Более того, инкапсуляция может (и должна) применяться в более широком смысле; любое сокрытие информации является инкапсуляцией. Аналогично тому, как любой класс разбивается на публичный интерфейс (абстракцию) и реализацию, любая более высокоуровневая конструкция также содержит эти две составляющие: любая сборка в .Net-е содержит открытые классы, определяющие интерфейс и внутренние классы, определяющие реализацию; пакеты в java также поддерживает подобное разделение; и даже в языках, в которых подобное напрямую не поддерживается (например, в С++) мы стараемся это эмулировать путем анонимных пространств имен или других подобных техник.
Аналогичный принцип можно применить и к иерархии классов, когда сам факт наличия наследников мы можем спрятать за фабричным методом, сделав, тем самым, это лишь деталью реализации, которая никак не повлияет на клиентов этой иерархии:
sealed class ConcreteCommand1 : AbstractCommand
{
private Action completeAction;
public ConcreteCommand1(Action completeAction)
{
// Теперь завершающие действия передаются в виде "стратегии"
// через параметр конструктора, что устраняет необходимость
// в наследнике для каждого нового завершающего действия
this.completeAction = completeAction;
}
public static ConcreteCommand1 CreateConcreteSubCommand1()
{
// Получаем или создаем функцию завершения для данного конкретного
// случая.
Action completeAction = () => { };
return new ConcreteCommand1(completeAction);
}
protected override void CommandAction()
{
// Некоторые действия команды
Console.WriteLine("ConcreteCommand.Execute");
}
protected override void CompletedAction()
{
// Теперь завершающее действие сводится всего лишь к вызову
// стратегии
completeAction();
}
}
Теперь, если разработчики придут к выводу, что нужно убрать стратегию и добавить наследников для каждого завершающего действия, эти изменения не будут распространены на клиентский код.
Подведение итогов
К любым принципам проектирования, методикам разработки, языкам или технологиям не стоит относиться, как к серебряной пуле и слепо следовать их канонам. Более того, сами авторы этих подходов зачастую относятся более прагматично к чистоте методологии, нежели их ярые приверженцы. Кроме того, даже в одной и той же методологии могут быть советы, диаметрально противоположные друг другу и ООП, вместе с паттернами проектирования, является ярким тому подтверждением. При выборе архитектуры или дизайне некоторой фичи, главное не высекать это решение в камне; принимайте решения таким образом, чтобы его можно было изменить с разумными затратами не поломав всю систему целиком.
---------------------
(*) В оригинальной цитате, которая взята из книги Роберта Мартина «Принципы, паттерны и методики гибкой разработки на языке C#» речь шла о паттернах проектирования, но это справедливо для любого языка, методологии или инструмента.
(**) Вот некоторые примеры нарушения канонов ООП разработчиками языка C#: 1) цикл foreach не требует наличия у класса определенного интерфейса (в частности IEnumerable), достаточно чтобы объект содержал метод GetEnumerator. 2) для нормальной работы инициализаторов коллекций (Collection Initializer) достаточно, чтобы объект коллекции содержал метод Add и реализовывал интерфейс IEnumerable, при этом никакого интерфейса, содержащего метод Add, опять же, не требуется. И это не единственные примеры, когда команда разработчиков языка C# пожертвовала канонами ООП ради простоты использования или эффективности.
(***) Не говоря уже о том, что я и методом Sort класса List<T> пользуюсь-то не особенно, ввиду наличия такой замечательной штуки, как LINQ.
По-моему здесь просто напрашивается передачу делегата заменить на событие "CommandFinished".
ОтветитьУдалитьВ итоге обработчиков события может быть несколько и выглядит это все более красиво.
Событие означает, что обработчик события знает, что с ним делать. Совсем не факт, что это так. Здесь просто команда разбита на две составляющие при этом сами действия команды и действия, выполняемые по завершею команды являются логикой одного уровня абстракции. Таким образом, разнесение подобной логики на совершенно разные уровни, скорее всего, ни к чему хорошему не приведет.
ОтветитьУдалитьВ данном случае очень важно, что по-сути, этот делегат не является внешним по отношению иерархии наследования. Вся информация, необходимая для его создании уже находится внутри этой иерархии. Именно это позволяет рассматривать эти два варианта как равнозначные.
у вашего sealed класса protected методы - не красиво :)
ОтветитьУдалитьсо статьей согласен. всяческие концепции и приемы - это возможности а не ограничения.
В смысле какие методы не красиво? Это же переопределенные методы базового класса.
ОтветитьУдалитьЕще можно воспользоваться паттерном "Декоратор".
ОтветитьУдалитьНапример, так:
// Интерфейс команды
public interface ICommand
{
````void Execute();
}
// Конкретная команда, реализует основную бизнес-логику команды
class BusinessLogicCommand1 : ICommand
{
````public BusinessLogicCommand1()
````{
````}
````public void Execute()
````{
````````// Некоторые действия команды
````````Console.WriteLine("BusinessLogicCommand1.Execute");
````}
}
// Базовый класс полной команды
class CompleteCommand : ICommand
{
````ICommand command;
````public CompleteCommand(ICommand command)
````{
````````this.command = command;
````}
````public void Execute()
````{
````````command.Execute();
````````completeAction();
````}
````protected virtual void completeAction()
````{
````````// Ничего не делаем
````}
}
// Конкретная команда, дополняет основную бизнес-логику завершающим действием
sealed class ConcreteCommand1 : CompleteCommand
{
````protected override void completeAction()
````{
````````// Конкретное завершающее действие
````````Console.WriteLine("CompleteAction1");
````}
}
Можно, но это декоратор используется, когда мы заранее не знаем, какое количество дополнительных действий нам нужно выполнить. Когда же количество действий строго фиксировано и их порядок строго определен используется именно "Метод Шаблона".
ОтветитьУдалитьЕсть несколько замечаний ;-) (все имхо конечно)
ОтветитьУдалить- рассматривается 2 в некотором роде разных варианта реализации, а базовые классы в обоих случаях одинаковые, что добавляет совершенно ненужный уровень в иерархии классов
- во 2-ом варианте зачем-то создана ConcreteCommand1 и затем ConcreteSubCommand1 и ConcreteSubCommand2, хотя опять же ConcreteCommand1, имхо конечно, лишнее увеличение глубины иерархии
Ну и по мелочи:
- как по мне "метод шаблона" очевиден, но это субъективно
- аргумент про то, что использование делегата как-то там "загрязняет" ООП подход уж простите, но в данном случае не аргумент, а...ну я даже озвучивать не буду