This note covers what happens when an object is copied, moved and destroyed, and how operator overloading works. These are the places in C++ where it is easiest to write code that runs and is nonetheless wrong.
The functions the compiler writes for you
For any class, the compiler will generate six special member functions on demand:
C++class Widget {
Widget(); // default constructor
~Widget(); // destructor
Widget(const Widget&); // copy constructor
Widget& operator=(const Widget&); // copy assignment
Widget(Widget&&) noexcept; // move constructor
Widget& operator=(Widget&&) noexcept; // move assignment
};
The generated versions all work member by member: copy each member, or move each member. For a class holding only int, std::string and std::vector members that is exactly right, and you write nothing.
The trouble starts when a class holds a raw resource.
The classic shallow-copy accident
C++class Buffer {
public:
Buffer(size_t n) : size_(n), data_(new char[n]) {}
~Buffer() { delete[] data_; }
private:
char* data_;
size_t size_;
};
Buffer a(1024);
Buffer b = a; // compiler-generated copy constructor: copies the pointer value
Now a.data_ and b.data_ point at the same block. At the end of the scope both destructors delete[] it β a double free, and a crash. Even without the crash, modifying a changes b.
This is the difference between a shallow copy and a deep copy: the first copies the pointer, the second copies what the pointer points at.
The rules of three and five
The Rule of Three: if you need to write any one of the destructor, copy constructor, or copy assignment operator yourself, you almost certainly need all three.
The reasoning is direct. You need a destructor because the class manages a resource; and if it manages a resource, the default member-wise copy is necessarily wrong.
With move semantics in C++11 this became the Rule of Five, adding the move constructor and move assignment.
But the rule actually worth remembering is the Rule of Zero: design classes that need none of them. Use std::vector instead of new[], std::unique_ptr instead of a raw pointer, and hand all six functions back to the compiler. Rewrite the Buffer above with std::vector<char> and every problem disappears, in less code.
The only things that should hand-write these functions are resource-management classes β and the standard library has usually written those for you already.
Copy assignment and copy-and-swap
Three things are easy to miss when hand-writing copy assignment: self-assignment, exception safety, and returning a reference.
C++// the broken version
Buffer& operator=(const Buffer& other) {
delete[] data_; // if &other == this, we just destroyed the source
data_ = new char[other.size_]; // if this throws, the object is half-wrecked
std::memcpy(data_, other.data_, other.size_);
size_ = other.size_;
return *this;
}
The copy-and-swap idiom solves all three at once:
C++Buffer& operator=(Buffer other) { // by value: the copy happens here
swap(*this, other); // exchange contents
return *this; // other's destructor takes the old data away
}
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
Why it is correct:
- Self-assignment safe β the parameter is a copy taken by value, unrelated to
*this. - Exception safe β the only thing that can throw is constructing that copy, and at that point
*thishas not been touched. This is the strong guarantee: either the operation succeeds, or the object is unchanged. - No duplicated logic β the copying lives in exactly one place, the copy constructor.
- A free bonus β this one function serves as both copy assignment and move assignment, because when the argument is an rvalue the copy is produced by the move constructor.
The cost is one possible extra move, which in nearly every case is not worth thinking about.
Move semantics
The idea behind moving: if the source object is about to be destroyed anyway, do not duplicate its resources β steal them.
C++Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr; // essential: leave the source safely destructible
other.size_ = 0;
}
Two points deserve attention.
Mark move constructors noexcept. When a std::vector grows it has to relocate its elements. If an element's move constructor might throw, the vector cannot maintain the strong guarantee, so it falls back on copying instead. A missing noexcept turns every vector reallocation from O(n) moves into O(n) copies β a difference that can be tens of times, and completely silent.
A moved-from object must be left in a valid but unspecified state. The only hard requirement is that it can be safely destroyed and assigned to. Do not read its value after moving from it.
std::move does not move anything
std::move is only a cast. It turns an lvalue into an rvalue reference so that overload resolution can pick the move version. It generates no code of its own:
C++std::string a = "hello";
std::string b = std::move(a); // the actual move happens here, in the move constructor
Operator overloading
An overloaded operator is just a function whose name happens to be operator+ and so on.
C++class Vec2 {
public:
Vec2& operator+=(const Vec2& r) { x += r.x; y += r.y; return *this; }
float x, y;
};
// symmetric binary operators go outside the class, so the left operand can convert too
inline Vec2 operator+(Vec2 l, const Vec2& r) { l += r; return l; }
A few rules of thumb:
- Write the compound assignment (
+=) first and implement the binary form (+) in terms of it. One copy of the logic. - Make binary arithmetic operators non-members. A member version does not allow conversion on the left operand, so
2 * vecfails to compile whilevec * 2works β an asymmetry that reads badly. - Preserve the operator's usual meaning.
+should commute and should not modify its operands;==should be an equivalence relation. An overload that violates the reader's expectation is worse than no overload at all.<<for output streams is the one sanctioned exception. - In C++20, comparisons collapse to one line:
auto operator<=>(const Vec2&) const = default;generates all six comparison operators.
For more on move semantics, rvalue references and perfect forwarding as introduced in C++11, see C++ #9 C++11 New Features.