Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Constructors and Destructors for Resource Management

Recommended resources

Hello! Welcome to the third lesson in your "C++ for ESP32" course.

Introduction

In our last lesson, we explored how to create objects from a class blueprint, focusing on the crucial difference between stack and heap allocation. You learned that while stack objects have their memory managed automatically, heap-allocated objects require manual cleanup with delete to prevent memory leaks.

Today, we'll address that manual cleanup challenge head-on. The learning outcome for this lesson is to implement constructors and destructors for resource initialization and cleanup. These are special member functions that C++ provides to automate the setup and teardown of your objects. By mastering them, you can write code that is not only cleaner but also significantly safer and more robust—a necessity for reliable embedded systems.

This lesson introduces a fundamental C++ concept called RAII (Resource Acquisition Is Initialization), which is the cornerstone of modern C++ resource management.


1. What is a Constructor?

A constructor is a special member function that is automatically called whenever a new object of a class is created. Its primary job is to initialize the object's member variables, ensuring the object starts its life in a valid state.

Let's watch a short video that defines constructors and their main types.

Constructors And Destructors In C++ | Constructors In C++ | C++ Tutorial For Beginners | Simplilearn

This video from Simplilearn provides a clear introduction to what constructors are, their special properties, and the different types you'll encounter.

Please watch from 00:51 to 04:40. Focus on: The definition of a constructor and its key characteristics (same name as the class, no return type). The three types of constructors mentioned: Default, Parameterized, and Copy.

As the video explained, constructors are your tool for initialization. Let's apply this to the DCMotor class from our previous lessons.

Previously, we did this:

DCMotor motor;
motor.name = "Conveyor Belt"; // Manual initialization after creation

With a parameterized constructor, we can make this much cleaner:

class DCMotor {
public:
    std::string name;
    int speed_rpm;
    bool is_rotating;

    // This is a parameterized constructor
    DCMotor(std::string motor_name) {
        name = motor_name;
        speed_rpm = 0;       // Initialize to a safe default
        is_rotating = false; // Initialize to a safe default
        std::cout << "DCMotor object '" << name << "' created." << std::endl;
    }

    // ... other methods like start(), stop() ...
};

int main() {
    // Now, we provide the name when the object is created
    DCMotor motor1("Conveyor Belt"); // Constructor is called automatically

    // The object is already initialized and ready to use
    motor1.start();
}

This ensures that a DCMotor object can't be created without a name and that its state variables (speed_rpm, is_rotating) are set to sensible defaults from the very beginning.


2. A Better Way to Initialize: Member Initializer Lists

In the example above, we initialized the members inside the constructor's curly braces {}. This is technically assignment, not initialization. The members are first default-constructed, and then they are assigned new values.

C++ provides a more efficient and powerful way to initialize members: the member initializer list.

class DCMotor {
public:
    std::string name;
    int speed_rpm;
    bool is_rotating;

    // Constructor using a member initializer list
    DCMotor(std::string motor_name)
        : name(motor_name), speed_rpm(0), is_rotating(false) // This is the initializer list
    {
        // The constructor body can be empty or contain other setup logic
        std::cout << "DCMotor object '" << name << "' created and initialized." << std::endl;
    }

    // ... other methods ...
};

The initializer list (the part after the colon :) tells the compiler to construct the member variables directly with the given values. This is more performant and is required for certain types of members, such as const variables or references.

The following video explains this concept very well.

C++ object lifecycle 101: the essentials

This video from the 'Code for yourself' channel explains the object lifecycle and provides an excellent, detailed look at why member initializer lists are the correct way to initialize members.

Watch the segment from 02:17 to 04:21. Pay close attention to the distinction between initialization (in the list) and assignment (in the body), and the note about the order of initialization.

Best Practice: Always prefer using member initializer lists for initialization.


3. What is a Destructor?

Just as a constructor is automatically called at the beginning of an object's life, a destructor is automatically called at the very end. Its purpose is to perform cleanup and release any resources the object was managing.

A destructor has a specific signature:

  • Its name is the class name preceded by a tilde (~).
  • It takes no arguments and has no return value.
class MyResourceWrapper {
public:
    // Constructor
    MyResourceWrapper() {
        std::cout << "Resource acquired." << std::endl;
    }

    // Destructor
    ~MyResourceWrapper() {
        std::cout << "Resource released." << std::endl;
    }
};

int main() {
    std::cout << "Entering scope." << std::endl;
    {
        MyResourceWrapper wrapper; // Constructor called here
    } // `wrapper` goes out of scope, destructor is called here
    std::cout << "Exited scope." << std::endl;
    
    return 0;
}

If you run this code, you will see the output:

Entering scope.
Resource acquired.
Resource released.
Exited scope.

The destructor is called automatically, guaranteeing that cleanup happens, whether the object was on the stack and went out of scope, or on the heap and was freed with delete.


4. The RAII Paradigm: Automatic Resource Management

Constructors and destructors are the building blocks for one of the most powerful paradigms in C++: Resource Acquisition Is Initialization (RAII).

The principle is simple:

  1. Acquire a resource in an object's constructor.
  2. Release that resource in the object's destructor.

