Why I no longer use c++ inheritance

Posted on 2025-06-05 in category C++

introduction

I paved my way into the world of programming with Java and C++, It was almost mandatory to learn OOP and inheritance, when I first started programming, I was told that inheritance was the best way to reuse code and create a hierarchy of classes. Nobody in my surroundings never questioned this, we were even encouraged and forced to use inheritance in multiple university assignments. It almost seemed obvious, when I was into game development, there were multiple layers of inheritance in the enemies and items, each layer was inheriting for a different class, and it was easy to add new items and enemies by just inheriting from the base class.

But as I had more experience with C++ and I had to deal with projects which used inheritance extensively, I started to see the cracks in the system.

Blog Post Index

1. Traditional Uses of Inheritance and Interfaces

In any language, an Interface (or API) is a contract which defines which methods can be called for specific functionality. In the case of C++, interfaces are often tied to a class which defines some data and methods to work with that data. However, in C++ it is also common to use this interfacing for polymorphism, which is achieved via inheritance.

When we talk about inheritance in c++, we can refer to Interfacing via inheritance or expanding via inheritance, which are two different concepts.

When we talk about interfacing via inheritance, we’re referring to the practice of using pure virtual functions in a base class to define a set of operations that derived classes must implement. This is a key part of achieving polymorphism in C++. The base class acts as an interface, and derived classes provide the specific implementations.

However, we can also expand via inheritance, where a derived class builds upon the functionality provided by a base class. In this case, the base class is not necessarily abstract. It may contain implemented methods and data members that are shared among derived classes. The derived class can reuse, override, or extend these members to provide more specialized behavior.

In this post, I will explain why I think that either of this approaches shouldn’t rely on inheritance at all, and you should only use inheritance for metaprogramming.

Interfaces via Pure Virtual Classes

In C++, a common way to define an interface is with an abstract base class that has one or more pure virtual methods. For example:

struct IRenderable {
    virtual ~IRenderable() = default;    // Ensure proper cleanup
    virtual void render() const = 0;      // Must be overridden
};

While the syntax may look a bit rough, = 0 makes render() pure virtual, meaning that any subclass must provide its own implementation and making IRendereable not constructable. It is also good practice to mark The destructor as virtual, which makes deleting through an IRenderable* to invoke the correct derived destructor.

Interfaces for dynamic dispatch

In many cases, when we inherit from a base class, we gain the ability to use polymorphism and dynamic dispatch. This allows us to write code that can work with objects of different derived classes through a base class pointer or reference, without knowing the exact type at compile time.

void drawScene(const std::vector<std::unique_ptr<IRenderable>>& items) {
    for (const auto& item : items) {
        item->render();
    }
}

With this approach, we can store using the interface class as common type, different classess that implement the same interface in a container, and call the specific implementation of each derived class for an object at runtime.

Inheritance for Data Composition

Inheritance is also sometimes used to compose objects that hold data, with this pattern, we are not directly defininf an interface, but adding methods and data to a class, provinent from another class.

Example of Data Composition via Inheritance:

class EntityData {
protected:
    int health;
    int mana;
public:
    EntityData(int h, int m) : health(h), mana(m) {}
};

class EnemyData : public EntityData {
private:
    int threatLevel;
public:
    EnemyData(int h, int m, int threat) : EntityData(h, m), threatLevel(threat) {}
};

Virtual Method Pitfalls

Overriding in C++ relies on exact signature matching for virtual functions:

  • Signature Sensitivity: A typo or mismatched parameter list silently creates a new non‑virtual method rather than overriding the base version.
  • False Confidence: Without the override specifier, developers may assume a method is virtual when it isn’t, leading to unexpected behavior at runtime.
struct Base { virtual void update(int) = 0; };
struct Derived : Base {
    void update(float);      // Compiles, but does NOT override Base::update(int)
    void update(int) override; // Compiler error if signature mismatches
};

Best Practice: Always append override to virtual methods to let the compiler catch signature mismatches early.

Non‑virtual Destructor Pitfall

