Недостатки типичной реализации

В статье намеренно не приведен пример типичной реализации паттерна посетителя в C++.

Если вы не знакомы с этим паттерном, то вот тут можно с ним ознакомиться.

А если в двух словах, то этот паттерн очень полезен если вам нужно обойти коллекцию из указателей на абстрактный базовый класс, применив к ним какую-то операцию в зависимости от типа, который скрывается за абстракцией.

Поэтому перейдем сразу к недостаткам, которые хотели бы устранить.

  • При добавлении класса в иерархию (или удалении), приходится изменить много мест в коде. (Каждый раз лезть и добавлять чисто виртуальную функцию-член в абстрактный класс, и почти во все реализации.)

  • При добавлении нового посетителя, приходится наследоваться от абстрактного класса, даже если мы всего то хотим вызвать просто шаблон функции. При этом мы не можем сделать шаблон виртуальной функции-члена visit у посетителя (причина этого проста, шаблонов виртуальных функций-членов просто нет). Каждую реализацию функции-члена visit приходится делать отдельно.

  • Класс посетителя привязан к предметной области.

Что хотим получить?

  • Имея на руках указатель на абстрактный базовый класс, хотим сразу получать реальный тип объекта и отправлять его самого или его тип в какой-нибудь шаблон функции, не используя при этом никаких конструкций из dynamic_cast или static_cast и не создавая множество одинаковых переопределений виртуальных функций.

  • Простым способом добавлять или удалять классы, которые посетитель может обойти.

  • Не привязанный к предметной области посетитель.

Реализация

Начинаем с создания абстрактного посетителя.

Source:
template< class T >
struct AbstractVisitor
{
    virtual ~AbstractVisitor() = default;
    virtual void visit( T& ) = 0;
};

(Пояснение: здесь виртуальная функция-член visit не является шаблоном, количество виртуальных функций при инстанцировании класса AbstractVisitor точно известно т.к. T является параметром шаблона класса, а не функции )

Инстанс конкретного посетителя сможет обойти только один тип объекта. Нам же необходимо чтобы посетитель мог обходить несколько различных типов объектов.

Для этого создадим простой список типов TypeList и класс агрегатор AbstractVisitors. Список у AbstractVisitors будет содержать все типы объектов, которые посетитель может обойти.

Source:
template< class ... T >
struct TypeList
{

};

template< class T >
struct AbstractVisitor
{
    virtual ~AbstractVisitor() = default;
    virtual void visit( T& ) = 0;
};

template< class ...T >
struct AbstractVisitors;

template< class ... T >
struct AbstractVisitors< TypeList< T... > > : AbstractVisitor< T >...
{
};

Т.к. мы не хотим каждый раз наследоваться от абстрактного посетителя, наследуемся от него один раз и будем принимать функтор (если быть точным, то принимать будем обобщённую лямбду). Для этого создадим класс Dispatcher.

Source:
template< class Functor, class ... T >
struct Dispatcher;

template< class Functor, class ... T >
struct Dispatcher< Functor, TypeList< T... > > : AbstractVisitors< TypeList< T... > >
{
    Dispatcher( Functor functor ) : functor( functor ) {}

    Functor functor;
};

Теперь необходимо для всех типов из листа переопределить виртуальную функцию-член visit.

Для этого создадим класс Resolver, который этим и будет заниматься. А сам класс Dispatcher унаследуем от всех возможных типов Resolver-ов.

Дополнительно необходимо вызывать функтор в переопределенной функции, воспользуемся (CRTP) и передадим тип Dispatcher как аргумент шаблона во все Resolver.

(Подробнее о том что такое CRTP можно почитать тут).

Source:
template< class Dispatcher, class T >
struct Resolver : AbstractVisitor< T >
{
    void visit( T& obj ) override 
    {
        static_cast< Dispatcher* >( this )->functor( obj );
    };
};

template< class Functor, class ... T >
struct Dispatcher< Functor, TypeList< T... > > : AbstractVisitors< TypeList< T... > >, Resolver< Dispatcher< Functor, TypeList< T... > >, T >...
{
    Dispatcher( Functor functor ) : functor( functor ) {}

    Functor functor;
};

Вроде все в порядке.

Попробуем создать объект класс Dispatcher. Компилятор начинает ругаться на то, что объект класса Dispatcher абстрактный, как же так?

Причина этого в том, что мы переопределили виртуальные функции для Resolver, но для Dispatcher мы ведь ничего не переопределяли.

Чтобы этого избежать, необходимо сделать наследование от AbstractVisitor< T >виртуальным.(Подробнее о размещении объектов в памяти и виртуальном наследовании можно почитать тут.)

Source:
template< class ... T >
struct AbstractVisitors< TypeList< T... > > : virtual AbstractVisitor< T >...
{
};

