Двойная диспетчеризация в C++ — это механизм, который распределяет вызов функции по разным конкретным функциям в зависимости от типов среды выполнения двух объектов, участвующих в вызове. Проще говоря, его функция вызывается с использованием двух разных виртуальных таблиц соответствующих двух объектов. Я знаю, что это звучит загадочно, но не волнуйтесь, я приду к решению с двойной отправкой после того, как попробую большинство наивных решений, так что вы получите полное понимание концепции без ненужной путаницы.
/!\: Первоначально опубликовано @ www.vishalchovatiya.com.
Мотивация
- Поначалу указатель на базовый класс имел смысл; вам не нужно было знать фактический производный класс. Итак, вы решили предоставить своим клиентам единую коллекцию указателей базового класса следующим образом:
struct Animal { virtual const char *name() = 0; };
using AnimalList = vector<Animal*>
- Когда вы добавили свои первые несколько классов, ваши предположения подтвердились; вам никогда не нужно было знать фактический тип.
struct Cat : Animal { const char *name() { return "Cat"; } };
struct Dog : Animal { const char *name() { return "Dog"; } };
- Но требования меняются.
- Однажды клиент пришел к вам и сказал: «Я пытаюсь смоделировать человека, который боится собак, поэтому они убегают, когда видят их. Но они любят кошек, поэтому стараются погладить их, когда увидят».
- Черт. Теперь ваши предположения неверны. Тип нужно знать. И вы находитесь под давлением, чтобы уложиться в срок.
Идентификация типа во время выполнения
- Тогда вы подумали: «Ну, есть только два вида животных, это не так уж и плохо». Итак, вы написали такой код:
struct Person { void ReactTo(Animal *_animal) { if (dynamic_cast<Dog *>(_animal)) RunAwayFrom(_animal); else if (dynamic_cast<Cat *>(_animal)) TryToPet(_animal); }
void RunAwayFrom(Animal *_animal) { cout << "Run Away From " << _animal->name() << endl; }
void TryToPet(Animal *_animal) { cout << "Try To Pet " << _animal->name() << endl; } };
- Затем клиент сказал, что если
Animal
этоHorse
, они хотят попробовать на нем прокатиться.
void Person::ReactTo(Animal *_animal) {
if (dynamic_cast<Dog *>(_animal))
RunAwayFrom(_animal);
else if (dynamic_cast<Cat *>(_animal))
TryToPet(_animal);
else if (dynamic_cast<Horse *>(_animal))
TryToRide(_animal);
}
- Вы видите, что это сходит с ума. В какой-то момент в будущем вам может не понравиться работать с собственным кодом. Мы все были там. Тем не менее, эта тенденция продолжалась некоторое время, пока вы не столкнулись с таким беспорядком:
void Person::ReactTo(Animal *_animal) {
if (dynamic_cast<Dog *>(_animal) || dynamic_cast<Gerbil *>(_animal)){
if (dynamic_cast<Dog *>(_animal) && dynamic_cast<Dog>()->GetBreed() == DogBreed.Daschund) // Daschund's are the exception
TryToPet(_animal);
else
RunAwayFrom(_animal);
}
else if (dynamic_cast<Cat *>(_animal) || dynamic_cast<Pig *>(_animal))
TryToPet(_animal);
else if (dynamic_cast<Horse *>(_animal))
TryToRide(_animal);
else if (dynamic_cast<Lizard *>(_animal))
TryToFeed(_animal);
else if (dynamic_cast<Mole *>(_animal))
Attack(_animal)
// etc.
}
- Этот список становится довольно длинным, как-то подумали вы про себя. Все эти dynamic_cast кажутся неправильными, к тому же они какие-то медленные. Итак, в дополнение к рефакторингу, вы придумываете решение, которое идентифицирует
typeid()
, это немного быстрее, чем dynamic_cast, но все же это не оптимальная производительность.
Использование полиморфизма
- Поскольку кто-то из вашего старшего/наставника предлагает вам использовать перечисление с полиморфными методами для определения типа, вы написали следующий код:
enum class AnimalType { Dog, Cat }; struct Animal { virtual const char *name() = 0; virtual AnimalType type() = 0; };
struct Cat : Animal { const char *name() { return "Cat"; } AnimalType type() { return AnimalType::Cat; } };
struct Dog : Animal { const char *name() { return "Dog"; } AnimalType type() { return AnimalType::Dog; } };
struct Person { void ReactTo(Animal *_animal) { if (_animal->type() == AnimalType::Cat) TryToPet(_animal); else if (_animal->type() == AnimalType::Dog) RunAwayFrom(_animal); } void RunAwayFrom(Animal *_animal) { cout << "Run Away From " << _animal->name() << endl; } void TryToPet(Animal *_animal) { cout << "Try To Pet " << _animal->name() << endl; } };
int main() { Person p; Animal *animal_0 = new Dog; p.ReactTo(animal_0); Animal *animal_1 = new Cat; p.ReactTo(animal_1); return 0; }
- Вы можете получить улучшение производительности, но все же у вас останется длинный список
if/else-if
.
Более функциональный и модульный подход
using PersonMethodPtr = void (Person::*)(Animal *); using ReactionHash = unordered_map<AnimalType, PersonMethodPtr>;
void Person::ReactTo(Animal *_animal){ static const ReactionHash reactionFunctions{ {AnimalType::Cat, &TryToPet}, {AnimalType::Dog, &RunAwayFrom}, // etc. }; reactionFunctions[_animal->type()](_animal); }
- Но здесь вы косвенно пишете свою собственную виртуальную таблицу (очень плохую виртуальную таблицу), которая может вообще не дать прироста производительности из-за накладных расходов на хэширование и поиск. Кроме того, вы платите немного больше за память для хранения вашей таблицы поиска.
Единая отправка
- Таким образом, вместо того, чтобы хранить какой-либо идентификатор для каждого типа или RTTI, мы можем использовать посредника для направления вызова функции в соответствующее поведение.
struct Animal { virtual string name() = 0; virtual void Visit(class ReactVisitor *visitor) = 0; };
struct ReactVisitor { class Person *person = nullptr; };
struct Person { void ReactTo(Animal *_animal) { ReactVisitor visitor{this}; _animal->Visit(&visitor); } void RunAwayFrom(Animal *_animal) { cout << "Run Away From " << _animal->name() << endl; } void TryToPet(Animal *_animal) { cout << "Try To Pet " << _animal->name() << endl; } };
struct Cat : public Animal { string name() { return "Cat"; } void Visit(ReactVisitor *visitor) { visitor->person->TryToPet(this); } };
struct Dog : public Animal { string name() { return "Dog"; } void Visit(ReactVisitor *visitor) { visitor->person->RunAwayFrom(this); } };
int main() { Person p; vector<Animal*> animals = {new Dog, new Cat};
for(auto&& animal : animals) p.ReactTo(animal);
return 0; }
- Чтобы сохранить посредника в вызове функции маршрута, мы должны добавить метод
visit(ReactVisitor *)
, который принимает посредника, то естьReactVisitor
, в качестве параметра. Затем мы добавляем соответствующее поведение для каждого типаAnimal
, то естьDog
иCat
.
Проблемы с единым диспетчерским подходом
- Почему класс
Dog
должен диктовать, как на него реагироватьPerson
? Мы просочились в подробности реализации классаPerson
и, следовательно, нарушили инкапсуляцию. - Что, если у класса
Person
есть другие варианты поведения, которые они хотят реализовать? Неужели мы собираемся добавить новый виртуальный метод в базовый класс для каждого из них?
Решение вышеуказанной проблемы приведет нас к использованию механизма двойной отправки.
Двойная отправка в C++
- Мы можем преодолеть недостаток Single-Dispatch, добавив еще один уровень косвенности (например,
AnimalVisitor
).
/* -------------------------------- Added Visitor Classes ------------------------------- */ struct AnimalVisitor { virtual void Visit(struct Cat *) = 0; virtual void Visit(struct Dog *) = 0; };
struct ReactVisitor : AnimalVisitor { ReactVisitor(struct Person *p) : person{p} {} void Visit(struct Cat *c); void Visit(struct Dog *d); struct Person *person = nullptr; }; /* --------------------------------------------------------------------------------------- */
struct Animal { virtual string name() = 0; virtual void Visit(struct AnimalVisitor *visitor) = 0; };
struct Cat : Animal { string name() { return "Cat"; } void Visit(AnimalVisitor *visitor) { visitor->Visit(this); } // 2nd dispatch <<--------- };
struct Dog : Animal { string name() { return "Dog"; } void Visit(AnimalVisitor *visitor) { visitor->Visit(this); } // 2nd dispatch <<--------- };
struct Person { void ReactTo(Animal *_animal) { ReactVisitor visitor{this}; _animal->Visit(&visitor); // 1st dispatch <<--------- } void RunAwayFrom(Animal *_animal) { cout << "Run Away From " << _animal->name() << endl; } void TryToPet(Animal *_animal) { cout << "Try To Pet " << _animal->name() << endl; } };
/* -------------------------------- Added Visitor Methods ------------------------------- */ void ReactVisitor::Visit(Cat *c) { // Finally comes here <<------------- person->TryToPet(c); } void ReactVisitor::Visit(Dog *d) { // Finally comes here <<------------- person->RunAwayFrom(d); } /* --------------------------------------------------------------------------------------- */
int main() { Person p; for(auto&& animal : vector<Animal*>{new Dog, new Cat}) p.ReactTo(animal); return 0; }
- Как вы можете видеть выше, скорее зависящий непосредственно от
ReactVisitor
, мы взялиAnimalVisitor
как еще один уровень косвенности. И методvisit(AnimalVisitor *)
в классеCat
иDog
принимаетAnimalVisitor
в качестве параметра. - Это дает нам два преимущества: i). нам не нужно записывать поведение человека в классах
Cat
иDog
, поэтому мы не нарушаем правило инкапсуляции, и ii). мы выделяем реакцию Person в отдельный класс (т.е.ReactVisitor
), поэтому мы поощряем Принцип единой ответственности.
Как работает механизм двойной отправки?
Я знаю, что все усложняется, но я бы сказал, что это достаточно сложно. Фрейм стека функций и одно изображение цепочки вызовов функций с фрагментом кода значительно упростят его.
- Из
Person::ReactTo
мы вызываемAnimal::visit
, который перенаправляется на соответствующий переопределенный визит, т. е. либоCat::visit
, либоDog::visit
. - Из переопределенного
Cat::visit(AnimalVisitor*)
мы вызываемAnimalVisitor::visit
, который снова будет отправляться на соответствующий переопределенный, т. е. либоReactionVisitor::visit(Cat*)
, либоReactionVisitor::visit(Dog*)
.
Альтернативный подход к двойной отправке в современном C++ с использованием std::variant и std::visit
struct Animal { virtual string name() = 0; };
struct Cat : Animal { string name() { return "Cat"; } };
struct Dog : Animal { string name() { return "Dog"; } };
struct Person { void RunAwayFrom(Animal *animal) { cout << "Run Away From " << animal->name() << endl; } void TryToPet(Animal *animal) { cout << "Try To Pet " << animal->name() << endl; } };
struct ReactVisitor { void operator()(Cat *c) { person->TryToPet(c); } void operator()(Dog *d){ person->RunAwayFrom(d); } Person *person = nullptr; };
using animal_ptr = std::variant<Cat*, Dog*>;
int main() { Person p; ReactVisitor rv{&p}; for(auto&& animal : vector<animal_ptr>({new Dog, new Cat})) std::visit(rv, animal); return 0; }
- Так что те из вас, кто не знаком с
std::variant
, могут рассматривать его как союз. И строкаstd::variant<Cat*, Dog*>
предполагает, что вы можете использовать/назначать/получать доступ либо кCat*
, либо кDog*
одновременно. - А Современный C++ предоставляет нам
std::visit
, которые принимают callable, т.е.ReactVisitor
в нашем случае с перегруженным оператором функции для каждого типа иstd::variant
. Вы также используете лямбда-функции, а не функтор, то естьReactVisitor
.
Преимущества механизма двойной отправки
- Соблюдение принципа единой ответственности, что означает выделение логики конкретного типа в отдельный объект/класс. В нашем случае
ReactVisitor
обрабатывает реакцию только для разных типов животных. - Соблюдение принципа открытого-закрытого означает, что новые функции могут быть добавлены, не касаясь каких-либо заголовков классов, после того, как мы вставили метод
visit()
для иерархии. Например, если вы хотите добавить методsound()
для каждого отдельного животного, вы можете создатьSoundVisitor
и остальную часть редактирования идет так жеReactVisitor
. - Это будет очень полезно, когда вы уже выполнили модульное тестирование для всей своей иерархии, и теперь вы не хотите его трогать и хотите добавить новую функциональность.
- Производительность выше
dynamic_cast
,typeid()
и проверка наenum
/string
сравнения.
Пример использования механизма двойной отправки
- Сортировка смешанного набора объектов: Вы можете реализовать фильтрацию с двойной отправкой. Например: «Дайте мне все
Cats
изvector<Animal*>
». - Вы можете добавить дополнительные функции ко всей иерархии наследования, не изменяя ее снова и снова. если вы хотите добавить метод
sound()
для каждого отдельного животного, вы можете создатьSoundVisitor
, а остальная часть редактирования будет такой же, какReactVisitor
. - Системы обработки событий, которые используют как тип события, так и тип объекта-приемника для вызова правильной процедуры обработки событий.
- Адаптивные алгоритмы коллизий обычно требуют, чтобы коллизии между разными объектами обрабатывались по-разному. Типичным примером является игровая среда, где столкновение между космическим кораблем и астероидом вычисляется иначе, чем столкновение между космическим кораблем и космической станцией.
Вывод
Каждое решение имеет свои преимущества и недостатки, и выбор одного из них зависит от конкретных потребностей вашего проекта. C++ представляет уникальные проблемы при проектировании таких высокоуровневых абстракций, поскольку он сравнительно жесткий и статически типизированный. Абстракции в C++ также стремятся быть как можно более дешевыми с точки зрения производительности во время выполнения и потребления памяти, что добавляет еще одно измерение сложности к проблеме.
Ссылка
Эта статья стала побочным продуктом, пока я писал о классическом Visitor Design Pattern, потому что без механизма двойной диспетчеризации в C++ классический посетитель не существует. Большая часть заслуг в этой статье и изображениях принадлежит Энди Джи. Фрагменты кода, которые вы видите в этой статье, упрощены, а не сложны.
Есть предложения, вопросы или пожелания Hi
? Снимите давление, вы на расстоянии одного клика.🖱️