воскресенье, 4 марта 2012 г.

Идиома Accessor (C++)

Введение

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

Название и классификация

Акцессор – идиома.

Назначение

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

 
Мотивация

Задача ограничения прав доступа к некоторым частям интерфейсов классов встречается достаточно часто при проектировании программ. Для поддержки данной функциональности язык С++ предоставляет такие средства как ключевое слово friend и вложенные классы. Ограничить права доступа также можно посредством разделения интерфейса класса, передавая клиентам только необходимый им интерфейс.
Проблема с friend и вложенными кассами при их непосредственном использовании заключается в том, что каждый раз при добавлении в систему клиентов, которым нужен привилегированный доступ к классу, необходимо изменять его интерфейс (добавляя новых "друзей", либо делая этих клиентов вложенными). Другая проблема данного подхода заключается в том, что клиентам открывается полный доступ ко всему интерфейсу и данным этого класса.
Реализация с разделением интерфейсов также имеет недостатки. Во-первых, она не всегда работает так, как требуется. Например, клиент, создающий экземпляр класса, имеет доступ ко всем его интерфейсам и может их использовать, что в ряде случаев нежелательно. Во-вторых, тот факт, что клиент имеет доступ только к одному из интерфейсов класса, еще не говорит ему, что он не должен иметь доступа к другим интерфейсам данного класса.
Таким образом, мы хотели бы иметь возможность выбирать, какие клиенты должны иметь доступ к привилегированным функциям класса, но при этом избежать изменения его интерфейса при добавлении новых привилегированных клиентов. Это и позволяет реализовать идиома Акцессор.

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

Используйте идиому Акцессор, когда:
- возникает необходимость предоставить клиентам разные права доступа к разным частям интерфейса некоторого класса;
- велика вероятность появления новых клиентов, которым необходим доступ к привилегированным функциям класса.

Структура

 
 
Участники

1) Subject – субъект:
- содержит привилегированные функции, доступ к которым должны иметь только привилегированные клиенты;
- содержит встроенный класс акцессора, либо объявляет другом ("friend") известный класс акцессора для предоставления ему доступа к привилегированным функциям.
2) Accessor – акцессор:
- защищает себя от непосредственного создания клиентами;
- содержит защищенные функции, обеспечивающие вызов соответствующих привилегированных функций Subject.
3) PriviledgedClient – привилегированный клиент:
- наследует Accessor для обеспечения доступа к привилегированным функциям Subject через функции Accessor;
- вызывает привилегированные функции Subject, путем обращения к соответствующим функциям Accessor.
 
Отношения

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

Результаты

Идиома Акцессор имеет следующие достоинства:
- позволяет без изменения интерфейса класса добавлять в систему клиентов, имеющих доступ к его привилегированным функциям;
- позволяет ограничить доступ привилегированным клиентам к приватным свойствам/функциям класса;
- исключает необходимость разделять интерфейс класса только для обеспечения различного уровня доступа к его частям.
Идиома Акцессор имеет следующие недостатки:
- требует, чтобы привилегированные клиенты были наследниками класса Accessor, что затрудняет возможность использования данной идиомы с классами, интерфейс которых не должен (не может) изменяться;
- может привести к увеличению размера объектов привилегированных клиентов (например, в С++ привилегированному клиенту часто необходимо виртуально наследоваться от класса акцессора, что при использовании ряда компиляторов может привести к увеличению размера объектов клиента на величину дополнительного указателя).


Реализация

