Skip to content

Instantly share code, notes, and snippets.

@Voxeles
Last active April 5, 2026 21:50
Show Gist options
  • Select an option

  • Save Voxeles/841205b6bb8f6c645f668d5e2ab1f049 to your computer and use it in GitHub Desktop.

Select an option

Save Voxeles/841205b6bb8f6c645f668d5e2ab1f049 to your computer and use it in GitHub Desktop.
A Critique Of The Two Trivial Relocatability Papers

There are two main papers attempting to add trivial relocatability to C++: P1144 and P2786.

The latter one almost made it to C++26 before being pulled back at the last minute. While the former has been stuck in committee hell since late 2018 (8 years).

I want to give my unprofessional armchair critique of these two papers as a C++ user and an uneducated swine.

Background

Normally, when you move a type you need to call its move constructor.

But calling such a function is overkill in most cases, as moving a type typically the same as memcpy'ing it, it'd be more efficient for, say, std::vector<std::unique_ptr> to move its contents via single memcpy than to call each unique_ptr's move constructor one-by-one.

But, the real reason the move constructor exists is so that the moved-from object can be put into a 'disabled' state, such that its destructor doesn't run and cause double-frees. Take unique_ptr, its move constructor does two things: 1. Copy the stored pointer to the new location 2. Set the pointer at the old location to null. Now, when the old object's destructor runs, it will simply do nothing (free(nullptr) is a no-op) instead of potentially double-freeing.

void foo(std::vector<std::unique_ptr<int>>& vec) {
    std::unique_ptr<int> uptr(new int{1});
    vec.emplace_back(std::move(uptr));
    // uptr.~unique_ptr() is called here, but it's fine since uptr.get() is nullptr now
}

Sidenote: In such an obvious case, the compiler can elide uptr's destructor. But what if the move was conditional? What if the move was dependent on a runtime condition? Well:

  • In C++, the destructor call will always happen, and it's the programmer's (specifically, whoever wrote the class) responsibility to make it a no-op if the object is moved-from (which includes giving objects a moved-from state).
  • In Rust, the compiler will inject a variable to track the object's lifetime and a branch at the end of scope to conditionally call the destructor based on said variable.
  • In C, it depends. Either the vendor has made the destructor conditional (like free()) or the user must call it conditionally (and calling it with a dead object results in UB) - check the docs, I guess.

However, in the case of containers like std::vector, it is the responsibility of the container to call the destructor - as such, we can simply copy the objects and not call any destructors, then just free the old memory after the move. No need to 'disable' objects whose lifetime will end right there and then, and whose destructors in such a state will be no-op anyway.

