type_erasure_implementation_details
Breaking Dependencies - C++ Type Erasure - The Implementation Details - Klaus Iglberger
A motivating example
Would you provide an abstraction for callable by means of an inheritance hierarchy?
class Command {
public:
virtual void operator()(int) const = 0;
};
class PrintCommand : public Command { /**/ };
class SearchCommand : public Command { /**/ };
class ExecuteCommand : public Command { /**/ };
void f(Command* command);
NO, you wouldn't. std::function is probably a better approach! Type erasure instead of inheritance provides:
- no inheritance hierarchies
- non-intrusive
- less dependencies
- less pointers
- no manual dynamic allocation (and thus no manual life-time management)
- value semantics
- less code to write
- potentially better performance
class PrintCommand { /**/ };
class SearchCommand { /**/ };
class ExecuteCommand { /**/ };
void f(std::function<void(int)> command);
"Inheritance is rarely the answer. (Andrew Hunt, David Thomas, The pragmatic programmer)"
Type erasure - terminology
Type erasure is not ...
- a
void* - a pointer-to-base
- a
std::variant
Type erasure IS
- a templated constructor plus
- a completely non-virtual interface polus
- a mixed of severl design pattern: External polymorphism + Bridge + Prototype
A type-erased Shape
class Circle {
public:
explicit Circle(double rad)
: radius{rad}, //...
{}
double getRadius() const noexcept;
// getCenter(), getRotation(), ...
private:
double radius;
// remaining data members
};
class Square {
public:
explicit Square(double s)
: side{s}, //...
{}
double getSide() const noexcept;
// getCenter(), getRotation(), ...
private:
double side;
// remaining data members
};
So Circle and Square are ...
- don't need a base class
- don't know about each other
- don't know anything about their operations (affordances)
struct ShapeConcept {
virtual ~ShapeConcept() = default;
virtual void do_serialize(/*...*/) const = 0;
virtual void do_draw(/*...*/) const = 0;
};
template <typename ShapeT>
struct ShapeModel : public ShapeConcept {
ShapeModel(ShapeT shape) : shape_(std::move(shape)) {}
void do_serialize(/*...*/) const override { serialize(shape_, /*...*/); }
void do_draw(/*...*/) const override { draw(shape_, /*...*/); }
ShapeT shape_;
};
- So the key part of the
serializeanddrawcall is that, the implementation of the virtual functionsdo_serialize/do_drawin theShapeModeldefines the affordances required by the typeT. serializeanddraware free functions that is required to make compile to work. It's basically the same requirement that your base class enforces you.- The
ShapeConceptandShapeModelare the external polymorphism design: we create a separate hierarchy, separate model for some independent types. By doing so, we extract the isolated operations out from class.
The external polymorphism design pattern
- Allows any
shape_to be treated polymorphically - extracts implementation details (single responsibility principle)
- removes dependencies to operations (affordances)
- creates the opportunity for easy extension (open close principle)
Continue the example
The affordances
- These functions resolve the requirements posed by the External Polymorphism design pattern
- There can be many implementation, spread over many header/source files (e.g. for OpenGL, Metal, ...)
void serialize(const Circle&, /*...*/);
void draw(const Circle&, /*...*/);
void serialize(const Square&, /*...*/);
void draw(const Square&, /*...*/);
Usage
void drawAllShapes(const std::vector<std::unique_ptr<ShapeConcept>>& shapes) {
for (const auto& shape : shapes) {
shape->draw();
}
}
void main() {
using Shapes = std::vector<std::unique_ptr<ShapeConcept>>;
Shapes shapes;
shapes.emplace_back(std::make_unique<ShapeModel<Circle>>{2.0});
shapes.emplace_back(std::make_unique<ShapeModel<Square>>{1.5});
shapes.emplace_back(std::make_unique<ShapeModel<Circle>>{4.2});
drawAllShapes(shapes);
}
Improved with real type-erased
- Create an outer class
Shapeand putShapeConceptandShapeModelinto itsprivatesecction. - Add a private member:
std::unique_ptr<ShapeConcept> pimpl; - Add a public templated constructor for the
Shape- template parameter
ShapeTis used to construct thepimplaccordingly in this templated constructor ofShape - You can imagine, the templated constructor creating a bridge to construct the
pimpl(Bridge design pattern). It bridges implementation details.
- template parameter
- If we have a
Circle, we put it into the constructor, the ctor will create theShapeModel, instantiate it, gives it to the pointer ofShapeConcept, stores it in thepimpl, and we effectively erase the typeCircle- all we stored is a pointer to the baseShapeConcept. - Finally, we need few
friendfunction to use thepimpl- For the 2 friend serialize/draw function, despite being defined inside the class definition, these friend functions are free functions and injected into the surrounding namespace.
class Shape { // newly added outer class
private:
struct ShapeConcept { // same as before, but inside Shape
virtual ~ShapeConcept() = default;
virtual void do_serialize(/*...*/) const = 0;
virtual void do_draw(/*...*/) const = 0;
};
template <typename ShapeT> // same as before, but inside Shape
struct ShapeModel : public ShapeConcept {
ShapeModel(ShapeT shape) : shape_(std::move(shape)) {}
void do_serialize(/*...*/) const override { serialize(shape_, /*...*/); }
void do_draw(/*...*/) const override { draw(shape_, /*...*/); }
ShapeT shape_;
};
std::unique_ptr<ShapeConcept> pimpl; // newly created member
public: // newly added
template <typename ShapeT>
Shape(ShapeT shape)
: pimpl{std::make_unique<ShapeModel<ShapeT>>(std::move(shape))} {}
Shape(const Shape& other); // To be discussed
Shape& operator=(const Shape& other); // To be discussed
Shape(Shape&& other); // To be discussed
Shape& operator=(Shape&& other); // To be discussed
private:
friend void serialize(const Shape& shape, /*...*/) {
shape.pimpl->do_serialize(/*...*/);
}
friend void draw(const Shape& shape, /*...*/) {
shape.pimpl->do_draw(/*...*/);
}
};
Dig in further, how do we copy a Shape?
- We only have a pointer to base
ShapeConcept, how do we copy while not knowing the actual type passed in constructor? - We need to add a
clone()function... (e.g. the Prototype design pattern)- Note that the use of copy constructor of the
ShapeModelinShapeModel::clone: This will always do the right thing even if other code changes.
- Note that the use of copy constructor of the
class Shape {
private:
struct ShapeConcept {
virtual ~ShapeConcept() = default;
virtual void do_serialize(/*...*/) const = 0;
virtual void do_draw(/*...*/) const = 0;
virtual std::unique_ptr<ShapeConcept> clone() const = 0; // newly added
};
template <typename ShapeT>
struct ShapeModel : public ShapeConcept {
ShapeModel(ShapeT shape) : shape_(std::move(shape)) {}
// newly added
std::unique_ptr<ShapeConcept> clone() const override {
return std::make_unique<ShapeModel>(*this);
}
void do_serialize(/*...*/) const override { serialize(shape_, /*...*/); }
void do_draw(/*...*/) const override { draw(shape_, /*...*/); }
ShapeT shape_;
};
std::unique_ptr<ShapeConcept> pimpl;
public:
template <typename ShapeT>
Shape(ShapeT shape)
: pimpl{std::make_unique<ShapeModel<ShapeT>>(std::move(shape))} {}
// newly added implementations
Shape(const Shape& other) : pimpl(other.pimpl->clone()) {}
Shape& operator=(const Shape& other) {
// copy-and-swap idiom
Shape tmp(other);
std::swap(pimpl, tmp.pimpl);
// or you can use:
// other.pimpl->clone().swap(pimpl);
return *this;
}
Shape(Shape&& other); // To be discussed
Shape& operator=(Shape&& other); // To be discussed
private:
friend void serialize(const Shape& shape, /*...*/) { /*...*/
}
friend void draw(const Shape& shape, /*...*/) { /*...*/
}
};
How about the move operations?
- Option 1: Moved-from shapes are semantically equivalent to a
nullptr(e.g. thepimplbecomesnullptrunder the hood) - Option 2: Move remains undefined, copy servers as a fallback. However, the implication is that the move operations (which fallbacks to copy) are not
noexcept. - Option 3: Leave move constructor undefined (so fallback to copy), the move assignment operator is implemented in terms of swap
// Shape(Shape&& other); // leave it undefined
Shape& operator=(Shape&& other) noexcept {
pimpl.swap(other.pimpl);
return *this;
}
The usage
Inside of the Shape, it's a little bit complicated, but outside of it, for the user of Shape:
- No more pointers in the usage
- No manual dynamic allocation
- No manual life-time management
- value semantics
void serialize(const Circle&, /*...*/);
void draw(const Circle&, /*...*/);
void serialize(const Square&, /*...*/);
void draw(const Square&, /*...*/);
void drawAllShapes(const std::vector<Shape>& shapes) {
for (const auto& shape : shapes) {
draw(shape);
}
}
void main() {
using Shapes = std::vector<Shape>;
Shapes shapes;
shapes.emplace_back(Circle{2.0});
shapes.emplace_back(Square{1.5});
shapes.emplace_back(Circle{4.2});
drawAllShapes(shapes);
}
What about testability?
- The concern is separated, so it's easier to test than you think
- "High level"
Shapeis the abstraction lives, which is more stable than the "low level". It doesn't contain any details of individual shapes. - What if people still want to inject something for testing?
template <typename ShapeT, typename DrawStrategy>
struct ExtendedModel : public Concept {
explicit ExtendedModel(ShapeT shape, DrawStrategy drawer)
: shape_(std::move(shape)), drawer_(std::move(drawer)) {}
void do_draw() const override { drawer_(shape_, /* ... */); }
void do_serialize() const override { drawer_(shape_, /* ... */); }
std::unique_ptr<Concept> clone() const override {
return std::make_unique<ExtendedModel>(*this);
}
ShapeT shape_;
DrawStrategy drawer_;
};
// and then we can inject it with ...
class Shape {
//..
template <typename ShapeT, typename DrawStrategy>
Shape(ShapeT shape, DrawStrategy drawer)
: pimpl(std::make_unique<ExtendedModel<ShapeT, DrawStrategy>>(
std::move(shape), std::move(drawer))) {}
//...
};
// then in main, you could use a lambda as the DrawStrategy like this, and
// potentially inject some testing logic
void main() {
using Shapes = std::vector<Shape>;
Shapes shapes;
shapes.emplace_back(Circle{2.0});
shapes.emplace_back(Square{1.5});
shapes.emplace_back(Circle{4.2},
[/* ... */](const Circle& circle, /* ... */) {
/* Implementing the logic for drawing a circle*/
});
drawAllShapes(shapes);
}
How about performance?
- It's actually similar to tradition OO with polymorphism.
- How to improve?
Optimization 1: Small Buffer Optimization (SBO)
- This part of code is doing a dynamic memory allocation via
new
class Shape {
//...
std::unique_ptr<ShapeConcept> pimpl;
public:
template <typename ShapeT>
Shape(ShapeT shape)
: pimpl{std::make_unique<ShapeModel<ShapeT>>(std::move(shape))} {}
//...
};
- We can do this instead...
class Shape {
private:
struct ShapeConcept {
virtual ~ShapeConcept() = default;
virtual void do_serialize(/*...*/) const = 0;
virtual void do_draw(/*...*/) const = 0;
virtual void clone(Concept* memory) const = 0; // newly added
virtual void move(Concept* memory) const = 0; // newly added
};
template <typename ShapeT>
struct ShapeModel : public ShapeConcept {
ShapeModel(ShapeT shape) : shape_(std::move(shape)) {}
// newly added
void clone(Concept* memory) const override { ::new (memory) Model(*this); }
// newly added
void move(Concept* memory) const override {
::new (memory) Model(std::move(*this));
}
void do_serialize(/*...*/) const override { serialize(shape_, /*...*/); }
void do_draw(/*...*/) const override { draw(shape_, /*...*/); }
ShapeT shape_;
};
Concept* pimpl() noexcept {
return reinterpret_cast<Concept*>(buffer.data());
}
Concept* pimpl() const noexcept {
return reinterpret_cast<Concept*>(buffer.data());
}
static constexpr size_t buffersize = 128UL;
static constexpr size_t alignment = 16UL;
alignas(alignment) std::array<std::byte, buffersize> buffer;
public:
template <typename ShapeT>
Shape(const ShapeT& shape) {
using M = Mode<ShapeT>;
static_assert(sizeof(M) <= buffersize, "Given type is too large");
static_assert(alignof(M) <= alignment, "Given type is over-aligned");
::new (pimpl()) M(shape);
}
~Shape() { pimpl()->~Concept(); } // newly added
Shape(const Shape& other) { other.pimpl()->clone(pimpl()); }
Shape& operator=(const Shape& other) {
// copy-and-swap idiom
Shape tmp(other);
buffer.swap(copy.buffer);
return *this;
}
Shape(Shape&& other) { other.pimpl()->move(pimpl()); }
Shape& operator=(Shape&& other) {
Shape tmp(std::move(other));
buffer.swap(tmp.buffer);
return *this;
}
//...
};
- If hardcoded size is not ideal, you could try...
template<size_t buffersize = 128UL, size_t alignment = 16UL>
class Shape {
//...
};
or policy based design...
template<typename StoragePolicy>
class Shape {
//...
};
class DynamicStorage { /*...*/ };
class StaticStorage { /*...*/ };
class HybridStorage { /*...*/ };
//...
- ... skip for this talk.
Optimization 2: Manual virtual dispatch (MVD)
- Motivation, there is extra overhead here:
void draw(const Shape& shape) { /*Drawing the given shape*/ }
int main() {
Circle circle(1.2);
Square square(2.3);
draw(circle);
draw(square);
}
- This will compile fine, but under the hood a temporary Shape is constructed whenever we make a call. (And, if without SBO, it will be an dynamic allocation)
- Can we have some kind of view like class that would have prevented the allocation nor copy?
class ShapeConstRef {
public:
template<typename ShapeT>
ShapeConstRef(const ShapeT& shape)
: shape_{&shape} // or shape_{std::addressof(shape)}
// stateless lambda can be converted to function pointer
, draw_{[](void const* shape) {
draw(*static_cast<const ShapeT*>(shape));
}}
{}
private:
friend void draw(const ShapeConstRef& shapeCR) {
shapeCR.draw_(shape.shape_);
}
private:
const void* shape_{nullptr};
using DrawOperation = void(void const*);
DrawOperation* draw_{nullptr};
};
// Then we could do this:
// void draw(const ShapeConstRef& shape) { /*...*/ }
//
// and
// Circle circle;
// draw(circle);
//
// ... etc