template< class Dispatcher, class T >
struct Resolver : virtual AbstractVisitor< T >
{
    void visit( T& obj ) override 
    {
        static_cast< Dispatcher* >( this )->functor( obj );
    };
};

Создадим абстрактный базовый класс (AbstractObject) и какие-нибудь классы (Object1, Object2), которые хотели обойти.

Так же создадим функцию test и шаблон функции test, которые будут получать непосредственно ссылку на объект определенного типа.

Пример использования:

Source:
struct Object1;
struct Object2;

using ObjectList = TypeList< Object1, Object2 >;

struct AbstractObject
{
    virtual void accept( AbstractVisitors< ObjectList >& visitor ) = 0; 
};

struct Object1 : AbstractObject
{
    void accept( AbstractVisitors< ObjectList >& visitor ) override 
    { 
        static_cast< AbstractVisitor< Object1 >& >( visitor ).visit( *this );  
    };
};

struct Object2 : AbstractObject
{
    void accept( AbstractVisitors< ObjectList >& visitor ) override 
    { 
        static_cast< AbstractVisitor< Object2 >& >( visitor ).visit( *this );
    };
};

void test( Object1& obj )
{
    std::cout << "1" << std::endl;
}

template< class T >
void test( T& obj )
{
    std::cout << "2" << std::endl;
}

int main()
{
    Object1 t1,t2,t3,t4;
    Object2 e1,e2,e3;

    std::vector< AbstractObject* > vector = { &t1, &e1, &t2, &t3, &e2, &e3, &t4 };
		
    auto l = []( auto& obj ){ test(obj); };
    Dispatcher<decltype(l), ObjectList> dispatcher;
  
    for( auto* obj : vector )
    {
        obj->accept( dispatcher );
    }
}

(Пояснение: мы не можем просто написать visitor.visit( *this ), это приведет к неоднозначности, если классов в иерархии будет больше двух.)

Строчки на которых создается обобщенная лямбда и объект класса Dispatcher какие-то то страшные и не удобные, спрятать бы все это от глаз.

Так же, хотелось бы спрятать функцию-член accept у AbstractObject, Object1 и Object2, т.к. тело функции для всех типов объектов будет одинаковое, различаться будет только тип объекта.

Для этого создадим абстрактный класс Dispatchable. Cделаем у него чисто виртуальную функцию-член accept и шаблон функции-члена который будет принимать функтор. В нем собственно и будем создавать наш Dispatcher.

Помимо этого создадим макрос DISPATCHED, он понадобится чтобы спрятать переопределение функции-члена accept у Object1 и Object2.

Source:
template< class TypeList >
struct Dispatchable
{
    virtual ~Dispatchable() = default;
    virtual void accept( AbstractVisitors< TypeList >& ) = 0;

    template< class Functor >
    void dispatch( Functor functor )
    {
        static Dispatcher< decltype(functor), TypeList > dispatcher( functor );
        accept( dispatcher );
    };
};

#define DISPATCHED( TYPE, TYPE_LIST )     void accept( AbstractVisitors< TYPE_LIST >& visitor ) override     {         static_cast< AbstractVisitor< TYPE >& >( visitor ).visit( *this );      }

Затем наследуем AbstractObject от класса Dispatchable. А в классы Object1 и Object2 добавляем макрос DISPATCHED.

Source:

struct Object1;
struct Object2;

using ObjectList = TypeList< Object1, Object2 >;

struct AbstractObject : Dispatchable< ObjectList >
{
};

struct Object1 : AbstractObject
{
    DISPATCHED( Object1, ObjectList )
};

struct Object2 : AbstractObject
{
    DISPATCHED( Object2, ObjectList )
};

Отлично, мы спрятали все функции-члены accept и вынесли общий код. Вот теперь все готово.

Пример использования:

Source:
void test( Object1& obj )
{
    std::cout << "1" << std::endl;
}

template< class T >
void test( T& obj )
{
    std::cout << "2" << std::endl;
}

int main()
{
    Object1 t1,t2,t3,t4;
    Object2 e1,e2,e3;

    std::vector< AbstractObject* > vector = { &t1, &e1, &t2, &t3, &e2, &e3, &t4 };

    for( auto* obj : vector )
    {
        obj->dispatch( []( auto& obj ) { test(obj); } );
    }
}

Output:

1

2

1

1

2

2

1

Производительность

Хороший вопрос был задан в комментариях, чем этот подход лучше по сравнению с уже имеющимися в стандартной библиотеке std::visit и std::variant.

Проблема std::variant в том, что он не выделяет общего поведения. Т.е. мы не можем просто взять и вызвать напрямую какую-то функцию базового класса, необходимо обращаться к std::visit.

