Operator overloading is a topic that new C++ programmers often find difficult, partly because the syntax can look cryptic at first. In this post, I will explain what happens when one class object is assigned to another, why a pointer member can lead to a shallow copy, and how a copy-assignment operator can provide a deep copy. Okay, so let us start with a small Packet class that stores up to 256 bytes in a dynamically allocated buffer.

#include <algorithm>
#include <cstddef>
#include <cstring>
#include <iostream>
#include <stdexcept>
class Packet
{
public:
Packet() = default;
Packet(const char* data, std::size_t size)
{
if (size > 0 && data == nullptr) {
throw std::invalid_argument{
"data must not be null when size is nonzero"};
}
buffer_ = new char[max_buffer_size]{};
const std::size_t bytes_to_copy =
std::min(size, max_buffer_size);
std::memcpy(buffer_, data, bytes_to_copy);
}
void print_bytes(std::size_t size) const
{
if (!buffer_) {
return;
}
const std::size_t bytes_to_print =
std::min(size, max_buffer_size);
for (std::size_t i = 0; i < bytes_to_print; ++i) {
std::cout << buffer_[i] << ' ';
}
std::cout << '\n';
}
void release_buffer()
{
delete[] buffer_;
buffer_ = nullptr;
}
private:
static constexpr std::size_t max_buffer_size{256};
char* buffer_{nullptr};
};
int main()
{
char dummy_data[26]{};
for (std::size_t i = 0; i < 26; ++i) {
dummy_data[i] = static_cast<char>('a' + i);
}
Packet first{dummy_data, 26};
first.print_bytes(26);
first.release_buffer();
}

The Packet constructor allocates a buffer and copies the supplied data into it. The print_bytes() function prints the stored bytes, while release_buffer() releases the allocated array. The manual release function is used here to demonstrate the shallow-copy problem.

Assigning one Packet object to another

Now let us create a second Packet and assign the first packet to it:

Packet second;
second = first;

The Packet class does not currently declare its own copy-assignment operator. The compiler therefore provides one. That operator assigns each non-static data member from the object on the right-hand side to the object on the left-hand side. The class currently has one non-static data member:

char* buffer_{nullptr};

A compiler-provided copy-assignment operator would look somewhat like the following:

It performs a memberwise copy. Because buffer_ is a pointer, the pointer value, which is the memory address, is copied. The dynamically allocated array is not copied. So the following will produce something like shown in the diagram below:

Diagram explaining compiler-provided copy constructor in programming. It shows two packet objects, 'first' and 'second', both holding a pointer to the same memory address (0x7F20), indicating memberwise copying results in a shallow copy. A visual representation of one allocated buffer containing elements 'a', 'b', 'c', and 'd' is included.
Figure 1: Compiler-provided copy-assignment operator copies the pointer value, leaving both Packet objects referring to the same dynamically allocated buffer.

Both objects contain the same address and therefore refer to the same allocation. This is known as shallow copy.

Releasing the shared buffer

The following program releases the buffer through first after assigning first to second. The final call to second.print_bytes() is deliberately unsafe. It demonstrates what happens when second retains an address for an allocation that has already been released.

Example 2: Demonstrating a Dangling Pointer After Shallow Assignment

packet_shallow_copy.cpp

#include <algorithm>
#include <cstddef>
#include <cstring>
#include <iostream>
#include <stdexcept>
class Packet
{
public:
Packet() = default;
Packet(const char* data, std::size_t size)
{
if (size > 0 && data == nullptr) {
throw std::invalid_argument{
"data must not be null when size is nonzero"};
}
buffer_ = new char[max_buffer_size]{};
const std::size_t bytes_to_copy =
std::min(size, max_buffer_size);
std::memcpy(buffer_, data, bytes_to_copy);
}
void print_bytes(std::size_t size) const
{
if (!buffer_) {
return;
}
const std::size_t bytes_to_print =
std::min(size, max_buffer_size);
for (std::size_t i = 0; i < bytes_to_print; ++i) {
std::cout << buffer_[i] << ' ';
}
std::cout << '\n';
}
void release_buffer()
{
delete[] buffer_;
buffer_ = nullptr;
}
private:
static constexpr std::size_t max_buffer_size{256};
char* buffer_{nullptr};
};
int main()
{
char dummy_data[26]{};
for (std::size_t i = 0; i < 26; ++i) {
dummy_data[i] = static_cast<char>('a' + i);
}
Packet first{dummy_data, 26};
Packet second;
second = first;
first.release_buffer();
second.print_bytes(26); // Undefined behavior
}

The important sequence is:

Packet second;
second = first;
first.release_buffer();
second.print_bytes(26);

The compiler-generated assignment copies the address from first.buffer_ into second.buffer_. Both pointers now contain the same address.

Dangling Pointer Problem

Calling first.release_buffer() releases the dynamically allocated array. However, second.buffer_ is still pointing to an address for which the underlying memory has been released. At this point, second.buffer_ is a dangling pointer. A dangling pointer still contains an address, but the object or allocation at that address has reached the end of its lifetime. Calling second.print_bytes(26) attempts to access the released allocation and causes undefined behavior.

Unintended Data Manipulation Problem

There is another side-effect before the buffer is released. Because both objects refer to the same array, changing the array through first changes the data observed through second, and changing it through second changes the data observed through first.

For this Packet class, each object is expected to own an independent buffer. The compiler-generated memberwise assignment does not provide that ownership model.

Overloading the copy-assignment operator

We can define a copy-assignment operator that allocates a separate buffer for the destination object and copies the bytes into it. The syntax looks like the following:

Packet& operator=(const Packet& other)

Let us break that down. The function is called operator= because it overloads the assignment operator. The function has one declared parameter:

const Packet& other

