What Is a Class Destructor?

Every object eventually reaches its last instruction. A destructor is the one member function a class can nominate to run at that moment, after the object has been used for the final time and before its storage goes away.

Most classes do not need one. If an object is nothing but a few int members, its storage is reclaimed along with the object and there is no work left to do. A destructor earns its place when the object went out and acquired something during its life: a block of heap memory from new, a handle to a file or a database connection, a slot in a table kept somewhere else in the program. None of that comes back on its own, and the destructor is the last chance the program has to hand it back.

So the two special functions answer opposite questions. The constructor answers what this object needs before anyone touches it. The destructor answers what the program still owes once nobody will touch it again.

The Signature You Do Not Get to Choose

A destructor is written far more rigidly than a constructor, and every restriction traces back to a single fact: you do not issue the call, the compiler does.

Constructor Destructor
Name the class name the class name with a leading ~
Parameters any number, and it can be overloaded none, ever
Return type none none
How many per class as many as you care to write exactly one
Who calls it your code, by creating an object the compiler, when the object ends

Because the compiler generates the call, there is no caller to supply arguments and no caller to read a result, which is why parameters and return types are both forbidden. And with no parameters there is nothing to overload on, so a class gets exactly one destructor no matter how many ways it can be constructed.

You can write the call by hand, as in north.~Turnstile();, and it is almost always a bug: the automatic call still happens afterwards, so the cleanup runs a second time on an object that has already given everything back. Calls in the other direction are safe. The destructor body may call any other member function it likes, because the object is still intact for the whole of that body and is only dismantled once the body returns.

What Schedules the Call

There is no single rule like "at the end of the function". The trigger depends on how the object was created.

Kind of object The destructor runs
A local (automatic) object when control leaves the block it was declared in, by any route
An object created with new when a pointer to it reaches a delete, and never before
A member of a larger object just after the enclosing object's own destructor body finishes
An element sitting inside a container such as std::vector when the container itself is destroyed, or when that element is erased
A global or static object after main() has returned

The next program mixes the first two so the difference in timing is visible. Watch where gate 3 appears relative to gate 1:

#include <iostream>

class Turnstile
{
private:
    int m_gate{};

public:
    explicit Turnstile(int gate)
        : m_gate{ gate }
    {
        std::cout << "gate " << m_gate << " unlocked\n";
    }

    ~Turnstile()
    {
        std::cout << "gate " << m_gate << " locked\n";
    }

    int gate() const { return m_gate; }
};

int main()
{
    Turnstile north{ 1 };

    {
        Turnstile east{ 2 };
        std::cout << "inner block is using gate " << east.gate() << '\n';
    }

    Turnstile* south{ new Turnstile{ 3 } };
    std::cout << "heap object holds gate " << south->gate() << '\n';
    delete south;

    std::cout << "main is about to return\n";

    return 0;
}

Output:

gate 1 unlocked
gate 2 unlocked
inner block is using gate 2
gate 2 locked
gate 3 unlocked
heap object holds gate 3
gate 3 locked
main is about to return
gate 1 locked

Gate 2 is locked at the closing brace of the inner block, long before main() ends, because that block is its entire lifetime. Gate 3 is built after gate 1 and torn down before it, which looks backwards until you notice that a heap object has no scope of its own. Its ending is wherever you wrote delete, and here that is halfway through main(). Gate 1 is last because automatic objects in the same block are destroyed in the reverse of the order they were built.

Owning Something the Compiler Cannot See

The interesting case is a class that hands out one destructor call to clean up an unknown quantity of work. Here an itinerary builds a chain of legs on the heap, one new per leg, and unwinds the whole chain when it dies:

#include <iostream>

struct Segment
{
    int miles{};
    Segment* onward{};
};

class Itinerary
{
private:
    Segment* m_head{};

public:
    Itinerary() = default;

    Itinerary(const Itinerary&) = delete;
    Itinerary& operator=(const Itinerary&) = delete;

    void addLeg(int miles)
    {
        m_head = new Segment{ miles, m_head };
    }