If the base destructor isn’t declared virtual, deleting via a Base* only calls Base::~Base(), and the Derived destructor (and any of its cleanup) is never invoked—leading to resource leaks and undefined behavior.

#include <print>

struct Base {
    Base()  { std::println("Base ctor"); }
    ~Base() { std::println("Base dtor"); }  // Not virtual!
};

struct Derived : Base {
    Derived()  { std::println("Derived ctor"); }
    ~Derived() { std::println("Derived dtor"); }
};

int main() {
    Base* obj = new Derived();
    delete obj;  
    // Output:
    //   Base ctor
    //   Derived ctor
    //   Base dtor       <-- Derived dtor never called!
    return 0;
}

Best Practice: Always declare the base class destructor as virtual if the class is meant to be used polymorphically:

struct Base {
    virtual ~Base() = default;  // Now delete obj properly calls Derived::~Derived()
};

This guarantees the full chain of destructors runs, ensuring proper cleanup of derived resources, this is one of the most common ways memory leak when using inheritance.

Hidden Data Layout

Using inheritance to compose data can scatter an object’s state across multiple base classes:

  • Distributed State: Member variables live in different levels of the hierarchy, often in separate files.
  • Maintenance Overhead: Tracking total memory footprint or locating a specific field may require jumping between class definitions.
  • Testing & Serialization Complexity: Gathering all data for unit tests or serializing an object graph becomes error‑prone when parts of its state are hidden deep in parent classes.

Multiple Inheritance Challenges

Deriving from more than one class introduces method and state ambiguities:

  • Name Hiding: When two base classes define members with the same name or signature, the derived class must disambiguate explicitly using qualified names. This includes method calls, member variables, and inherited types.
struct A { void draw(); };
struct B { void draw(); };
struct C : A, B {
    void render() {
        A::draw();  // Must choose A or B explicitly
    }
};

The Diamond Problem and Virtual Inheritance

The classic diamond occurs when two intermediate classes share the same ancestor:

    Base
   /    \
  A      B
   \    /
    Derived

Derived ends up with two separate Base subobjects. Accessing Base members becomes unclear, Base from which inherited class?, for this, we can use virtual inheritance by prefixing the intermediate bases with virtual. This ensures that a single shared Base instance is instantiated.

struct Base { int id; };
struct A : virtual Base { };  
struct B : virtual Base { };
struct C : A, B { };  // C now has one Base subobject

Slicing

When objects of derived types are passed or assigned by value to base class objects, the derived parts are lost.

struct Base { int a; };
struct Derived : Base { int b; };

Base b = Derived(); // b.b is sliced off and lost

Too many specific keywords and syntax for inheritance

C++ inheritance introduces a number of syntactic constructs that are rarely encountered outside of class hierarchies, this makes the language harder and adds little bits of information scattered around all the class that directly affect the behavior, which complicates the mental map of these classess.

  • Access-specifier on inheritance:

    struct Derived : public Base { /*…*/ };
    struct PrivateDerived : private Base { /*…*/ };
  • Virtual base specifier:

    struct V : virtual Base { };
  • Pure-specifier (= 0): declares abstract methods

    virtual void f() = 0;
  • Override and final: enforce overriding rules

    void f() override;
    void f() final;
  • Inheriting constructors: import all constructors from the base

    struct D : Base { using Base::Base; };
  • Using-declaration for overloads: bring selected base overloads into scope

      struct Base { void foo(int); void foo(double); };
      struct D : Base {
          using Base::foo;
          void foo(std::string);
      };
  • Dynamic casting which only support inherited types

    Base* b = new Derived();
    typeid(*b);        // RTTI required
    dynamic_cast<D*>(b);
  • Qualified lookup for disambiguation: call a specific base’s member

    D d;
    d.Base1::foo();
  • Covariant returns: allow a derived type to refine the return

    struct B { virtual B* clone(); };
    struct D : B { D* clone() override; };
  • Explicitly defaulted or deleted special member functions The base class can influence derived class behavior in implicit and subtle ways, especially with rule-of-five and defaulting/deleting:

    Edit
    struct Base {
        Base(const Base&) = delete;
    };
    struct D : Base {
        // Copy constructor deleted too
    };
  • CRTP (Curiously Recurring Template Pattern): static polymorphism via inheritance CRTP can be hard to read and maintain, as the recursive inheritance pattern is non-intuitive and tightly couples the base to the derived class, but it offers zero-cost abstraction (which is good! 🎉), however it is also another case of inheritance-specific constructs.

    template<typename T>
    struct Base { /*…*/ };
    struct Derived : Base<Derived> { /*…*/ };

