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.