Skip to content
academia.sh

Lesson 05 / 14

Copying and Assignment

Measures that the copy constructor and copy assignment operator produce a deep copy that takes its own array, and that the depth of the copy depends on the design of the class.

Published Updated

In this lesson

  • Distinguishes when the copy constructor is called from when copy assignment is called
  • Measures that a class taking its own array produces a deep copy
  • Applies why the self-assignment check is necessary
Contents

The previous lesson claimed that a guard holds its resource by value, so when the guard is copied, its resource is copied along with it. This lesson measures that claim: when an object is copied, does the copy share the same memory as the original, or does it take its own memory? The measurement kind is copy: b, constructed from a with Buffer b(a), is a newly constructed object, different from a — but how deep that “different” runs depends on how the class’s copy constructor is written.

In C, copying an array was always an explicit operation: memcpy is called by hand, the destination being large enough is checked by hand, and the source and destination not overlapping is guaranteed by hand. In C++, when a class object is copied via = or passed by value as an argument, the copying is implicit — the compiler decides on its own which function to call, no explicit call is written by hand. This makes copying easier, but it also hides it: seeing a line like Buffer b(a); and understanding how much work it does (allocating new memory, copying element by element) requires looking at the class’s definition — the line itself does not say.

Copy Constructor and Copy Assignment

A class’s copy constructor is a constructor that takes a const& of the same class — Buffer(const Buffer& other). This constructor is called during a construction, as in Buffer b(a); or Buffer b = a;: b does not yet exist, and is constructed from a. Copy assignment, on the other hand, is an overload of operator= — Buffer& operator=(const Buffer& other). This is called during an assignment, as in b = a;: b already exists (it was constructed earlier), and only its value is replaced with a’s. Both take const Buffer& as their argument type, but one constructs a new object while the other changes the contents of an existing object — this difference is what determines which one is called: whether the object on the left is new or already existing.

This distinction is not merely syntactic. While the copy constructor is running, the object’s data members are not yet constructed — the same rule from the third lesson’s member initializer list applies here too: data_(new int[other.size_]) initializes the member for the first time. While copy assignment is running, on the other hand, the object is already constructed, and data_ already points at a valid array — this is why operator= must first release the old array (delete[] data_); otherwise the old array is never freed while a new one is assigned over it, which is a memory leak. The copy constructor has no such prior state, because the object does not yet exist — this is why the copy constructor never calls delete[], it only allocates.

// buffer.hpp — a class holding its own array, copying deeply
#ifndef BUFFER_HPP
#define BUFFER_HPP

#include <cstdio>

class Buffer {
public:
    explicit Buffer(int size) : size_(size), data_(new int[size]) {
        for (int i = 0; i < size_; ++i) {
            data_[i] = i;
        }
        std::printf("constructor ran size=%d\n", size_);
    }

    Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
        for (int i = 0; i < size_; ++i) {
            data_[i] = other.data_[i];
        }
        std::printf("copy constructor ran size=%d\n", size_);
    }

    Buffer& operator=(const Buffer& other) {
        if (this == &other) {
            return *this;
        }
        delete[] data_;
        size_ = other.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; ++i) {
            data_[i] = other.data_[i];
        }
        std::printf("copy assignment ran size=%d\n", size_);
        return *this;
    }

    ~Buffer() {
        delete[] data_;
    }

    int first_element() const { return data_[0]; }
    void set_first_element(int value) { data_[0] = value; }
    const int* address() const { return data_; }

private:
    int size_;
    int* data_;
};

#endif

A Deep Copy Takes Its Own Array

// copy-measurement.cpp — is b, freshly constructed from a, a copy that takes its own array
#include <cstdio>

#include "buffer.hpp"

int main() {
    Buffer a(3);
    Buffer b(a);

    std::printf("do a and b share an array %s\n", a.address() == b.address() ? "yes" : "no");

    b.set_first_element(99);
    std::printf("a first element %d\n", a.first_element());
    std::printf("b first element %d\n", b.first_element());
    return 0;
}
constructor ran size=3
copy constructor ran size=3
do a and b share an array no
a first element 0
b first element 99