При использовании идиомы акцессора надо рассмотреть следующие вопросы:
- обеспечение невозможности создания отдельного экземпляра класса Accessor. В случае, когда экземпляр класса Accessor можно создать напрямую (без необходимости наследования от него) любой класс в системе сможет получить доступ к привилегированным функциям Subject, создав экземпляр класса Accessor и вызвав его соответствующие открытые функции. Но даже если сделать данные функции класса Accessor закрытыми остается неясным вопрос: зачем создавать экземпляр класса, ни одну из функций которого нельзя вызвать? Делая конструктор и деструктор класса Accessor защищенными, мы явно указываем, что этот класс предназначен только для наследования;
- класс Subject может иметь несколько акцессоров, где каждый акцессор обеспечивает доступ к подмножеству привилегированных функций Subject. Таким образом можно обеспечить многоуровневый доступ к Subject. Однако при каждом добавлении нового акцессора приходится изменять интерфейс Subject.
Отметим также, что класс Subject может содержать данные-члены.
- использование виртуального наследования от класса Accessor. Класс Accessor не должен содержать данных-членов. Единственная его обязанность – перенаправлять вызовы соответствующим функциям класса Subject. В этом плане он подобен интерфейсу за исключением того факта, что его функции не объявляются виртуальными, поскольку содержат единую реализацию для всех классов-потомков. Соответственно, подобно интерфейсам, следует рассмотреть возможность виртуально наследовать класс от Accessor, чтобы исключить коллизии имен, возникающие в случае, когда привилегированный класс кроме наследования от Accessor, наследуется также от некоторого другого класса, который, в свою очередь, тоже наследуется от Accessor
Поскольку конструктор Accessor не имеет аргументов, то отсутствует необходимость вызывать его явно из конструкторов привилегированных классов (что важно при виртуальном наследовании).
В более простых случаях, когда описанная выше ситуация исключена (например, для «листовых» классов), использование не виртуального наследования может оказаться более предпочтительным, поскольку позволяет исключить дополнительные накладные расходы виртуального наследования;
- использования открытого (наследование интерфейса) и закрытого (наследование реализации) наследования от класса Accessor. Это вопрос выбора разработчика привилегированного класса. При использовании открытого наследования все потомки привилегированного класса автоматически становятся привилегированными. Закрытое наследование обеспечивает доступ к Subject на уровне отдельных классов, вне зависимости от их места в иерархии.
При закрытом виртуальном наследовании Accessor в С++ следует обратить внимание на тот факт, что в списке наследования класс Accessor должен предшествовать любым другим классам, которые закрыто наследуют Accessor. В противном случае, унаследованные имена protected-функций Accessor будут перекрыты соответствующими унаследованными private-именами;
- функции класса Accessor являются хорошими кандидатами на встраивание (inline), поскольку всё, что они выполняют это перенаправление вызова соответствующей функции Subject. Объявление функций Accessor как встраиваемых при не виртуальном наследовании  Accessor во многих случаях позволяет исключить практически все дополнительные издержки производительности, налагаемые идиомой;
- Accessor можно реализовать в С++ либо как друга, либо как встроенный класс Subject – это вопрос выбора разработчика системы. Однако нам кажется, что семантически вложенный класс более наглядно отражает отношения объектов системы;
- имена функций Accessor могут быть скрыты именами членов привилегированного класса, производного от Accessor. Для устранения данного эффекта можно использовать using-объявления, а также следить за именованием функций Accessor (выбирать имена функций Accessor таким образом, чтобы уменьшить вероятность совпадения имен в привилегированных классах);
- систему управляемого доступа к Subject легко разрушить. Для этого привилегированному классу достаточно самому выступить в роли Accessor, предоставив своим клиентам публичные функции доступа к привилегированным функциям Subject. Задача разработчиков системы не допускать подобного.

Пример кода

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

Интерфейсный класс команды:

class ICommand {
public:
       class Executor; // Execute-Unexecute Accessor
       virtual ~ICommand() {}
// ...
private:
       virtual void Execute() = 0;
       virtual void Unexecute() = 0;
};

Встроенный класс акцессора:

class ICommand::Executor {
protected:
       Executor() {}
       ~Executor() {}

       inline void ExecuteCommand( ICommand *command) {
             command->Execute();
       }
       inline void UnexecuteCommand(ICommand *command) {
             command->Unexecute();
       }
};

