Here is a simple variant type I have made. For simplicity, it only can handle two different values. I am planning on using this to create a variadic variant template, using inheritance and recursion.
The requirements for my variant type:
- It must have performance similar to
std::variant
- It must not dynamically allocate memory
- There must be no undefined behavior
- It should check as much as possible at compile-time, and throw an exception if access is attempted to the wrong type
- It must support nontrivial types
Here is the source:
#include <iostream>
#include <cstddef>
#include <typeindex>
#include <optional>
#include <algorithm>
#include <stdexcept>
#include <functional>
/** A variant (sum type) that can contain one of two different types at any one time**/
template<typename T1, typename T2>
class Either {
using Bigest = std::conditional_t<sizeof(T1) <= sizeof(T2), T1, T2>;
alignas(sizeof(Bigest)) std::byte storage[sizeof(Bigest)];
std::optional<std::type_index> conatinedType;
std::function<void(std::byte*)> destructor;
public:
Either() : conatinedType(std::nullopt) {}
template<typename T>
Either(const T& value) : conatinedType(typeid(T)) {
static_assert(std::is_same<T1,T>::value || std::is_same<T2,T>::value, "Either cannot contain type T");
const auto src = reinterpret_cast<const std::byte*>(&value);
for(size_t i = 0; i < sizeof(T); i++) {
storage[i] = src[i];
}
destructor = [](std::byte* data){ (*reinterpret_cast<T*>(data)).~T(); };
}
class BadVariantAccess : public std::exception {};
template<typename T>
const T& getAs() const {
static_assert(std::is_same<T1,T>::value || std::is_same<T2,T>::value, "Either cannot contain type T");
if(conatinedType == typeid(T)) {
const auto ptr = reinterpret_cast<const T*>(&storage);
return *ptr;
}
else throw BadVariantAccess();
};
};
And a test:
struct MyInt {
int val;
MyInt(const int& val) : val(val) {
std::cout << "making int!" << std::endl;
}
~MyInt() {
std::cout << "destructing int!" << std::endl;
}
};
struct MyDouble {
double val;
MyDouble(const double& val) : val(val) {
std::cout << "making double!" << std::endl;
}
~MyDouble() {
std::cout << "destructing double!" << std::endl;
}
};
int main()
{
Either<MyInt, MyDouble> e = MyDouble(5.0987);
try{
std::cout << e.getAs<MyDouble>().val << std::endl;
}
catch(...) {
std::cerr << "Bad Variant Access" << std::endl;
}
return 0;
}
1 Answer 1
Construction and destruction of non-trivial types
Unfortunately, your class does not really support non-trivial types, because your class copies the representation of the supplied values and instead of invoking the proper copy constructor. In other words, you create no object in the Either
class, so the behavior of reinterpret_cast
ing the storage and calling the destructor is undefined.
Interestingly, you store the destructor
function object but never actually call it, so the class ends up working for trivial types, which do not require invocations to the corresponding constructor or destructor. However, making your code work by letting two errors cancel out each other is not really a good idea.
The copy and move semantics of your class is also broken. We'll try to fix this problem later.
Use of runtime type information (typeid
)
The usage of typeid
is a bit overkill here and not optimal, because it forces the user to pass exactly the same type as one of the stored types, disallowing conversions. In fact, simply storing an index seems to suffice.
std::variant
uses overload resolution to deduce the type, which is hard to implement. You may consider supporting in_place
construction. (On the other hand, std::variant
is typically implemented with unions because of constexpr
requirements.)
The storage
using Bigest = std::conditional_t<sizeof(T1) <= sizeof(T2), T1, T2>; alignas(sizeof(Bigest)) std::byte storage[sizeof(Bigest)]; std::optional<std::type_index> conatinedType; std::function<void(std::byte*)> destructor;
Consider using std::aligned_storage
. As I said above, we can simply store an index.
using storage_type = std::aligned_storage_t<std::max( sizeof(T1), sizeof(T2)),
std::max(alignof(T1), alignof(T2))>;
storage_type storage;
std::size_t index;
For valueless variants, we can provide a special index:
static constexpr std::size_t npos = std::numeric_limits<std::size_t>::max();
The constructors
The default constructor of std::variant
default-constructs the first alternative type. You can go into the valueless state instead:
Either() noexcept
: index{npos}
{
}
Here's how you'd support in place constructors:
template <std::size_t I, typename... Args>
explicit Either(std::in_place_t<I>, Args&&... args)
{
construct<I>(std::forward<Args>(args)...);
}
template <std::size_t I, typename U, typename... Args>
explicit Either(std::in_place_t<I>, std::initializer_list<U> ilist, Args&&... args)
{
construct<I>(ilist, std::forward<Args>(args)...);
}
where the private member function template construct
is defined like
template <std::size_t I>
struct alternative_type {
static_assert(I < 2);
using type = std::conditional_t<I == 0, T1, T2>;
};
template <std::size_t I>
using alternative_type_t = typename alternative_type<I>::type;
// not exception safe
template <std::size_t I, typename... Args>
void construct(Args&&... args)
{
index = I;
::new (static_cast<void*>(&storage))
alternative_type_t<I>(std::forward<Args>(args));
}
Special member functions
As I said before, you need to implement the copy/move/destruction operations. Here's the copy constructor for example: (SFINAE and explicit
issues are omitted for simplicity.)
Either(const Either& other)
{
if (other.index == 0) {
construct<T1>(other.get<0>());
} else if (other.index == 1) {
construct<T2>(other.get<1>());
} else {
index = npos;
}
}
conatinedType
->containedType
,Bigest
->Biggest
. \$\endgroup\$