Lesson 15 of 25

Constructors & Destructors

The Special Member Functions

Six member functions are special: the compiler will write them for you if you do not. Knowing which six, and what the generated versions do, is the difference between C++ classes that quietly work and C++ classes that quietly corrupt memory.

The default constructor builds an object with no arguments. The destructor cleans it up. The copy constructor builds a new object as a duplicate of an existing one, and the copy assignment operator makes an already-existing object into a duplicate of another. C++11 added the move constructor and move assignment operator, which transfer contents out of an object that is about to be destroyed rather than duplicating them.

The compiler-generated versions do the obvious thing: they act on each member in turn. The generated copy constructor copies every member; the generated destructor destroys every member. For a class whose members are all int, std::string and std::vector, that is exactly right, and you should write none of the six.

It stops being right the moment a member is a raw pointer to memory the object owns. Copying a pointer copies the address, not the thing it points to, so both objects now believe they own the same memory. That is the shallow-copy problem, and it is the subject of two sections' time.

One rule to fix in your head now, because it explains behaviour that otherwise looks arbitrary: if you declare any constructor at all, the compiler stops generating the default one. Add a two-argument constructor to a class and Student s; stops compiling. If you want the default back, ask for it explicitly with Student() = default;. The mirror of that is = delete, which removes a function the compiler would otherwise supply — the standard way to make a class non-copyable.

  • Default constructor — Student()
  • Destructor — ~Student()
  • Copy constructor — Student(const Student&)
  • Copy assignment — Student& operator=(const Student&)
  • Move constructor — Student(Student&&)
  • Move assignment — Student& operator=(Student&&)
Example
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class Student {
private:
    std::string name_ = "Unknown";
    int grade_ = 0;
    std::vector<int> scores_;

public:
    Student() = default;                         // ask for it back explicitly

    Student(std::string name, int grade)
        : name_(std::move(name)), grade_(grade) {}

    void addScore(int s) { scores_.push_back(s); }
    const std::string& name() const { return name_; }
    std::size_t scoreCount() const { return scores_.size(); }
};

int main() {
    Student a("Ananya", 10);
    a.addScore(91);
    a.addScore(88);

    Student b = a;      // compiler-generated copy constructor: copies all 3 members
    std::cout << b.name() << ' ' << b.scoreCount() << '\n';   // Ananya 2

    Student c;          // works only because of `= default`
    c = a;              // compiler-generated copy assignment
    std::cout << c.name() << '\n';                            // Ananya
}

Destructors: Automatic, Ordered, and Occasionally Virtual

A destructor is named ~ClassName, takes no parameters and returns nothing. It runs automatically when an object's lifetime ends — at the closing brace for a local, when the containing object is destroyed for a member, when delete is called for a heap object. You never call it yourself.

The ordering is defined and worth knowing. Local objects in a scope are destroyed in the reverse of the order they were created, so an object can safely use something declared before it. A class's members are destroyed in the reverse of their declaration order, after the destructor body has finished running. And crucially, destructors run when a scope is left by an exception as well as by a normal return — that guarantee is the entire foundation of RAII.

This is why the destructor is where cleanup belongs. A class that opens a file in its constructor and closes it in its destructor cannot leak that file handle, no matter how its user's code exits. The same applies to locks, network connections, and memory.

There is one destructor rule that causes real, hard-to-find bugs, and although inheritance is the next lesson it belongs here: if you ever delete a derived object through a base-class pointer, the base class's destructor must be virtual. Without it, only the base part is destroyed. The derived class's members — its strings, its vectors, its file handle — are never cleaned up, and you have a leak that no amount of staring at the derived class will explain.

The rule to apply mechanically: any class you intend to inherit from should declare virtual ~Base() = default;. It costs one line and one virtual function table pointer per object, and it removes the problem permanently. A class not designed for inheritance does not need it.

Example
#include <iostream>
#include <memory>
#include <string>

class Logger {
    std::string tag_;
public:
    explicit Logger(std::string tag) : tag_(std::move(tag)) {
        std::cout << tag_ << " opened\n";
    }
    ~Logger() { std::cout << tag_ << " closed\n"; }
};

struct Base {
    virtual ~Base() = default;      // REQUIRED if deleted via Base*
};

struct Derived : Base {
    std::string big = "a lot of data";
    ~Derived() override { std::cout << "Derived cleaned up\n"; }
};

