Question
Can you call virtual methods on a base class in a union when a derived class is active?
While std::variant
is great for some use cases, it's a bit analogous to std::tuple
in that you can't name each individual variant. Often a struct
is better than a tuple
.
I'm wondering if it's legal to implement named union fields through the following trick: Have all values in the union share a superclass; use a virtual method on the superclass to check the active type of the union; and rely on a virtual destructor to destroy the active union field properly. The hope is that, possibly conditioned on running pointers through std::launder
, something like the following code would be correct:
#include <iostream>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>
#include <typeinfo>
#include <vector>
struct UnionBase {
constexpr UnionBase() = default;
constexpr virtual ~UnionBase() = default;
virtual const std::type_info &type() const { return typeid(void); }
static UnionBase *base(UnionBase *b) { return std::launder(b); }
static const UnionBase *base(const UnionBase *b) { return std::launder(b); }
static void destroy(UnionBase *b) { std::destroy_at(base(b)); }
};
template<typename T> struct UnionArm : UnionBase {
T val_;
const std::type_info &type() const override { return typeid(T); }
T &activate()
{
destroy(this);
std::construct_at(this);
return std::launder(this)->val_;
}
auto get(this auto &&self)
{
if (base(&self)->type() != typeid(T))
throw std::logic_error("inactive union field accessed");
return &self.val_;
}
decltype(auto) operator*(this auto &&self) { return *self.get(); }
auto operator->(this auto &&self) { return self.get(); }
};
struct MyUnion {
union {
UnionBase base_;
UnionArm<std::string> str;
UnionArm<std::vector<int>> vec;
};
MyUnion() : base_() {}
~MyUnion() { UnionBase::destroy(&base_); }
};
int
main()
{
{
MyUnion mu;
mu.str.activate() = "hello";
std::cout << *mu.str << std::endl;
mu.vec.activate();
*mu.vec = {1, 2, 3};
std::cout << mu.vec->at(0) << std::endl;
}
return 0;
}
The code works in practice with all the compilers I've tried, so this is really a language-lawyer question: is the approach in fact legal? If not, is there some what to make it legal? If so, are all the std::launder
calls required, or which can I get rid of?
Could I simplify even further and, instead of a virtual type() function, just run typeid(*std::launder(this)) to get the correct dynamic type information in UnionBase
? (There's still a virtual destructor, so runtime type information will be available for any UnionBase*
.)