Pointers and allocations

When you use inheritance with standard containers, you almost always end up storing pointers to the base class and dynamically allocating each derived object. Since the compiler only knows the size of the base type at compile time—not the maximum size of all possible derived classes—you cannot place actual objects into the container directly:

std::list<BaseClass*> list;
list.push_back(new DerivedA(/*...*/));  // allocates list node + DerivedA instance
  • Double Allocation & Indirection: Each push_back allocates a list node and separately allocates the derived object on the heap, then stores a pointer—resulting in two allocations and an extra pointer dereference on access.

  • Heap Fragmentation & Performance: Frequent insertions and removals of variably sized objects can fragment the heap, hurting cache locality and increasing allocation latency.

  • Pointer Safety & Lifetime Management:Raw pointers may be nullptr, dangling, or leak memory if delete is forgotten. You must ensure:

    • A virtual destructor in BaseClass for proper cleanup.
    • A clear ownership discipline or use smart pointers (std::unique_ptr<BaseClass>, std::shared_ptr<BaseClass>)—though these add some overhead in storage or reference counting.

Additionally, consider the use of an array:

std::array<BaseClass*, 5> list;
  • Lose of cache locality: Each element inside the array is potentially non-contiguous in memory, meaning that we lose all cache-locality when traversing this array.

Misleading Access-Modifier Gotchas


Developers sometimes conflate member access modifiers (public/protected/private) with the effects of inheritance specifiers, leading to subtle bugs and misunderstandings. Here are a few common pitfalls—and how to avoid them:

  1. Overriding Is Independent of Inheritance Specifier

    • You can override any virtual member that is visible in the derived class, regardless of whether you inherit with public, protected, or private access. The inheritance specifier only affects how base members and conversions are exposed, not override semantics.
  2. Re‑Exposing Hidden Base Methods

    • When you inherit privately or protectedly, base-class members lose their original access in the derived class (e.g., public methods become non-public). To make specific methods public again, use a using declaration:
    struct Base { void api(); };
    struct Derived : private Base {
        using Base::api;  // exposes api() as public in Derived
    };
  3. Constructor Visibility

    • Base constructors are not accessible to clients of a privately inherited class unless you explicitly import them with using Base::Base;. Without this, you cannot construct Derived with the same signature as Base unless you write forwarding constructors yourself.

Visitor Pattern Boilerplate and Double Dispatch

  • C++ only supports single dispatch—method resolution based on the dynamic type of one object. The visitor pattern enables double dispatch, selecting behavior based on both the element and the visitor type.
  • This requires every derived class to implement an accept() method that calls back into the visitor with *this, allowing the correct overload of visit() to be invoked based on the actual derived type.
class Base {
public:
    virtual void accept(Visitor& v) = 0;
};

class DerivedA : public Base {
public:
    void accept(Visitor& v) override {
        v.visit(*this);
    }
};

class DerivedB : public Base {
public:
    void accept(Visitor& v) override {
        v.visit(*this);
    }
};

class Visitor {
public:
    virtual void visit(DerivedA& a) = 0;
    virtual void visit(DerivedB& b) = 0;
};

class ConcreteVisitor : public Visitor {
public:
    void visit(DerivedA& a) override {
        // Handle DerivedA
    }
    void visit(DerivedB& b) override {
        // Handle DerivedB
    }
};

This pattern is verbose and requires predefining all combinations of visitors and visitable types, limiting flexibility and increasing maintenance cost. It also contradicts the open/closed principle, since adding a new type requires modifying every visitor class.