Сравним производительность при их типичном использовании.

А именно:

  1. Диспетчеризацию через метод dispatch и std::visit.

  2. Вызов виртуальной функции-члена с вызовом обычной, обернутой в std::visit.

  3. Вызов обычной функции-члена с вызовом обычной функции, обернутой в std::visit.

Вызов виртуальной функции сравнивать с вызовом виртуальной обернутой в std::visit нет особого смысла.

1. Диспетчеризация

Clang 10.0, Optim=OFast

GCC 10.1, Optim=OFast

2. Вызов виртуальной функции

Clang 10.0, Optim=OFast


GCC 10.1, Optim=OFast

3. Вызов обычной функции

Clang 10.0, Optim=OFast

GCC 10.1, Optim=OFast

Тут ссылка на код для Quick Benchmarks.

Когда использовать?

Если у вас есть иерархия классов и вы хотите помимо вызова диспетчеризации использовать обычные и виртуальные функции. Где базовый класс может быть как и абстрактным, так и не быть им.

Пример:

Source
template< class T >
void templateFunc1()
{
	/*some code*/
}

template< class T >
void templateFunc2( T& obj )
{
	/*some code*/
}

std::vector< AbstractObject* > vector = { /* ... */ };

for( auto* obj : vector )
{
	//вызов обычной функции-члена
	obj->getName();
	
	//вызов виртуальной функции-члена
	obj->action();
	
	//получение типа и передача его в шаблон-функции
	obj->dispatch([](auto& obj) 
	{ 
		//1 case
		using T = std::decay_t< decltype(obj) >;	
		templateFunc1<T>();		
		
		//2 case
		templateFunc2( obj );	
	};
}

Заключение

  • Чтобы добавить или удалить класс, который будет обрабатываться посетителем, достаточно просто изменить список типов.

  • Посетитель не привязан к предметной области, т.к. является шаблоном класса.

  • Можем в полной мере пользоваться шаблонами функций.

  • Остается возможность пользоваться обычной реализацией посетителя, достаточно лишь сделать своего наследника от AbstractVisitors и передавать его в функцию accept.

Какие недостатки?

  • Дополнительная косвенность, т.к. Dispatcher содержит функтор.

Ссылка на код в compiler explorer.

Full source:
#include <type_traits>
#include <iostream>
#include <vector>

template< class ... T >
struct TypeList
{

};

template< class T >
struct AbstractVisitor
{
    virtual ~AbstractVisitor() = default;
    virtual void visit( T& ) = 0;
};

template< class ...T >
struct AbstractVisitors;

template< class ... T >
struct AbstractVisitors< TypeList< T... > > : virtual AbstractVisitor< T >...
{
};

template< class Dispatcher, class T >
struct Resolver : virtual AbstractVisitor< T >
{
    void visit( T& obj ) override 
    {
        static_cast< Dispatcher* >( this )->functor( obj );
    };
};

template< class Functor, class ... T >
struct Dispatcher;

template< class Functor, class ... T >
struct Dispatcher< Functor, TypeList< T... > > : AbstractVisitors< TypeList< T... > >, Resolver< Dispatcher< Functor, TypeList< T... > >, T >...
{
    Dispatcher( Functor functor ) : functor( functor ) {}

    Functor functor;
};

template< class TypeList >
struct Dispatchable
{
    virtual ~Dispatchable() = default;
    virtual void accept( AbstractVisitors< TypeList >& ) = 0;

    template< class Functor >
    void dispatch( Functor functor )
    {
        static Dispatcher< decltype(functor), TypeList > dispatcher( functor );
        accept( dispatcher );
    };
};

#define DISPATCHED( TYPE, TYPE_LIST )     void accept( AbstractVisitors< TYPE_LIST >& visitor ) override     {         static_cast< AbstractVisitor< TYPE >& >( visitor ).visit( *this );      }

struct Object1;
struct Object2;

using ObjectList = TypeList< Object1, Object2 >;

struct AbstractObject : Dispatchable< ObjectList >
{
};

struct Object1 : AbstractObject
{
    DISPATCHED( Object1, ObjectList )
};

struct Object2 : AbstractObject
{
    DISPATCHED( Object2, ObjectList )
};

void test( Object1& obj )
{
    std::cout << "1" << std::endl;
}

template< class T >
void test( T& obj )
{
    std::cout << "2" << std::endl;
}

int main()
{
    Object1 t1,t2,t3,t4;
    Object2 e1,e2,e3;

    std::vector< AbstractObject* > vector = { &t1, &e1, &t2, &t3, &e2, &e3, &t4 };

    for( auto* obj : vector )
    {
        obj->dispatch( []( auto& obj ) { test(obj); } );
    }
}