may contain source code

published: • tags:

The open-closed principle needs no introduction. It’s a part of SOLID, it’s a valuable design tool, mountains of text have been written about it.

To recap: The principle says to make your software open for extension, but closed for modification.

Once you decide that following that principle is valuable for a component of your software, how do you implement it? Immediately everybody thinks of a polymorphic class hierarchy. After all, that’s the example used in virtually every article on the topic. I want to quickly point out another possibility that should be equally obvious to a C++ programmer in 2020: std::variant combined with std::visit.

These are two common and general approaches to solve the open-closed issue. They are not the only possibilities by far.

What’s the difference? How to decide if and which one to use? It boils down to one crucial question: Open for what kind of extension?

Regarding that question polymorphic hierarchies and variant/visit both have the same two major aspects: A set of types and a set of operations on objects of those types. For a polymorphic hierarchy the set of types is the base class and its derived classes. The set of operations are the pure virtual member functions defined in the base class: the interface functions. For variant/visit the set of types is the variant’s type list. The set of operations is comprised of all the visitors for that particular variant.

Each of the approaches makes it simple to extend one aspect and annoying to extend the other. Which one is simple and which one is annoying is exactly opposite. You need openness where you expect change in the form of extensions to happen. That determines which approach to choose.

  • Polymorphic hierarchies are open for extending the set of types through new derived classes. They are closed for adding new operations, i.e. interface functions.
  • Variant/visitor are open for adding new operations through new visitors. They are closed for extending the set of types, i.e. the variant’s type list.

That sounds quite abstract and academic, so let me try again with examples. Let’s start with one of the classic examples for a polymorphic hierarchy.

class Shape {
public:
    virtual void draw() = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() override;
};

class Square : public Shape {
public:
    void draw() override;
};

What if you need to add another derived class Triangle to this hierarchy? No problem. Just derive from Shape, implement the interface functions, done. No need to change any of the existing code. That’s openness regarding the set of types. But what if you need to add another interface function to the base class, say area()? Sorry, the set of operations is closed. Adding that function requires changes in all of the existing derived classes.

With variant/visitor it’s the other way around.

// note: no member functions for drawing or area calculation
class Circle { /* ... */ };
class Square { /* ... */ };

using Shape = std::variant<Circle, Square>;

struct ShapeDrawer {
    void operator()(const Circle&);
    void operator()(const Square&);
};

What if you need to add a Triangle here? Sorry, closed. You’d have to touch every existing visitor implementation to add support for the new type. But what if you need a new operation to calculate the area? Everything is open for that kind of extension. No need to touch existing implementations. Just implement a ShapeAreaCalculator visitor, done.

In conclusion, before you settle on a specific design, be aware of the kind of extension you need to be open for. Picking the wrong one will make implementing extensions painful. But it’s a trade off and in a real code base other considerations play their role, too. If it turns out that violating the open-closed principle is the simplest, most elegant and most maintainable solution in the grand scheme of things, don’t hesitate.

Comments