This should make you think: At this point, why use virtual polymorphic dispath at all?

2. Composition Over Inheritance for data composition

There have been discussions for decades about composition or inheritance, and since this article talks about how to ditch (as much as possible) inheritance, it only makes sense to recommend and prefer to use composition over inheritance.

What this really means is that, instead of inheriting data members from a base class (which also brings all the methods), composition involves including an instance of another class as a member within your class. This keeps all data localized, avoiding the pitfalls of inheritance-based data composition.

struct EntityData {
    int health;
    int mana;
    EntityData(int h, int m) : health(h), mana(m) {}
};

class EnemyData {
private:
    EntityData entity;
    int threatLevel;
public:
    EnemyData(int h, int m, int threat) : entity(h, m), threatLevel(threat) {}
};

Here, EnemyData contains an EntityData object as a member. All data (health, mana, and threatLevel) is encapsulated within EnemyData, making the class’s state explicit and easier to manage.

You could even take it a step further and “un-generalize” the code if the hierarchy isn’t truly necessary:

struct EnemyData {
    int health;
    int mana;
    int threatLevel;
};

Composition and interfaces

Composition does not mean to not have interfaces, interfaces are a powerful abstraction tool that allows us to specify the minimum set of functionality that a class must provide.

Consider the following interface:

class IRenderable {
public:
    virtual ~IRenderable() = default;
    virtual void render() const = 0;
};

class Sprite : public IRenderable {
public:
    void render() const override {
        // Render the sprite
    }
};

Here, IRenderable uses inheritance to define a behavioral contract: any class implementing it must provide a render method. This is a legitimate and powerful use of inheritance, enabling polymorphism without imposing data requirements.

Now, imagine combining this with composition for data:

struct RenderData {
    Texture texture;
    Position pos;
};

class Sprite : public IRenderable {
private:
    RenderData data;  // Composed data
public:
    Sprite(const Texture& tex, Position p) : data{tex, p} {}
    void render() const final override {
        // Use data.texture and data.pos to render
    }
};

In this design:

  • Inheritance handles the interface (IRenderable), ensuring Sprite adheres to the render contract, final keyword is used to tell the compiler that this method cannot be overriden, and since the base class had it virtual, it will probably devirtualize it to avoid performance penalty.
  • Composition manages the data (RenderData), keeping it self-contained within Sprite.

This separation of concerns leverages the strengths of both approaches: inheritance for behavior, composition for data.

Non-virtual interfaces with Concepts

Introduced in C++20, concepts offer a powerful way to define requirements on types—both behavior and data—without relying on inheritance. Unlike pure virtual interfaces, in a way, a concept is making questions to the compiler, and returning true or false from the requires expression depending if the compiler can compile the code or agrees with your statements.

Here’s an example of a concept that refines Renderable, notice that, unlike with virtual interfaces, here we not only can ask questions about the methods that exits, but also about the internal data representation or any question we have about the type.

template <typename T>
concept Renderable = requires(T t) {
    { t.render() } -> std::same_as<void>;      // Must have a render method
    { t.texture } -> std::same_as<Texture&>;   // Must have a texture member
};

This concept ensures that any type used with it has:

  • A render method returning void.
  • A texture member of type Texture&.

You can then use it in a template:

template <Renderable R>
void draw(const R& renderable) {
    // Access renderable.texture and call renderable.render()
}

or with auto:

void draw(Rendereable auto& rendereable){
    // Access renderable.texture and call renderable.render()
}

This way, we are specifying an “untied-contract”, any type that implements the method render() and has an attribute texture can call this draw function.

A class satisfying this might look like:

class Sprite {
public:
    Texture& texture;
    Sprite(Texture& tex) : texture(tex) {}
    void render() const {
        // Render using texture
    }
};

Notice that we don’t require to specify anything, if we were to use this in code that calls the Rendereable method.

