Дополню предыдущий комментарий. Предположим, что вам ни как нельзя иметь один абстрактный базовый класс. (Как вы и указали) Хорошо. Можно немного поэкспериментировать и сделать так:
В статье приведено описание того когда применять подход.
Его можно применять когда есть базовый класс/абстрактный базовый класс.
Используя ваше описание классов с интерфейсами, например можно сделать так:
Ситуация с оптимизациями такая, что соптимизировав в одном месте, мы можем потерять где-то в другом.
Согласен.
На счет нужных и не нужных данных, это уже зависит исключительно от конкретной задачи.
Разумеется бывает, что в класс попадает то что ему совсем не надо из за наследования, здесь уже нужно в каждом конкретном случае думать о том, а нужно ли вообще здесь наследование, зачем эти классы так связаны, как хотим укладывать данные в памяти и что вообще хотим получить.
Ну давайте во-первых разберемся, зачем вообще нужно имя name в классе.
У классов SomeObject1 и SomeObject2 нету общих полей.
1. Поле name было приведено в качестве примера общего поля для потомков класса BaseObject.
В данном случае это поле name предназначено для хранения имени конкретного объекта.
Потомки это SomeObject1 и SomeObject2, для них будет общее поле name и общая функция getName().
В-третьих, даже если вам прямо необходимо вынести общую функциональность в базовый класс,
2. Вы вынесли общее поле (name), я то же это сделал в примере который привел в предыдущем комментарии.
Но вы не вынесли здесь общую функциональность (функция getName).
Смотрите, если у вас будет N потомков от BaseObject, вам надо будет писать N раз функцию getName().
Достаточно ее один раз написать в базовом классе.
Во-вторых, не всегда то, что кажется дублированием кода, является дублированием кода.
Если вы в 2-х местах заиспользовали переменную с одинаковым именем, это еще не значит, что вам нужно бежать и искать по всему коду, не попадалось ли там этой переменной с таким же именем, и думать, как эту переменную оттуда вычистить.
3. Мы сейчас рассматриваем конкретную иерархию из трех классов: BaseObject, SomeObject1, SomeObject2.
А так же возможное расширение этой иерархии, не более.
В данном случае я не вижу никакой проблемы с дублированием кода, да и самого дублирования кода я тоже не вижу
4. Вы дублирете поле name и функцию getName() в классах SomeObject1 и SomeObject2. В коде я оставил комментарий, где собственно происходит дублирование.
Ну вот вы в этой фразе и нарушили принцип SRP
5. Вы могли бы развернуто ответить, каким образом здесь нарушается SRP?
Про производительность, вы сами в примере «Ваш случай: №2» показали, как ее можно улучшить
6. Вы согласны с утверждением что вызов (в моем случае) обычной функции проще и дешевле, чем вызов функции обертки (в вашем случае), в которой вызывается
std::visit и уже в которой вызывается собственно сама функция?
Производительность диспатчеризации сопоставима, так зачем мне пользоваться оберткой?
Примеры в статье я привел.
Поэтому нету смысла говорить об общих, не общих полях. В каждом классе поля свои, и только свои.
7. Вы в своем же примере показали, когда приводили код, что это не так. BaseObject как раз и содержит общие поля.
Что мешает разделить такой класс на 2 части — с интерфейсом и общими полями?
8. Мне не ясно зачем это делать (в данном конкретном случае). Что вам не нравится в классе BaseObject с общим полем name и общей функцией getName?
Положим появились еще класс наследник только уже от SomeObject1.
В языке С++ уже есть механизм наследования, чтобы наследник мог получить доступ к полям SomeObject1 и специфичным для SomeObject1 функциям.
В том смысле, что объекты класса SomeObject1 и SomeObject2 не могут обратиться к полям друг друга
9. К специфичным полям, которые определены в самих класса SomeObject1 и SomeObject2 — нет, к полям, которые определены в классе BaseObject — да. Ведь они для них общие.
Вы не могли бы привести полностью развернутый пример кода т.е. включая иерархию из BaseObject, SomeObject1, SomeObject2 и вашего класса обертки и с разделением класса BaseObject на 2 части?
Попытаюсь провести параллель своего подхода с вашим.
(Если в вашем случае где то ошибусь, поправьте пожалуйста или приведите развернутый пример).
И надеюсь что этот пример все прояснит.
Положим у нас имеется простая иерархия классов, где каждый класс имеет поле «имя»
и какое то дополнительное поведение (функция action) в зависимости от своего типа.
Мой случай
struct BaseObject
{
/* contructors */
//общая функция для всех потомков
std::string_view getName() const { return name; }
//строго говоря, эта функция не обязана быть чисто виртуально, мы вполне может сделать
//базовое определение
virtual void action() = 0;
//virtual void action() { /* BaseObject specific code */}
protected:
std::string name; //общее поле для всех потомков
};
struct SomeObject1 : BaseObject
{
/* contructors */
void action() override { /* SomeObject1 specific code */ }
protected:
//имеем доступ к name
};
struct SomeObject2 : BaseObject
{
/* contructors */
void action() override { /* SomeObject2 specific code */ }
protected:
//имеем доступ к name
};
template< class T >
void test( T& obj )
{
/*some code*/
}
std::vector< BaseObject* > vector = { /* some objects */ };
//Что хотел бы по пунктам:
for( auto* obj : vector )
{
//1. вызывать обычную функцию базового класса напрямую (без оберток)
std::cout « obj->getName();
//2. вызывать виртуальную функцию (без оберток)
obj->action();
//3. а вот тут нам понадобился реальный тип, чтобы вызвать шаблон функции.
//И об этом как раз статья и написана
//obj->dispatch( [](auto&) { test( obj ); } );
}
Ваш случай: №1
struct SomeObject1
{
/* contructors */
std::string_view getName() const { return name; } //ok
void action() { /* specific SomeObject1 code */ } //ok
protected:
std::string name; //ok
};
struct SomeObject2
{
/* contructors */
//дублируем код (В SomeObject1 уже есть такая функция )
std::string_view getName() const { return name; }
void action() { /* specific SomeObject2 code */ } //ok
protected:
std::string name; //дублируем код (В SomeObject1 уже есть такая функция)
};
struct Variant //Ваш класс обертка
{
/* contructors */
void getName() const { std::visit([]( auto&& obj) { return obj.getName(); }, var ) }
void action() { std::visit([]( auto&& obj) { return obj.getName(); }, var ); }
template< class Functor >
void dispatch( Functor functor )
{
/* ... */
}
private:
std::variant< Object1, Object2 > var;
};
std::vector< Variant > vector = { /* some objects */ };
//Проводим парарелль
for( auto& var : vector )
{
//1. вызываем функцию обертки, в которой вызываем std::visit и только потом вызываем getName.
std::cout « var.getName();
//2. тоже что и в 1
var.action();
//3. вытаскиваем тип тип из обертки и передали в шаблон функции
var.dispatch( [](auto&) { test( obj ); } );
}
Проблемы:
1. Если иерархия у вас будет чуть более сложной, то что вы будете делать с общими полями всех классов?
(Под более сложной я понимаю, что в дереве наследования могут быть поддеревья, корни которых тоже могут иметь общие поля только уже для поддерева.)
Как и зачем вы будете решать эту проблему?
Для этого в языке C++ уже есть необходимый механизм — наследование.
(Да бывают ситуации, когда наследование можно заменить композицией)
2. SomeObject1 и SomeObject2 ничего не знают об имени,
но ведь по смыслу как раз они должны этим именем владеть.
И к остальным общим полям SomeObject1 и SomeObject2 тоже должны иметь доступ,
их поведение ведь может от этих полей зависеть, не зря ведь мы их собственно сделали общими.
3. Дублирование кода
4. Производительность. Обращение к функциям-членам базового класса происходит через несколько слоев косвенности, тоже самое про виртуальные функции. В моем же случае, вызов происходит напрямую.
Только при диспетчеризации есть выигрыш у std::visit т.е. это пункт 3
На счет SRP.
На мой взгляд, наследование и вызов функций базового класса ни как не нарушают SRP.
Ну как правило наследование используется в качестве интерфейсов объектов
Наследование, это не только выделение интерфейсов. В целом можно сказать что это механизм для переиспользования кода, который в том числе (как вы и сказали) используется для выделения интерфейсов.
В таком случае, проблема не исчезнет (вы ее просто спрячите). Более того для Object1 и Object2 будут уже не доступны общие поля и функции-члены, а вот это уже плохо.
Дополню предыдущий комментарий, по производительности
std::visit выигрывает у данного подхода только при деспетчиризации в 1.2 раза.
По итогу:
1. Если есть не связанные между собой классы, то использование std::variant подходит лучше чем подход описанный в статье.
2. Если есть классы связанные между собой наследованием и через указатель на базовый класс достаются не только их настоящие типы, но и вызываются общие функцию-члены, то подход указанный в статье будет лучше.
(В любом случа, зависит от того, как часто вы вызываете те или иные функции ).
Здесь стоит перейти к вопросу о том, зачем нам наследование.
Наследование позволяет выделить общие функции-члены/поля для потомков.
Более того, можно ведь делать виртуальные функции-члены и в потомках их переопределять.
Вот только с std::variant есть проблема, вариант не наследует общего поведения.
Если у вас есть коллекция из указателей на базовый класс и вам необходимо
вызвать общую функцию-член, то проще и дешевле напрямую вызвать его имея на руках указатель на базовый класс, чем обходить std::variant с помощью std::visit.
Конечно, вы можете сделать вариант от всех видов потомков и с помощью
std::visit делать вызов метода, вот так:
Если сравнить производительность, то std::visit проигрывает.
Что стравнивалось:
1. вызов обычной функции-члена test с вызовом test через visit; (здесь visit почти в 5 раз проигрывает )
2. вызов виртуальной функции-члена virtualTest с вызовом virtualTest через visit;
3. вызов виртуальной функции-члена virutalTest с вызовом test через visit; (только в этом случае вызов через visit догнал виртуальный вызов, но не обогнал)
Вот тут код для quick bench.
Компилятор был выставлен Clang 10.0, std=c++17, optim=O3
Да, здесь Dispatcher не имеет собственного стейта.
Механизм не может быть неинтрузивным, т.к. все типы, которые посетитель может обойти определены заранее в листе.
Если я вас правильно понял, вам нужно использовать какой-то уже имеющийся посетитель с внутренним стейтом, то можно сделать например вот так:
Да, можно и без листа, но тогда придется во всех методах accept писать весь список того, что посетитель может обойти (тут можно ошибиться и забыть что-нибудь). Если вам потребуется добавить/удалить класс из иерархии, то придется изменять все места, где присутствует эта последовательность. Лучше задавать этот список в одном месте и передавать куда необходимо.
Обычный посетитель сможет сделать все то же самое что и обобщенный.
Отличие будет только в том, что придется писать больше одинакового кода.
Приведу пример:
Положим у вас есть M абстрактных посетителей и N классов, которые эти посетители могут обойти.
Если вам необходимо вызывать шаблон функции, где аргумент шаблона будет типом, который будет скрываться за абстракцией, то вам необходимо будет реализовать MxN одинаковых функций visit у разных наследников от абстрактных посетителей.
Все реализации функции visit будут отличаться только тем, что будут иметь разный тип объекта.
Идея была в том, чтобы все это не писать каждый раз.
Двойная диспетчеризация здесь присутствует так же как и у обычного посетителя.
1-й вызов функции-члена accept у AbstractObject внутри метода dispatch.
2-й вызов функции-члена visit у AbstractVisitor T.
После чего вызывается обобщенная лямда, где мы и получаем тип объекта, который можем использовать в шаблоне функции.
Отличие в том, что все это завернуто в функцию-член dispatch.
Подчеркну, что для такого случая данный подход не предназначался изначально.
Его можно применять когда есть базовый класс/абстрактный базовый класс.
Используя ваше описание классов с интерфейсами, например можно сделать так:
Согласен.
На счет нужных и не нужных данных, это уже зависит исключительно от конкретной задачи.
Разумеется бывает, что в класс попадает то что ему совсем не надо из за наследования, здесь уже нужно в каждом конкретном случае думать о том, а нужно ли вообще здесь наследование, зачем эти классы так связаны, как хотим укладывать данные в памяти и что вообще хотим получить.
1. Поле name было приведено в качестве примера общего поля для потомков класса BaseObject.
В данном случае это поле name предназначено для хранения имени конкретного объекта.
Потомки это SomeObject1 и SomeObject2, для них будет общее поле name и общая функция getName().
2. Вы вынесли общее поле (name), я то же это сделал в примере который привел в предыдущем комментарии.
Но вы не вынесли здесь общую функциональность (функция getName).
Смотрите, если у вас будет N потомков от BaseObject, вам надо будет писать N раз функцию getName().
Достаточно ее один раз написать в базовом классе.
3. Мы сейчас рассматриваем конкретную иерархию из трех классов: BaseObject, SomeObject1, SomeObject2.
А так же возможное расширение этой иерархии, не более.
4. Вы дублирете поле name и функцию getName() в классах SomeObject1 и SomeObject2. В коде я оставил комментарий, где собственно происходит дублирование.
5. Вы могли бы развернуто ответить, каким образом здесь нарушается SRP?
6. Вы согласны с утверждением что вызов (в моем случае) обычной функции проще и дешевле, чем вызов функции обертки (в вашем случае), в которой вызывается
std::visit и уже в которой вызывается собственно сама функция?
Производительность диспатчеризации сопоставима, так зачем мне пользоваться оберткой?
Примеры в статье я привел.
7. Вы в своем же примере показали, когда приводили код, что это не так. BaseObject как раз и содержит общие поля.
8. Мне не ясно зачем это делать (в данном конкретном случае). Что вам не нравится в классе BaseObject с общим полем name и общей функцией getName?
Положим появились еще класс наследник только уже от SomeObject1.
В языке С++ уже есть механизм наследования, чтобы наследник мог получить доступ к полям SomeObject1 и специфичным для SomeObject1 функциям.
9. К специфичным полям, которые определены в самих класса SomeObject1 и SomeObject2 — нет, к полям, которые определены в классе BaseObject — да. Ведь они для них общие.
Вы не могли бы привести полностью развернутый пример кода т.е. включая иерархию из BaseObject, SomeObject1, SomeObject2 и вашего класса обертки и с разделением класса BaseObject на 2 части?
(Если в вашем случае где то ошибусь, поправьте пожалуйста или приведите развернутый пример).
И надеюсь что этот пример все прояснит.
Положим у нас имеется простая иерархия классов, где каждый класс имеет поле «имя»
и какое то дополнительное поведение (функция action) в зависимости от своего типа.
Проблемы:
1. Если иерархия у вас будет чуть более сложной, то что вы будете делать с общими полями всех классов?
(Под более сложной я понимаю, что в дереве наследования могут быть поддеревья, корни которых тоже могут иметь общие поля только уже для поддерева.)
Как и зачем вы будете решать эту проблему?
Для этого в языке C++ уже есть необходимый механизм — наследование.
(Да бывают ситуации, когда наследование можно заменить композицией)
2. SomeObject1 и SomeObject2 ничего не знают об имени,
но ведь по смыслу как раз они должны этим именем владеть.
И к остальным общим полям SomeObject1 и SomeObject2 тоже должны иметь доступ,
их поведение ведь может от этих полей зависеть, не зря ведь мы их собственно сделали общими.
3. Дублирование кода
4. Производительность. Обращение к функциям-членам базового класса происходит через несколько слоев косвенности, тоже самое про виртуальные функции. В моем же случае, вызов происходит напрямую.
Только при диспетчеризации есть выигрыш у std::visit т.е. это пункт 3
На счет SRP.
На мой взгляд, наследование и вызов функций базового класса ни как не нарушают SRP.
Наследование, это не только выделение интерфейсов. В целом можно сказать что это механизм для переиспользования кода, который в том числе (как вы и сказали) используется для выделения интерфейсов.
В таком случае, проблема не исчезнет (вы ее просто спрячите). Более того для Object1 и Object2 будут уже не доступны общие поля и функции-члены, а вот это уже плохо.
Дополню предыдущий комментарий, по производительности
std::visit выигрывает у данного подхода только при деспетчиризации в 1.2 раза.
По итогу:
1. Если есть не связанные между собой классы, то использование std::variant подходит лучше чем подход описанный в статье.
2. Если есть классы связанные между собой наследованием и через указатель на базовый класс достаются не только их настоящие типы, но и вызываются общие функцию-члены, то подход указанный в статье будет лучше.
(В любом случа, зависит от того, как часто вы вызываете те или иные функции ).
Наследование позволяет выделить общие функции-члены/поля для потомков.
Более того, можно ведь делать виртуальные функции-члены и в потомках их переопределять.
Вот только с std::variant есть проблема, вариант не наследует общего поведения.
Если у вас есть коллекция из указателей на базовый класс и вам необходимо
вызвать общую функцию-член, то проще и дешевле напрямую вызвать его имея на руках указатель на базовый класс, чем обходить std::variant с помощью std::visit.
Конечно, вы можете сделать вариант от всех видов потомков и с помощью
std::visit делать вызов метода, вот так:
Но, на мой взгляд такой код выглядет лучше:
Если сравнить производительность, то std::visit проигрывает.
Что стравнивалось:
1. вызов обычной функции-члена test с вызовом test через visit; (здесь visit почти в 5 раз проигрывает )
2. вызов виртуальной функции-члена virtualTest с вызовом virtualTest через visit;
3. вызов виртуальной функции-члена virutalTest с вызовом test через visit; (только в этом случае вызов через visit догнал виртуальный вызов, но не обогнал)
Вот тут код для quick bench.
Компилятор был выставлен Clang 10.0, std=c++17, optim=O3
Пример:
Механизм не может быть неинтрузивным, т.к. все типы, которые посетитель может обойти определены заранее в листе.
Если я вас правильно понял, вам нужно использовать какой-то уже имеющийся посетитель с внутренним стейтом, то можно сделать например вот так:
Спасибо что заметили)
Отличие будет только в том, что придется писать больше одинакового кода.
Приведу пример:
Положим у вас есть M абстрактных посетителей и N классов, которые эти посетители могут обойти.
Если вам необходимо вызывать шаблон функции, где аргумент шаблона будет типом, который будет скрываться за абстракцией, то вам необходимо будет реализовать MxN одинаковых функций visit у разных наследников от абстрактных посетителей.
Все реализации функции visit будут отличаться только тем, что будут иметь разный тип объекта.
Идея была в том, чтобы все это не писать каждый раз.
Двойная диспетчеризация здесь присутствует так же как и у обычного посетителя.
1-й вызов функции-члена accept у AbstractObject внутри метода dispatch.
2-й вызов функции-члена visit у AbstractVisitor T.
После чего вызывается обобщенная лямда, где мы и получаем тип объекта, который можем использовать в шаблоне функции.
Отличие в том, что все это завернуто в функцию-член dispatch.