    int totalMiles() const
    {
        int travelled{ 0 };

        for (const Segment* step{ m_head }; step; step = step->onward)
            travelled += step->miles;

        return travelled;
    }

    ~Itinerary()
    {
        Segment* step{ m_head };

        while (step)
        {
            Segment* finished{ step };
            step = step->onward;

            std::cout << "releasing a " << finished->miles << " mile leg\n";
            delete finished;
        }
    }
};

int main()
{
    Itinerary trip{};

    trip.addLeg(120);
    trip.addLeg(45);
    trip.addLeg(310);

    std::cout << "planned distance is " << trip.totalMiles() << " miles\n";

    return 0;
}

Output:

planned distance is 475 miles
releasing a 310 mile leg
releasing a 45 mile leg
releasing a 120 mile leg

Nothing in main() frees anything. The three new expressions live in addLeg(), the three matching delete expressions live in the destructor, and the only thing that connects them is the lifetime of trip. Note also that acquisition did not have to happen in the constructor. It just has to happen inside the object's life, so that the destructor can be the thing that closes it out.

Tying a resource to an object's lifetime this way has a name: RAII, for resource acquisition is initialization. The name advertises the wrong half. What makes the technique work is the guaranteed release, which is why std::string and std::vector never ask you to free their buffers, and why a class of yours that allocates with new[] must pair that with delete[] in its destructor.

This is also why a class of plain members needs no destructor at all. Members that own nothing cost nothing to discard, and members that do own something (a std::string, a std::vector, another RAII class of yours) already run their own destructors when the enclosing object is destroyed. An empty destructor added "just in case" buys nothing.

Warning
A class that owns a pointer must say what copying means, which is why Itinerary deletes its copy operations. The compiler's generated copy would duplicate the Segment* value and nothing else, leaving two objects pointing at one chain. Both destructors would then run, and the second would delete memory that is already gone. Deleting the copy operations makes the compiler reject the copy instead of letting it corrupt the heap; writing a copy that duplicates the chain is the other option, covered when we reach deep copying.

Two Ways the Call Never Happens

The guarantee is strong but not absolute, and both gaps matter when the cleanup is something a user would notice.

The first is std::exit(). It stops the program where it stands, without unwinding anything that is currently alive:

#include <cstdlib>
#include <iostream>

class Journal
{
public:
    Journal()
    {
        std::cout << "journal opened\n";
    }

    ~Journal()
    {
        std::cout << "journal written out\n";
    }
};

int main()
{
    Journal daily{};

    std::cout << "recording one line\n";

    std::exit(0);
}

Output:

journal opened
recording one line

The third line never prints. Had the destructor been flushing buffered records to a file or committing a transaction, that work would simply have been dropped.

The second gap is a heap object whose delete is never reached, whether because a branch skipped it, an early return jumped over it, or the pointer was overwritten. The object is still there, its destructor never runs, and both the memory and whatever the destructor was meant to release are lost for the rest of the run.

Best Practice
Return normally from main() rather than calling std::exit() when any live object is responsible for cleanup that has to happen.

Summary

Purpose: the destructor is the single member function a class can have run at the end of an object's life, and it is where anything the object acquired gets handed back.

Form: the class name behind a ~, no parameters, no return type, one per class. All of that follows from the compiler being the caller rather than you.

Timing: a local object ends at the closing brace of its block, a newed object ends at its delete, a member ends just after its owner, and a global ends after main(). Objects in the same block go in the reverse of the order they arrived.

Do not call it yourself: the automatic call is still coming, so a manual call means the cleanup happens twice. Calling other members from inside the destructor is fine.

When you need one: only when the object holds something outside itself, such as heap memory or a handle. Built-in members and RAII members like std::string clean themselves up.

RAII: acquire inside an object's lifetime, release in its destructor, and leaks stop being something the caller has to remember.

Ownership implies copy rules: a class holding a raw pointer should delete or properly define its copy operations, or two objects will try to release the same resource.

Two escapes: std::exit() and a delete that never runs. In both cases the destructor is skipped and its work is lost.