Advantages of Concepts Over Virtual Interfaces:

  • Data Requirements: Unlike pure virtual classes, concepts can mandate specific data members (e.g., texture), not just methods.
  • No Inheritance: Types don’t need to derive from a base class—any class meeting the requirements works, reducing coupling and hierarchy complexity.
  • No virtualization: There is no runtime vtable overhead, you don’t need to mark functions as “final”.
  • Concept composition and metaprogramming: Concepts can be composed and used in more ways than inheritance, we can create concepts that include other concepts or use them to conditionally-call different code.

Out-of-class interface

In c++, the language-way to expand the functionality of a class is to create new methods or adding new data inside the class, However, there are times when you need to extend a class’s functionality but cannot modify its source code directly.

A common solution is to create a proxy object. This could involve:

  • Wrapping the original class, exposing its methods through proxy methods while introducing new functionality.
  • Inheriting from the original class and adding new methods.

This, however, is a problem, since when creating these proxy objects, we are changing the type of underlying object, meaning that code that expects an specific type does not work with our new type.

In c++, when we wanted common functionality not-dependent on the object, we usually relied on namespaces and function overloads, this way, we could use free-functions to provide functionality that accepts any type.


namespace mylib::serialize {

    void serialize(const TYPE& obj) {
        // Serialization logic here
    }
}

The good thing about namespaces, is that, in contrast to structs, we can define parts of a namespace in any place in a non-contiguous manner, this means that, if we want to introduce support for a new type on our serialize function, we can do so in a new header, by just adding the correct signature

namespace mylib::serialize {

    void serialize(const NEW_TYPE& obj) {
        // Serialization logic here
    }
}

One of the drawbacks of this approach, pre C++20 is that we ahd to be very careful with implicit conversions and objects that satisfied more than one method. However, concepts fixes the previous issues we had with these by allowing us to create concepts to restrict better the typing.

Enhancing Flexibility with C++20 Concepts

When defining interfaces wth concepts, we improve the readibility and the ease-of-programming of these overloads, now, we can for example, define a concept that checks for a type (that can be any custom type) if it is able to perform an action, and this concept could check if exists a function inside a namespace, for example, for serialization that accept this overload.

This way, we can create functions that conscript objects that are able to perform an action, and get the exact error in case it doesn’t fit the constraint.

Consider defining a concept to check if a type is serializable based on the existence of a serialize function in the mylib::serialize namespace:

template <typename T>
concept Serializable = requires(const T& obj) {
    mylib::serialize::serialize(obj);
};

template <Serializable T>
void process_and_serialize(const T& obj) {
    // Process the object
    mylib::serialize::serialize(obj);
}

If you attempt to pass a type lacking a corresponding serialize function, the compiler will produce a clear error message, such as:

error: constraint 'Serializable' not satisfied

But introducing serializable for our object would be as easy as implementing it in our namespace as shown before.

These out-of-class and not relying on class methods are a good way to separate data and code, and is the prefered way to work with languages like rust, and, while not being required for it, it also fits well with the next topic of non-polymorphic dynamic dispatch.

3. Non-polymorphic dynamic dispatch

C++17 introduced std::variant, a type‑safe discriminated union that can hold one of several compile‑time‑known alternatives. Its size equals that of a contiguous buffer large enough for the largest alternative, plus a small integral tag to indicate which type is currently active.

The standard also adds std::visit, an analog of the visitor pattern that requires no inheritance. Unlike traditional polymorphism—which depends on v‑tables, base‑class pointers and dynamic allocations to support new types—std::variant dispatches by inspecting its tag in O(1) time.

This design delivers stronger type safety, prevents “slicing” (where a derived object loses data when accessed via a base interface), and incurs zero per‑object heap allocations, resulting in improved cache locality and allowing it to be used in embedded environments.

  • Strong type safety: Only the correct alternative can be accessed.
  • No slicing: You never lose derived‐class data by treating it as a base.
  • Zero per‐object heap allocations: Everything lives in the variant’s buffer (unless the type heap-allocates internal data).
  • Excellent cache locality: Data is stored contiguously, improving performance.

