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.
#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
:
virtual Shape::ShapeModel<ConcreteShape>::draw()
is provided to dispatch to correct freedraw(ConcreteShape const&)
implementation somewhere else, that theConcreteShape
must provide. Currently it is implemented as::draw(concrete_shape);
, which allows the innerdraw(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 expecteddraw(concrete_shape)
to work just fine and findConcreteShape
'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?- 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 referenceShape&
binds to the copy constructor instead. However, after debugging this problem I also realized that whenConcreteShape
passed toShape
constructor is a reference, then theobject_
member of typeConcreteShape
may also be a reference, and no copy will be made, which is probably unexpected. Is this assumption correct? Should I cvref-stripConcreteShape
before passing it tomake_unique
?
Finally, is this a correct implementation of the type erasure pattern, or did I miss something? Anything else you would change?