Last Update:
Replacing unique_ptr with C++17's std::variant a Practical Experiment
Table of Contents
Some time ago I wrote about a new way to implement runtime polymorphism which is based not on virtual functions but on std::visit
and std::variant
. Please have a look at this new blog post where I experiment with this approach on my home project. The experiment is more practical than artificial examples.
See the advantages, disadvantages and practical code issues.
Intro
The new kind of runtime polymorphism is based on the fact that you can call std::visit
and then - at runtime - select the best matching overload for the active type in the variant:
Here’s a code sample which summarises this technique:
struct A {
void PrintName() const {
std::cout << "calling A!\n"
}
};
struct B {
void PrintName() const {
std::cout << "calling B!\n"
}
};
struct CallPrintName {
void operator()(const A& a) { a.PrintName(); }
void operator()(const B& b) { b.PrintName(); }
};
std::variant<Derived, ExtraDerived> var;
var = B{};
std::visit(CallPrintName{}, var);
// alternative (with a generic lambda):
auto callPrintName = [](const auto& obj) { obj.PrintName(); };
std::visit(callPrintName, var);
As you can see, we have two classes (unrelated, with just a ) and we “pack” them into a single std::variant
which can represent the first or the second type. Then when we want to call a given member function, we need to create a function object which handles both types (we can also create a generic lambda).
What are the advantages?
- No dynamic allocation to create a polymorphic class
- Value semantics, variant can be easily copied
- Easy to add a new “method”, you have to implement a new callable structure. No need to change the implementation of classes
- There’s no need for a base class, classes can be unrelated
- Duck typing: while virtual functions need to have the same signatures, it’s not the case when you call functions from the visitor. They might have a different number of argument, return types, etc. So that gives extra flexibility.
You can read more in: Bartek’s coding blog: Runtime Polymorphism with std::variant and std::visit
Let’s try to implement this approach on my project, is this as easy as it sounds on an artificial example?
What to Change in the Project
My project (sorting algorithms visualization, C++, WinApi, OpenGL, see at github) has a notion of algorithm manager class which has an “active” algorithm.
This active algorithm is just a unique pointer to IAlgorithm
- a base class for all available algorithms:
CBubbleSortAlgorithm,
CShakerSortAlgorithm,
CSelectionSortAlgorithm,
CInsertionSortAlgorithm,
CShellSortAlgorithm,
CQuickSortAlgorithm,
CShuffleElementsAlgorithm
Naturally, I selected virtual polymorphism as it’s easy to implement and work with. But this place is also a good candidate to experiment with std::variant
.
So I can create the following variant:
using AlgorithmsVariant = std::variant<
CBubbleSortAlgorithm,
CShakerSortAlgorithm,
CSelectionSortAlgorithm,
CInsertionSortAlgorithm,
CShellSortAlgorithm,
CQuickSortAlgorithm,
CShuffleElementsAlgorithm
>;
See Bartek’s coding blog: Everything You Need to Know About std::variant from C++17 if you want to know more about std::variant
.
Ok, so let’s make some comparisons:
Size
The first thing that you can observe is that we don’t need any v-table pointers so that we can make class smaller (a bit):
// with virtual functions
Debug x64
sizeof(IAlgorithm): 80
sizeof(CBubbleSortAlgorithm): 96
sizeof(CInsertionSortAlgorithm): 104
sizeof(CSelectionSortAlgorithm): 104
sizeof(CQuickSortAlgorithm): 160
Release x64
sizeof(IAlgorithm): 72
sizeof(CBubbleSortAlgorithm): 88
sizeof(CInsertionSortAlgorithm): 96
sizeof(CSelectionSortAlgorithm): 96
sizeof(CQuickSortAlgorithm): 152
After changing into variant:
Debug x64
sizeof(IAlgorithm): 72
sizeof(CBubbleSortAlgorithm): 88
sizeof(CInsertionSortAlgorithm): 96
sizeof(CSelectionSortAlgorithm): 96
sizeof(CQuickSortAlgorithm): 152
sizeof(AlgorithmsVariant): 160
Release x64
sizeof(IAlgorithm): 64
sizeof(CBubbleSortAlgorithm): 80
sizeof(CInsertionSortAlgorithm): 88
sizeof(CSelectionSortAlgorithm): 88
sizeof(CQuickSortAlgorithm): 144
sizeof(AlgorithmsVariant): 152
The size between debug and release changes because of the string: sizeof(string): 32
in Release and 40
in Debug.
We don’t have v-pointer so how can we call a function on that variant object? It’s not as easy as with a virtual dispatch.
How To Call a Member Function?
With unique_ptr
you can just call a virtual function:
AlgManager::RunAgain() {
currentAlgPtr->Init(m_viArrayCurrent); // reset
}
But how to do it with std::variant
?
The basic idea is to use std::visit
and then pass a generic lambda that calls the proper member function:
AlgManager::RunAgain() {
auto InitCaller = [](auto& obj ) { obj.Init(??); }
std::visit(InitCaller, currentAlgorithm);
}
In the above example, we perform runtime polymorphism by leveraging the visit
technique. In short, this function selects the best function overload based on the active type in the variant. Having a generic lambda allows us to have a simple way to call the same function for all possible types in the variant. This is, however, achieved through duck typing.
Problem: Passing Arguments
If you noticed, I put ??
in the generic lambda. This is because there’s no easy way to pass a parameter to the function from std::visit
!
To solve the issue we can capture the argument into out lambda:
AlgManager::RunAgain() {
auto InitCaller = [&m_viArrayCurrent](auto& obj ) { obj.Init(m_viArrayCurrent); }
std::visit(InitCaller, currentAlgorithm);
}
The code is straightforward for simple built-in types, pointers or references, but it might be problematic when you have some larger objects (we’d like to forward the arguments, not copy them if possible).
Problem: Where to Store Lambdas?
Ok, but there might be several places where you want to call the Init
function on the current algorithm, for example in two or more member functions of the Algorithm Manager class. In that case, you’d have to write your lambdas twice, or store them somewhere.
You cannot store it (easily) as a static member of a class as there’s no auto type deduction available. You can keep them as static variables in a given compilation unit.
For my experiments I skipped lambdas and went for function objects that are declared in the IAlgorithm
class:
class IAlgorithm {
public:
struct InitFn {
CViArray<float>* viData;
template<typename T>
inline void operator()(T& alg) const { alg.Init(viData); }
};
struct StepFn {
template<typename T>
inline void operator()(T& alg) const { alg.Step(); }
};
struct GetNameFn {
template<typename T>
inline const std::string& operator()(const T& alg) const { return alg.GetName(); }
};
struct IsDoneFn {
template<typename T>
inline bool operator()(const T& alg) const { return alg.IsDone(); }
};
struct GetStatsFn {
template<typename T>
inline const AlgOpsWrapper& operator()(const T& alg) const { return alg.GetStats(); }
};
public:
// ctors and the rest of the interface...
};
And now, in all places where you’d like to call a member function
of an algorithm you can just write:
void CAlgManager::RunAgain() {
std::visit(IAlgorithm::InitFn{ &m_viArrayCurrent }, m_CurrentAlg);
}
void CAlgManager::SetAlgorithm(uint16_t algID) {
m_CurrentAlg = AlgorithmFactory::Create(algID);
std::visit(IAlgorithm::InitFn{ &m_viArrayCurrent }, m_CurrentAlg);
}
Is that the best way?
Copyable Again
CAlgManager
had a unique_ptr
as a data member. To make this class copyable, I had to define copy/move constructors. But with std::variant
it’s not the case!
With std::variant
your classes have value semantics out of the box.
Source Code
All the code is available on my repo; there’s a separate branch for this experiment:
https://github.com/fenbf/ViAlg-Update/tree/variant
Summary
Let’s compare the outcome, how about the positive side:
- value type, no dynamic memory allocation (no unique or smart pointers needed)
- copyable types, no
unique_ptr
issues - no need to v-table, so smaller objects (if that’s important)
But how about the negative side:
- function objects - where to put them?
- need to add types to
using AlgorithmsVariant = std::variant<...
explicitly - duck typing sometimes can be painful, as the compiler cannot warn you about available methods of a given class (maybe this could be improved with concepts?)
- no override use, so the compiler cannot report issues with derived classes and their lack of full interface implementation
- no pure virtual functions - you cannot
So… was this a right approach?
Not sure, as it was quite painful to get everything working.
It would be good to see other use cases where you have, for example, a vector of unique pointers. Replacing this to a vector of variant can reduce lots of small dynamic allocations.
Anyway, I did those experiments so you can see the “real” code and “real” use case rather than nice artificial examples. Hope it helps when you’d like to apply this pattern in your projects.
Let us know your experience in comments below the article.
I've prepared a valuable bonus if you're interested in Modern C++!
Learn all major features of recent C++ Standards!
Check it out here: