void.pointer
void.pointer

Reputation: 26365

Why do overloaded move assignment operators return lvalue reference instead of rvalue reference?

So this is really just something I can't make sense of semantically. Assignment chaining make sense for copy semantics:

int a, b, c{100};
a = b = c;

a, b, and c all are 100.

Try a similar thing with move semantics? Just doesn't work or make sense:

std::unique_ptr<int> a, b, c;
c = std::make_unique<int>(100);
a = b = std::move(c);

This doesn't even compile, because a = b is a copy assignment, which is deleted. I could make an argument that after the final expression executes, *a == 100 and b == nullptr and c == nullptr. But this isn't guaranteed by the standard. This doesn't change much:

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

This still involves copy assignment.

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

This one actually does work, but syntactically it is a huge deviation from copy assignment chaining.

Declaring an overloaded move assignment operator involves returning an lvalue reference:

class MyMovable
{
public:
    MyMovable& operator=(MyMovable&&) { return *this; }
};

But why isn't it an rvalue reference?

class MyMovable
{
public:
    MyMovable() = default;
    MyMovable(int value) { value_ = value; }
    MyMovable(MyMovable const&) = delete;
    MyMovable& operator=(MyMovable const&) = delete;
    MyMovable&& operator=(MyMovable&& other) {
        value_ = other.value_;
        other.value_ = 0;
        return std::move(*this);
    }
    int operator*() const { return value_; }
    int value_{};
};

int main()
{
    MyMovable a, b, c{100};
    a = b = std::move(c);

    std::cout << "a=" << *a << " b=" << *b << " c=" << *c << '\n';
}

Interestingly this example actually works as I expect, and gives me this output:

a=100 b=0 c=0

I'm not sure if it shouldn't work, or why it does, especially since formally move assignment isn't defined this way. Quite frankly this just adds more confusion in an already-confusing world of semantic behaviors for class types.

So I've brought up a few things here, so I'll try to condense it into a set of questions:

  1. Are both forms of assignment operators valid?
  2. When would you use one or the other?
  3. Is move assignment chaining a thing? If so, when/why would you ever use it?
  4. How do you even chain move assignment that returns lvalue references in a meaningful way?

Upvotes: 3

Views: 421

Answers (4)

Ayjay
Ayjay

Reputation: 3433

Are both forms of assignment operators valid?

According to the standard, I believe so, but be very careful. When you return an rvalue reference from operator=, you're saying that it can be freely modified. This can easily result in surprising behaviour. For example,

void foo(const MyMovable& m) { }
void foo(MyMovable&& m) {
    m.value_ = 666; // I'm allowed to do whatever I want to m
}

int main() {
    MyMovable d;
    foo(d = MyMovable{ 200 }); // will call foo(MyMovable&&) even though d is an lvalue!
    std::cout << "d=" << *d << '\n'; // outputs 666
}

The proper way to fix this would probably be to define your operator= like this:

MyMovable&& operator=(MyMovable&& other) && {...

Now this only works if *this is already an rvalue, and the example above will not compile since it's using operator= on an lvalue. However, this then doesn't allow your chaining move operators to work. I'm not sure how to allow both move operator chaining whilst defending against behaviour like in my example above.

When would you use one or the other?

I don't think I'd ever return an rvalue reference to *this. It's prone to surprising the caller, as in the example above, and I'm not really sure that enabling move assignment chaining is something I would really want to do.

Is move assignment chaining a thing? If so, when/why would you ever use it?

I don't ever use assignment chaining. It saves a few characters but I think it makes the code less obvious and, as this question demonstrates, can be a little tricky in practice.

How do you even chain move assignment that returns lvalue references in a meaningful way?

If I had to do this, I would use the form you used above: a = std::move(b = std::move(c)); This is explicit and obvious.

Upvotes: 1

Jarod42
Jarod42

Reputation: 217293

  1. Are both forms of assignment operators valid?

assignment operator are mostly regular method and doesn't need to return lvalue reference to self type, returning void, char would also be valid.

To avoid surprise, we try to mimic built-in and so to allow chaining assignment, we return lvalue reference to self type.

  1. When would you use one or the other?

I would personally only use MyMovable& operator=(MyMovable const&)

  1. Is move assignment chaining a thing? If so, when/why would you ever use it?

I don't thing so.

but to allow syntax a = std::move(b) = std::move(c);, you might do:

    MyMovable& operator=(MyMovable const&) = delete;
    MyMovable&& operator=(MyMovable&& other) &&;
    MyMovable& operator=(MyMovable&& other) &;

Upvotes: 3

walnut
walnut

Reputation: 22152

Yes, you are free to return whatever you want from an overloaded assignment operator and so your MyMovable is fine, but users of your code may be confused by the unexpected semantics.

I don't see any reason to return a rvalue-reference in the move assignment. A move assignment usually looks like this:

b = std::move(a);

and after that, b should contain the state of a, while a will be in some empty or unspecified state.

If you chain it in this way:

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

then you would expect b to not loose its state, because you never applied std::move to it. However if your move assignment operator returns by rvalue-reference, then this will actually move the state of a into b and then the left-hand assignment operator would also call the move assignment, transferring the state of b (which was a's before) to c. This is surprising, because now both a and b have empty/unspecified state. Instead I would expect a to be moved into b and copied into c, which is exactly what happens if the move assignment returns a lvalue-reference.

Now for

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

it works as you expect, calling the move assignment in both cases and it would be clear that the state of b is moved as well. But why would you want to do that? You could have transferred a's state to c directly with c = std::move(a); without clearing b's state in the process (or worse putting it in a not directly usable state). Even if you want that, it would be clearer stated as a sequence

c = std::move(a);
b.clear(); // or something similar

As for

c = std::move(b) = std::move(a)

at least it is clear that b is moved from, but it seems as if the current state of b was moved, rather than the one after the right-hand move and again, the double move is redundant. As you noticed this still calls the copy-assignment for the left-hand, but if you really want to make this call the move assignment in both cases, you can return by rvalue-reference and to avoid the issue with c = b = std::move(a) explained above, you would need to differentiate on the value category of the middle expression. This can be done e.g. in this way:

MyMovable& operator=(MyMovable&& other) & {
    ...
    return *this;
}
MyMovable&& operator=(MyMovable&& other) && {
    return std::move(operator=(std::move(other)));
}

The & and && qualifiers signify that the particular overload should be used if the expression that the member function is called on is a lvalue or rvalue.

I don't know whether there is any case you would actually want to do that.

Upvotes: 7

eerorika
eerorika

Reputation: 238351

  1. Are both forms of assignment operators valid?

They are well-formed and have well defined behaviour. But returning an rvalue reference to *this from a function that isn't rvalue qualified would be non-conventional and probably not a good design.

It would be very surprising if:

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

caused b to be moved from. Surprisingness is not a good feature for an API.

  1. When would you use one or the other?

In general, you'd never want to return an rvalue reference to *this except from rvalue qualified function. From rvalue qualified function, it's preferable to return an rvalue reference or maybe even a prvalue depending on context.

  1. Is move assignment chaining a thing? If so, when/why would you ever use it?

I've never seen it used, and I cannot think of an attractive use case.

  1. How do you even chain move assignment that returns lvalue references

Like you showed, with std::moves.

in a meaningful way?

I'm not sure if there is a way to introduce meaning to it.

Upvotes: 5

Related Questions