Exception Safety and Destruction

  • Strong guarantee
    When assigning a new alternative, variant performs the destruction of the old object and the construction of the new one without explicitly having to delete them.
  • No dangling references
    Because everything resides in one contiguous buffer, there is no indirection; you cannot accidentally have a dangling base‑class pointer into a freed object.

Comparison Table

Aspect std::variant Inheritance + Visitor Pattern
Extensibility Closed set (recompile or open with std::any + dispatcher) Open set (can inherit new types)
Memory layout Single buffer + tag (stack or embedded) Pointer to heap‑allocated object
Dispatch cost Tag check + inline branch (O(1)) Double virtual call
Type safety Compile‑time checked (no cast needed) Requires downcasting or overloaded Visit methods
Object size Max(aligned size of alternatives) + tag Size of pointer per object + heap cost
Cache friendliness Excellent (objects are contiguous) Poorer (pointer chasing)
Runtime overhead Minimal (no allocation, no v‑table lookup) Allocation and v‑table indirection
Exhaustiveness Enforced by compiler Enforced by template instantiation
Type compatibility Does not require modification to types Requires types that inherit from a common base and have an accept method

⛔ Approach 1: Classic Visitor Pattern via Inheritance

In situations where you have a fixed class hierarchy (e.g., Base with several DerivedX classes) and you want to perform operations that depend on the concrete derived type at runtime, the Visitor Pattern is a well-known solution. It lets you define a separate “visitor” object to encapsulate behavior, rather than putting all of that logic inside each derived class or using cumbersome dynamic_casts.

The accept() method in each Derived class calls back into the visitor’s visit(DerivedX&) method, allowing the correct overload to run, You must introduce an abstract Visitor interface and declare a visit(DerivedX&) overload for every concrete subclass. Likewise, each Derived must override accept() to invoke the visitor.

This boilerplate can be tedious—especially when the hierarchy grows.

#include <print>
#include <memory>
#include <vector>

// Forward‑declare concrete types
class DerivedA;
class DerivedB;

// 1. Visitor interface
class Visitor {
public:
    virtual void visit(DerivedA& a) = 0;
    virtual void visit(DerivedB& b) = 0;
    virtual ~Visitor() = default;
};

// 2. Base class with accept()
class Base {
public:
    virtual void accept(Visitor& v) = 0;
    virtual ~Base() = default;
};

// 3. Concrete classes override accept()
class DerivedA : public Base {
public:
    void accept(Visitor& v) override { v.visit(*this); }
    void actionA() { std::println("Action in A"); }
};

class DerivedB : public Base {
public:
    void accept(Visitor& v) override { v.visit(*this); }
    void actionB() { std::println("Action in B"); }
};

// 4. ConcreteVisitor implements Visitor
class ConcreteVisitor : public Visitor {
public:
    void visit(DerivedA& a) override {
        std::println("Visiting A");
        a.actionA();
    }
    void visit(DerivedB& b) override {
        std::println("Visiting B");
        b.actionB();
    }
};

int main() {
    std::vector<std::unique_ptr<Base>> objects;
    objects.emplace_back(std::make_unique<DerivedA>());
    objects.emplace_back(std::make_unique<DerivedB>());

    ConcreteVisitor vis;
    for (auto& obj : objects) {
        obj->accept(vis);   // double‑dispatch via virtual calls
    }
}

Try in compiler-explorer - Dispatch: obj->accept(vis) → virtual visit(*) → concrete visit(DerivedX&).
- Boilerplate: Base, Visitor interface, concrete classes, visitor implementation.


✨ Approach 2: variant + visit

If your goal is simply to hold a heterogeneous collection of “either A or B or C …” and then perform type‐specific behavior, you can avoid the boilerplate of the classic visitor‐pattern entirely by using std::variant. Instead of an inheritance hierarchy, you create plain structs (or value types), pack them into a std::variant<A, B>, and call std::visit with a callable (functor or lambda) that handles each type.

It has the same limitation as the inheritance visitor pattern, where every possible variant alternative must be listed up front (e.g., std::variant<A,B>) (altought a variant could be a pointer to a base class and process that specially)

