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 or initialize it. While programmers write most constructors, two are necessary for correct object behavior: the default and copy constructors. Programmers can use these constructors, but the compiler provides them if they don't. (The compiler also provides an assignment operator, which we explore in the next chapter.) Although they are not always required, C++ also allows programmers to write a similar but opposite function called a destructor.

A destructor destroys or "cleans up" an object when the program no longer needs it. In general, destroying or cleaning up an object means 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 resource examples. What it means to "clean up" an object depends on the object, what it contributes to the program, and what resources it uses. For example, if the object created some temporary files (using disk space), the program should remove them before it terminates. Or, if the object opened a database (using data), the database should be closed. But destructors are most often used to deallocate dynamic memory allocated on 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 be careful to 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 exponentially 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 creating 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;		// (a)
...

p1 = new Person;			// (b)
 
void f()
{
	Person* p2 = new Person;		// (c)
	...
}
Memory leak examples. The new operator allocates and returns the address of heap memory, which the program typically stores 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 inadvertently create memory leaks in two ways:
  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. Once its address is lost, the first object is unreachable, unusable, and the program cannot delete it. Overwriting an address is a logical programming error whose only solution is programmer awareness and avoidance.

  3. Allocates memory for an object on the heap and saves its address in the variable p2. p2 is a local (stack) variable the program deallocates when the function ends. However, the program doesn't deallocate the heap memory for the object, and it becomes unreachable and unusable.
The first example assumes that statements (a) and (b) are in the same scope. It's illegal for (b) to appear in global scope, so the two statements must be in a function or the same block. But, unlike (c), scope is not the underlying problem and is omitted for simplicity. Replacing the function in example (c) with a nested block results in the same error.

Destructors: Managing Memory

If destructors can't help us avoid the memory leaks illustrated above, what errors do they help prevent? Fortunately, they help us with aggregation: complex classes with pointer members. Safe and secure programs always initialize object pointers and deallocate all unused heap memory. Constructors perform the initialization, and destructors handle the deallocation. Destructors and constructors are similar in many ways:

However, there are two syntactic differences: destructor names always begin with a tilde character, ~, and they may not have any parameters (implying that programmers cannot overload them). Destructors can perform any "clean-up" steps necessary to destroy an object, but memory deallocation is the most common.

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. We again extend the Person class introduced previously by adding a destructor. The Person class has a pointer member variable, making it a "complex" class in need of 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.

The question of ownership in a whole-part relationship further complicates dynamic memory management. The part object's owner is responsible for destroying or deleting the part 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. So, the compiler leaves managing all heap memory to the programmer.
  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 (as it is in Figure 2), but the delete operator signals that the program is finished with the dynamic memory, and that is sufficient to trigger a call to the Person destructor.

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 otherwise 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.