Implementing copy and move assignment with a single function
Asked Answered
B

2

7

Typically, given some type T, to implement copy and move assignment, one needs two functions

T& operator=(T&&) { ... }
T& operator=(const T&) { ... }

Recently, I come to realize that a single one is just sufficient

T& operator=(T v) {
  swap(v);
  return *this;
}

This version takes advantage of the copy/move constructor. Whether the assignment is copy or move depends on how v is constructed. This version may even be faster than the first one, since pass-by-value allows for more space for compiler optimization [1]. So, what is the advantage of the first version over the second one that even the standard library uses it?

[1] I guess this explains why tag and function objects are passed by value in the standard library.

Boles answered 15/1, 2016 at 4:19 Comment(11)
This has been beaten to death a million times - e.g., https://mcmap.net/q/504727/-should-the-copy-and-swap-idiom-become-the-copy-and-move-idiom-in-c-11Motorbike
@Motorbike I'm confused. Could you elaborate more on it? Possibly posting an answer?Boles
I think it's covered pretty well in this discussion of the copy and swap idiomCowbane
@Cowbane That article describes this as a successful solution with the description And that's it! With one fell swoop, all three problems are elegantly tackled at once.Boles
@Lingxi: I never meant to imply that the approach was good or bad; only to point out that your questions has been discussed quite thoroughly in that context.Cowbane
@Cowbane Thanks for the reference. It's very informative. Anyway, my question is, should it be good, why such practice is not widely adopted with the standard library in particular.Boles
@Motorbike I may have made a typo in the original question. I don't intent to move v into *this, but swap with it. Please see the updated question.Boles
Read Howard Hinnant's answer I linked to, and the slides he linked to, and the comments under that answer. The short answer is copy-and-swap can be significantly less efficient, because copy construction can be a lot more expensive than copy assignment, as the latter can reuse the lhs's resources.Motorbike
The real answer should be: don't do this because if you do, you cannot offer a noexcept move assignment operation.Och
@NirFriedman I think the single operator= can be marked noexcept, since it doesn't throw in the function body. Should throwing in parameter construction be considered?Boles
@Boles I think it is dubious at best, though opinions may differ. I just asked a question on this: #34805422. I think it's extremely misleading and should be avoided, even if you "can" do it.Och
C
5

std::swap is implemented by performing move construction followed by two move assignment operations. So unless you implement your own swap operation that replaces the standard-provided one, your code as presented is an infinite loop.

So you can either implement 2 operator= methods, or implement one operator= method and one swap method. In terms of the number of functions called, it's ultimately identical.

Furthermore, your version of operator= is sometimes less efficient. Unless the construction of the parameter is elided, that construction will be done via a copy/move from the caller's value. Following this are 1 move construction and 2 move assignments (or whatever your swap does). Whereas proper operator= overloads can work directly with the reference it is given.

And this assumes that you cannot write an optimal version of actual assignment. Consider copy-assigning one vector to another. If the destination vector has enough storage to hold the size of the source vector... you don't need to allocate. Whereas if you copy construct, you must allocate storage. Only to then free the storage you could have used.

Even in the best case scenario, your copy/move&swap will be no more efficient than using a value. After all, you're going to take a reference to the parameter; std::swap doesn't work on values. So whatever efficiency you think will be lost by using references will be lost either way.

The principle arguments in favor of copy/move&swap are:

  1. Reducing code duplication. This is only advantageous if your implementation of copy/move assignment operations would be more or less identical to the copy/move construction. This is not true of many types; as previously stated, vector can optimize itself quite a bit by using existing storage where possible. Indeed many containers can (particularly sequence containers).

  2. Providing the strong exception guarantee with minimal effort. Assuming your move constructor is noexcept.

Personally, I prefer to avoid the scenario altogether. I prefer letting the compiler generate all of my special member functions. And if a type absolutely needs me to write those special member functions, then this type will be as minimal as possible. That is, it's sole purpose will be managing whatever it is that requires this operation.

That way, I just don't have to worry about it. The lion's share of my classes don't need any of these functions to be explicitly defined.