By doing this, the lifetime of the resource is tied to the lifetime of the object. Since the compiler guarantees that the destructor is called when the object is destroyed, it also guarantees your resource is released. This makes your code exception-safe and prevents resource leaks.

Imagine a class that manages memory allocated on the heap:

class MemoryBlock {
private:
    int* data; // Pointer to a resource (heap memory)

public:
    // Constructor: ACQUIRE the resource
    MemoryBlock(int size) {
        data = new int[size];
        std::cout << "MemoryBlock: Allocated " << size << " integers." << std::endl;
    }

    // Destructor: RELEASE the resource
    ~MemoryBlock() {
        delete[] data; // The '[]' is important for arrays
        std::cout << "MemoryBlock: Freed allocated memory." << std::endl;
    }
};

void manage_memory() {
    MemoryBlock block(100); // Constructor allocates memory
    // ... use the block for some work ...
} // `block` goes out of scope, destructor is automatically called, memory is freed.

With this MemoryBlock class, it's now impossible to forget to call delete[]. The object manages the resource for you. This is the core idea behind C++'s smart pointers (std::unique_ptr, std::shared_ptr), which you will encounter later.

Why is this so important?

What happens if an error occurs during construction? If you don't use RAII, you can easily leak resources.

Object Lifetime in C++

This article, 'Object Lifetime in C++', from KDAB, provides a more advanced look at what happens when things go wrong. It's a great illustration of why RAII is so critical for writing robust code.

Read the section 'Complex objects and their lifetimes'. Focus on the first code example under that heading, where two new calls are made in the constructor. The author explains that if the second new throws an exception, the destructor is never called, and the memory from the first new is leaked. This is a powerful motivation for using RAII.

The takeaway from that article is that managing raw pointers and new/delete yourself is risky. Wrapping resources in classes that follow RAII makes your code far safer.


5. An ESP32-Context Example

In the ESP32 world, your resources aren't just memory. They are GPIO pins, hardware timers, I2C bus access, and Wi-Fi connections. RAII is the perfect pattern for managing them.

Let's design a simple class to manage a GPIO pin that controls an LED. The ESP-IDF functions to do this are complex, but we can wrap them in a simple C++ class.

#include <iostream>

// --- This is a simplified simulation of ESP32 GPIO functions ---
void gpio_set_direction(int pin, std::string mode) {
    std::cout << "Pin " << pin << " configured as " << mode << "." << std::endl;
}
void gpio_set_level(int pin, int level) {
    // In a real scenario, this would set the pin voltage high or low.
}
void gpio_reset_pin(int pin) {
    std::cout << "Pin " << pin << " reset to default state." << std::endl;
}
// ----------------------------------------------------------------

class Led {
private:
    int gpio_pin;

public:
    // Constructor: Acquires and configures the GPIO pin
    Led(int pin) : gpio_pin(pin) {
        gpio_set_direction(gpio_pin, "OUTPUT");
        std::cout << "Led object for GPIO " << gpio_pin << " is ready." << std::endl;
    }

    // Destructor: Releases the GPIO pin
    ~Led() {
        turn_off(); // Ensure the LED is off before releasing
        gpio_reset_pin(gpio_pin);
        std::cout << "Led object for GPIO " << gpio_pin << " is destroyed." << std::endl;
    }

    void turn_on() {
        gpio_set_level(gpio_pin, 1);
        std::cout << "LED on pin " << gpio_pin << " turned ON." << std::endl;
    }

    void turn_off() {
        gpio_set_level(gpio_pin, 0);
        std::cout << "LED on pin " << gpio_pin << " turned OFF." << std::endl;
    }
};

void control_led_briefly() {
    std::cout << "--- Starting LED control task ---" << std::endl;
    Led onboard_led(2); // Pin 2 is often the onboard LED on ESP32 boards
    onboard_led.turn_on();
    // Imagine a delay here...
    onboard_led.turn_off();
    std::cout << "--- LED control task finished ---" << std::endl;
} // `onboard_led` destructor is called automatically here

int main() {
    control_led_briefly();
    return 0;
}

In this example, the Led object handles all the low-level gpio_... calls. When you create an Led object, the pin is automatically configured. When the object goes out of scope, the pin is automatically reset. You, as the user of the Led class, only need to think about turning it on and off.


Conclusion

This lesson is a major step toward writing professional C++ code for embedded systems. By letting the compiler manage object lifetimes, you can automate resource management.

Key Takeaways:

  • Constructors are special functions that run automatically when an object is created. They are used to initialize the object's state, often using a member initializer list.
  • Destructors are special functions (~ClassName) that run automatically when an object is destroyed. They are used to release any resources the object holds.
  • RAII (Resource Acquisition Is Initialization) is a powerful C++ paradigm. By acquiring resources in the constructor and releasing them in the destructor, you tie the resource's lifetime to the object's lifetime, preventing leaks and making your code safer.
  • This pattern is essential for managing hardware resources like GPIO pins, timers, and communication buses on an ESP32.

In our next lesson, we will focus on access specifiers (public, private, protected). These keywords allow you to control which parts of your class are exposed to the outside world, a concept known as encapsulation, which is key to creating robust and easy-to-maintain code.