Теперь при добавлении в систему классов, которые должны иметь возможность запуска команд, достаточно сделать эти классы производными от ICommand::Executor.
 Класс менеджера команд с поддержкой Undo/Redo:

class UndoCommandManager: private virtual ICommand::Executor {
public:
       void Execute( ICommand *command) {
//...
             ExecuteCommand( command); // Выполнить команду через Accessor
//добавить команду в список Undo/Redo
}
       void Undo() {
             //...
             UnexecuteCommand( lastCommand);
//...
}
       void Redo(); // Выполнить последнюю отмененную команду
//...
private:
//...
};

Добавим теперь к системе класс макрокоманды. Обратите внимание, что в приведенном ниже примере MacroCommand является ICommand (Subject), но при этом назначается также и исполнителем команд (ICommand::Executor). Таким образом, макрокоманду может выполнить/отменить только менеджер команд, однако только макрокоманда ответственна за выполнение/отмену команд, содержащихся в ней:

class MacroCommand: public virtual ICommand, public virtual ICommand::Executor {
public:
       virtual ~MacroCommand();
       void Add( ICommand *command);
       void Remove( ICommand *command);
//...
private:
       virtual void Execute() {
             //для всех команд {
                    ExecuteCommand( command); // Выполнить команду через Accessor
//}
}
       virtual void Unexecute() {
//для всех команд {
                    UnexecuteCommand( command); // Отменить команду через Accessor
//}
}
//...
};


Типичные примеры решения задачи

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


1. Использование ключевого слова friend. Пример кода:

class ICommand {
       friend class UndoCommandManager;
       friend class MacroCommand;
public:
       virtual ~ICommand() {}
// ...
private:
       virtual void Execute() = 0;
       virtual void Unexecute() = 0;
};

class UndoCommandManager {
public:
       void Execute( ICommand *command) {
             //...
             command->Execute();
             //добавить команду в список Undo/Redo
       }
       void Undo() {
             //...
             lastCommand->Unexecute();
             //...
       }
       void Redo(); // Выполнить последнюю отмененную команду
//...
private:
//...
};

class MacroCommand: public virtual ICommand {
public:
       virtual ~MacroCommand();
       void Add( ICommand *command);
       void Remove( ICommand *command);
//...
private:
       virtual void Execute() {
             //для всех команд {
                    command->Execute();
             //}
       }
       virtual void Unexecute() {
             //для всех команд {
                    lastCommand->Unexecute();
             //}
       }
//...
};

Преимущества решения:
- не усложняет код.
Недостатки решения:
- при появлении нового класса, который должен иметь возможность выполнять команды, придется изменять интерфейсный класс ICommand. Если ICommand уже используют клиенты, то придется им поставить новую версию этого класса и заново откомпилировать весь зависимый от ICommand код (существует множество публикаций на тему: почему интерфейсы изменять нежелательно);
- каждый новый друг ICommand имеет неограниченный доступ ко всем приватным данным ICommand. Разработчик вынужден каждый раз самостоятельно отслеживать какие из функций ICommand может вызывать новый друг, а какие нет. И хотя в рассматриваемом примере такой проблемы нет, однако, в более сложном случае (например, когда ICommand имеет данные-члены) нарушение инкапсуляции ICommand может свести на нет все преимущества ООП.


2. Разделение интерфейса ICommand. Пример кода:


class ICommand {
public:
       virtual ~ICommand() {}
// ...
};

class IExecutableCommand: public virtual ICommand {
public:
       virtual void Execute() = 0;
       virtual void Unexecute() = 0;
};

class UndoCommandManager {
public:
       void Execute( IExecutableCommand *command) {
             //...
             command->Execute();
             //добавить команду в список Undo/Redo
       }
       void Undo() {
             //...
             lastCommand->Unexecute();
             //...
       }
       void Redo(); // Выполнить последнюю отмененную команду
//...
private:
//...
};

