Constexpr optional and trivial relocation

Here’s a simple C++20 constexpr-friendly Optional type. (Godbolt.)

template<class T>
class [[trivially_relocatable(std::is_trivially_relocatable_v<T>)]] Optional {
public:
  constexpr explicit Optional() {}

  template<class... Args>
  constexpr void emplace(Args&&... args) {
    if (engaged_) {
      std::destroy_at(&t_);
      engaged_ = false;
    }
    std::construct_at(&t_, std::forward<Args>(args)...);
    engaged_ = true;
  }

  Optional(Optional&&)
    requires std::move_constructible<T> && std::is_trivially_move_constructible_v<T> = default;
  constexpr Optional(Optional&& rhs)
    noexcept(std::is_nothrow_move_constructible_v<T>)
    requires std::move_constructible<T>
  {
    engaged_ = std::exchange(rhs.engaged_, false);
    if (engaged_) {
      std::relocate_at(&rhs.t_, &t_);
    }
  }

  Optional(const Optional&)
    requires std::copy_constructible<T> && std::is_trivially_copy_constructible_v<T> = default;
  constexpr Optional(const Optional& rhs)
    noexcept(std::is_nothrow_copy_constructible_v<T>)
    requires std::copy_constructible<T>
  {
    engaged_ = rhs.engaged_;
    if (engaged_) {
      std::construct_at(&t_, rhs.t_);
    }
  }

  constexpr void swap(Optional& rhs)
    noexcept(std::is_nothrow_move_constructible_v<T> && std::is_nothrow_swappable_v<T>)
  {
    using std::swap;
    if (engaged_ && rhs.engaged_) {
      union U {
        char pad_;
        T t_;
        constexpr U() {}
        constexpr ~U() {}
      } temp;
      std::relocate_at(&t_, &temp.t_);
      std::relocate_at(&rhs.t_, &t_);
      std::relocate_at(&temp.t_, &rhs.t_);
    } else if (engaged_) {
      std::relocate_at(&t_, &rhs.t_);
    } else if (rhs.engaged_) {
      std::relocate_at(&rhs.t_, &t_);
    }
    swap(engaged_, rhs.engaged_);
  }

  Optional& operator=(Optional&&)
    requires std::movable<T> && std::is_trivially_copyable_v<T> = default;
  constexpr Optional& operator=(Optional&& rhs)
    noexcept(std::is_nothrow_move_constructible_v<T> && std::is_nothrow_move_assignable_v<T>)
    requires std::movable<T>
  {
    auto copy = std::move(rhs);
    copy.swap(*this);
    return *this;
  }

  Optional& operator=(const Optional&)
    requires std::copyable<T> && std::is_trivially_copyable_v<T> = default;
  constexpr Optional& operator=(const Optional& rhs)
    noexcept(std::is_nothrow_move_constructible_v<T> && std::is_nothrow_move_assignable_v<T>)
    requires std::copyable<T>
  {
    auto copy = rhs;
    copy.swap(*this);
    return *this;
  }

  ~Optional()
    requires std::is_trivially_destructible_v<T> = default;
  constexpr ~Optional() {
    if (engaged_) {
      std::destroy_at(&t_);
    }
  }
private:
  union {
    char pad_;
    T t_;
  };
  bool engaged_ = false;
};

This Optional behaves basically like std::optional, with one important semantic difference: This Optional is what P2786 calls “replaceable.” That simply means that instead of std::optional’s case-wise assignment operator:

  optional& operator=(const optional& rhs) {
    if (engaged_ && rhs.engaged_) {
      t_ = rhs.t_;
    } else if (engaged_) {
      std::destroy_at(&t_);
      engaged_ = false;
    } else if (rhs.engaged_) {
      std::construct_at(&t_, rhs.t_);
      engaged_ = true;
    }
    return *this;
  }

ours does copy-and-swap, and swaps via relocation rather than via assignment. Thus we never call T::operator=, which means we don’t care if T’s assignment operator is wonky (for example, if T is std::tuple<int&>). This behavioral difference is easily observable (Godbolt), but defensible in a third-party Optional type.

