[Из песочницы] Variadic Templates, Low Coupling и немного размышлений
Каждый программист, наверняка, сталкивался с ситуацией, когда в приложении имеется набор классов (возможно, сервисных), которые используются во многих участках программы. И вроде бы всё ничего, но как только появлялась необходимость менять эти классы, это могло негативно влиять и на вызывающий код. Да, как и указано в заголовке, речь в статье пойдет о том самом паттерне «Low Coupling».
Проблема не нова и давно известна. Путей ее решения может быть несколько, все зависит от предметной области. Я предлагаю читателю возможное решение, которое я нашел, занимаясь прикладной задачей. Как идеалиста, найденное решение меня устроило не полностью. Так же, оно было спроектировано в бОльшей степени от желания воспользоваться новыми возможностями стандарта C++11. Естественно, все написанное подлежит обсуждению, а возможно, кто-то предложит более стройный вариант.
Формулировка задачиВ системе имеются различные устройства. Например, температурные датчики, датчики скорости, сигнализация, светофор и прочие. Как читатель и предполагает, все эти устройства в программе представлены наследниками от базового класса. Разумеется, у каждого наследника свои специфические функции-члены.
-Все как обычно. В чем проблема-то?
Проблема заключается в том, что систем несколько. В каждой такой системе устройства могут различаться как по составу, так и по функциональности (расширять или наоборот, сужать). Скажем, в одной системе нет светофора, а в другой, все как и в первой, но сигнализация имеет несколько режимов срабатываний. Возможный случай я привел на диаграмме.
Видно, что конкретная реализация SyncBoard для первой системы, только переопределяет метод запроса скорости, тогда как для второй системы еще добавляются новые методы. Таким образом, вызывающему необходимо знание о конкретном типе каждого устройства, с которым он работает. Но вызывающий не меняется от системы к системе. Его основная логика неизменна, меняются только способы взаимодействия с устройствами.
-Разумеется, если вызывающему необходимо задействовать новый функционал устройства, то он просто обязан знать его конкретный тип.
Но давайте посмотрим на это с другой стороны. Имеется некий модуль/класс, который в одной системе использует тип SyncBoard_System1, а во второй, соответственно, SyncBoard_System2. Как я сказал выше, данный модуль/класс не меняет своей логики в этих двух системах. Меняется только взаимодействие с устройством. Какие есть варианты?1) Скопировать данный модуль и использовать новый способ взаимодействия. В разных системах использовать разные копии модулей. -Спасибо, посмеялся.
2) Вынести взаимодействие с устройством в отдельную виртуальную функцию. В наследниках переопределять взаимодействие с устройством. -Уже лучше. Только ты на каждое устройство, с которым взаимодействует класс будешь делать по виртуальной функции? С новыми устройствами должны будут появляться новые виртуальные функции в заинтересованных классах.
3) Инкапсулировать взаимодействие с устройством в отдельный класс и использовать его как стратегию. -Звучит как в умных книжках. Но ведь для нового функционала устройства, стратегия тоже должна его реализовать. То есть интерфейс стратегии будет меняться, и тогда все равно понадобиться знать конкретную реализацию (тип) стратегии.
Конкретика не ушла, но количество связей уменьшено. Вместо того, чтобы каждый вызывающий модуль был завязан на конкретную реализацию устройства (которых множество), можно определить менеджера (стратегию), который будет делегировать вызовы этим устройствам. -Очень похоже на Mediator или Facade.
Только внешне. Общее у этих паттернов и моей задачей то, что вместо множества связей, создается одна единственная связь с «менеджером». Пока что остановимся на том решении, что необходим класс, который скрывает в себе все конкретные реализации устройств и предоставляет интерфейс клиентам для взаимодействия с ними.Поиск варианта реализации Вариант 1 — в лоб. Менеджер хранит в себе список всех устройств и предоставляет соответствующий интерфейс.
-А если у тебя в системе более одного температурного датчика? Функция getTemperature () какую вернет?
Справедливое замечание. Получается необходимо в каждую такую функцию еще добавить параметр — идентификатора устройства. Поправим.
Вариант этот мне сразу пришел в голову, т.к. самый простой. И это, пожалуй, его последний плюс. Давайте представим, что нас ждет при добавлении нового устройства. Помимо создания самого класса нового устройства, мы должны будем отобразить все его функции и в менеджере (и делегировать их, естественно). А если это устройство вдруг добавляется сразу во все системы, то еще и добавить все эти функции во все конкретные реализации менеджеров всех систем. «Копи-паст» в чистом виде.
-Не красиво, но не смертельно.
Другой сценарий. Две системы абсолютно одинаковы. Отличаются только отсутствием одного единственного устройства. В таком случае, мы уже не можем использовать написанный класс менеджера, т.к. в нем присутствуют функции, которые не нужны. Ну, точнее можем, но есть мнение, что это неправильно. Остается либо наследоваться и заменять пустой реализацией эти методы, либо копировать класс и удалять лишнее. Ни то ни другое меня не устроило.Вариант 2 — куча case«ов. А что, если мы сделаем у менеджера один единственный виртуальный метод, внутри которого будет происходить делегирование? -Ты про разные параметры функций и разные возвращаемые значения не забыл?
Для этого мы создадим базовый класс данных, через который вызывающий код будет передавать и получать параметры.
Пример реализации class IDevData { public: IDevData (); virtual ~IDevData ();
virtual int getID () = 0; };
class DevManager_v2: public IDeviceManager { public: bool initialize () { // очень полезная инициализация }
// супер метод virtual void callMethod (int dev_id, IDevData const& data) { switch (data.getID ()) { case DATA_SYNC_BOARD_GET_STATE: // вызываем у устройтсва с идентификатором dev_id метод getState (); // результат складываем обратно в IDevData break; case DATA_SYNC_BOARD_GET_VELOCITY: // … break; // … etc } } }; Чего мы добились? У нас теперь только один виртуальный метод. Все изменения происходят только в нем, при добавлении/удалении устройств от системы к системе исчезают автоматом ненужные функции. Вызывающим классам вообще не нужно знать конкретных реализаций менеджера! Им нужно только знать метод callMethod и … -Да, да! И конкретный тип IDevData для каждого вызова. Если ты бежал раньше от связывания с конкретными реализациями устройств, то пришел к связыванию к конкретным реализациям оберток IDevData. Забавненько.
Какой-то замкнутый круг. Приехали к тому же, с чего начали. Перед тем как вызвать один единственный метод у менеджера, вызывающему нужно будет точно знать какой конкретно тип IDevData создавать. И чем это отличается от ситуации, когда вызывающий знал конкретный тип устройств? Да ничем! Вариант 3 — C++11 Идея единственной функции callMethod () мне понравилась. Но проблема с передачей и возвращением параметров сводили все старания на нет. Было бы замечательно, если мы смогли бы передавать в эту единственную функцию любые параметры в любом количестве и могли бы получать из нее же любой тип возвращаемого значения… -Да все уже поняли, что ты говоришь о шаблонах и C++11. Давай, рассказывай, как космические корабли бороздят…©
Новый стандарт как раз предоставляет такие возможности. Стало ясно, что функция callMethod должна быть шаблонной и иметь следующий прототип:
template
Действительно, это создает проблему. Есть два варианта ее решения. Добавить еще один параметр — int method_id, что мне совсем не нравится, либо придать иной смысл параметру — int dev_id. Назовем его, скажем, command_id, а означать он будет теперь — конкретный метод конкретного класса. То есть некий идентификатор пары Класс→метод. Таким образом значений этих command_id будет ровно столько же, сколько методов у всех классов устройств. По хорошему, конечно, это нужно превратить в перечисление, но не будем заостряться на этом. Теперь по поводу «куда обращаться с command_id» и «кому передавать Args&&». Подсказку нам дает уже сам параметр command_id. Предполагается некая коллекция методов, обращение к которой идет по command_id. Иными словами, необходима следующая схема:1) Создать хранилище для любой функций с любой сигнатурой2) В callMethod изымать из хранилища по ключу command_id нужный объект и передавать все параметры3) Profit! -Спасибо, КЭП.
Пункт 1 уже решен до меня. В частности, на хабре так же была статья про type erasure. Прочитал и немного видоизменил под свои нужды. Спасибо rpz и прочие источники.Для тех, кому лень или некогда перечитывать, вкратце расскажу, как это работает. Прежде всего, нам необходим базовый класс с одной полезной виртуальной функцией для проверки типов.
class base_impl { public: virtual std: type_info const& getTypeInfo () const = 0; }; Далее, создаем наследника — шаблонный класс. В него можно передать любую функцию. Чтобы не создавать разные шаблоны для разных функций (метод класса, или простая функция) я решил использовать уже готовую std: function. Все, что требуется от этого класса — перегрузить оператор operator (), в который и передаются параметры для делегирования вызова.
Шаблон type_impl
template
template
type_impl
// в этом классе хранится секрет фокуса.
// объявляем шаблонный класс — наследник от base_impl.
// теперь чем бы мы не параметризировали type_impl, его ссылку
// всегда можно присвоить указателю base_impl
// параметризуется шаблон возвращаемым значением функции — ResType
// и аргументами функции — Args…
template
// та самая функция предка, которая выполняет проверку типов. // с ее помощью, мы гарантируем корректное приведение типов. // ну или exception. std: type_info const& getTypeInfo () const { return typeid (_Fn); }
// ну, а тут, очевидно, идет делегирование вызова сохраненному колбеку.
ResType operator ()(Args&& … args)
{
return _func (std: forward
std: unique_ptr
// шаблонный метод, через который получаем доступ к сохраненной функции
template
// предположим, что класс test, какое-то устройство class test { public: test () {} int fn1(int a) { cout << "test::fn1!!! " << a << endl; return ++a; }
int fn2(int a, int b) { cout << "test::fn2!!! " << a << endl; return a + 2; } int fn3(int a, int b) { cout << "test::fn3!!! " << a << endl; return a + 3; } };
class IDeviceManager
{
protected:
std: map
template
const int FN1_ID = 0; const int FN2_ID = 1; const int FN3_ID = 2;
class DevManager_v3: public IDeviceManager
{
std: unique_ptr
std: function
// складываем в коллекцию все методы m_funcs[FN1_ID] = new FuncWrapper (_func1); m_funcs[FN2_ID] = new FuncWrapper (_func2); m_funcs[FN3_ID] = new FuncWrapper (_func3); }
~DevManager_v3() { // тут подчищаем коллекцию } };
int _tmain (int argc, _TCHAR* argv[]) { DevManager_v3 dev_manager; dev_manager.initialize ();
// Вуая-ля! К любому методу можно обратится через его идентификатор.
dev_manager.callMethod
getchar (); } Ого! У нас теперь есть один единственный виртуальный метод initialize (), который создает все необходимые устройства для данной системы и помещает их методы в коллекцию. Вызывающему даже не нужно знать конкретный тип менеджера. Шаблонный метод callMethod () все сделает за нас. Для каждой конкретной системы, создается нужный экземпляр IDevManager с помощью, скажем, <фабрики>. Вызывающему нужно иметь только указатель на предка IDevManager. -Кажется, ты достиг своей цели.
Да, но появляются новые недостатки и они, пожалуй, имеют более весомые негативные последствия, по сравнению с первыми вариантами. Код становится не безопасным! Во-первых, посмотрите внимательно на callMethod (). Если мы передадим ключ, которого нет в коллекции, то получим исключение. Само собой, необходимо сначала проверять, имеется ли данный ключ в коллекции. Но что делать, когда выяснилось, что ключа не существует (запрошен не существующий метод)? Генерировать исключение? И самое главное, что на этапе компиляции мы этого отловить не сможем. Такое может произойти, когда в какой-то системе отсутствует устройство, или часть его методов.Во-вторых, редактор кода не подскажет какие параметры ожидаются на входе callMethod () — не высветит название/тип/количество параметров. Если мы передадим не тот тип параметра, или неверное количество параметров, нас ждет опять исключение, но уже в методе call () класса test_impl. И снова, мы не сможем это отловить на этапе компиляции. Такое может легко произойти по невнимательности программиста.Применительно к поставленной задаче, меня это не устроило, по следующим причинам: — На этапе компиляции всегда известно точное количество классов (соответственно методов), к которым нужен доступ.— Эти классы меняются только при проектировании разных систем.Поэтому пришлось начать с нуля.
— «Шо, опять?!» ©
Вариант 4 — конечный?
К нему я пришел, увидев весьма незатейливую конструкцию:
template
Значит нужно каким-то образом сделать методы уникальными, даже если у них одинаковая сигнатура. И я знал, где есть подсказка. Спасибо, Андрей! Тут все просто
template
template
template
template
int fn2(int a, int b) { cout << "test::fn2!!! " << a << endl; return a + 2; } int fn3(int a, int b) { cout << "test::fn3!!! " << a << endl; return a + 3; } };
int _tmain (int argc, _TCHAR* argv[])
{
test t;
std: function
ClassifiedWrapper
cw1(ValueToType
cw1(ValueToType
В итоге у нас есть класс, который позволяет обернуть любую функцию и сделать его уникальным. Помните первоначальную проблему, с чего начался этот вариант? Теперь можно применить тот же фокус с множественным наследованием, но наследоваться уже от CalssifiedWrapper.Сначала объявим заготовку:
template
// нельзя создать с конструктором по умолчанию
Dissembler () = delete;
virtual ~Dissembler () {};
// основной метод, который и делает всю грязную работу.
template
Идея в основе проста — множественное наследование. Но как только мы встречаем ранее указанные проблемы (два одинаковых класса в цепочке наследования или ромбовидное наследование), то все перестает работать. Для этого мы заводим класс (ClassifiedWrapper), который может как бы «приписать уникальную метку» (на самом деле ничего он не приписывает, это я так красиво выразился) любой функции. При этом и сам ClassifiedWrapper является, естественно, уникальным (опять же, понятно, что при разных параметрах шаблона). Далее, мы просто создаем «статический список» таких уникальных функций обернутых в ClassifiedWrapper, и наследуемся от всех них. Фух, проще, наверное, не смогу объяснить. Вообще, примененный мною фокус с Variadic Template, много где описан. В частности на хабре. -А почему в замыкании рекурсии нет метода call?
Потому что нет смысла огород городить вокруг одной единственной функции. То есть если кто-то захочет использовать Dissembler не для множества функций, а для одной — то затея эта не имеет смысла. Вот как предполагается все это хозяйство использовать: Как правильно
int _tmain (int argc, _TCHAR* argv[])
{
test t;
std: function
std: function
Dissembler<
ValueToType
dis.call
getchar ();
}
Я намеренно указал для демонстрации два разных типа «идентификатора» функций — ValueToType
Почти так же, как и в варианте 3, но с маленькими отличиями.Пример
typedef Dissembler<
ValueToType
class IDeviceManager
{
protected:
std: unique_ptr
template
class DevManager_v4: public IDeviceManager
{
std: unique_ptr
m_wrapperPtr.reset (new SuperWrapper (
ValueToType
int _tmain (int argc, _TCHAR* argv[]) { DevManager_v4 v4; v4.initialize ();
v4.callMethod<1, int>(0, 1); v4.callMethod<0, int>(10, 31);
getchar (); } Определения SuperWrapper (для каждой системы свой собственный), придется вынести в отдельный заголовочный файл. И разделять каждое определение #ifdef«ами, чтоб в нужном проекте подключался «правильный» SuperWrapper. Именно по этой причине, я поставил знак вопроса при написании варианта 4. Конечный?