int main() {
    {
        Logger first("first");
        Logger second("second");
    }   // destroyed in reverse: "second closed" then "first closed"

    // Because ~Base is virtual, ~Derived runs too
    std::unique_ptr<Base> p = std::make_unique<Derived>();
    p.reset();      // prints "Derived cleaned up"

    std::cout << "end of main\n";
}
Notes
  • A destructor should never throw an exception. If one is already propagating and a destructor throws a second, the program terminates. Wrap anything risky inside a destructor in a try/catch that swallows the error, or move that work into a method the user calls deliberately.

Shallow Copy, Deep Copy and the Rule of Three

Here is the bug that made the Rule of Three famous. Write a class that owns memory through a raw pointer — allocating in the constructor, freeing in the destructor — and do nothing else. The compiler will still generate a copy constructor for you, and it will copy the pointer.

That is a shallow copy: two objects, one buffer, both convinced they own it. Everything looks fine until they are destroyed. The first destructor frees the memory; the second frees the same memory again. A double free corrupts the allocator and the program crashes, usually somewhere unrelated and long afterwards. Worse, while both objects are alive, writing through one changes what the other sees, which produces bugs that look like the compiler has gone mad.

A deep copy is the fix: the copy constructor allocates its own buffer and copies the contents into it. Now the two objects are genuinely independent and each destructor frees its own memory. The copy assignment operator has to do the same thing plus two extra jobs — release whatever the target was already holding, and cope with self-assignment, since a = a must not free the buffer it is about to read from.

The Rule of Three summarises this: if your class needs a custom destructor, copy constructor, or copy assignment operator, it almost certainly needs all three. The reasoning is that needing any one of them means the class manages a resource, and managing a resource means all three have to agree about ownership. A class with a destructor but a compiler-generated copy constructor is the classic broken shape.

The example below is written out in full because you should be able to read this pattern in older code and in interview questions. What you should take away, though, is the conclusion of the next two sections: in modern C++ you would not write this class at all — you would hold a std::vector<int> and write none of the three.

Example
#include <algorithm>
#include <cstddef>
#include <iostream>

class IntBuffer {
    int* data_;
    std::size_t size_;

public:
    explicit IntBuffer(std::size_t n) : data_(new int[n]{}), size_(n) {}

    // 1. Destructor
    ~IntBuffer() { delete[] data_; }