As written above, it is invariably true that is_trivially_relocatable_v<T> == is_trivially_relocatable_v<Optional<T>>. This invariant is not only nice to have, but should be intuitively obvious: If you can shuffle around objects of type T by copying their bytes, then you must be able to shuffle around objects of type Optional<T> the same way, because Optional<T>’s memory footprint contains nothing but a T and a bool.

What about types with “weird” assignment operators, like tuple<int&>? Under P1144, is_trivially_relocatable_v<tuple<int&>> == false. However, we could safely warrant Optional<tuple<int&>> as trivially relocatable, since it never uses tuple’s wonky assignment or swap. P1144’s “sharp knife” lets us cut out an exception for tuple<int&> if we want to, either generically:

template<class T>
class [[trivially_relocatable(std::is_trivially_relocatable_v<T> ||
    (std::is_trivially_move_constructible_v<T> && std::is_trivially_destructible_v<T>))]]
    optional {
  ~~~~

or by name:

template<class T>
inline constexpr bool optional_be_trivially_relocatable =
  std::is_trivially_relocatable_v<T>;

template<class... Ts>
inline constexpr bool optional_be_trivially_relocatable<std::tuple<Ts...>> =
  ((std::is_reference_v<Ts> || std::is_trivially_relocatable_v<Ts>) && ...);

template<class T>
class [[trivially_relocatable(optional_be_trivially_relocatable<T>)]] optional {
  ~~~~

Either way, we accomplish our goal: we make the compiler to understand that Optional achieves greater regularity than the tuple it contains.

static_assert(!std::is_trivially_relocatable_v<std::tuple<int&>>);
static_assert(std::is_trivially_relocatable_v<Optional<std::tuple<int&>>>);

C++26 will make this a little weirder and a little harder

Unfortunately for C++ users, there are two competing models of “trivial relocation.” There’s the “P1144” model everyone uses in practice, and then there’s the “P2786” model that was voted into C++26 in Hagenberg in February despite loud technical objections from the userbase about problems which remain unaddressed.

Here’s the same optional written in P2786’s syntax: Godbolt. It’s almost exactly the same; the only syntactic difference is in the class-head, where instead of an attribute with an explicit condition, P2786 asks us to use a pair of keywords instead:

template<class T>
class optional trivially_relocatable_if_eligible
               replaceable_if_eligible {
    ~~~~

P2786 (the rising C++26) requires us to juggle two traits, and each of them has a little hiccup when we look closely.

First: Under P2786 it is not true that Optional<T> is trivially relocatable if-and-only-if T is trivially relocatable. That’s because P2786 defines “trivially relocatable” more broadly, as a type-specific operation not necessarily tantamount to memcpy. P2786 says that polymorphic types are trivially relocatable, but unions of polymorphic types aren’t necessarily trivially relocatable. Thus:

struct Poly { virtual int f(); };
struct Holder { int i; Poly p; };
static_assert(std::is_trivially_relocatable_v<Holder>); // P2786 makes this true
IMPL_DEFINED(std::is_trivially_relocatable_v<Optional<Holder>>); // may be true or false

Second: We remarked above that our Optional is invariably what P2786 calls “replaceable.” In C++26 we can mark it as replaceable_if_eligible; but that keyword has “dull knife” semantics: it doesn’t always cut straight. The compiler will look at the type inside the union — say, tuple<int&> — and if it discovers that that type is not known to be replaceable, then the Optional class won’t be considered replaceable either. We have no path to accomplish our goal: the C++26 compiler cannot be made to understand that Optional achieves any greater regularity than its contained tuple type.

static_assert(!std::is_replaceable_v<std::tuple<int&>>);
static_assert(!std::is_replaceable_v<Optional<std::tuple<int&>>>); // P2786 forces this
  // to false, even though conceptually it should be true

We can turn off either of these traits (and we’ll see an example tomorrow where that’s required for correctness), but we can’t turn them on if the compiler decides suboptimally. This means that our Optional<tuple<int&>> cannot with P2786 get the optimizations for vector::erase, rotate, and the like, which it can get with P1144.

Tomorrow we’ll see how we can gain “replaceability” for Optional, even under P2786, by sacrificing our constexpr support.


See also:

Posted 2025-05-08