template <class T>
void MyVector::resize_to(size_t new_count) {
    size_t old_count = this->m_count;
    T* old_mem = this->m_mem;
    T* new_mem = (T*)malloc(sizeof(T) * new_count);
    if constexpr (std::is_trivially_copyable_v<T>) {
        // Trivial path
        memcpy(new_mem, old_mem, old_count * sizeof(T))
        free(old_mem);
    }
    else {
        // Move and destroy path
        for (size_t i = 0; i < new_count; i++) {
            new (new_mem[i]) T(std::move(old_mem[i]);
            old_mem[i]->~T(); // Call the (usually) no-op destructor...
        }
        free(old_mem);
    }
    for (size_t i = old_count; i < new_count; i++)
        new (new_mem[i]) T{};
    this->m_count = new_count;
    this->m_mem = new_mem;
}

It should be noted that there are types which are not trivially relocatable; in such cases the move constructor does more than just copy-and-disable. For example, std::list may store the first node within itself, therefore upon moving the move constructor must also update the next node's prev pointer to instead point to std::list's new location. std::string may also need to be updated upon moving, as some implementations (libstdc++ and MSTL) make the data pointer point to the std::string object itself when SSO is active. This avoids a branch that other SSO-capable std::string implementations (libc++) incur.

SideSidenote: The lack of trivial relocatability in C++ is usually cited as the reason as to why std::vector doesn't call realloc(). This isn't strictly true - C++ already allows you to move-via-memcpy types which are trivially copyable. This covers built-in and POD types, but not RAII types like unique_ptr. This would allow std::vector<int> to call realloc(), but the real reason why it doesn't is because std::allocator doesn't expose such a function, see this as for why.

Anyway, let's now look at the papers:

P1144

Described as the 'sharp knife' approach, it's designed to mimic the existing tag-like semantics of today's library solutions to this problem, which is to use a 'tag' type and specialize it for types you want to mark as trivially relocatable. P1144 standardizes this as an annotation.

Example:

class [[trivially_relocatable]] Example {
    int* m_ptr;
public:
    Example() : m_ptr(new int{1}) {}
    Example(const Example& other) : m_ptr(new int{*other.m_ptr}) {};
    Example(Example&& other) noexcept : m_ptr(other.m_ptr) { other.m_ptr = nullptr; };
    ~Example() { delete m_ptr; };
};

Now, when std::vector is resized and needs to move the data from the old to the new memory, it can either:

  • Check std::is_trivially_relocatable_v and call memcpy based on that
  • Call std::uninitialized_relocate_n(old_location, old_size, new_location)

But this is a simple case. Indeed, the ease of use of P1144's is greatly reduced by the fact that member variables can be non-trivially relocatable. The trivially_relocatable annotation can be made conditional via a predicate - but that requires the programmer to remember to add any new member variables to the predicate. This effectively makes using the annotation a liability, since it can greatly complicate code and requires extra maintenance in exchange for some performance.

Example:

struct [[trivially_relocatable(
    std::is_trivially_relocatable_v<std::string>
    && std::is_trivially_relocatable_v<std::list<int>>
    && std::is_trivially_relocatable_v<library::sbo_container>
    && std::is_trivially_relocatable_v<std::unordered_map<std::string, int>>
)]] Example2 {
    std::string str; 
    std::list<int> list;
    library::sbo_container other;
    std::unordered_map<std::string, int> map;
    Example2();
    Example2(Example2&&);
    ~Example2();
};

You may think that the std::unordered_map can be removed from the predicate, since it 'obviously' can't have SBO and is therefore always trivially relocatable. But can you? It will always be safer to just include every member type in the predicate than to bet your program's correctness on implementation-defined details. Remember - the cost of guessing wrong is data corruption, stale pointers, and other frustrating hard-to-debug errors.

Worse, it puts library maintainers in an awkward position, as it strips them from the ability to make types non-trivially relocatable in the future. Libraries like Qt declare constructors and destructors as non-trivial, even if their definition (in the source file) is empty - this prevents potential optimizations, but grants ABI stability should these functions be made really non-trivial in the future. See this commit for example.

So here comes P1144, no fucks given, giving unsuspecting programmers the ability to shoot themselves in the foot and blow their heads off via a magic 'simple' annotation. And how are libraries supposed to guard against this? Never add SBO / make types non-trivially relocatable? Add scary comments saying "NO WE ARE SERIOUS WE CAN AND WILL BREAK THE ABI". This might work to ward off people from using internal functions, but a naive programmer just marking their "POD" struct as [[trivially_relocatable]] will not bother to the check each member type's definition just to ensure that such an annotation is safe.

A much better solution in this case is to make the annotation automatically conditional - something like memberwise_trivially_relocatable.

Which brings us to...

P2786

This paper ditches P1144's annotation syntax and instead introduces a new keyword: trivially_relocatable_if_eligible.

Example:

struct Example3 trivially_relocatable_if_eligible replaceable_if_eligible  {
    std::string str; 
    std::list<int> list;
    library::sbo_container other;
    std::unordered_map<std::string, int> map;
    Example3();
    Example3(Example3&&);
    ~Example3();
};

Wait, what's replaceable_if_eligible? Uh oh...

See, P2786 thinks differently. Its idea of 'trivial' isn't 'by memcpy', but rather 'it's known to the compiler'. Of special interest here are signed vtable pointers available on platforms like ARM64e - these cannot be copied bytewise as the signature is dependent on their location. Who uses this platform? Apple.

So, P2786's compromise is to bend the definition of 'trivial' to allow this use case. On paper this seems like a small change, but the actual consequences are huge.

First, relocation can no longer be performed by memcpy - instead the new library utility 'std::relocate' or 'std::uninitialized_relocate' must be used. This couples the feature tightly with the standard library for no good reason. Many C++ programmers tend to minimize their dependency on the standard library, primarly due to the compile-time cost of including spurious headers.

Second, no library actually uses P2786's definition in production. P2786 is attempting to optimize a theoretical use case with no real proof of it's viability. As far as I can tell, no benchmarks or a concrete example has been shown that allowing polymorphic types is beneficial beyond theory. It's optional<T&> all over again.

Third, by defining trivial relocatability as different from memcpy, P2786 makes it impossible to use realloc. While P1144 at least opens the door for libraries and users to use realloc as they wish, P2786 not only shuts that door tight, but it also places a massive footgun for good measure. It effectively loses any safety gained by being extremely unintuitive and not following standard practice - the exact practice it's trying to make legal.

Fourth, while polymorphic types are always trivially relocatable (according to P2786's definition), for unions containing polymorphic types it is implementation-defined. This gives users, albeit in a deranged way, to check if 'trivially relocatable' actually means what it typically means (memcpy). This should not be such a specific workaround - this should be a normal feature - it's what most implementations actually want.

Fifth, by using a context-sensitive keyword, P2786 cannot use a predicate as that would introduce a new most-vexing-parse (Quick: name everything that 'some_symbol()' could mean). Why is the predicate important? Well...

Sixth, without a predicate version, some types like std::optional with buffer-based SBO will need to use workarounds to mark themselves as trivially relocatable only if their arguments are. P2786 proposes a workaround using a templated dummy type which conditionally contains a destructor, effectively bringing us back to the SFINAE days of using odd structs and specializations just to do the thing we want. Except here, if you do it incorrectly, you get UB and data corruption.

Seventh, for some trivially relocatable types it would be more efficient to treat them as NOT trivially relocatable. For example: inplace_vector. If the size of its buffer is large, then it'd be more efficient to just copy the elements that are active (as in, form data() to data() + size()) than the entire buffer. Again, no predicate - no way to actually do this easily.

There is a theme with all these issues - they only really affect third-party implementations and not the standard library. The dependency on a library function doesn't matter, the fact that you can't use realloc doesn't matter, the fact that some SBO types cannot be optimized doesn't matter. It's hard not to feel like this feature was added by the committee, for the committee, given the constraints that the standard library already has. In that sense, it's not even a language feature, since a typical C++ user won't even benefit from it - it's for the standard library only.

Now, one can argue that P1144's definition of trivial relocatability can be approximated using P2786's definitions as std::is_trivially_relocatable_v<T> && std::is_replacable_v<T>. However, this does not do P2786 any favours as it essentially means that base trait is unusable without workarounds, on top of all the workaround mentioned above. The programmer must fight and claw their way through the misleading definitions to get any benefits from P2786. I cannot even safely say if the above workaroud is truly synonymous with P1144's definition or if there is yet another edge case.

Conclusion

I believe that:

  1. P1144's definition of trivial relocatability should be standardized
  2. P1144's annotation should be standardized as two: [[trivially_relocatable_if_eligible]] and [[trivially_relocatable_if(/* predicate */)]]
  3. P2786's definitions should not be pursued until proven beneficial despite additional complexity
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment