10.5.2. Destructors

Time: 00:03:57 | Download: Large, Large (CC), Small | Streaming | Slides (PDF)
Review

Whenever a program instantiates or creates a new object, it automatically calls a constructor to construct, build, or initialize it. While programmers write most constructors, two are necessary for correct object behavior, and the compiler creates them automatically. Programmers can accept the compiler-provided default and copy constructors or replace them. (The compiler also provides an assignment operator, which we explore in the next chapter.) Although they are not always necessary, C++ also allows programmers to write similar but opposite functions called destructors.

Destructors destroy or remove objects, "cleaning up" programs when they no longer need the objects. In general, destroying or removing an object entails releasing any resources that the object holds. A resource is any part of a computer system - hardware, software, or data - needed to complete a task. Disk space, main memory, and CPU time are common examples. What it means to destroy an object depends on the object, what it contributes to the program, and what resources it uses. For instance, if the object creates some temporary files (using disk space), the program should remove them before it terminates. Or, if the object opens a database (using data), the program should close it before stopping. Nevertheless, the most frequent destructor task is deallocating dynamic memory allocated from the heap.

Memory management for automatic or local objects is scope-driven. The program automatically allocates and deallocates stack memory when an object comes into or goes out of scope. However, memory management for dynamic objects allocated on the heap is under direct programmer control. Programmers allocate and deallocate heap memory with the new and delete operators, respectively.

Person* p = new Person;
	. . .
delete p;
Allocating and deallocating heap memory. Two operators allow programmers to manage heap memory:
new
  1. Allocates memory on the heap
  2. Calls the appropriate constructor
  3. Returns the address of the allocated memory and stores it in a pointer variable
delete
  1. Calls the destructor if there is one
  2. Deallocates the memory - i.e., returns the memory to the heap (also called the free store)

Java has an automatic garbage collector, making it easier than C++ to manage heap memory. But automatic garbage collection also places a greater burden1 on a running program than explicitly deleting a single, specific object when the program no longer needs it. So, in this respect, a correct C++ program will generally outperform an equivalent Java program. But, the C++ approach also burdens programmers who must carefully avoid memory leaks. Destructors help programmers avoid memory leaks but can't prevent them.

Memory Leaks

Memory leaks are notoriously difficult to find, and the difficulty increases rapidly with the size of the program and the number of program files. So, the best programming practice is understanding what causes memory leaks and avoiding them. The following figure illustrates two common programming errors creating memory leaks that destructors can't prevent.

Overwriting An AddressLost Address
Person* p1 = new Person;		// (i)
...

p1 = new Person;			// (ii)
 
void f()
{
	Person* p2 = new Person;
	...
}
(a)(b)
Memory leak examples. The new operator allocates from the heap and returns its address, and the program typically saves the address in a pointer variable. If the program loses the address, it can't use, recover, or delete the allocated memory, creating a memory leak. (The memory isn't physically lost, and the operating system reclaims it when the program terminates.) Programmers typically create memory leaks in two ways:
  1. The first way requires at least two allocation statements, usually separated by other statements obscuring the error. Both allocation statements must run in the same scope.
    1. Allocates memory for an object on the heap and saves its address in the variable p1.
    2. Allocates memory for a new object and saves its address in p1. Saving the new address overwrites or replaces the previous address, losing it. Overwriting an address is a logical programming error whose only solution is programmer awareness and avoidance.
  2. Allocates memory for an object on the heap and saves its address in the variable p2, a local function or stack variable. The program deallocates ps when the function ends, discarding the saved address. However, the program doesn't deallocate the heap memory for the object, creating a memory leak.

Destructors: Managing Memory

Destructors are member functions, so they can't prevent the memory leaks illustrated in the previous figure. However, they simplify managing an object's resources, including its dynamically allocated memory. The following examples demonstrate how programmers use destructors to support aggregation implemented with complex classes with pointer members. Safe and secure programs always initialize object pointers when creating aggregated parts, and they always release resources and deallocate unused heap memory when finished with aggregation. Constructors perform the initialization, and destructors handle the release and deallocation.

Constructors Destructors
Names Same as the class Same as the class, begins with a tilde, ~
Return type None None
Parameters Any number None (implying they can't be overloaded)
Call Automatic with object instantiation Automatic with object destruction
Tasks Allocate memory and initialize members Deallocate memory and release resources
Constructors vs. destructors. Destructors and constructors are similar in many ways and naturally different in others.
UML Person class:
Person
--
- name : string* = nullptr
- weight : int = 0
- height : int = 0
--
+ Person()
+ Person(n : string, w : int, h : double)
+ Person(w : int, h : height)
+ ~Person()
+ setName(n : string*) : void
class Person
{
    private:
        string*	name = nullptr;
        int	weight = 0;
        double	height = 0;

    public:
        Person()  {}

        Person(string n, int w, double h)
            : name(new string(n)), weight(w), height(h) {}

        Person(int w, double h) : name(nullptr), weight(w), height(h) {}
        
~Person() { if (name != nullptr) delete name; }
void setName(string* n) { if (name != nullptr) delete name; name = n; } };
A UML class diagram showing an aggregation relationship between two classes: Person (the whole) and string.
A simple destructor example. The example extends the Person class by adding a destructor. The Person class has a pointer member variable, making it a "complex" class requiring a destructor. The destructor deletes or deallocates name if the Person has one. Deleting a null pointer should be safe, but as recently as 2019, I witnessed a program fail when it did. So, I still test before deleting, but the test is probably no longer needed. Conversely, the delete operation is crucial to prevent a memory leak.

In a whole-part relationship, the questions of object sharing and ownership further complicate dynamic memory management: what program objects share the part and which one "owns" it? The part object's owner is responsible for destroying or deleting it when it's no longer needed. When the part is not shared, the whole object is the owner. Assigning the ownership responsibilities to a specific class is more challenging when two or more objects share a part. If the underlying problem or other design constraints don't favor one, choose the one that makes the program easiest to write. The automatic destruction of a part object is a common destructor task.

Local Variables Heap Variables
void g()
{
    Person p1("Wally");			// (a)
}
 
void f()
{
    Person* p2 = new Person("Dilbert");
    delete p2;					// (b)
}
Preventing memory leaks with destructors. Carefully compare Figures 2 and 4. We must look closely to see the significant differences. Understanding the differences is more difficult. Local or stack variables are always deallocated when they go out of scope, but not dynamic memory allocated on the heap with the new operator. Programmers may use dynamic memory outside the function where it is allocated (for example, the function might return it). The compiler cannot "understand" when the programmer wants to discard or retain dynamic memory when a function ends, leaving the programmer to manage all heap memory.
  1. p1 is a local (stack) variable that the program deallocates when the function (or block) ends. If the variable is an object instantiated from a class with a destructor, the program calls the destructor automatically.
  2. p2 is a pointer, and the delete operator triggers a call to the Person destructor, which performs any necessary "clean up" operations, and then signals the heap manager to return the dynamic memory to the heap.

  1. Java's garbage collector is implemented as a low-priority thread running a mark-and-sweep algorithm. The collector is a part of every program, but it only runs when needed or when the program is idle. Nevertheless, a mark-and-sweep process takes a (relatively) long time to run: First, it marks all allocated objects as garbage. Next, it follows the references (i.e., the pointers) stored in the program's reference variables and erases the "garbage mark" from the reachable objects. Finally, the unreachable objects retain the mark, and the collector returns them to the heap. It's a simple and effective algorithm, but the program must suspend all other activity while the garbage collector runs.