class MacroCommand: public virtual IExecutableCommand {
public:
       virtual ~MacroCommand();
       void Add( IExecutableCommand *command);
       void Remove( IExecutableCommand *command);
//...
       virtual void Execute() {
             //для всех команд {
                    command->Execute();
             //}
       }
       virtual void Unexecute() {
             //для всех команд {
                    lastCommand->Unexecute();
             //}
       }
//...
};

Преимущества решения:
- не обнаружены (для рассматриваемого примера).
Недостатки решения:
- команду MacroCommand может выполнить кто угодно, поскольку функции Execute/Unexecute публичные. Одним из таких объектов может быть создатель MacroCommand. Даже объекты, обладающие только интерфейсом ICommand, могут привести его (dynamic_cast) к IExecutableCommand и выполнить команду; - разделение интерфейса ICommand в рассматриваемом случае не оправдано.

UPD

Спустя почти полгода после публикации статьи мне удалось найти идиому с тем же назначением, что и у Акцессора. Название этой идиомы - "Доверенный клиент". Однако Доверенный клиент имеет другую реализацию и результаты. С использованием идиомы Доверенного клиента приведенный выше пример может быть переписан так:
Интерфейсный класс команды:

class ICommand {
              friend class CommandAttorneyClient;
public:
       virtual ~ICommand() {}
// ...
private:
       virtual void Execute() = 0;
       virtual void Unexecute() = 0;
};

Класс доверенного клиента:

class CommandAttorneyClient {
              friend class UndoCommandManager;
              friend class MacroCommand;
              //...
private:
       static void Execute( ICommand *command) {
             command->Execute();
       }
       static void Unexecute(ICommand *command) {
             command->Unexecute();
       }
};

 Класс менеджера команд с поддержкой Undo/Redo:

       class UndoCommandManager {
       public:
             void Execute( ICommand *command) {
      //...
                   CommandAttorneyClient::Execute( command);
      //добавить команду в список Undo/Redo
      }
             void Undo() {
                   //...
                   CommandAttorneyClient::Unexecute( lastCommand);
      //...
      }
             void Redo(); // Выполнить последнюю отмененную команду
      //...
      private:
      //...
      };

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

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

  1. Станислав, можете показать пример кода, где станет ясно, что разделение интерфейсов и friend не очень подходящие решения?

    ОтветитьУдалить
    Ответы
    1. Ок. Привёл примеры в разделе "Типичные примеры решения задачи".

      Удалить
  2. Ответы
    1. Прежде чем начинать сравнение паттернов необходимо определиться с терминологией. Само понятие "паттерн", как собственно и паттерн Заместитель, хорошо раскрыты в классической книге банды четырех (Гамма, Хелм и др.) "Паттерны проектирования". Из этой работы мы знаем, что каждый паттерн характеризуется не только названием, но и задачей (назначением), решением и результатами. Сравнивая паттерн Заместитель и идиому Accessor можно отметить их различия в каждом из этих пунктов. Структура паттернов разная. Но самое главное - у них разное назначение (а, следовательно, это разные паттерны по классическому определению).
      Назначение паттерна Заместитель (защищающий): во время ВЫПОЛНЕНИЯ проверять имеет ли вызывающий ОБЪЕКТ необходимые для выполнения права (при выполнении программы мы ВЫЗЫВАЕМ конкретную функцию объекта она может выполниться, а может и нет).
      Назначение идиомы Accessor: на этапе РАЗРАБОТКИ описывать КЛАССЫ таким образом, чтобы только привилегированные классы могли иметь доступ к привилегированным функциям (мы ничего НЕ вызываем, а если попробовали бы сделать неправильный вызов, то код не откомпилировался бы, то есть мы не можем сделать неправильный вызов).
      Это абсолютно разные задачи, имеющие абсолютно разное решение и результаты.

      Удалить
  3. Почему ты считаешь, что ICommand - это интерфейс?

    ОтветитьУдалить
    Ответы
    1. Ты прав - это скорее интерфейсный класс. В данном случае интерфейс не более чем слэнг.

      Удалить