C++ Logo

std-proposals

Advanced search

Re: [std-proposals] Trivial Relocatability - A better place for these specifiers

From: Arthur O'Dwyer <arthur.j.odwyer_at_[hidden]>
Date: Tue, 11 Feb 2025 17:22:49 -0500
On Tue, Feb 11, 2025 at 2:39 PM Wojciech Kwiliński <W.Kwilinski_at_[hidden]>
wrote:

> Thank you for the explanation!
>
> So, correct me if I'm wrong, but the big difference between move
> construction and
> trivial relocation is that trivial relocation is a memcpy that doesn't
> leave
> the original object in a 'valid but unspecified' state, instead the
> original object
> reaches end-of-life right there and then.
>
Yes.

> realloc() performs trivial relocation, because the move constructor is
> just a memcpy,
> and the destructor is a no-op.
>
In a sense, yes, you could say that.
I would prefer to say that `realloc`, like `memcpy`, is just a low-level
primitive — `realloc` doesn't perform *any* value-semantic operation.
However, for types which are trivially relocatable, you can *implement*
std::vector's reallocation/growth *using* a simple `realloc`.

To clarify what I mean by "`realloc` is lower-level than any value semantic
operation," consider a container-ish type with a `clear_and_reserve` method:

template<class T>
struct ClearableCache {
    T *data_;
    size_t size_;
    size_t capacity_;
    void clear() { std::destroy(data_, data_ + size_); size_ = 0; }
    void clear_and_reserve(size_t new_capacity) {
        if constexpr (std::is_trivially_destructible_v<T>) {
            data_ = realloc(data_, new_capacity * sizeof(T));
            size_ = 0;
            capacity_ = new_capacity;
        } else {
            clear();
            data_ = realloc(data_, new_capacity * sizeof(T));
            capacity_ = new_capacity;
    }
};

In this example, the first call to `realloc` is being used to implement
"trivially destroy `size_` elements and then reallocate a buffer of
garbage"; the second call to `realloc` is being used to implement
"reallocate a buffer of garbage." In neither case are we conceptually
"making" any objects in the new buffer; so in this example we're not using
`realloc` to implement "relocation" in any sense.

Digging deep into the papers and blogs you listed, I found this:
> https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1029r3.pdf
> Which, like me, assumes that trivial relocation is a property of the move
> constructor,
> and we assign that property to the constructor via "= bitcopies".
>
Yes, but that's a different and IMHO-less-useful property. That's an
evolution from "trivially destructible after move,"
https://quuxplusone.github.io/blog/2025/01/10/trivially-destructible-after-move/
and basically means (see P1029R3 page 3):
- T has one special value which for convenience we'll call "the singular
value." (For example, for `unique_ptr<int>` the singular value is "null.")
- The singular value is trivially copyable, in the sense that if we have an
object `src` holding the singular value, we can construct another object
`dst` also holding the singular value via `memcpy(dst, src, sizeof(T))`.
(This holds for `unique_ptr<int>` as well, on sane platforms.) Notice that *no
other value of type T* need have this property — just the singular value.
- `::new (dst) T(std::move(*src));` has the same effect as `memcpy(dst,
src, sizeof(T)); memcpy(src, &g, sizeof(T));` where `g` is an invented
variable holding the singular value.
- `src->~T();` has the same effect as a no-op, if `*src` holds the singular
value.

Now, "trivially destructible after move" has BIG problems (see my blog post
above). "Move = bitcopies" is not nearly so bad. But it still has a few
problems:
- We still don't know what the bit-pattern of that singular value *is* —
it's not necessarily all-bits-zero. Maybe we don't need to know; but it's
certainly unusual to say that the library should do an optimization based
on the fact that X behaves like Y when we can't nail down exactly what the
behavior of Y would be, especially when the optimization that we hope to
perform really doesn't have anything to do with *either* X
(move-constructing) or Y (memcpying a singular value).
- The notion of "singular value" is oddly outside the normal realm of value
semantics. We could say that the singular value must be the
default-constructed value; but what if `T` has no default constructor?
(It's easy to imagine a trivially relocatable type with no default
constructor.) Besides, in the case of MSVC's std::list, we *don't want* the
singular value to be the default-constructed value: destroying a
default-constructed list is not a no-op. We want the singular value for
MSVC's std::list to be an "emptier-than-empty," all-bits-null list object.
No object can ever have such a value; and if you actually try to run the
destructor on such a value, it'll crash. Again that feels like a shaky
foundation to build reliability on top of.

Meanwhile its only advantage is what attracted you to it: that,
syntactically, it puts the warrant marking directly adjacent to the
move-constructor instead of adjacent to the name of the class. But trivial
relocatability is a holistic property of the class! It *isn't* a property
of just the move-constructor. Even "move=bitcopies"-ness is very clearly a
holistic property of the whole class, based on the four bullet points
above, which involve the move-constructor, the destructor, and even this
nebulous emptier-than-empty *singular value*. It's not a property of the
move-constructor alone.
In fact, a C++ class can have multiple move-constructors
<https://eel.is/c++draft/class.ctor#class.copy.ctor-2>:
template<class T>
struct S {
    S(S&&) requires A<T> { ... } // move constructor
    S(S&&) requires B<T> { ... } // also a move constructor
    S(const S&&, int=0) { ... } // also a move constructor
};
So you'd have to figure out what happens if one of the eligible
move-constructors is marked with the `=bitcopies` warrant and another is
not.

[...]
>
> Still, my main worry regarding the existing syntax is that it's too
> "heavy" for something
> that every programmer should do. Like I said, most types are trivially
> relocatable, so
> ideally this property should be easy to assign and light in terms of
> visual clutter it makes.
> Having two lengthy specifiers also doesn't help.
>
FWIW, I strongly agree with you there. P2786's syntax *is* too heavy (two
keywords, as opposed to one attribute) and lags behind the established
industry practice.

This is why I mentioned the rule-of-five, it is already common to add
> noexcept to
> the move constructor (because of std::move_if_noexcept) so I saw trivial
> relocation
> as "a better noexcept" (in terms of optimizations it allows for).
>
> Anyway, thank you for your time, and thank you for the links :)
>
You're welcome!
–Arthur

>

Received on 2025-02-11 22:23:05