Введение
Идея
управления доступом к классу путем использования наследования и внутренних
классов пришла ко мне во время работы над одним из проектов. Вполне возможно,
что данная идиома уже открыта и используется, однако мне об этом пока ничего не
известно. Я буду очень признателен за любую информацию, связанную с данной
идиомой. (см. 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 не имеет аргументов, то отсутствует необходимость вызывать его явно из конструкторов привилегированных классов (что важно при виртуальном наследовании).
В более
простых случаях, когда описанная выше ситуация исключена (например, для
«листовых» классов), использование не виртуального наследования может оказаться
более предпочтительным, поскольку позволяет исключить дополнительные накладные
расходы виртуального наследования;
- использования
открытого (наследование интерфейса) и закрытого (наследование реализации)
наследования от класса 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. Пример кода:
Преимущества решения:
- не усложняет код.
Недостатки решения:
- при появлении нового класса, который должен иметь возможность выполнять команды, придется изменять интерфейсный класс ICommand. Если ICommand уже используют клиенты, то придется им поставить новую версию этого класса и заново откомпилировать весь зависимый от ICommand код (существует множество публикаций на тему: почему интерфейсы изменять нежелательно);
- каждый новый друг ICommand имеет неограниченный доступ ко всем приватным данным ICommand. Разработчик вынужден каждый раз самостоятельно отслеживать какие из функций ICommand может вызывать новый друг, а какие нет. И хотя в рассматриваемом примере такой проблемы нет, однако, в более сложном случае (например, когда ICommand имеет данные-члены) нарушение инкапсуляции ICommand может свести на нет все преимущества ООП.
2. Разделение интерфейса ICommand. Пример кода:
Преимущества решения:
- не обнаружены (для рассматриваемого примера).
Недостатки решения:
- команду MacroCommand может выполнить кто угодно, поскольку функции Execute/Unexecute публичные. Одним из таких объектов может быть создатель MacroCommand. Даже объекты, обладающие только интерфейсом ICommand, могут привести его (dynamic_cast) к IExecutableCommand и выполнить команду; - разделение интерфейса ICommand в рассматриваемом случае не оправдано.
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;
friend class CommandAttorneyClient;
public:
virtual ~ICommand() {}
// ...
private:
virtual void Execute()
= 0;
virtual void
Unexecute() = 0;
};
Класс доверенного клиента:
class CommandAttorneyClient {
friend class UndoCommandManager;
friend class MacroCommand;
//...
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 можно спрятать от клиентов.
Станислав, можете показать пример кода, где станет ясно, что разделение интерфейсов и friend не очень подходящие решения?
ОтветитьУдалитьОк. Привёл примеры в разделе "Типичные примеры решения задачи".
Удалитьчем то напоминает Заместителя
ОтветитьУдалитьПрежде чем начинать сравнение паттернов необходимо определиться с терминологией. Само понятие "паттерн", как собственно и паттерн Заместитель, хорошо раскрыты в классической книге банды четырех (Гамма, Хелм и др.) "Паттерны проектирования". Из этой работы мы знаем, что каждый паттерн характеризуется не только названием, но и задачей (назначением), решением и результатами. Сравнивая паттерн Заместитель и идиому Accessor можно отметить их различия в каждом из этих пунктов. Структура паттернов разная. Но самое главное - у них разное назначение (а, следовательно, это разные паттерны по классическому определению).
УдалитьНазначение паттерна Заместитель (защищающий): во время ВЫПОЛНЕНИЯ проверять имеет ли вызывающий ОБЪЕКТ необходимые для выполнения права (при выполнении программы мы ВЫЗЫВАЕМ конкретную функцию объекта она может выполниться, а может и нет).
Назначение идиомы Accessor: на этапе РАЗРАБОТКИ описывать КЛАССЫ таким образом, чтобы только привилегированные классы могли иметь доступ к привилегированным функциям (мы ничего НЕ вызываем, а если попробовали бы сделать неправильный вызов, то код не откомпилировался бы, то есть мы не можем сделать неправильный вызов).
Это абсолютно разные задачи, имеющие абсолютно разное решение и результаты.
Почему ты считаешь, что ICommand - это интерфейс?
ОтветитьУдалитьТы прав - это скорее интерфейсный класс. В данном случае интерфейс не более чем слэнг.
Удалить