trainings/CreatingReliableSoftwareCpp/Presentation/visitor.md

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.

Visitor UML ___

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.

Visitor first apprach ___

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, DamageVisitor implementations 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 accept method 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 override method, 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.

Visitor benchamrk

Simplified class hierarchy

Visitor better design

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

Visitor acyclic

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

Acyclic visitor benchmark ___

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

Acyclic visitor UML ___

Exericse 1

  • Go into directory Core and implement a base class PrintVisitor which should implement visit method for two objects: Ship and Store
  • Create PrettyPrintVisitor which will print cargo from Ship and Sotore (just move the implementation from printCargo).
  • Add the rest of necessary implementation for Ship and Store. Visitor should be taken as std::unique_ptr
  • Verify in the main.cpp if cargo print in the same way as previously.

Exercise 2

  • Create another visitor: BasicPrintVisitor which should print the cargo but without special frames, just raw info.
  • For Ship print Name and Amount
  • For Store print Name Amount and Price
  • You shouldn't modify anything in your code, just change the visitor in main.cpp and 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.