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*.)

 4  83  4
1 Jan 1970

Solution

 3

Your code has undefined behavior already at the line

mu.str.activate()

str is not the active member of the union. Therefore mu.str refers to an object outside of its lifetime. Calling a non-static member function on an object outside its lifetime (or during construction/destruction) has undefined behavior per [basic.life]/6.2.

There is no way to remedy this, using the non-active name is fundamentally the problem.

The only ways that union members can be used outside their lifetime (i.e. when the member is inactive) other than uses that are always permitted out-of-lifetime are:

  • to active the member with a built-in or trivial assignment per rules of [class.union.general]/5, and
  • to read one of its non-static data members if it is part of the common initial sequence with the active union member (if both are standard-layout types) per rules of [class.mem.general]/26.

Also, there is no guarantee that the offset of the base class object in the derived objects is zero, which you are implicitly assuming. Because your types are not standard-layout (as they have virtual functions), there is no layout guarantee by the standard at all. Of course there will normally be some ABI in effect on a given setup that specifies layouts in detail.

If polymorphic types should be used to implement std::variant-like behavior portably you will need to use a unsigned char array member to provide storage for the derived class objects and always need to keep a pointer to the emplaced object's base class object as additional member, or analogously keep track of the emplaced type, so that you can recalculate the offset. But if you need to keep track of the type externally like that, then there isn't any point in using a polymorphic type for the implementation to begin with.

Also, usually, the reason to use polymorphism via virtual is to enable supporting an unbounded and unknown set of derived types. For a union or variant the set of types is bounded an known and you loose the main benefit of virtual.


What you can do instead is use a simple union with accessor functions returning references to the contained element, so that e.g.

std::construct_at(&mu.vec());
mu.vec() = {1, 2, 3};

will work.

Or if you prefer to use the activate function and indirection operator, you can have vec() return a proxy type that wraps the reference to the element and put the operator* and activate on it.

The latter approach would also allow to make the same syntax work with std::variant as implementation, with the potential benefit of providing error checking for type mismatch.


As another note, I think it is generally a good thing that it is impossible to name an element in a std::variant directly. Directly accessing a specific type should be avoided anyway. It is always unsafe and risks either undefined behavior if the wrong type is accessed or an exception if a checking accessor is used.

Instead, prefer to use std::visit as early as possible at the point where element types are initially discriminated. That way there is no potential to make a mistake in manually keeping track of types and the visitor is free to use a sensible name for each of the discriminated elements in the parameters of its call operator overloads.

2024-07-18
user17732522