CR10. Buffer b(a); constructs b from a with the copy constructor; the copy constructor reads other.size_ and allocates a separate array with its own call to new int[other.size_], then copies other.data_’s elements into this new array one by one. This is a deep copy: b.data_ and a.data_ are different addresses, a.address() == b.address() prints no. This is why b.set_first_element(99) changes only b’s own array; a.first_element() is still 0. b is of kind copy relative to a: newly constructed, different from a in address, but equal to it in the value it held at the moment of construction.

Copy Assignment and the Self-Assignment Check

// assignment-measurement.cpp — does b = a run copy assignment
#include <cstdio>

#include "buffer.hpp"

int main() {
    Buffer a(2);
    Buffer b(5);

    b.set_first_element(-1);
    a.set_first_element(42);
    b = a;
    std::printf("after assignment is b's first element the same as a's %s\n",
                b.first_element() == a.first_element() ? "yes" : "no");
    std::printf("do a and b share an array %s\n", a.address() == b.address() ? "yes" : "no");

    b = b;
    std::printf("after self-assignment is b still valid %s\n", b.first_element() == a.first_element() ? "yes" : "no");
    return 0;
}
constructor ran size=2
constructor ran size=5
copy assignment ran size=2
after assignment is b's first element the same as a's yes
do a and b share an array no
after self-assignment is b still valid yes

CR11. In the line b = a;, b is already a constructed object, so copy assignment is called, not the copy constructor. operator= first releases b’s old array with delete[] data_, then allocates a new array of the same length as a’s and copies a.data_’s contents into it — this is why, after the assignment, b’s first element equals a’s (the contents were copied), but b.address() is not equal to a.address() (a new array was allocated, not shared). b’s address before the assignment is not separately printed and compared here: whether the new that immediately follows the delete[] returns the same address depends on the allocator, and the C++ standard does not guarantee this — so such a comparison would not count as evidence, only the contents and the not-shared-with-a state are measured. The line b = b; triggers the if (this == &other) return *this; check at the start of operator=: without this check, the function would first release b’s own array with delete[], then try to read other.data_ (which is now the same, already-released, invalid address) — this would be undefined behavior. Thanks to the check, b = b; safely returns early, and b remains unchanged.

The Risk of a Shallow Copy, and the Rule of Three

If Buffer’s copy constructor and copy assignment operator were not written by hand, the compiler would use its own generated default versions — these copy every data member member by member. Because size_ is an int, this causes no problem, but data_ is an int*; member-by-member copying copies the pointer itself, not the array it points at. This is a shallow copy: two Buffers would share the same data_ address. The result of a shallow copy is this — when one is destroyed, it runs delete[] data_, while the other still points at the same (now released) array; when that object is also destroyed, it tries to release the same array again with delete[]. This double free is undefined behavior, and this lesson does not run it — it is only mentioned in prose; Buffer is always run with the hand-written deep copy constructor and assignment.

This observation leads to the rule of three: if a class has to write its destructor, copy constructor, or copy assignment operator by hand (usually because it holds a resource through a raw pointer), it must write all three by hand — if one is written by hand and the other two are left to the compiler, the risk of a shallow copy arises. Buffer fully applies this rule: the destructor, the copy constructor, and copy assignment, all three manage data_ correctly.

There is a reason the rule is limited to “three”: all three are different faces of the same problem — ownership of the resource. The destructor defines when the resource is released, the copy constructor defines how the resource is duplicated, and copy assignment defines how an existing resource is changed. If one is forgotten (for example, only the destructor is written and the copy constructor is left to the compiler), the compiler-generated shallow copy constructor shares data_ — then two objects each try to release the same array with delete[], which is a double free. The mere existence of a destructor is itself a sign that the other two must also be written by hand: if a class does something special in its destructor (something beyond just delete[]), this usually means the class is the owner of a resource, and ownership must also be thought through during copying.

