Introduction
Hello! In our last lesson, we saw that creating an object and then calling multiple setter methods to initialize it feels a bit clunky. We also saw that this approach can lead to objects existing temporarily in an invalid state, for example, if we forget to call a setter.
This lesson introduces a more robust and elegant solution: constructors. Constructors are special member functions that are automatically called the moment an object is created. Their primary job is to ensure that the object is initialized into a valid and usable state from the very beginning.
We will cover the three main types of constructors you'll encounter:
- Default Constructor: Creates an object with a default state.
- Parameterized Constructor: Creates an object with a specific state provided as arguments.
- Copy Constructor: Creates a new object as a copy of an existing one.
Furthermore, we'll explore what happens when an object's life ends. For that, we have destructors, special functions that handle cleanup. This is especially critical for classes that manage resources like dynamic memory, which is a central theme in C++ and highly relevant to your goal of understanding how systems work "under the hood".
1. Why Constructors? The Need for Initialization
Before we write a constructor, let's see why they are necessary. When you create an object of a class, the memory for its member variables is allocated, but C++ makes no guarantee about what's in that memory for primitive types like int or float. It's often just leftover "garbage" data.
This video provides an excellent demonstration of this problem and introduces constructors as the solution.
Watch this segment from The Cherno's "Constructors in C++" video. It clearly shows the problem of uninitialized member variables and how a simple default constructor solves it.
Watch from the beginning to 03:33. Pay attention to: The random values printed from the uninitialized Entity object. The temporary, but clumsy, solution of an init() method. How a default constructor provides a clean, automatic way to initialize the object's state. The important note that, unlike Java, C++ does not automatically initialize primitive types to zero.
2. Parameterized Constructors and Initializer Lists
A default constructor is great for creating an object in a known default state. But what if we want to create an object with specific initial values, like we tried to do with setters? For that, we use a parameterized constructor. As the name suggests, it accepts parameters to initialize the object's members.
A class can have multiple constructors (this is a form of function overloading), allowing for flexible object creation.
class Song {
private:
std::string m_title;
int m_year;
public:
// 1. Default constructor
Song() {
m_title = "Untitled";
m_year = 0;
std::cout << "Default constructor called\n";
}
// 2. Parameterized constructor
Song(std::string title, int year) {
m_title = title;
m_year = year;
std::cout << "Parameterized constructor called\n";
}
};
int main() {
Song song1; // Calls default constructor
Song song2("Bohemian Rhapsody", 1975); // Calls parameterized constructor
return 0;
}
A Better Way: Member Initializer Lists
The implementation above works, but there's a more idiomatic and efficient way to initialize members in C++: member initializer lists. Notice the syntax difference:
// Assignment inside constructor body
Song(std::string title, int year) {
m_title = title; // Assignment
m_year = year; // Assignment
}
// Initialization using a member initializer list
Song(std::string title, int year) : m_title(title), m_year(year) {
// Body can be empty or used for other logic
}
Why is the second way better?
- Efficiency: It performs direct initialization. The first method default-constructs the members first and then assigns new values to them, which can be less efficient, especially for complex objects.
- Necessity: Some members, like
constvariables or references, must be initialized in the initializer list because they cannot be assigned to later.
Given your interest in how things work at a lower level, understanding the distinction between initialization and assignment is key.
Understanding C++ Constructors, Destructors and Some key concepts
This article provides a superb explanation of why member initializer lists are important, especially when dealing with const members.
Read the section titled "4. Constructor Initializer Lists". Focus on: Why assignment fails for const members. The step-by-step flow of object creation. How the initializer list fits into this flow to perform initialization before the constructor body runs.
3. Resource Management: Destructors and the Copy Problem
Now let's move to a more complex scenario. Imagine a class that manages a resource, like a dynamically allocated block of memory. This is common in C++ for things like custom strings, image buffers, or any data whose size isn't known at compile time.
The Destructor (~)
If a constructor allocates a resource (e.g., using new), something must be responsible for releasing it to prevent memory leaks. This is the job of the destructor. It's a special member function that is automatically called when an object is destroyed (e.g., when it goes out of scope or is explicitly deleted).
A destructor has the same name as the class, prefixed with a tilde (~), and takes no arguments.
class MyResourceHolder {
private:
int* m_data;
public:
MyResourceHolder() {
m_data = new int[100]; // Allocate resource
std::cout << "Resource allocated.\n";
}
~MyResourceHolder() {
delete[] m_data; // Release resource
std::cout << "Resource released.\n";
}
};
This pattern is fundamental to C++ and is called RAII (Resource Acquisition Is Initialization).
The Copying Problem: Shallow vs. Deep Copy
Now, what happens if we copy an object of our MyResourceHolder class?
MyResourceHolder obj1;MyResourceHolder obj2 = obj1; // What happens here?
By default, C++ performs a shallow copy, meaning it copies the values of the member variables directly. In our case, it copies the m_data pointer. Now both obj1 and obj2 point to the same block of memory. When obj1 is destroyed, its destructor frees the memory. When obj2 is destroyed later, its destructor tries to free the same memory again, leading to a crash (a "double free" error).
The solution is to perform a deep copy: when an object is copied, we must allocate new memory for the copy and then copy the contents of the original memory block. To do this, we must define our own copy constructor.
This video is one of the best explanations of this entire process. It builds a String class from scratch, demonstrating the destructor, the shallow copy problem, and the deep copy solution using a copy constructor.
Copying and Copy Constructors in C++
This video, "Copying and Copy Constructors in C++" by The Cherno, is a masterclass in C++ resource management. It will walk you through building a class that manages dynamic memory and show you precisely why a custom copy constructor is essential.
This is the core of the lesson. Please watch carefully from 03:51 to 16:13. (03:51 - 09:04) Watch how the String class is built. Note the use of new char[...] in the constructor and delete[] in the destructor. (09:04 - 13:06) This is the critical part. See how the default copy leads to a crash and how modifying the copy also modifies the original. This is the shallow copy problem in action. (13:06 - 16:13) Finally, observe how a custom copy constructor is implemented to perform a deep copy, fixing all the issues.
The canonical form of a copy constructor is ClassName(const ClassName& other). The const is there so you can copy from constant objects, and the reference (&) is there to prevent an infinite loop of copy constructor calls.
4. Hands-On Exercise: The MusicNote Class
Let's put all this together. Given your graduation project on a music notation app, let's create a simple MusicNote class that manages its pitch name using dynamic memory.
Instructions:
- Create a class named
MusicNote. - Give it a single private member:
char* m_pitch;. - Default Constructor:
MusicNote(). Use a member initializer list to setm_pitchtonullptr. - Parameterized Constructor:
MusicNote(const char* pitch). This constructor should:- Calculate the length of the input
pitchstring (usestrlenfrom<cstring>). - Allocate a new
chararray of the correct size (length + 1 for the null terminator). - Copy the input
pitchstring into your newm_pitchbuffer (usestrcpyfrom<cstring>).
- Calculate the length of the input
- Destructor:
~MusicNote(). It should print a message (e.g., "Destroying note...") and then check ifm_pitchis notnullptrbefore callingdelete[] m_pitch;. This prevents trying to delete a null pointer. - Copy Constructor:
MusicNote(const MusicNote& other). This should perform a deep copy:- Check if
other.m_pitchisnullptr. If so, setthis->m_pitchtonullptrand return. - Otherwise, allocate a new buffer for
this->m_pitchand copy the contents ofother.m_pitchinto it.
- Check if
- Add a public method
void display() constthat prints the pitch.
Use the following main function to test your implementation. If it runs without crashing and produces the correct output, you've succeeded!
#include <iostream>
#include <cstring> // For strlen and strcpy
// --- Your MusicNote class definition and implementation goes here ---
class MusicNote {
private:
char* m_pitch;
public:
// Default Constructor
MusicNote() : m_pitch(nullptr) {
std::cout << "Default constructor: Note created (empty).\n";
}
// Parameterized Constructor
MusicNote(const char* pitch) {
std::cout << "Parameterized constructor: Creating note " << pitch << ".\n";
if (pitch) {
size_t len = strlen(pitch);
m_pitch = new char[len + 1];
strcpy(m_pitch, pitch);
} else {
m_pitch = nullptr;
}
}
// Copy Constructor (Deep Copy)
MusicNote(const MusicNote& other) {
std::cout << "Copy constructor: Copying note " << (other.m_pitch ? other.m_pitch : "empty") << ".\n";
if (other.m_pitch) {
size_t len = strlen(other.m_pitch);
m_pitch = new char[len + 1];
strcpy(m_pitch, other.m_pitch);
} else {
m_pitch = nullptr;
}
}
// Destructor
~MusicNote() {
std::cout << "Destructor: Destroying note " << (m_pitch ? m_pitch : "empty") << ".\n";
delete[] m_pitch;
}
void display() const {
if (m_pitch) {
std::cout << "Note Pitch: " << m_pitch << std::endl;
} else {
std::cout << "Note Pitch: (empty)" << std::endl;
}
}
};
int main() {
std::cout << "--- Creating original note ---\n";
MusicNote note1("C#4");
note1.display();
std::cout << "\n--- Creating a copy of the note ---\n";
MusicNote note2 = note1; // Calls the copy constructor
note2.display();
std::cout << "\n--- Program scope ending, objects will be destroyed ---\n";
// Watch the destructor messages. They should be called for both note2 and note1.
// If your program crashes here, you likely have a shallow copy issue!
return 0;
}
Conclusion
You've just tackled some of the most important concepts in C++! Mastering constructors and destructors is the key to writing safe and robust object-oriented code, especially when dealing with system resources.
Key Takeaways:
- Constructors initialize objects. They ensure an object starts its life in a valid state.
- Default: No arguments.
- Parameterized: Takes arguments for custom initialization.
- Copy: Takes another object of the same type to create a copy.
- Member Initializer Lists are the preferred C++ way to initialize member variables for both efficiency and necessity.
- Destructors clean up resources when an object is destroyed. This is the foundation of the RAII principle.
- The default compiler-generated copy constructor performs a shallow copy, which is dangerous for classes managing resources.
- You must implement a custom copy constructor to perform a deep copy for classes that manage dynamic memory or other resources.
Next Up
In this lesson, you may have noticed the use of this->member in some examples, or m_ prefixes to distinguish member variables. Inside any member function, C++ provides a special, hidden pointer called this, which points to the specific object instance the function was called on.
In our next lesson, we will explore the this pointer in detail. We will also learn how to overload operators (like + or <<) for our custom classes and dive deeper into the const keyword to make our classes even more robust.