Metaprogramming Polymorphism
Runtime polymorphism via virtual functions is the traditional way to achieve dynamic dispatch in C++, but it comes with overhead: vtable lookups, cache misses, and heap allocations. With C++20, we can leverage std::variant and concepts to create compile-time polymorphism that sidesteps virtual dispatch entirely.
Now, there are some irritants with this way of doing things. The compiler sort of ditches you on the side of the road, but we’ll get into that.
The Traditional Approach
Here’s a typical command buffer using inheritance:
struct ICommand {
virtual ~ICommand() = default;
virtual void Execute() = 0;
};
struct AddCommand : ICommand {
int value;
void Execute() override { /* add logic */ }
};
struct RemoveCommand : ICommand {
int id;
void Execute() override { /* remove logic */ }
};
std::vector<std::unique_ptr<ICommand>> commands;
Every Execute() call requires a vtable lookup. For performance-critical code, we can do better.
The Variant Approach
Using std::variant and concepts, we can achieve polymorphism without inheritance:
#include <variant>
#include <vector>
#include <concepts>
// Define what it means to be a command
template<typename T>
concept Command = requires(T cmd) {
{ cmd.Execute() } -> std::same_as<void>;
};
struct AddCommand {
int value;
void Execute() {
std::cout << "Adding: " << value << "\n";
}
};
struct RemoveCommand {
int id;
void Execute() {
std::cout << "Removing: " << id << "\n";
}
};
struct UpdateCommand {
int id;
int newValue;
void Execute() {
std::cout << "Updating " << id << " to " << newValue << "\n";
}
};
// Variant holds all possible command types
using CommandVariant = std::variant<AddCommand, RemoveCommand, UpdateCommand>;
class CommandBuffer {
std::vector<CommandVariant> commands;
public:
template<Command T>
void Add(T&& cmd) {
commands.emplace_back(std::forward<T>(cmd));
}
void ExecuteAll() {
for (auto& cmd : commands) {
std::visit([](auto& c) { c.Execute(); }, cmd);
}
}
void Clear() {
commands.clear();
}
};
Those of you who have used concepts before will understand how amazing they are, but they don’t do everything. For example, using std::same_as<void>; or similiar, the compiler will not be able to hint to you
what functions you can “overload” in a type you are intending to use this polymorphism with. There really isn’t a fix, and the larger the interface you’re restricting, the more annoying it can be.
Usage
int main() {
CommandBuffer buffer;
buffer.Add(AddCommand{42});
buffer.Add(RemoveCommand{7});
buffer.Add(UpdateCommand{3, 99});
buffer.ExecuteAll();
// Output:
// Adding: 42
// Removing: 7
// Updating 3 to 99
}
Some Trade-offs
Pros:
- No vtable overhead - dispatch happens at compile-time via
std::visit - Better cache locality - commands stored contiguously
- No heap allocations for polymorphism
- Compiler can inline through variant visitors
Cons:
- All types must be known at compile-time (you must update the variant for new types)
- Less compiler help - adding a new command type requires updating the variant manually
- Larger stack footprint, variant size is the max of all alternatives
- Not suitable for plugin architectures or runtime-loaded types
When to Use This
This approach shines in performance-critical systems where:
- You have a closed set of types known at compile-time
- You’re executing thousands of commands per frame
- Cache coherency matters (game engines, simulations)
- You want zero-cost abstractions
Note: This isn’t a replacement for traditional polymorphism, it’s an alternative for specific scenarios where performance matters and the type set is closed.