Move construction and assignment of class with constant member

Viewed 721

I have a class with a const member that needs both a move constructor and assignment.

I implemented it the following way:

struct C
{
    const int i;
    C(int i) : i{i} {}
    C(C && other) noexcept: i{other.i} {}

    C & operator=(C && other) noexcept
    {
        //Do not move from ourselves or all hell will break loose
        if (this == &other)
            return *this;

        //Call our own destructor to clean-up before moving
        this->~C();

        //Use our own move constructor to do the actual work
        new(this) C {std::move(other)};

        return *this;
    }

    //Other stuff here (including the destructor)....
}

This compiles and works as expected.

The question is whether this is the normal way to implement such a move assignment or there is a less contrived way to do it?

3 Answers

Unfortunately this is undefined behavior. You cannot overwrite a const object like this and refer to it by the same name afterwards. This is covered by [basic.life]/8

If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if: [...]

  • the type of the original object is not const-qualified, and, if a class type, does not contain any non-static data member whose type is const-qualified or a reference type, [...]

The simple fix is to make i non const and private and just use a getter to stop any modification from happening.

After some research I came to the following.

There are related questions:

The relevant information was found in:

What clarified this for me was the example in https://en.cppreference.com/w/cpp/utility/launder

struct X {const int i; };
X * p = new X{0};
X * np = new (p) X{1};

This will result in undefined behavior:

const int i = p->i

But the following is valid:

const int i = np->i;

Per my original question, a modified version of the move assignment would be needed:

struct C
{
    const int i;
    C() : i{} {}
    C(C && other) noexcept: i{other.i} {}

    C & operator=(C && other) noexcept
    {
        if (this == &other) return *this;
        this->~C(); //Ok only if ~C is trivial
        return *(new(this) C {std::move(other)});
    }
}

C a;
C b;
b = std::move(a);

//Undefined behavior!
const int i = b.i; 

This would work as expected but would result in undefined behavior for the following reasons.

When the destructor is invoked the objects' lifetime ends. Following that it is safe to call the move constructor. But at any point the compiler is free to assume that the content of b never changes. Thus, by using our move assignment we have a contradiction that results in undefined behavior.

On the other hand, although the return value from the placement new is the same as this, when the compiler performs access through that, returned, pointer/reference it must not assume anything about that object.

Given that C& C::operator=(C&&) returns the result of the placement new, the following should be valid (but not really useful).

const int i = (b = std::move(a)).i;

Thank to @NathanOliver, whos' answer was the correct one all along and, also, to him and @SanderDeDycker for playing brain ping-pong with me.

As of c++20, this can now be done without UB. This is because the basic.life now allows replacing objects that contain consts. Prior to c++20 this was disallowed.

Secondly, lack of UB can be proven using constexpr compile time evaluation. Compilers are required to detect UB in compile time evaluation. However, placement new is still not consteval so while legal in regular code w/o UB, it can't be checked in compile time functions. But c++20 provides 2 functions that will work, std::destroy_at and std::construct_at.

This code allows assignment using your example:

#include <memory>
struct C
{
    const int i;
    constexpr C(int i) : i{ i } {}
    constexpr C(const C& other) noexcept = default;
    constexpr C& operator=(const C& other) noexcept
    {
        //Do not move from ourselves or all hell will break loose
        if (this == &other)
            return *this;

        //Call our own destructor to clean-up before moving
        std::destroy_at(this);
        std::construct_at(this, other);

        return *this;
    }
};

// Compilers are required to detect any UB evaluating this
consteval int foo()
{
    C a(1), b(2);   // create two difference objects
    C c(b);         // CTOR
    a = c;
    return a.i;     // returns 2;
}

int main()
{
    return foo();
}
Related