Part of the Design Patterns series:
I recently wrote about the visitor design pattern, which is designed to add functionality to your objects without modifying the objects themselves, thus staying in compliance with the Open-Closed Principle. Unfortunately, this means adding new objects is no longer simple, as our visitor abstractions will need to be modified to handle these new classes. This time, I will be writing about the strategy design pattern, which solves the opposite problem: making it easier to create new types of objects without large changes to any existing implementation details, at the expense of not being able to easily add new operations.
Let’s consider two classes that need to be rendered as part of a 2D graphics project:
C++ 1class Shape {
2 public:
3 virtual ~Shape() = default;
4 virtual void draw() const = 0;
5};
6class Square : public Shape {
7 public:
8 explicit Square(double side) : side_(side) {};
9 void draw() const override;
10 private:
11 double side_;
12};
13class Circle : public Shape {
14 public:
15 explicit Circle(double radius) : radius_(radius) {};
16 void draw() const override;
17 private:
18 double radius_;
19};
In both cases, engineers wanting to use OpenGL might include it in the circle.cpp
and square.cpp
files and use it in their respective draw()
functions. Unfortunately, this is a bad design for a few reasons. First of all, both shape classes now depend on OpenGL and are no longer simple geometric data types; they now lug a large graphics library along with them. Secondly, we can no longer easily change how shapes are drawn without modifying our class. If we change our graphics library, we need to modify the class, violating the OCP. Also, having these shapes be in charge of drawing is a violation of the SRP and should be avoided.
If we wish to redesign this without using the visitor design pattern (perhaps because we expect many other shapes to be added in the future), we can use the strategy design pattern to accomplish this. The strategy design pattern involves abstracting out implementation details that will be injected into our classes to handle implementation details on the class’s behalf. Let’s take a look at a better solution for drawing our shapes:
C++ 1class DrawCircleStrategy {
2 public:
3 virtual ~DrawCircleStrategy() = default;
4 virtual void draw(Circle const& circle) const = 0;
5};
6class OpenGLCircleStrategy : public DrawCircleStrategy {
7 public:
8 void draw(Circle const& circle) const override;
9};
10
11class Circle : public Shape {
12 public:
13 explicit Circle(double radius, std::unique_ptr<DrawCircleStrategy> drawer) : radius_(radius), drawer_(std::move(drawer)) {};
14 void draw() const override {
15 drawer_->draw(*this);
16 }
17 private:
18 double radius_;
19 std::unique_ptr<DrawCircleStrategy> drawer_;
20};
21
22class DrawSquareStrategy {
23 public:
24 virtual ~DrawSquareStrategy() = default;
25 virtual void draw(Square const& square) const = 0;
26};
27class OpenGLSquareStrategy : public DrawSquareStrategy {
28 public:
29 void draw(Square const& square) const override;
30};
31
32class Square : public Shape {
33 public:
34 explicit Square(double side, std::unique_ptr<DrawSquareStrategy> drawer) : side_(side), drawer_(std::move(drawer)) {};
35 void draw() const override {
36 drawer_->draw(*this);
37 }
38 private:
39 double side_;
40 std::unique_ptr<DrawSquareStrategy> drawer_;
41};
We can now implement the drawing of circles and squares using OpenGL by injecting the respective strategy class into our Circle
and Square
classes during construction:
1int main() {
2 std::vector<std::unique_ptr<Shape>> shapes;
3 shapes.push_back(std::make_unique<Circle>(12, std::make_unique<OpenGLCircleStrategy>()));
4 shapes.push_back(std::make_unique<Square>(14, std::make_unique<OpenGLSquareStrategy>()));
5 for (auto const& shape : shapes) {
6 shape->draw();
7 }
8}
If we need to switch to a different graphics library for squares in the future, we can build out a new class that inherits from the DrawSquareStrategy
and implements usage of this different library. Our Square
class will never know of this change, nor will any of the other classes.
Other than the previously mentioned issue that the strategy pattern makes adding new functionality to our classes more difficult (since we need to add new virtual interface methods back into our base class), the implemented strategy pattern above will slow us down with all the indirections coming from virtual functions. But that is okay because this issue can be handled with static polymorphism using templates:
C++ 1template<typename DrawCircleStrategy>
2class Circle : public Shape {
3 public:
4 explicit Circle(double radius, DrawCircleStrategy drawer) : radius_(radius), drawer_(std::move(drawer)) {};
5 void draw() const override {
6 drawer_.draw(*this);
7 };
8 private:
9 double radius_;
10 DrawCircleStrategy drawer_;
11};
12
13template<typename DrawSquareStrategy>
14class Square : public Shape {
15 public:
16 explicit Square(double side, DrawSquareStrategy drawer) : side_(side), drawer_(std::move(drawer)) {};
17 void draw() const override {
18 drawer_.draw(*this);
19 };
20 private:
21 double side_;
22 DrawSquareStrategy drawer_;
23};
24
25int main() {
26 std::vector<std::unique_ptr<Shape>> shapes;
27 shapes.push_back(std::make_unique<Circle<OpenGLCircleStrategy>>(12, OpenGLCircleStrategy()));
28 shapes.push_back(std::make_unique<Square<OpenGLSquareStrategy>>(14, OpenGLSquareStrategy()));
29 for (auto const& shape : shapes) {
30 shape->draw();
31 }
32}
Instead of injecting a unique_ptr
with our draw strategy, we pass it as a template argument. As always, we lose some runtime flexibility with this approach but achieve significant performance improvements.
One of the biggest issues with the strategy design pattern is the potential explosion of classes and large constructors for injection. If our shapes need to support classes to serialize them and export to JSON, we now need to add new strategies for each derived shape class, and each of these needs to be injected into our class, which can quickly clutter our constructors.
The strategy design pattern is another tool in our toolbelt for designing software that is subject to change. In this case, we now have the ability to abstract out implementation details without concern that changes to those details in the future will impact our classes.