Комментарии 25
Совпадение?! Только сегодня на работе про этот шаблон говорили :)
А если в двух словах, то этот шаблон очень полезен если вам нужно обойти коллекцию из указателей на абстрактный базовый класс, применив к ним какую то операцию в зависимости от типа, который скрывается за абстракцией.
Вот это утверждение вызывает у меня недопонимание.
А можно более приближенный к реальной задаче пример и требования, которые обычный посетитель не выполняет. Обычно посетитель применяется, чтобы в него добавлять новые типы, которые реализуют некоторую специфичную операцию над собой за абстрактным интерфейсом, и с использованием функционала посетителя(если двойная диспетчеризация) — через его явный интерфейс или через возвращаемые значения. Соответственно, набор типов изменяется и расширяется, но остается связанным некоторой функциональностью посетителя, через реализацию своих интерфейсов, или через интерфейс самого посетителя.
Обобщенный посетитель же, выполняющий роль проксирования вызова обобщенной лямбды на множество типов, выглядит немного оторванным от жизни. Что должно происходить и как с результатом применения лямбды на множество объектов в контексте набора типов? Если я правильно понял, в вашей реализации каждый посетитель независимо инстанцируется и каждый объект получает одну и ту же лямбду, в собственном посетителе.
А идея посетителя без двойной диспетчеризации, иметь разные обработчики на разные объекты, объединенные общей идеей и необходимостью.
Отличие будет только в том, что придется писать больше одинакового кода.
Приведу пример:
Положим у вас есть M абстрактных посетителей и N классов, которые эти посетители могут обойти.
Если вам необходимо вызывать шаблон функции, где аргумент шаблона будет типом, который будет скрываться за абстракцией, то вам необходимо будет реализовать MxN одинаковых функций visit у разных наследников от абстрактных посетителей.
Все реализации функции visit будут отличаться только тем, что будут иметь разный тип объекта.
template< class T>
void test()
{
}
struct AbstractVisitor1
{
virtual void visit( Object1& obj ) = 0;
virtual void visit( Object2& obj ) = 0;
};
struct AbstractVisitor2
{
virtual void visit( Object3& obj ) = 0;
virtual void visit( Object4& obj ) = 0;
};
struct Visitor1 : AbstractVisitor1
{
void visit( Object1& obj ) override { test<Object1>(); }
void visit( Object2& obj ) override { test<Object2>(); }
};
struct Visitor2 : AbstractVisitor2
{
void visit( Object3& obj ) override { test<Object3>(); }
void visit( Object4& obj ) override { test<Object4>(); }
};
Идея была в том, чтобы все это не писать каждый раз.
Двойная диспетчеризация здесь присутствует так же как и у обычного посетителя.
1-й вызов функции-члена accept у AbstractObject внутри метода dispatch.
2-й вызов функции-члена visit у AbstractVisitor T.
После чего вызывается обобщенная лямда, где мы и получаем тип объекта, который можем использовать в шаблоне функции.
Отличие в том, что все это завернуто в функцию-член dispatch.
template< class ... >
struct Dispatcher;
Спасибо что заметили)
Механизм не может быть неинтрузивным, т.к. все типы, которые посетитель может обойти определены заранее в листе.
Если я вас правильно понял, вам нужно использовать какой-то уже имеющийся посетитель с внутренним стейтом, то можно сделать например вот так:
class Some
{
public:
void visit( Object1& obj ) { /*some code*/}
void visit( Object2& obj ) { /*some code*/}
private:
int internalState;
};
Some some;
for( auto* obj : vector )
{
obj->dispatch( [&some]( auto& obj ) { some.visit( obj ); } );
}
Пример:
struct SomeVisitor : AbstractVisitors< ObjectList >
{
void visit( Object1& obj ) override { /*some code*/ }
void visit( Object2& obj ) override { /*some code*/ }
};
SomeVisitor some;
for( auto* obj : vector )
{
obj->accept( some );
}
Вроде там сделано почти тоже самое, только классам не нужно наследоваться
Наследование позволяет выделить общие функции-члены/поля для потомков.
Более того, можно ведь делать виртуальные функции-члены и в потомках их переопределять.
Вот только с std::variant есть проблема, вариант не наследует общего поведения.
Если у вас есть коллекция из указателей на базовый класс и вам необходимо
вызвать общую функцию-член, то проще и дешевле напрямую вызвать его имея на руках указатель на базовый класс, чем обходить std::variant с помощью std::visit.
Конечно, вы можете сделать вариант от всех видов потомков и с помощью
std::visit делать вызов метода, вот так:
std::vector< std::variant< Object1*, Object2* > > vector = { &t1, &e1, &t2, &t3, &e2, &e3, &t4 };
for( auto& v : vector )
{
std::visit([](auto&& obj){ obj->test(); }, v);
}
Но, на мой взгляд такой код выглядет лучше:
std::vector< AbstractObject* > vector = { &t1, &e1, &t2, &t3, &e2, &e3, &t4 };
for( auto* obj : vector )
{
obj->test();
}
Если сравнить производительность, то 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
Но тут например можно попробовать обернуть весь variant класс и выставить из него наружу методы, которые будут просто вызывать
std::visit([]{obj.method()}, var);
тем самым скрыв от пользователя весь трэш
struct Object1
{
void func() { /* */ }
};
struct Object2
{
void func() {/* */}
};
struct ObjectInterface
{
void func() { std::visit( [](auto&& obj) { obj->func(); }
std::variant< Object1*, Object2* > var;
};
std::vector< ObjectInterface > vector;
for( auto& v : vector )
{
v.func();
}
В таком случае, проблема не исчезнет (вы ее просто спрячите). Более того для Object1 и Object2 будут уже не доступны общие поля и функции-члены, а вот это уже плохо.
Дополню предыдущий комментарий, по производительности
std::visit выигрывает у данного подхода только при деспетчиризации в 1.2 раза.
По итогу:
1. Если есть не связанные между собой классы, то использование std::variant подходит лучше чем подход описанный в статье.
2. Если есть классы связанные между собой наследованием и через указатель на базовый класс достаются не только их настоящие типы, но и вызываются общие функцию-члены, то подход указанный в статье будет лучше.
(В любом случа, зависит от того, как часто вы вызываете те или иные функции ).
Более того для Object1 и Object2 будут уже не доступны общие поля и функции-члены
Они будут доступны через класс-обертку.
То есть, если раньше у вас был интерфейс
class Interface {
virtual void method1() = 0;
virtual void method2() = 0;
};
то теперь этого интерфейса у вас не будет, а вместо него будет класс
class Variant {
void method1() {
std::visit([]{var.method1()}, var);
}
void method2() {
...
}
};
по производительности
std::visit выигрывает у данного подхода только при деспетчиризации в 1.2 раза
Когда я мерял производительность visit vs virtual method, то visit выигрывал. Но за достоверность тестов не ручаюсь. А выше я как раз предлагаю заменить виртуальные методы visit-ом
Если есть классы связанные между собой наследованием и через указатель на базовый класс достаются не только их настоящие типы, но и вызываются общие функцию-члены, то подход указанный в статье будет лучше.
А тут как я понимаю, нарушается «принцип единой ответственности». Почему в вашем случае не сделать 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 ); } );
}
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 ); } );
}
struct SomeObject1 //ok код не дублируется
{
/* contructors */
void action() { /* specific SomeObject1 code */ }
};
struct SomeObject2 //ok код не дублируется
{
/* contructors */
void action() { /* specific SomeObject2 code */ }
};
struct SamePart //Допустим
{
std::string name;
};
struct Variant //Ваш класс обертка
{
/* contructors */
std::string_view getName() const { return samePart.name; }
void action() { std::visit([]( auto&& obj) { return obj.action(); }, var ); }
template< class Functor >
void dispatch( Functor functor )
{
/* ... */
}
private:
SamePart samePart;
std::variant< Object1, Object2 > var;
};
Проблемы:
1. Если иерархия у вас будет чуть более сложной, то что вы будете делать с общими полями всех классов?
(Под более сложной я понимаю, что в дереве наследования могут быть поддеревья, корни которых тоже могут иметь общие поля только уже для поддерева.)
Как и зачем вы будете решать эту проблему?
Для этого в языке C++ уже есть необходимый механизм — наследование.
(Да бывают ситуации, когда наследование можно заменить композицией)
2. SomeObject1 и SomeObject2 ничего не знают об имени,
но ведь по смыслу как раз они должны этим именем владеть.
И к остальным общим полям SomeObject1 и SomeObject2 тоже должны иметь доступ,
их поведение ведь может от этих полей зависеть, не зря ведь мы их собственно сделали общими.
3. Дублирование кода
4. Производительность. Обращение к функциям-членам базового класса происходит через несколько слоев косвенности, тоже самое про виртуальные функции. В моем же случае, вызов происходит напрямую.
Только при диспетчеризации есть выигрыш у std::visit т.е. это пункт 3
На счет SRP.
На мой взгляд, наследование и вызов функций базового класса ни как не нарушают SRP.
Ну как правило наследование используется в качестве интерфейсов объектов
Наследование, это не только выделение интерфейсов. В целом можно сказать что это механизм для переиспользования кода, который в том числе (как вы и сказали) используется для выделения интерфейсов.
struct SomeObject1
{
/* contructors */
std::string_view getName() const { return "Some Object 1"; } //ok
}
или
const static std::string someObjectName = "Some object 1";
struct SomeObject1
{
/* contructors */
std::string_view getName() const { return someObjectName; } //ok
}
Во-вторых, не всегда то, что кажется дублированием кода, является дублированием кода. Если вы в 2-х местах заиспользовали переменную с одинаковым именем, это еще не значит, что вам нужно бежать и искать по всему коду, не попадалось ли там этой переменной с таким же именем, и думать, как эту переменную оттуда вычистить. В данном случае я не вижу никакой проблемы с дублированием кода, да и самого дублирования кода я тоже не вижу
В-третьих, даже если вам прямо необходимо вынести общую функциональность в базовый класс, то вы все еще можете сделать это
struct BaseClass {
std::string name;
};
struct SomeObject1: public BaseClass
{
/* contructors */
std::string_view getName() const { return name; } //ok
}
Дальше,
Про более сложную иерархию. Тут тоже нужно думать над примером. Вообще, возможно сама по себе «более сложная иерархия» уже является проблемой?
Но я согласен, что в случае, если у вас уже сложилась какаято иерархия классов, то возможно применить ваш подход к ней будет проще чем мой
Про производительность, вы сами в примере «Ваш случай: №2» показали, как ее можно улучшить, если это вдруг понадобиться.
И к остальным общим полям SomeObject1 и SomeObject2 тоже должны иметь доступ,
их поведение ведь может от этих полей зависеть, не зря ведь мы их собственно сделали общими.
У классов SomeObject1 и SomeObject2 нету общих полей. В том смысле, что объекты класса SomeObject1 и SomeObject2 не могут обратиться к полям друг друга (то есть, имея объект
SomeObject1 var1;
я не могу получить доступ к члену SomeObject2. Или могу?)
Поэтому нету смысла говорить об общих, не общих полях. В каждом классе поля свои, и только свои.
В целом можно сказать что это механизм для переиспользования кода, который в том числе (как вы и сказали) используется для выделения интерфейсов.
Ну вот вы в этой фразе и нарушили принцип SRP. Что мешает разделить такой класс на 2 части — с интерфейсом и общими полями?
Ну давайте во-первых разберемся, зачем вообще нужно имя 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 части?
Если мы хотим здесь обсудить одну из этих оптимизаций, то это можно было бы сделать, но я не вижу смысла. То есть, если вы считаете, что ваши оптимизации лучше моих оптимизаций, то видимо мне вас уже не переубедить.
Ситуация с оптимизациями такая, что соптимизировав в одном месте, мы можем потерять гдето в другом.
Вы, например, соптимизировали вызов функции getName, сохранив имя класса в поле внутри класса. Да, теперь вызов этой функции проходит нереально быстро. Но теперь, мы в каждом объекте любого класса имеем довольно большой кусок данных. Что важнее? мне кажется, оптимизировать метод получения имени класса объекта никому не нужно, а заиметь у себя в каждом объекте несколько десятков байт (а то и больше) никому не нужной инфы, это по моему уже хуже
Так что если мы собираемся обсуждать различные методы оптимизации кода, то я думаю, это затянется надолго.
Плюс, опять же, мое решение не запрещает проводить никакие из представленных выше оптимизаций
Вы не могли бы привести полностью развернутый пример кода т.е. включая иерархию из BaseObject, SomeObject1, SomeObject2 и вашего класса обертки?
То что вы описали в «Ваш вариант №1» для меня более чем удовлетворительно
Ситуация с оптимизациями такая, что соптимизировав в одном месте, мы можем потерять где-то в другом.
Согласен.
На счет нужных и не нужных данных, это уже зависит исключительно от конкретной задачи.
Разумеется бывает, что в класс попадает то что ему совсем не надо из за наследования, здесь уже нужно в каждом конкретном случае думать о том, а нужно ли вообще здесь наследование, зачем эти классы так связаны, как хотим укладывать данные в памяти и что вообще хотим получить.
Другой вопрос думать в рамках контрактов классов. То есть, если например классы имеют такие интерфейсы
class Interface1 {};
class Interface2 {};
class SomeObject1: public Interface1, Interface2 {}
class SomeObject2: public Interface1{}
class SomeObject3: public Interface2 {}
То есть один класс реализует сразу 2 интерфейса, а 2 других по одному интерфейсу. Как в этом случае подстроить эти классы под наши архитектуры? В моем случае видимо придется делать вариант из указателей
using CommonVariant = std::variant<SomeObject1, SomeObject2, SomeObject3>
using Interface1Variant = std::variant<SomeObject1*, SomeObject2>;
using Interface2Variant = std::variant<SomeObject1*, SomeObject3>;
Другой вопрос, что такая иерархия врядли слишком часто встречается, и возможно нету смысла ее обсуждать
Его можно применять когда есть базовый класс/абстрактный базовый класс.
Используя ваше описание классов с интерфейсами, например можно сделать так:
struct SomeObject1;
struct SomeObject2;
struct SomeObject3;
using SomeObjectList = TypeList< SomeObject1, SomeObject2, SomeObject3 >;
struct Interface1
{
virtual void action1() = 0;
};
struct Interface2
{
virtual void action2() = 0;
};
struct AbstractSomeObject : Dispatchable< SomeObjectList >
{
virtual ~AbstractSomeObject() = default;
};
struct SomeObject1: AbstractSomeObject, Interface1, Interface2
{
DISPATCHED( SomeObject1, SomeObjectList )
void action1() override {}
void action2() override {}
};
struct SomeObject2: AbstractSomeObject, Interface1
{
DISPATCHED( SomeObject2, SomeObjectList )
void action1() override {}
};
struct SomeObject3: AbstractSomeObject, Interface2
{
DISPATCHED( SomeObject3, SomeObjectList )
void action2() override {}
};
template< class T >
void testCommon( T& obj )
{
/*Common*/
}
int main()
{
SomeObject1 o1;
SomeObject2 o2;
SomeObject3 o3;
std::vector< AbstractSomeObject* > vector = { &o1, &o2, &o3 };
for( auto* obj : vector )
{
obj->dispatch( []( auto& obj ) { testCommon(obj); } );
}
}
struct SomeObject1;
struct SomeObject2;
struct SomeObject3;
struct Interface1;
struct Interface2;
using SomeObjectList = TypeList< SomeObject1, SomeObject2, SomeObject3 >;
struct Interface1 : Dispatchable< SomeObjectList >
{
virtual void action1() = 0;
};
struct Interface2 : Dispatchable< SomeObjectList >
{
virtual void action2() = 0;
};
struct SomeObject1: Interface1, Interface2
{
DISPATCHED( SomeObject1, SomeObjectList )
void action1() override {}
void action2() override {}
};
struct SomeObject2: Interface1
{
DISPATCHED( SomeObject2, SomeObjectList )
void action1() override {}
};
struct SomeObject3: Interface2
{
DISPATCHED( SomeObject3, SomeObjectList )
void action2() override {}
};
template< class T >
void testCommon( T& obj )
{
/*Common*/
}
int main()
{
SomeObject1 o1;
SomeObject2 o2;
SomeObject3 o3;
std::vector< Interface1* > vector1 = { &o1, &o2 };
std::vector< Interface2* > vector2 = { &o1, &o3 };
for( auto* obj : vector1 )
{
obj->action1();
obj->dispatch([](auto& obj){ testCommon( obj );});
}
for( auto* obj : vector2 )
{
obj->action2();
obj->dispatch([](auto& obj){ testCommon( obj );});
}
}
Подчеркну, что для такого случая данный подход не предназначался изначально.
Обобщаем паттерн посетитель (С++)