Preventing Copying with = delete

Not every class needs to be copyable. If a resource is meant to have a single owner — for example, a file handle or a network connection should not be shared with a second object — the copy constructor and copy assignment can be explicitly deleted with = delete: Buffer(const Buffer&) = delete;. This does not tell the compiler to never generate that function, but to give a compile error when it is used — code that tries to write Buffer b(a); does not compile. Buffer remains copyable for this lesson’s measurement, = delete is only mentioned here; which classes should be designed as non-copyable, and how that decision relates to the ownership model, is K05’s topic.

Why the Copy Constructor’s Argument Is const&

The copy constructor’s argument is taken as const Buffer&, not as Buffer. If the argument were taken by value (Buffer other), calling the function would itself require a copy of other — and the function that would construct that copy would be the copy constructor itself, an infinite loop. Taking it by reference breaks this loop: the original object is accessed directly, with no extra copy produced. const, meanwhile, guarantees that other will only be read, not modified during the copy — the copy constructor must not modify its source, only read from it.

A Copy Does Not Reveal the Depth of the Source

Like the Guard from the previous lesson, Buffer also holds its resource by value — but while Guard’s resource_ member is a Resource value, Buffer‘s data_ member is an int*; the resource (the array) lives on the heap, and the member is only a pointer to it. This lesson closes that gap by writing Buffer by hand to copy deeply, but the depth of a copy is never automatic — it depends on what the class’s copy constructor writes. If a class is tied to a record shared through a raw pointer (for example, if the int* only points at an array owned by another object, and ownership is not with Buffer), member-by-member copying also copies this pointer, and the copy shares the same record as the original — a deep copy only happens when the class is the owner of its own resource, and the copy constructor explicitly reallocates it.

This means that different members of the same class can call for different copying behavior. If Buffer also had a member named const int* reference_, pointing at a resource it does not own, it would be correct for the copy constructor to deliberately copy this member shallowly — copying the pointer as it is, without allocating new memory — because Buffer is not the owner of that resource, it only looks at it. The question that must be asked when writing a copy constructor is this: is this member a resource this object owns, or only a resource it looks at? The answer can differ from member to member, and the copy constructor’s body is the sum of these decisions — the compiler-generated default member-by-member copy never makes this distinction, it copies every member the same way (shallowly).

Move — a resource being transferred by taking it over rather than by copying it — solves this problem in a different way, and is outside this course’s scope (K05). M11/K01 covered a struct being copied whole, field by field, through assignment, and memory layout — member order, alignment, and padding; this lesson combines the two: copying conforms both to value semantics (a copy is independent of the original) and to memory management (a copy takes its own memory) at the same time.

The Bounding Measurement

The expression this lesson measures is of kind copy: b is newly constructed from a, an object separate from a in address. But a copy does not, by itself, say how “deep” it is — this information is not in the result of the copy, it is hidden in how the class’s copy constructor behaves with its source. Buffer copies deeply because its copy constructor allocates a new array with new int[...]; a class tied to a resource through a raw pointer, one that has not written its own copy constructor correctly, copies shallowly, and the copy shares the same resource as the original. To read a class and answer the question “can this be copied, and what happens when it is copied,” you have to look at the body of the copy constructor — the class’s name or the way it is used does not say.

Summary

  • The expression this lesson measures is of kind copy: Buffer b(a) is newly constructed from a, an object separate from it in address.
  • The copy constructor is called during a construction, copy assignment during an assignment; whether the object on the left is new or already existing determines which one runs.
  • Buffer produces a deep copy by allocating its own array with new; member-by-member copying would only copy the pointer, which would be a shallow copy.
  • Copy assignment safely handles self-assignment with the this == &other check.
  • If one of the destructor, the copy constructor, or copy assignment is written by hand, all three must be written (the rule of three).

Next Step

This lesson measured that an object takes its own resource when it is copied; the next lesson moves away from copying and measures how a reference directly modifies the caller’s object — producing no copy at all.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close