8. polymorphic
— Polymorphic collections¶
This module contains collection classes based on the standard library collections
which are meant to store instances of polymorphic types. Through the Adapter
template template parameter, they allow to use regular polymorphism but also to
automagically adapt the stored types and implement concept-based polymorphism.
Every class in this module lives in the namespace polder::polymorphism
. The
collection classes all follow the same general design:
template<
typename Interface,
template<typename> class Adapter = default_adapter
>
class collection
{
// ...
};
In this class, the template parameter Interface
corresponds to the class
describing the concept or interface that the stored types must satisfy. With the
default adapter, it means that every type whose instances are stored in the
collection must derive from Interface
. It becomes a bit more subtle when we
use custom adapters, so we will come back to this in the part about concept-based
polymorphism.
The collection classes are design to be as close as possible from the standard library collection classes, so learning how to use them should be straightforward for anyone already using the standard library. There are however a few differences that you should know about:
- There is no
value_type
member in the polymorphic collections. That is because it would be impossible to sanely alias any type. We don’t want to aliasInterface
because it is generally an abstract class but it wouldn’t mean anything to aliasInterface&
orInterface*
either. We decided to avoid any surprise due to one assumption or another and to simply removevalue_type
. - The
emplace_*
family of functions takes an additional template parameter so it knows which type it has to construct and add to the collection. We generally want to construct an instance of a derived type and this additional type parameter is there for that. - The collections currently don’t handle custom allocators but that could be a future direction for the module.
8.1. Regular polymorphism¶
At first, we will ignore the Adapter
template template parameter and focus on
the first use case of polymorphic collections: store instances of polymorphic types.
So, let’s write a simple hierarchy of shapes:
struct Shape
{
virtual std::string name() const = 0;
virtual ~Shape() {};
};
struct Circle:
Shape
{
Circle(int /* x */, int /* y */, int /* radius */) {}
virtual std::string name() const override
{
return "Circle";
}
};
struct Rectangle:
Shape
{
Rectangle(int /* x */, int /* y */, int /* height */, int /* width */) {}
virtual std::string name() const override
{
return "Rectangle";
}
};
Nothing new here: we have a very simple hierarchy with a base class Shape
and two subclasses that reimplement the method name
. Here is an example
using a polymorphic::vector<Shape>
to demonstrate how simple it is to
add anything derived from Shape
into it:
int main()
{
// Create a collection, feed it the base class used by every
// type whose instances will be stored
polder::polymorphic::vector<Shape> shapes;
// Add elements at the end of the collection
shapes.emplace_back<Circle>(1, 2, 3);
shapes.push_back(Rectangle(4, 5, 6, 7));
// Insert elements wherever we want to
shapes.emplace<Rectangle>(shapes.begin(), 8, 9, 10, 11);
shapes.insert(shapes.end(), Circle(12, 13, 14));
// Print the name of the class of the stored instances, effectively
// calling Circle::name and Rectangle::name when needed
for (const Shape& shape: shapes)
{
std::cout << shape.name() << '\n';
}
}
As you can see, using such a class is merely as easy as using a standard library collection. Be careful however that you always use reference or pointer semantics. Raw value semantics don’t well with polymorphism so you could be up to get unlucky.
8.2. Concept-based polymorphism¶
Concept-based polymorphism is a powerful mechanism that allows to store instances
of several types in our polymorphic collections as long as they satisfy the concept
of the Interface
class. In other words, you can store them as long as you know
how to adapt them so that they are compatible with the virtual
methods of the
Interface
class without even having them actually derive from Interface
.
To make it easier to understand, let’s take our shapes from before and strip them
from their base class:
struct Circle
{
Circle(int /* x */, int /* y */, int /* radius */) {}
std::string name() const
{
return "Circle";
}
};
struct Rectangle
{
Rectangle(int /* x */, int /* y */, int /* height */, int /* width */) {}
std::string name() const
{
return "Rectangle";
}
};
The polymorphic
module will still allow you to store instances of these classes into
its collections as long as you know how to write the corresponding adapter. Fortunately,
it’s quite easy to write for such a simple Interface
class. Here is a ShapeAdapter
:
template<typename T>
struct ShapeAdapter:
Shape
{
template<typename... Args>
ShapeAdapter(Args&&... args):
data(std::forward<Args>(args)...)
{}
virtual std::string name() const override
{
return data.name();
}
T data;
};
This ShapeAdapter
does three things:
- It stores an instance of
T
, which would be an instance ofRectangle
orCircle
in our case. - It forwards everything it is constructed with to the constructor of the wrapped type so
that the
emplace_*
family of functions work like a charm. - It It reimplements the
virtual
functions fromShape
so that every reimplemented function calls the function with the same name in the wrapped class.
Now, we can rewrite the first examples with our new Circle
and Rectangle
classes
and let the ShapeAdapter
do its job to call the appropriate functions:
int main()
{
// Create a collection, feed it the class to be used as
// an interface and the class to adapt other classes to
// this interface
polder::polymorphic::vector<Shape, ShapeAdapter> shapes;
// Add elements at the end of the collection
shapes.emplace_back<Circle>(1, 2, 3);
shapes.push_back(Rectangle(4, 5, 6, 7));
// Insert elements wherever we want to
shapes.emplace<Rectangle>(shapes.begin(), 8, 9, 10, 11);
shapes.insert(shapes.end(), Circle(12, 13, 14));
// Print the name of the class of the stored instances, effectively
// calling Circle::name and Rectangle::name when needed
for (const Shape& shape: shapes)
{
std::cout << shape.name() << '\n';
}
}
With a single adapter, we managed to transform a non-polymorphic but consistent family of classes so that they can be stored and used as if they all derived from a same given base class. While adapters were originally design to implement concept-based polymorphism, they are actually a much more powerful mechanism and allow to add some common features to a consistent family of classes.