[C++] Combine Type Erasure And Strategies
You can find the coding example here on compiler explorer.
In this article we'll continue on type erasuer. In my previous article Use Type Erasure To Get Dynamic Polymorphic Behavior you can find the basic idea behind type erasure.
Let's recall what we had:
- Two independent charakters,
knight
andskeleton
- A
character
whith a templated constructer to holdknight
orskeleton
- Inside
character
we hadc_concept
andc_model
which represents a character interface
Our character
class was like this:
// this represents any character
class character
{
public:
// we had a templated constructor, to create concrete characters
template<typename T>
character(T&& value);
// two public member functions
void who_am_i() const;
std::size_t get_strength() const;
private:
// and our interface what functions a character needs to implement
struct c_concept {
virtual ~c_concept() {}
virtual std::size_t get_strength() const = 0;
virtual void who_am_i() const = 0;
};
template<typename T>
struct c_model : public c_concept
{
std::size_t get_strength() const override;
void who_am_i() const override;
T m_value;
};
private:
// finally, we'll hold in m_value the charcter
std::unique_ptr<c_concept> m_value;
};
Now imagine you were given the task to add a render
method to render a character to the screen.
It might appear easy to just extend c_concept
(our interface) with render
and call a render
function on the concrete character (similar to what we did with who_am_i
and get_strength
). Then we'd leave the implementation to each concrete character. But here issues are arrising:
- Imagine you already have several characters and moving the rendering to the concrete class, you need to change your concretre classes
- A render method most likely brings another external dependency into your project (any graphics library)
- If you already have unittests on your character, you'll also put a potential graphics library to your unittests
- It increases complexity on the clients code, because we moved rendering to them
Let's Introduce A Strategy
We'll modify our code to add a render strategy to our character. This is kind of the Strategy Pattern. If you aren't familiar with the strategy pattern, you can for instance read on Refactoring Guru about it. In short: It allows you to modify behavior of an object.
Take a look on the modification in character
and see the comments:
class character
{
public:
// we extended the template parameters by a Renderer parameter
template<typename T, typename Renderer>
character(T&& character, Renderer&& renderer) {
// the given Renderer is passed to our model
m_character = std::make_unique<c_model<T, Renderer>>(std::move(character), std::move(renderer));
}
void who_am_i() const {
m_character->who_am_i();
}
std::size_t get_strength() const {
return m_character->get_strength();
}
// we add a render method to character
void render()
{
m_character->render();
}
private:
// we do extend our concept/interface to call render
struct c_concept {
virtual ~c_concept() {}
virtual std::size_t get_strength() const = 0;
virtual void who_am_i() const = 0;
virtual void render() const = 0;
};
// we also extended the model with a Renderer type
template<typename T, typename Renderer>
struct c_model : public c_concept
{
// the constructor receives the the renderer
c_model(T const& value, Renderer const& renderer)
: m_character(value), m_renderer(renderer) {};
std::size_t get_strength() const override {
return m_character.get_strength();
}
void who_am_i() const override {
m_character.who_am_i();
}
// we implement the render function in our model
// note: we extended the members and instead of using our
// concrete character, we'll use the Renderer which receives the character
void render() const override{
m_renderer(m_character);
}
T m_character;
// and here we add the Renderer as member to our model
Renderer m_renderer;
};
private:
std::unique_ptr<c_concept> m_character;
};
And these are all the changes we need to do in our character
class, we don't need to change the concrete characters knight
and skeleton
. Now you can pass a render type/function to a character
:
// this could be a renderer for a knight
// note: in the model we call the renderer just with the bracket operator
// therefore we simply overload it here
struct knight_renderer{
void operator()(const cwt::knight& k) const {
std::cout << "I'll take care of rendering" << std::endl;
}
};
// we can also use a dedicated render function for knights
void knight_render_function(const cwt::knight& knight){
std::cout << "I could also render a knight" << std::endl;
}
int main(){
// lets create a character and pass a knight renderer to it
cwt::character character{cwt::knight(10), knight_renderer{}};
character.render();
// lets create another knight, but this time
// we use the knight render function wrapped in a lambda
character = cwt::character{cwt::knight(10), [](const cwt::knight& k){
knight_render_function(k);
}};
character.render();
// i changed my mind i want to be a skeleton, and let's render the
// skeleton inside the lambda
character = cwt::character{cwt::skeleton(2), [](const cwt::skeleton& s){
std::cout << "This might be another rendering technique" << std::endl;
}};
character.render();
}
You can find the entire example here on compiler explorer.
Conclusion
This is a very simple example, but it demonstrates the combination of a strategy and type erasuer. Just think about it, we haven't touched the concrete characters knight
and skeleton
and we can pass custom behavior from outside to it. If you'd have existing code you can modify the abstract character without forcing all concrete characters to change (of course you'd need to pass a default renderer or something similar to the constructor).
Also, if you want to test concrete characters or you already have tests for them, you can just pass a dummy to your concrete classes. If you take this example here, you wouldn't need to introduce a graphics library to your unittests because the rendering is encapsulated from the actual character.
I hope this could help and thats it for now.
Best Thomas