Cremona answered 15/1, 2016 at 5:8 Comment(1)
I get the idea. This idiom is not OK in general, but should be fine in the particular case of implementing intrusive_ptr.Boles
O
1

I realize that this has an accepted answer, but I feel I have to jump in. There are two different issues here:

  1. Unified assignment operator. This means that you have one assignment operator that takes by value, instead of two overloads that take by const & and &&.
  2. A copy and swap (CAS) copy assignment operator.

If you are doing 1, you generally do 2. Because you need to implement the swap/move assignment logic somewhere, and you can't implement it in the unified assignment operator, so generally you implement swap and call it. But doing 2 does not mean you have to do 1:

T& operator=(T&&) { /* actually implemented */ }
T& operator=(const T& t) { T t2(t); swap(*this, t2); return *this;}

In this case, we implement move assignment, but use the default swap (which does a move construction and two move assignments).

The motivation for doing CAS is to get the strong exception guarantee, though as T.C. points out in the comments, you can do:

T& operator=(const T& t) { *this = T(t); return *this;}

which is potentially more efficient.In most of the code that I write, performance is an issue and I have never needed the strong exception guarantee, so I would almost never do this, so it just depends on your use case.

You should do 1 never. It's preferable that they be separate functions so that move assignment can be marked noexcept.

Och answered 15/1, 2016 at 6:15 Comment(10)
(1) can also be marked noexcept, because construction of the parameter occurs at the call-site. It's counterintuitive for most programmers though. And of course, when calling with an lvalue which is copied, the whole construct (=including the "setup" at the client side) will not be noexcept. OTOH, if exception-free code is required, one has to be careful anyway, so this might not be that important.Huge
@PaulGroke Could you provide any reference on this?Boles
@PaulGroke It can be marked noexcept, but the question is really whether it should be. I don't think it's merely counterintuitive, I think it's actually bad practice. It boils down to: if a generic construct explicitly constrains a templated functor to be noexcept, and you marked a pass-by-value function as no except and use it, and the construct misbehaves, who's at fault? Personally I strongly feel that that the person marking it noexcept is at fault. See my question: #34805422Och
swap(*this, T(t)); won't compile and is pointless anyway. You already implemented move assignment, so *this = T(t); is both enough and potentially more efficient.Motorbike
5.2.2 Function call [expr.call] §4 ... The initialization and destruction of each parameter occurs within the context of the calling function. ... If a constructor or destructor for a function parameter throws an exception, the search for a handler starts in the scope of the calling function;Huge
@Nir Friedman What about const-ref parameters and implicit (possibly throwing) conversions then? Also the fault of the person declaring the function with the const-ref parameter noexcept? f(std::string const&) noexcept called via f("foo") and such. Also, as T.C. already commented in your question, you just need to use the noexcept operator properly. I.e. test an expression that includes the appropriate parameter-setup.Huge
@PaulGroke Your example is a good one. I still feel they are different in spirit, but I admit I can't articulate anything precise. You should post this example in answering my question, I think it would be valuable for people.Och
@PaulGroke: It's important to recognize the difference between the move assignment operator not throwing and a type being nothrow_move_assignable. Being assignable is defined by the expression std::declval<T>() = std::declval<U>() working. And being nothrow assignable is defined by that expression being noexcept. So if the initialization of the operator= parameter can throw, then the type is still not nothrow move assignable, since one of the parts of the expression can throw. More details can be [found in this paper](wg21.link/P0129).Cremona
@Nicol Bolas: I see your point. I was commenting on what Nir Friedman literally wrote ("can be marked noexcept"). I didn't consider the whole "nothrow assignable" part.Huge
@NicolBolas Actually, I'm not sure if my tests bear this out. I created a type with a non-noexcept copy constructor, wrote a noexcept swap, then wrote a unified copy assignment operator and marked it noexcept. For this class, I get is_nothrow_move_assignable true and is_nothrow_copy_assignable false. So I'm not sure what the distinction is between the move assignment operator not throwing, and the trait.Och

© 2022 - 2024 — McMap. All rights reserved.