It has also the benefit that instead of double-dispatching, std::visit uses a index over a table to call the correct method, being single-dispatch, which should be more performant.

At the same time, we have far less boilerplate, no abstract base class, no virtual dispatch, no accept() functions and everything is resolved at compile time.

#include <print>
#include <variant>
#include <vector>

// 1. Plain structs
struct A {
    void action() const { std::println("Action in A"); }
};
struct B {
    void action() const { std::println("Action in B"); }
};

// 2. Visitor functor
struct VariantVisitor {
    void operator()(A const& a) const {
        std::println("Visiting A");
        a.action();
    }
    void operator()(B const& b) const {
        std::println("Visiting B");
        b.action();
    }
};

int main() {
    // 3. Heterogeneous collection via variant
    std::vector<std::variant<A,B>> objects;
    objects.emplace_back(A{});
    objects.emplace_back(B{});

    // 4. Single dispatch via std::visit
    for (auto const& obj : objects) {
        std::visit(VariantVisitor{}, obj);
    }
}

Try in compiler-explorer

As we can see, the intent of the code is much clearer, we have less boilerplate and we don’t have to deal with pointer usage nor extra heap allocations.

Overloaded Pattern and “In‐Place” Visitors with Lambdas

So far, we’ve seen two ways to perform type-based dispatch:

Inheritance + classic Visitor (lots of boilerplate, virtual calls)

std::variant + a hand-written VariantVisitor struct

However, this is not all, with some C++ metaprogramming and following the “overloaded pattern” shown in the examples of cppreference we can create in-place visitors for our types.

Overloaded pattern implementation

Here, we have a possible overloaded pattern implementation as seen in the cppreference.

#include <variant>
#include <utility>

template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};

This Overloaded pattern uses a combination of variadic templates, multiple inheritance (for metaprogramming), and a fold‐expression to merge several callables (lambdas in this case!) into a single type that exposes all of their operator()() overloads.

This allows us to do something like:

    for (auto const& obj : objects) {
      std::visit(overloaded{
        [](A const &a) {
          std::println("Visiting A");
        },
        [](B const &b) {
          std::println("Visiting B");
        }
      }, obj);
    }

Which heavily reduces the visitors for simple use.

visit_variant helper

To streamline variant visits even further, you can wrap std::visit and the overloaded‐helper into a single function template.


template<typename Variant, typename... Fs>
decltype(auto) visit_variant(Variant&& var, Fs&&... fs) {
    return std::visit(overloaded<Fs...>{std::forward<Fs>(fs)...}, std::forward<Variant>(var));
}

By forwarding both the variant and the callables, you avoid unnecessary copies. You can pass rvalue references, move-only types, etc., without friction.

#include <print>
#include <variant>
#include <vector>

struct A {
  void action() const { std::println("Action in A"); }
};
struct B {
  void action() const { std::println("Action in B"); }
};

int main() {
  std::vector<std::variant<A, B>> objects;
  objects.emplace_back(A{});
  objects.emplace_back(B{});

  for (auto const &obj : objects) {
    visit_variant(
        obj,
        [](A const &a) {
          std::println("Visiting A");
          a.action();
        },
        [](B const &b) {
          std::println("Visiting B");
          b.action();
        });
  }
}

std::visit in this case, requires a struct that overlaods the operator () for all possible types of the variant, this means that if we fail to provide one, there will be a compile-time error, so we must be exhaustive. It will also always pick the method that better adheres to a type if an implicit conersion where to happen for simple types.

However, that’s not all, now we can also group common functionality using “auto”.

#include <print>
#include <variant>
#include <vector>

struct A {
  void action() const { std::println("Action in A"); }
};
struct B {
  void action() const { std::println("Action in B"); }
};

int main() {
  std::vector<std::variant<A, B>> objects;
  objects.emplace_back(A{});
  objects.emplace_back(B{});

  for (auto const &obj : objects) {
    visit_variant(
        obj,
        [](auto const &val) {
          val.action();
        });
  }
}