other refers to the object on the right-hand side of the assignment. For this expression:

second = first;

other refers to first.

The parameter is a reference, so calling the function does not create another copy of the argument. It is const because the assignment operation should not modify the source object.

The object on the left-hand side is the object on which the member function is called. Inside the function, it is accessible through the this pointer. In the expression above, this points to second.

The operator returns:

Packet&

This is a non-const reference to the left-hand object. Returning a reference avoids another copy and supports normal assignment expressions such as:

third = second = first;

The final statement is therefore:

return *this;

Inside a non-static member function, this points to the object on which the function was called. The expression *this refers to that object.

Implementing deep copy

A resource-owning class must consider destruction, copy construction, and copy assignment together. This is commonly called the Rule of Three.

The next version provides:

  • a destructor that releases the buffer;
  • a copy constructor that creates an independent buffer;
  • a copy-assignment operator that creates an independent copy of the source.

The assignment operator uses the copy-and-swap technique. It first creates a copy of other, then exchanges that copy’s members with the destination object.

Example 3: Implementing Deep Copy for a Resource-Owning Packet

packet_deep_copy.cpp

#include <algorithm>
#include <cstddef>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <utility>
class Packet
{
public:
Packet() = default;
Packet(const char* data, std::size_t size)
: size_{std::min(size, max_buffer_size)}
{
if (size_ > 0 && data == nullptr) {
throw std::invalid_argument{
"data must not be null when size is nonzero"};
}
if (size_ > 0) {
buffer_ = new char[max_buffer_size]{};
std::memcpy(buffer_, data, size_);
}
}
Packet(const Packet& other)
: size_{other.size_}
{
if (other.buffer_) {
buffer_ = new char[max_buffer_size]{};
std::memcpy(buffer_, other.buffer_, size_);
}
}
Packet& operator=(const Packet& other)
{
if (this == &other) {
return *this;
}
Packet copy{other};
swap(copy);
return *this;
}
~Packet()
{
delete[] buffer_;
}
void print_bytes() const
{
for (std::size_t i = 0; i < size_; ++i) {
std::cout << buffer_[i] << ' ';
}
std::cout << '\n';
}
void release_buffer()
{
delete[] buffer_;
buffer_ = nullptr;
size_ = 0;
}
private:
void swap(Packet& other) noexcept
{
using std::swap;
swap(buffer_, other.buffer_);
swap(size_, other.size_);
}
static constexpr std::size_t max_buffer_size{256};
char* buffer_{nullptr};
std::size_t size_{0};
};
int main()
{
char dummy_data[26]{};
for (std::size_t i = 0; i < 26; ++i) {
dummy_data[i] = static_cast<char>('a' + i);
}
Packet first{dummy_data, 26};
Packet second;
second = first;
first.release_buffer();
second.print_bytes();
}

The copy constructor performs the allocation used by the copy-assignment operator:

Packet copy{other};

copy receives its own buffer containing the same bytes as other.

The swap() call then exchanges the members of the temporary copy and the destination object:

swap(copy);

After the exchange, the destination contains the newly copied buffer. The temporary object contains the destination’s previous buffer. When the temporary object reaches the end of the function, its destructor releases that previous buffer.

The self-assignment check handles an expression such as:

first = first;

The function simply returns the existing object in that case.

After this assignment:

second = first;

the two objects have separate allocations:

first.buffer_ ─────────> first allocation
second.buffer_ ─────────> second allocation

The bytes held by the two buffers initially have the same values, but each Packet owns its own array. Releasing the buffer held by first therefore has no effect on the buffer held by second.

This independent copying of an owned resource is called a deep copy.

A C++17 alternative: the Rule of Zero

The raw pointer in the previous examples helps show exactly how shallow and deep copying work. For a fixed-capacity packet in production C++17 code, std::array can manage the storage directly.

std::array has value semantics. Copying a Packet containing a std::array copies the elements into the destination object. The class therefore needs no destructor, copy constructor, or copy-assignment operator of its own.

The following program implements the same fixed-capacity packet without manual memory management.

Example 4: Using std::array for Automatic Value-Based Copying

packet_rule_of_zero.cpp

#include <algorithm>
#include <array>
#include <cstddef>
#include <iostream>
#include <stdexcept>
class Packet
{
public:
Packet() = default;
Packet(const char* data, std::size_t size)
: size_{std::min(size, buffer_.size())}
{
if (size_ > 0 && data == nullptr) {
throw std::invalid_argument{
"data must not be null when size is nonzero"};
}
std::copy_n(data, size_, buffer_.begin());
}
void print_bytes() const
{
for (std::size_t i = 0; i < size_; ++i) {
std::cout << buffer_[i] << ' ';
}
std::cout << '\n';
}
private:
std::array<char, 256> buffer_{};
std::size_t size_{0};
};
int main()
{
char dummy_data[26]{};
for (std::size_t i = 0; i < 26; ++i) {
dummy_data[i] = static_cast<char>('a' + i);
}
Packet first{dummy_data, 26};
Packet second;
second = first;
second.print_bytes();
}

Here, the compiler-generated copy-assignment operator is exactly what the class needs. It assigns the std::array and size_ members, giving second its own copy of the stored bytes.

For a packet whose size changes at runtime, std::vector<char> provides the same automatic ownership and value-based copying. C++20 also provides std::span, which can replace a separate pointer-and-size input pair when the class only needs a view of caller-owned contiguous data.

The raw-pointer implementation explains the mechanics of shallow and deep copying. The std::array version is the simpler choice for this fixed-capacity design because the standard-library type already manages copying and object lifetime.

Complete example

Download the source code

The complete C++ examples from this article are available in the GitHub repository.

View source code on GitHub

Discover more from Tech For Talk

Subscribe to get the latest posts sent to your email.

Leave a Reply