четверг, 28 апреля 2011 г.

Дизайн ревью

Совсем недавно один из читателей моего блога спросил моего мнения о применении приемов функционального программирования в таких изначально объектно-ориентированных языках как 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.

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

  1. По-моему здесь просто напрашивается передачу делегата заменить на событие "CommandFinished".

    В итоге обработчиков события может быть несколько и выглядит это все более красиво.

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

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

    ОтветитьУдалить
  3. у вашего sealed класса protected методы - не красиво :)

    со статьей согласен. всяческие концепции и приемы - это возможности а не ограничения.

    ОтветитьУдалить
  4. В смысле какие методы не красиво? Это же переопределенные методы базового класса.

    ОтветитьУдалить
  5. Еще можно воспользоваться паттерном "Декоратор".

    Например, так:

    // Интерфейс команды
    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");
    ````}
    }

    ОтветитьУдалить
  6. Можно, но это декоратор используется, когда мы заранее не знаем, какое количество дополнительных действий нам нужно выполнить. Когда же количество действий строго фиксировано и их порядок строго определен используется именно "Метод Шаблона".

    ОтветитьУдалить
  7. Есть несколько замечаний ;-) (все имхо конечно)

    - рассматривается 2 в некотором роде разных варианта реализации, а базовые классы в обоих случаях одинаковые, что добавляет совершенно ненужный уровень в иерархии классов

    - во 2-ом варианте зачем-то создана ConcreteCommand1 и затем ConcreteSubCommand1 и ConcreteSubCommand2, хотя опять же ConcreteCommand1, имхо конечно, лишнее увеличение глубины иерархии

    Ну и по мелочи:
    - как по мне "метод шаблона" очевиден, но это субъективно
    - аргумент про то, что использование делегата как-то там "загрязняет" ООП подход уж простите, но в данном случае не аргумент, а...ну я даже озвучивать не буду

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