When using auto this way, what will happens under-the-hood is that two functions will be instantiated, one for A and other for B, but auto will create them for us, auto in this case is useful since we have some common behavior, we can also use auto as the “base-case”.

Another interesting thing we can do now is use concepts in order to discern over our visitor functions, for example:

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

template <typename T>
concept Sequence =
    std::ranges::range<T> && (!std::same_as<std::decay_t<T>, std::string>);

int main() {
  using Var = std::variant<int, double, std::string, std::vector<int>>;
  std::vector<Var> items = {
        42, 
        3.14, 
        std::string("Hello, Concepts!"),
        std::vector<int>{7, 8, 9}
  };

  for (auto const &item : items) {
    visit_variant(
        item,
        // inline lambdas, constrained by concepts:
        [](Numeric auto n) { std::println("Numeric: {}", n); },
        [](Sequence auto const &seq) {
          std::println("Sequence of elements:");
          for (auto const &x : seq) {
            std::println("  {}", x);
          }
        },
        // catch std::string exactly:
        [](std::string const &s) { std::println("String: {}", s); });
  }

  return 0;
}

Toy with it on compiler-explorer

My Thoughts

std::variant with std::visit offers a modern, efficient, and safer way to handle a fixed set of types without the boilerplate and overhead of inheritance. It shines when you want fast, cache-friendly dispatch and compile-time guarantees, and not only reduces errors, pointer usage, and complexity but is also generally more performant due to better memory layout and zero runtime overhead. This makes it an ideal choice for many use cases, especially where performance and safety are priorities. It also imposes better practices to programmers with less experience and avoids all the common pitfalls that inheritance may introduce.

4. Performance

Below is a comparison of three approaches—classic inheritance, std::variant with polymorphic classes, and std::variant with plain structs—each benchmarked on creation of 1 000 000 objects and running an update loop of 1 000 iterations over the same set.

The code for this comparison can be found here: quickbench

Benchmark cpu_time (ns) cpu_time (ms)
InheritanceCreation/1 000 000 149 719 601.83 149.72 ms
VariantCreation/1 000 000 14 892 745.37 14.89 ms
StructVariantCreation/1 000 000 3 461 833.84 3.46 ms
InheritanceUpdate/1 000 000 9 297 575 462.14 9 297.58 ms (9.30 s)
VariantUpdate/1 000 000 5 392 592 272.09 5 392.59 ms (5.39 s)
StructVariantUpdate/1 000 000 2 296 117 020.90 2 296.12 ms (2.30 s)

Creation

  • InheritanceCreation (~150 ms):
    Each new DerivedX() is a heap allocation + setting a v‐table pointer. This is the slowest approach by about an order of magnitude.
  • VariantCreation (~15 ms):
    Constructs each object in‐place inside a std::variant<Derived1,Derived2>, avoiding heap allocations. ~10× faster than inheritance but still using the derived classes.
  • StructVariantCreation (~3.5 ms):
    Emplaces plain struct SDerivedX objects into a variant. No v‐table setup at all ⇒ ~4× faster than VariantCreation, in this case, the struct is not using inheritance in any way.

Update

  • InheritanceUpdate (~9.3 s):
    A virtual call per object per iteration. 1 000 000 objects × 1 000 iterations = 1 billion update() calls via v‐table.
  • VariantUpdate (~5.4 s):
    std::visit dispatch on a variant<Derived1,Derived2>. Cheaper than a virtual call; ~1.7× faster than inheritance.
  • StructVariantUpdate (~2.3 s):
    std::visit on a variant<SDerived1,SDerived2>. No v‐table, only a jump‐table lookup. ~4× faster than inheritance.

Conclusion

  • Heap allocation overhead makes classic inheritance expensive at large scales.
  • std::variant dispatch is substantially cheaper than a virtual call.
  • Using plain structs inside a variant (no inheritance) is the fastest option if you simply need uniform API calls (.update()) with no run‐time extension.

Here, you can see the graphic as generated by quickbench