Introduction to Destructors

The cleanup problem

Consider a program that manages a log file. You want to accumulate several log messages in memory before writing them all at once to disk (since disk operations are slow). Such a class might be structured like this:

// This example won't compile because it is (intentionally) incomplete
class FileLogger
{
private:
    std::string m_filename{};
    MessageBuffer m_messages{};

public:
    FileLogger(std::string_view filename)
        : m_filename{ filename }
    {
    }

    void log(std::string_view message)
    {
        m_messages.append(message);
    }

    void flush()
    {
        // open file
        // write all messages
        // clear message buffer
    }
};

int main()
{
    FileLogger logger("application.log");

    logger.log("Starting application");
    logger.log("Loading configuration");

    logger.flush();

    return 0;
}

However, this FileLogger has a potential issue. It relies on flush() being explicitly called before the program shuts down. If the user of FileLogger forgets to do this, log messages won't be written to the file and will be lost when the program exits. Now, you might say, "well, it's not hard to remember to do this!", and in this particular case, you'd be right. But consider a slightly more complex example:

bool processRequest()
{
    FileLogger logger("request.log");

    logger.log("Processing request");
    logger.log("Validating input");

    if (errorOccurred)
        return false;

    logger.flush();
    return true;
}

In this case, if errorOccurred is true, the function returns early, and flush() is not called. This is an easier mistake to make because the flush() call is present—the program just doesn't path to it in all cases.

To generalize this issue, classes that use a resource (most often memory, but sometimes files, databases, network connections, etc.) often need to be explicitly released or closed before the class object using them is destroyed. In other cases, we may want to do some record-keeping prior to object destruction, such as writing information to a log file or sending telemetry to a server. The term "clean up" is often used to refer to any set of tasks that a class must perform before an object is destroyed to behave as expected. If we have to rely on the user of such a class to ensure cleanup is called prior to object destruction, we're likely to encounter errors.

But why are we requiring the user to ensure this? If the object is being destroyed, we know cleanup needs to be performed at that point. Should that cleanup happen automatically?

Destructors to the rescue

In the Introduction to constructors lesson, we covered constructors, which are special member functions called when an object of a non-aggregate class type is created. Constructors initialize member variables and perform any other setup tasks required to ensure objects of the class are ready for use.

Analogously, classes have another type of special member function called automatically when an object of a non-aggregate class type is destroyed. This function is called a destructor. Destructors are designed to allow a class to do any necessary cleanup before an object is destroyed.

Destructor naming

Like constructors, destructors have specific naming rules:

  1. The destructor must have the same name as the class, preceded by a tilde (~).
  2. The destructor cannot take arguments.
  3. The destructor has no return type.

A class can only have a single destructor.

Generally you should not call a destructor explicitly (as it will be called automatically when the object is destroyed), since there are rarely cases where you'd want to clean up an object more than once.

Destructors may safely call other member functions since the object isn't destroyed until after the destructor executes.

A destructor example

#include <iostream>

class Resource
{
private:
    int m_handle{};

public:
    Resource(int handle)
        : m_handle{ handle }
    {
        std::cout << "Acquiring Resource " << m_handle << '\n';
    }

    ~Resource() // here's our destructor
    {
        std::cout << "Releasing Resource " << m_handle << '\n';
    }

    int getHandle() const { return m_handle; }
};

int main()
{
    // Allocate a Resource
    Resource resource1{ 42 };
    {
        Resource resource2{ 99 };
    } // resource2 dies here

    return 0;
} // resource1 dies here

This program produces the following result:

Acquiring Resource 42
Acquiring Resource 99
Releasing Resource 99
Releasing Resource 42

Note that when each Resource object is destroyed, the destructor is called, which prints a message. "Releasing Resource 42" is printed after "Releasing Resource 99" because resource2 was destroyed before the end of the function, whereas resource1 was not destroyed until the end of main().

Remember that static variables (including global variables and static local variables) are constructed at program startup and destroyed at program shutdown.

Improving the FileLogger program

Back to our example at the top of the lesson, we can remove the need for the user to explicitly call flush() by having a destructor call that function:

class FileLogger
{
private:
    std::string m_filename{};
    MessageBuffer m_messages{};

public:
    FileLogger(std::string_view filename)
        : m_filename{ filename }
    {
    }

    ~FileLogger()
    {
        flush(); // make sure all messages are written before object is destroyed
    }

    void log(std::string_view message)
    {
        m_messages.append(message);
    }

    void flush()
    {
        // open file
        // write all messages
        // clear message buffer
    }
};

int main()
{
    FileLogger logger("application.log");

    logger.log("Starting application");
    logger.log("Loading configuration");

    return 0;
}

With such a destructor, our FileLogger object will always write whatever messages it has before the object is destroyed! The cleanup happens automatically, which means less chance for errors and fewer things to think about.

An implicit destructor

If a non-aggregate class type object has no user-declared destructor, the compiler will generate a destructor with an empty body. This destructor is called an implicit destructor, and it is effectively just a placeholder.

If your class doesn't need to do any cleanup on destruction, it's fine not to define a destructor at all and let the compiler generate an implicit destructor for your class.

A warning about the std::exit() function

In the Halts (exiting your program early) lesson, we discussed the std::exit() function, which can be used to terminate your program immediately. When the program is terminated immediately, it just ends. Local variables are not destroyed first, and because of this, no destructors will be called. Be wary if you're relying on your destructors to do necessary cleanup work in such a case.

Advanced note: Unhandled exceptions will also cause the program to terminate and may not unwind the stack before doing so. If stack unwinding does not happen, destructors will not be called prior to program termination.

Summary

Destructors: Special member functions that are called automatically when an object is destroyed. Destructors have the same name as the class, preceded by a tilde (~), take no arguments, and have no return type. A class can only have a single destructor.

Cleanup tasks: Destructors are designed to perform cleanup tasks before an object is destroyed, such as releasing resources (memory, files, network connections), writing to log files, or sending telemetry data.

Automatic execution: Destructors are called automatically when objects go out of scope or when the program ends. This ensures cleanup happens reliably without requiring manual intervention.

Implicit destructors: If no destructor is defined, the compiler generates an implicit destructor with an empty body. This is appropriate for classes that don't require explicit cleanup.

Important considerations: When using std::exit() to terminate a program, local variables are not destroyed and destructors are not called. Similarly, unhandled exceptions may prevent destructors from executing if stack unwinding doesn't occur.

By using destructors, we can ensure that classes properly clean up resources when objects are destroyed, making programs more reliable and preventing resource leaks. Destructors complement constructors by handling the end-of-life responsibilities for class objects.