3

I have recently watched a CppCon 2021 talk by Klaus Iglberger about type erasure pattern and tried to implement it based on the slides. Despite the code from slides not compiling, after a lot of searching and reading SO answers I got an implementation that seems to work, but I have further questions.

First, the complete implementation is presented. Shape is an "external" interface that specifies a draw() requirement. Circle and Square adhere to this interface requirement and provide implementations in the form of free functions. Furthermore, one of the goals of type erasure is for Shape to have value semantics.

Live on Compiler Explorer

#include <iostream>
#include <memory>
#include <type_traits>
#include <utility>
#include <vector>

class Circle {
   public:
    explicit Circle(double radius) : radius_(radius) {}

   private:
    double radius_;
};

class Square {
   public:
    explicit Square(double side) : side_(side) {}

   private:
    double side_;
};

void draw(Circle const& c) { std::cout << "Drawing a circle\n"; }
void draw(Square const& s) { std::cout << "Drawing a square\n"; }

class Shape {
    // Shape's interface is implemented as hidden friends.
    friend void draw(Shape const& shape) {
        // Dispatch in ShapeModel<ConcreteShape>::draw().
        shape.pimpl_->draw();
    }

    class ShapeConcept {
       public:
        virtual ~ShapeConcept() = default;

        // Shape's interface declaration.
        virtual void draw() const = 0;

        // Make ShapeConcept's children copyable through ShapeConcept pointer.
        [[nodiscard]] virtual std::unique_ptr<ShapeConcept> clone() const = 0;

       protected:
        ShapeConcept() = default;
        ShapeConcept(ShapeConcept const&) = default;
        ShapeConcept(ShapeConcept&&) noexcept = default;
        ShapeConcept& operator=(ShapeConcept&&) noexcept = default;
        ShapeConcept& operator=(ShapeConcept const&) = default;
    };

    template <typename ConcreteShape>
    class ShapeModel final : public ShapeConcept {
       public:
        explicit ShapeModel(ConcreteShape&& concrete_shape)
            // Copy-construct or move-construct the concrete type.
            : object_{std::forward<ConcreteShape>(concrete_shape)} {}

        void draw() const override {
            // Call free draw() on objects implementing the Shape interface.
            // XXX: why doesn't it work without scope resolution operator, by
            // ADL? draw(object_);
            ::draw(object_);
        }

        [[nodiscard]] std::unique_ptr<ShapeConcept> clone() const override {
            return std::make_unique<ShapeModel>(*this);
        }

       private:
        ConcreteShape object_;
    };

   public:
    template <
        typename ConcreteShape,
        // Make sure that templated forwarding constructor does not hide the
        // copy constructor. Another very important thing is to decay the
        // ConcreteShape before comparison, otherwise Shape& binds to this
        // constructor.
        std::enable_if_t<!std::is_same_v<Shape, std::decay_t<ConcreteShape>>,
                         bool> = true>
    explicit Shape(ConcreteShape&& concrete_shape)
        // XXX: Do I need to remove_cvref of ConcreteShape before passing it to
        // make_unique?
        : pimpl_{std::make_unique<ShapeModel<ConcreteShape>>(
              std::forward<ConcreteShape>(concrete_shape))} {}

    // Deep copy of the concrete shape object.
    Shape(Shape const& other) : pimpl_{other.pimpl_->clone()} {}

    // Deep copy of the concrete shape object.
    Shape& operator=(Shape const& rhs) {
        if (&rhs != this) {
            pimpl_ = rhs.pimpl_->clone();
        }
        return *this;
    }

    // unique_ptr is movable by default.
    Shape(Shape&&) noexcept = default;
    Shape& operator=(Shape&&) noexcept = default;

    ~Shape() = default;

   private:
    // Pointer to a ShapeModel, which has a member object of the concrete type.
    std::unique_ptr<ShapeConcept> pimpl_;
};

void draw_all_shapes(std::vector<Shape> const& shapes) {
    for (auto const& shape : shapes) {
        // Call Shape's hidden friend draw() by ADL.
        draw(shape);
    }
}

int main() {
    // Does Shape behave like a value?

    Circle c{3};
    Shape s1{c};
    Shape s2{Circle{3.0}};
    Shape s4{Shape{Circle{3.0}}};
    Shape s5{std::move(s2)};
    Shape s6 = s5;

    std::vector<Shape> shapes;
    shapes.emplace_back(Circle{1.0});
    shapes.emplace_back(Square{2.0});
    draw_all_shapes(shapes);

    Shape s7 = Shape{Circle{3.0}};
    Shape s8{s7};
    const Circle c1{1};
    Shape s9{c1};
    const Shape s10{std::move(c1)};
    const Shape s11{Shape{Circle{1}}};
    const Shape s12{s11};
    Shape& s13 = s9;
    Shape const& s14 = s12;

    // Everything *seems* to work..., but does it?
}

And now the questions (marked with XXX), ConcreteShape is either a Circle or a Square:

  1. virtual Shape::ShapeModel<ConcreteShape>::draw() is provided to dispatch to correct free draw(ConcreteShape const&) implementation somewhere else, that the ConcreteShape must provide. Currently it is implemented as ::draw(concrete_shape);, which allows the inner draw(ConcreteShape const&) to be looked up in global namespace. However, if I put it inside a namespace, it will not compile. Regardless, I would have expected draw(concrete_shape) to work just fine and find ConcreteShape's free implementation through ADL, and current implementation is a workaround. What went wrong, why doesn't the ADL work? And most importantly, is it possible to fix it, or do I have to hardcode the full path to exact namespace that has free implementations?
  2. Moving on to Shape's templated forwarding constructor. The talk does not show this, but based on the chapter about forwarding constructors in "Effective Modern C++" and other SO answers I have implemented a check for cvref-stripped type equivalence, so that an lvalue reference Shape& binds to the copy constructor instead. However, after debugging this problem I also realized that when ConcreteShape passed to Shape constructor is a reference, then the object_ member of type ConcreteShape may also be a reference, and no copy will be made, which is probably unexpected. Is this assumption correct? Should I cvref-strip ConcreteShape before passing it to make_unique?

Finally, is this a correct implementation of the type erasure pattern, or did I miss something? Anything else you would change?

Ave Milia
  • 599
  • 4
  • 12
  • 2
    [A simple example](https://godbolt.org/z/sadM7P5bP) to reproduce stand-alone `draw` not being found by ADL lookup. Not yet sure why. – Igor Tandetnik May 01 '22 at 16:53
  • 2
    The easy workaround is to name the `ShapeConcept`'s virtual method something other than `draw`. Since it's internal, you can give it any name. – Igor Tandetnik May 01 '22 at 17:24
  • 2
    Your intuition is correct: with `Circle c{3}; Shape s1{c};`, `s1` ends up holding a reference to `c`, not a copy thereof. [Demo](https://godbolt.org/z/19W1qz3aj) (note the same address printed twice). – Igor Tandetnik May 01 '22 at 17:33

0 Answers0