    // 2. Copy constructor — DEEP copy, its own buffer
    IntBuffer(const IntBuffer& other)
        : data_(new int[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // 3. Copy assignment — release, then deep copy, and survive self-assignment
    IntBuffer& operator=(const IntBuffer& other) {
        if (this == &other) return *this;        // a = a must not break
        int* fresh = new int[other.size_];       // allocate BEFORE freeing
        std::copy(other.data_, other.data_ + other.size_, fresh);
        delete[] data_;
        data_ = fresh;
        size_ = other.size_;
        return *this;
    }

    int& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

int main() {
    IntBuffer a(3);
    a[0] = 91;

    IntBuffer b = a;      // deep copy: b has its own buffer
    b[0] = 65;

    std::cout << a[0] << ' ' << b[0] << '\n';   // 91 65 — independent
}   // both destructors run, each frees its own memory
Notes
  • Notice that the assignment operator allocates the new buffer before deleting the old one. If new throws after you have already freed, the object is left holding a dangling pointer and its destructor will crash. Allocate first, then release — the order is the safety.

Move Semantics and the Rule of Five

Deep copying is correct but sometimes wasteful. Consider returning a std::vector of a lakh elements from a function, or pushing a large std::string into a container. In both cases the source object is a temporary that is about to be destroyed. Duplicating its buffer and then immediately freeing the original is pure waste — what you actually want is to hand over the buffer and leave the source empty.

That is moving, added in C++11. A move constructor takes an rvalue reference, written Type&&, which is the type system's way of saying "this object is a temporary; you may gut it". The move constructor steals the pointer, sets the source's pointer to null so its destructor has nothing to free, and returns. No allocation, no copying of contents, regardless of how much data there was.

std::move is the tool for saying "treat this named object as a temporary, I am finished with it". Despite its name, it does not move anything — it is purely a cast that changes which overload gets selected. After you move from an object, it is left in a valid but unspecified state: you may destroy it or assign a new value to it, but you should not assume anything about its contents.

The Rule of Five extends the Rule of Three: a class that manages a resource should define all five of the destructor, copy constructor, copy assignment, move constructor and move assignment. And there is a trap attached — declaring a destructor stops the compiler generating the move operations. So a class that had a custom destructor and relied on the implicit moves silently starts copying instead the day someone adds one. Nothing breaks; it just gets slower, which is the hardest kind of regression to notice.

One practical detail: mark move operations noexcept. std::vector will only use a type's move constructor while reallocating if that constructor is noexcept, because it needs to guarantee it can recover if something fails partway. A move constructor without noexcept is quietly ignored in exactly the situation it was written for.

Example
#include <cstddef>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class IntBuffer {
    int* data_ = nullptr;
    std::size_t size_ = 0;

public:
    explicit IntBuffer(std::size_t n) : data_(new int[n]{}), size_(n) {}
    ~IntBuffer() { delete[] data_; }

    IntBuffer(const IntBuffer&) = delete;             // (copy left out for brevity)
    IntBuffer& operator=(const IntBuffer&) = delete;

    // Move constructor: steal, then blank the source
    IntBuffer(IntBuffer&& other) noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // Move assignment: release own, steal, blank the source
    IntBuffer& operator=(IntBuffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }

    std::size_t size() const { return size_; }
};

int main() {
    IntBuffer a(1000000);
    IntBuffer b = std::move(a);        // no allocation, no copying
    std::cout << a.size() << ' ' << b.size() << '\n';   // 0 1000000

    // The same idea, for free, with standard types
    std::string big(1000000, 'x');
    std::vector<std::string> v;
    v.push_back(std::move(big));       // hands over the buffer
    std::cout << big.size() << ' ' << v[0].size() << '\n';   // 0 1000000
}
Notes
  • Do not use std::move on a variable you intend to keep using, and never on a return of a local variable — return std::move(x); actually prevents the compiler's copy elision and makes things slower. Plain return x; is both simpler and faster.

The Rule of Zero: The One You Should Actually Follow

Everything above is knowledge you need in order to read C++ and to answer interview questions. Here is the advice for the code you write: aim to declare none of the six.

The Rule of Zero says that resource management should be the job of a small number of dedicated types — std::string, std::vector, std::unique_ptr, std::shared_ptr, std::fstream — and that ordinary classes should be built out of those. If every member of your class already manages itself correctly, then the compiler-generated destructor, copy operations and move operations are all correct too, and you write nothing at all.

The IntBuffer class from the previous sections is a good illustration. Everything it does — allocate, deep-copy, move, free — is precisely what std::vector<int> already does, tested by millions of programs. Replace the raw pointer with a vector and the class shrinks to a few lines, has no way to leak, and gains correct copy and move behaviour for free.

The corollary is that when you genuinely cannot avoid managing a resource — wrapping a C library handle, say — you should write one small class that does only that, get its five functions right once, and then use it as a member everywhere else. That way the difficult code exists in one reviewed place rather than scattered through every class that happens to need the resource.

If you take one thing from this lesson, take this: reaching for new and delete inside a class is a decision, and it is usually the wrong one. Ask what standard type already does the job first.

Example
#include <iostream>
#include <memory>
#include <string>
#include <vector>

// Rule of Zero: no destructor, no copy, no move, and all of them correct.
class Report {
    std::string title_;
    std::vector<int> marks_;
    std::unique_ptr<std::string> optionalNote_;

public:
    Report(std::string title, std::vector<int> marks)
        : title_(std::move(title)), marks_(std::move(marks)) {}

    void addNote(std::string note) {
        optionalNote_ = std::make_unique<std::string>(std::move(note));
    }

    double average() const {
        if (marks_.empty()) return 0.0;
        double total = 0.0;
        for (int m : marks_) total += m;
        return total / marks_.size();
    }

    const std::string& title() const { return title_; }
};

int main() {
    Report r("Term 1", {91, 65, 78});
    r.addNote("Improving steadily");

    std::cout << r.title() << ' ' << r.average() << '\n';   // Term 1 78

    Report moved = std::move(r);      // move works, generated by the compiler
    std::cout << moved.title() << '\n';
}   // everything freed automatically
Notes
  • Because Report holds a std::unique_ptr, which cannot be copied, Report is automatically move-only too. The compiler works this out from the members. That is the Rule of Zero doing its job: the correct behaviour fell out of choosing the right member type.
Ask AI