20 KiB
Visitor
Another popular design pattern is Visitor. As previously, I'm certain that You also use it at least once, but maybe you didn't know about it. Let's check the following STL algorithm:
class VariantVisitor
{
public:
void operator()(int num) const {
std::cout << "int: " << num << "\n";
}
void operator()(std::string str) const {
std::cout << "string: " << str << "\n";
}
};
int main() {
std::variant<int, std::string, double> variant;
variant = "Ala has a cat";
// print: string: Ala has a cat
std::visit(VariantVisitor{}, variant);
}
Seems familiar? If not, let's check another example
class Shape
{
public:
virtual ~Shape() = default;
virtual void accept( ShapeVisitor const& v ) = 0; 3
};
class Circle : public Shape
{
public:
explicit Circle( double radius ): radius_( radius ) {}
void accept( ShapeVisitor const& v ) override { v.visit( *this ); }
double radius() const { return radius_; }
private:
double radius_;
};
class Square : public Shape
{
public:
explicit Square( double side ): side_( side ) {}
void accept( ShapeVisitor const& v ) override { v.visit( *this ); }
double side() const { return side_; }
private:
double side_;
};
class Draw : public ShapeVisitor
{
public:
void visit( Circle const& c, /*...*/ ) const override;
void visit( Square const& s, /*...*/ ) const override;
};
class Translate : public ShapeVisitor
{
public:
void visit( Circle const& c, /*...*/ ) const override;
void visit( Square const& s, /*...*/ ) const override;
};
Visitor
These two examples were two different implementations of the same design pattern which is Visitor. In short, the visitor is used to dispatch functions between a few types.
Example
Let's back to the pirates. In the previous example, we implemented the ammunition class and added an implementation of fire and draw methods, so what next? We need to add also methods that will calculate how much damage each ammunition does and what sound should be played.
Drawback
In the first approach, we can see that we tightly coupled the base class Visitor with a base class Ammunition and the implementation of Visitor with types of Ammunition. So we have a lot of dependencies. Unfortunately, the visitor is a nice pattern, but it is good only for extending functions, but it is closed for adding new types because when we add a new type of ammunition we also need to implement every visitor class. There is a solution for this, but it is inefficient, and can't be always used. I will talk about this solution later. For now, it should be enough to know that there is sth like Acyclic visitor that can solve this problem, but it is inefficient. In this solution, we have double dispatch and the compiler can't optimize such code, also we have to call 2 or 3 virtual functions which are not as fast as calling normal functions.
Implementation
let's start by defining an interface for both, the Ammunition and Visitor class.
class Ammunition {
public:
Ammunition(size_t amount)
: _amount(amount) {}
virtual ~Ammunition() = default;
virtual void accept(const AmmunitionVisitor&) = 0;
size_t amount() const { return _amount; }
private:
size_t _amount = 0;
};
class AmmunitionVisitor {
public:
virtual ~AmmunitionVisitor() = default;
virtual void visit(const RoundShot&) const = 0;
virtual void visit(const ChainShot&) const = 0;
virtual void visit(const GrapeShot&) const = 0;
};
Each class that is derived from Ammunition needs to create a definition of the accept method. The is needed because each class passes a pointer to *this which allows us to distinguish which inherited class was provided.
class RoundShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AmmunitionVisitor& visitor) {
visitor.visit(*this);
}
};
class ChainShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AmmunitionVisitor& visitor) {
visitor.visit(*this);
}
};
class GrapeShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AmmunitionVisitor& visitor) {
visitor.visit(*this);
}
};
At the end we provide implementation for each visitor
class DamageVisitor : public AmmunitionVisitor {
public:
void visit(const RoundShot& shot) const override {
std::cout << "Round shot make 20 damages\n";
}
void visit(const ChainShot& shot) const override {
std::cout << "Chain shot make 30 damages\n";
}
void visit(const GrapeShot& shot) const override {
std::cout << "Grape shot make 40 damages\n";
}
};
class SoundVisitor : public AmmunitionVisitor {
public:
void visit(const RoundShot& shot) const override {
std::cout << "Fiuuuuuuuu Bam!\n";
}
void visit(const ChainShot& shot) const override {
std::cout << "Fiuuuuuuuuuuu Bam bam!\n";
}
void visit(const GrapeShot& shot) const override {
std::cout << "Fiuuuuuuuuuuu Bam bam bam bam bam!\n";
}
};
Usage
int main() {
std::unique_ptr<Ammunition> grapeShot = std::make_unique<GrapeShot>(100);
SoundVisitor soundVisitor;
DamageVisitor damageVisitor;
grapeShot->accept(soundVisitor);
grapeShot->accept(damageVisitor);
}
Fiuuuuuuuuuuu Bam bam bam bam bam!
Grape shot make 40 damages
Drawbacks
Visitor in this approach has 4 drawbacks:
-
First I mentioned earlier it will be hard to add a new type, we can solve it by using
acyclic visitor -
Visitor is tightly coupled with other classes and not elastic. For instance,
DamageVisitorimplementations may be similar, but we need to implement a method for every class. We can separate common parts to another function, but still, it will be great if we can have one function for all types if needed. By using visitors, this is not possible -
If we want to introduce visitors in an already implemented hierarchy of classes we need to add the
acceptmethod to every base class, sometimes we don't have access to all files in the repo, or the repository is divided and it is not possible. There is a solution for this, which I will show you later -
If we add a new class that inherits from an already existing type, and we forget to
overridemethod, we will have the same behavior as the class that we inherit, which is a huge bug!
Improvements
As I said a few times, the modern approach uses by-value semantics, instead of virtual functions. We can use static or compile-time polymorphism to achieve the same and have even better performance. let's rewrite this example to use std::variant which allows us to decouple classes, and make a code more flexible and even faster (I will show you a benchmark later).
class Ammunition {
public:
Ammunition(size_t amount)
: _amount(amount) {}
virtual ~Ammunition() = default;
size_t amount() const { return _amount; }
private:
size_t _amount = 0;
};
class RoundShot final : public Ammunition {
public:
using Ammunition::Ammunition;
};
class ChainShot final : public Ammunition {
public:
using Ammunition::Ammunition;
};
class GrapeShot final : public Ammunition {
public:
using Ammunition::Ammunition;
};
class DamageVisitor {
public:
void operator()(const RoundShot& shot) const {
std::cout << "Round shot make 20 damages\n";
}
void operator()(const ChainShot& shot) const {
std::cout << "Chain shot make 30 damages\n";
}
void operator()(const GrapeShot& shot) const {
std::cout << "Grape shot make 40 damages\n";
}
};
class SoundVisitor {
public:
void operator()(const RoundShot& shot) const {
std::cout << "Fiuuuuuuuu Bam!\n";
}
void operator()(const ChainShot& shot) const {
std::cout << "Fiuuuuuuuuuuu Bam bam!\n";
}
void operator()(const GrapeShot& shot) const {
std::cout << "Fiuuuuuuuuuuu Bam bam bam bam bam!\n";
}
};
We get rid of a lot of virtual functions and simplify the code.
int main() {
using Shot = std::variant<RoundShot, ChainShot, GrapeShot>;
Shot shot = GrapeShot(100);
SoundVisitor soundVisitor;
DamageVisitor damageVisitor;
std::visit(damageVisitor, shot);
std::visit(soundVisitor, shot);
}
Grape shot make 40 damages
Fiuuuuuuuuuuu Bam bam bam bam bam!
Benchmark
I created a cimple bechmark to show you, that visitors based on variant may be faster. It depends on how heavy the is function visit, if we perform a fast operation, we will gain a lot, but if the operation is heavy we will barely see the difference between both solutions.
Simplified class hierarchy
Drawback std::variant
We improved our code, but still, one problem exists, we can't easily add new types. The variant also provides another drawback: When types differ in size a lot, we will waste a lot of memory. In such a case we may think about using std::unique_ptr but this will force us to use dynamic allocation, so we lost some improvements (not fully use by-value semantic).
Last things left: How to eliminate this problem by adding a new type? Let's check the acyclic variant class:
Acyclic visitor
Implementation
First, we create an abstract visitor class and three types of visitor for each type. This allows us to cut dependency between each visitor, because each visitor has knowledge about only one ammunition type.
class AbstractVisitor {
protected:
// Can be only use by derived class
virtual ~AbstractVisitor() = default;
};
class RoundShotVisitor {
public:
virtual void visit(const RoundShot& shot) const = 0;
protected:
virtual ~RoundShotVisitor() = default;
};
class GrapeShotVisitor {
public:
virtual void visit(const GrapeShot& shot) const = 0;
protected:
virtual ~GrapeShotVisitor() = default;
};
class ChainShotVisitor {
public:
virtual void visit(const ChainShot& shot) const = 0;
protected:
virtual ~ChainShotVisitor() = default;
};
Next, we create dedicated visitors for damage and for sound. To hide implementations detail, each class inherit also from AbstractVisitor. This allows to use any visitor as pointer to this abstract class.
class DamageVisitor : public AbstractVisitor,
public RoundShotVisitor,
public GrapeShotVisitor,
public ChainShotVisitor {
public:
void visit(const RoundShot& shot) const override {
std::cout << "Round shot make 20 damages\n";
}
void visit(const ChainShot& shot) const override {
std::cout << "Chain shot make 30 damages\n";
}
void visit(const GrapeShot& shot) const override {
std::cout << "Grape shot make 40 damages\n";
}
};
class SoundVisitor : public AbstractVisitor,
public RoundShotVisitor,
public GrapeShotVisitor,
public ChainShotVisitor {
public:
void visit(const RoundShot& shot) const override {
std::cout << "Fiuuuuuuuu Bam!\n";
}
void visit(const ChainShot& shot) const override {
std::cout << "Fiuuuuuuuuuuu Bam bam!\n";
}
void visit(const GrapeShot& shot) const override {
std::cout << "Fiuuuuuuuuuuu Bam bam bam bam bam!\n";
}
};
For now, everything looks ok. But unfortunately, because AbstracVisitor has empty interface, we need to cast visitor to a specific type. This dynamic_cast cost a lot and make code harder to optimize. That's why this visitor is much slower than previous implementations.
class RoundShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const RoundShotVisitor*>(&visitor)) {
v->visit(*this);
}
}
};
class ChainShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const ChainShotVisitor*>(&visitor)) {
v->visit(*this);
}
}
};
class GrapeShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const GrapeShotVisitor*>(&visitor)) {
v->visit(*this);
}
}
};
Usage
The usage of this code doesn't change in comparison to the first implementation. But we lost on efficiency.
int main() {
std::unique_ptr<Ammunition> grapeShot = std::make_unique<GrapeShot>(100);
SoundVisitor soundVisitor;
DamageVisitor damageVisitor;
grapeShot->accept(soundVisitor);
grapeShot->accept(damageVisitor);
}
Fiuuuuuuuuuuu Bam bam bam bam bam!
Grape shot make 40 damages
Lost efficiency
Improvement
When visitor operations are quick, better to avoid acyclic visitor to not make the code slower. But when the operations are heavy, the difference will be slightly visible, and we will decouple visitor and ammunition classes. There is also one improvement, that make a code easier to extend. Instead of writing every time a visitor for a type, we can use template.
template <typename T>
class Visitor {
public:
virtual void visit(const T& shot) const = 0;
protected:
virtual ~Visitor() = default;
};
class DamageVisitor : public AbstractVisitor,
public Visitor<RoundShot>,
public Visitor<ChainShot>,
public Visitor<GrapeShot> {
public:
void visit(const RoundShot& shot) const override {
std::cout << "Round shot make 20 damages\n";
}
void visit(const ChainShot& shot) const override {
std::cout << "Chain shot make 30 damages\n";
}
void visit(const GrapeShot& shot) const override {
std::cout << "Grape shot make 40 damages\n";
}
};
class RoundShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const Visitor<RoundShot>*>(&visitor)) {
v->visit(*this);
}
}
};
class ChainShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const Visitor<ChainShot>*>(&visitor)) {
v->visit(*this);
}
}
};
class GrapeShot : public Ammunition {
public:
using Ammunition::Ammunition;
void accept(const AbstractVisitor& visitor) {
if (const auto* v = dynamic_cast<const Visitor<GrapeShot>*>(&visitor)) {
v->visit(*this);
}
}
};
UML
Exericse 1
-
Go into directory
Coreand implement a base classPrintVisitorwhich should implementvisitmethod for two objects:ShipandStore -
Create
PrettyPrintVisitorwhich will print cargo fromShipandSotore(just move the implementation fromprintCargo). -
Add the rest of necessary implementation for
ShipandStore. Visitor should be taken asstd::unique_ptr -
Verify in the
main.cppif cargo print in the same way as previously.
Exercise 2
-
Create another visitor:
BasicPrintVisitorwhich should print the cargo but without special frames, just raw info. -
For
Shipprint Name and Amount -
For
Storeprint Name Amount and Price -
You shouldn't modify anything in your code, just change the visitor in
main.cppand you should get a new output
Exercise 3
- Create CargoDamageVisitor. You don't need to create a base class, because we know that we will not extend this code,
-
You should create three
operator()one for each cargo type: Alcohol, Fruit, Item, - For fruit, you should draw a number between 0 and 5 and subtract such an amount of cargo
- For alcohol, you should draw a number between 0 and 10 and subtract half an amount of cargo
- For item, you should draw a number between 0 and 20 and subtract one quarter of cargo
- Visitor should be taken as a template argument by Player class
- Apply visitor whenever you successfully deal damage to ship
- Check in the main class if ship lost cargo after get damaged.
Summary
As you can see visitor is a very useful pattern to add additional functionality to your class, in such was you don't need to modify already existing code, instead you can inherit from visitor and create totally new functionality. You can also use std::variant if you want to avoid modify original class (by adding accept method) or when you can't modify class (for instance it is in separate repo). Version, with variant, is also more flexible and faster than traditional implementation. If you really want to break all dependencies between visitor class and subject, you can use acyclic visitor, but this solution is less efficient, so you need to be sure you can accept this in your code.