C++ Logo

std-proposals

Advanced search

Re: [std-proposals] Relocation in C++

From: Arthur O'Dwyer <arthur.j.odwyer_at_[hidden]>
Date: Thu, 5 May 2022 16:48:01 -0400
On Thu, May 5, 2022 at 10:33 AM Sébastien Bini via Std-Proposals <
std-proposals_at_[hidden]> wrote:

> [Avi Kivity wrote:]
>
> That is, the destructor is called immediately, since the reloc operator
> ended the scope of the name. I think that's a good thing.
>
> I cannot be called [...] as it could then lead to objects being destructed
> twice
>

Right, I think there's a mismatch between the way Avi was
using/understanding the terminology and the way you (and I) use it.
- Immediately after a call to std::relocate_at (or the core-language
`operator reloc` or whatever), the source object *has become destroyed and
its lifetime is over*.
- But *the destructor* is not called.
This terminology is confusing in the context of today's C++, because
today's C++ does not (admit|permit) any difference between the ideas of
"the object's lifetime ends" and "the object's destructor is called." We
are able to use the same English phrase — "the object is destroyed" — to
mean both notions, interchangeably, without any ambiguity, because they are
literally synonymous in C++ today.
In C++-with-relocation (whether P1144 or otherwise), it is possible for an
object's lifetime to end in either of two different ways: *either* its
destructor is called, *or* it is relocated-from. In the former case, its
destructor is called; in the latter case, its destructor is never called.
The phrase "the object is destroyed" should now be avoided, because it is
ambiguous: it could be taken to mean *either* "the object's destructor is
called," *or* "the object's lifetime ends," and these notions are now no
longer synonymous.

And how do you relocate an object inside a container? Do you need an
> std::vector<T>::push_back(std::optional<T>) API? I'd find that disturbing.
>

You can in fact use the regular `emplace_back`, via a pattern I call the
"super constructing super elider":
https://quuxplusone.github.io/blog/2018/05/17/super-elider-round-2/
However, it's still pretty disturbing. :)

Here's a test case using P1144r6 syntax with the magic library function `T
std::relocate(T*)`:

    void relocate_into_a_vector(std::vector<Widget>& v, Widget *pw) {
        struct S {
            Widget *pw_;
            operator Widget() const { return std::relocate(pw_); }
        };
        v.emplace_back(S{pw});
    }

(Note that if you try this on Godbolt
<https://p1144.godbolt.org/z/dPv3E13Es> you'll get a move-and-destroy,
because my P1144 fork's `T std::relocate(T*)` is insufficiently magic.)


> I agree that the relocator should be unconditionally noexcept, such that
> it
> > should not be possible to throw out from it (but std::terminate, not UB).
> > This is because the source object will have been partially relocated and
> > thus will be incapable of releasing resources.
>
> Agreed.
>

Disagreed, and here's why.
C++ permits destructors to throw. If part of a destructor throws, then C++
takes care of cleaning up the rest of the object as part of stack
unwinding: in this godbolt, even though ~Car's attempt to call ~Wheels
threw an exception, the runtime still takes care of calling ~Doors, so that
no RAII resources are leaked.
https://godbolt.org/z/7jhqPf1eG
The upshot is that if a destructor throws, the caller (of that destructor)
is still guaranteed that all the object's RAII resources were freed; even
though the destructor "failed," the object is still 100% guaranteed to have
become destroyed and its lifetime is 100% over.

Also, more familiarly, C++ permits constructors to throw. If part of a
constructor throws, then C++ takes care of cleaning up the
already-constructed parts of the object. The upshot is that if a
constructor throws, the caller (of that constructor) is guaranteed that all
the new object's RAII resources were either freed or never-allocated; the
constructor failed and so the object is 100% guaranteed to have become
destroyed and its lifetime never started.

Analogously, your `operator reloc` should permit relocators to throw. The
relocator has two jobs: to *construct* the destination object and to
*destroy* the source object. Both of these jobs agree about what should
happen if an exception is thrown: C++ should take care of cleaning up the
already-constructed and/or not-yet-destroyed parts of the destination
and/or source object. If relocation fails, then constructing-the-new-object
failed (so there is no new object) and also destroying-the-old-object
"failed" (so there is no old object either). *A failed relocation
operation means that neither object exists anymore.*

Of course the Standard Library is free to not-work with program-defined
types that are not nothrow_relocatable, in the same way that today's
Standard Library doesn't-work with program-defined types that are not
nothrow_destructible. There's no shame in that. But the correct
*core-language* behavior seems indisputable to me.

I guess this is good motivation for me to finally publish a P1144r6.
P1144r5 gave the wrong exception behavior to `std::uninitialized_relocate`
(i.e. P1144r5 *disagreed* with my indisputable logic above, because I had
not yet figured it out). David Stone convinced me to change it, more than a
year ago. But I never got around to submitting P1144r6 to a mailing. It's
just been sitting around in draft format since March 2021:
https://quuxplusone.github.io/draft/d1144-object-relocation.html#wording-uninit-relocate


We do need a new syntax to call this special relocation constructor. The
> main reason is for std::relocate_at implementation (which is needed for
> container implementations). std::relocate_at(const T* src, T* dst) either
> (a) makes memcpy if trivially relocatable, (b) constructs dst with some
> kind of placement-new with the relocation ctor/dtor, or (c) placement-new
> move ctor + dtor: new (dst) T{const_cast<T&&>(*src)}; src->~T();
>
> In the first two revisions of my proposal, (b) was implemented by a call
> to the relocation ctor directly: new (dst) T{const_cast<T~>(*src)}; That
> worked well because of the relocation reference.
> The syntax I suggested in my last email (T::T reloc(T&&)) allowed to write
> the placement new: new (dst) T reloc{const_cast<T&&>(*src)};
>

If your right-hand side has dynamic lifetime already, you don't need
core-language support for `T reloc`; you can just use a tagged constructor
or anything really. Dynamic-lifetime objects admit library-only solutions,
because it's always the *library author's* job to keep track of *dynamic*
lifetimes — the core language needn't get involved in that case.
    ::new ((void*)dst) T(std::relocating, std::move(*src));
As I understand your `operator reloc` proposal, though, you're trying to
find a core-language way to tell the compiler that some static or automatic
variable is now "dead" and shouldn't be double-destroyed at the end of its
natural lifetime:
    {
    std::string src1 = "hello world";
    std::string dst1 = src1 reloc; // or something??
    std::string src2 = "hello world";
    std::string *dstp = ~~~~;
    ::new ((void*)dstp) std::string(src2 reloc);
    ~~~~
    } // Here dst1.~string() is implicitly called.
    // src1 and src2 were already destroyed via `reloc` so here nothing
happens to them.

P1144r6 proposes a magic library utility function `std::relocate(T*)`:
https://quuxplusone.github.io/draft/d1144-object-relocation.html#wording-relocate
With this magic function, you could express the above as
    {
    std::string src1 = "hello world";
    std::string dst1 = std::relocate(src1);
    std::string src2 = "hello world";
    std::string *dstp = ~~~~;
    ::new ((void*)dstp) std::string(std::relocate(src2));
    ~~~~
    } // Here dst1.~string() is implicitly called, as are src1.~string()
and src2.~string().
    // But src1 and src2 were already destroyed, so that's a double-free
bug and Bad Stuff happens.
    // P1144 doesn't try to solve this part; it just claims you shouldn't
be doing that.

my $.02,
Arthur

Received on 2022-05-05 20:48:14