Demystifying Constructors and Destructors in C++ (Part 2)

Lokesh Bihani
15 min readApr 18, 2024

--

This is the second part of my 6-part series on object-oriented programming in C++. In this part I’ll discuss Constructors and Destructors in great detail.

Constructors

Constructor is a special type of function that doesn’t have any return type, and is called implictly when an object is created. It’s name is always exactly same as the name of the class and it can never be static.

Constructors can be made private if there’s a requirement to do so. Generally it’s done while implementing Singleton Design pattern.

In case of Inheritance, you can call Base class’s constructor explicitly from Derived class’s constructor. (More details in Inheritance section of this series)

Types of constructors

class Complex {
int real;
int img;

public:
Complex() { } // Default or non parameterize constructor

Complex(int x, int y) { } // Parameterized constructor
Complex(int x) { } // Parameterized constructor
Complex(Complex& x) {} // copy constructor
/*
'Complex&' is very important here. You cannot just use Complex(Complex x)
because the compiler will run into recursion while calling the copy
constructor.
*/
Complex& operator=(const Complex& x) { } // copy assignment

Complex(Complex&&) { } // move constructor
Complex& operator=(Complex&&) { } // move assignment
};

By default, a class provides:

  • A default constructor: X()
  • A copy constructor: X(const X&)
  • A copy assignment: X& operator=(const X&)
  • A move constructor: X(X&&)
  • A move assignment: X& operator=(X&&)
  • A destructor: ˜X()

If you declare a copy operation, a move operation, or a destructor for a class, no copy operation, move operation, or destructor is generated for that class.

If you declare atleast one constructor of any type, compiler won’t generate default constructor.

Using default keyword, you can ask the compiler to generates it’s default version of the constructor.

class gslice {
valarray<size_t> size;
valarray<size_t> stride;
valarray<size_t> d1;

public:
gslice() = default;
˜gslice() = default;
gslice(const gslice&) = default;
gslice(gslice&&) = default;
gslice& operator=(const gslice&) = default;
gslice& operator=(gslice&&) = default;
// ...
};

Using delete keyword, you can ask the compiler to not generate its default constructor.

class Base {
// ...
Base& operator=(const Base&) = delete;// disallow copying
Base(const Base&) = delete;
Base& operator=(Base&&) = delete; // disallow moving
Base(Base&&) = delete;
};

Base x1;
Base x2 {x1}; // error : no copy constructor

Constructor delegation

When one constructor of a class invokes another constructor of the same class, it’s called constructor delegation.

class Collector {
private:
int size;
int capacity;
int* list;
public:
/*
Don't worry, I haven't yet discussed "member initializer list". For now,
just know that it's one of the ways to initialize data members of the class.
*/
Collector() : Collector(0) { } // constructor delegation using member initializer list
Collector(int cap) : capacity(cap), size(0) {
if (cap > 0) {
list = new int[cap];
}
else
capacity = 0;
}
bool append(int v) {
if (size < capacity) {
list [ size++ ] = v;
return true;
}
else
return false;
}
~Collector() {
if (capacity > 0) {
delete[] list;
}
}
};

Initialization without constructors

You cannot define a constructor for a built-in type, yet you can initialize it with a value of suitable type.

{} (initializer list syntax): This is a way to assign value to an object. The object could be of built-in type like int, double, etc.. or class/struct type.

int a {1}; // assigning 1 to 'a'
char∗ p {nullptr}; // assigning nullptr to 'p'

Similarly, you can initialize objects of a class for which you have not defined a constructor using:

  • memberwise initialization,
  • copy initialization, or
  • default initialization (without an initializer or with an empty initializer list).

However, there’s one exception: If a class has a private non-static data member, it needs a constructor to initialize it.

class Work {
public:
string author; // class type
string name; // class type
int year; // built-in type
};

Work s9 {
"Beethoven",
"Symphony No. 9 in D minor, Op. 125; Choral",
1824
}; // OK: memberwise initialization

Work s9 (
"Beethoven",
"Symphony No. 9 in D minor, Op. 125; Choral",
1824
); // Error: This is not considered as memberwise initialization.

Work currently_playing { s9 }; // copy initialization
Work none {}; // default initialization

The default initialization using {} is defined as initialization of each member by {}. So, “none” is initialized to {{},{},{}}, which is {“”,””,0}.

Where no parameterized constructor is declared, it is also possible to leave out the initializer completely.

// This is in global scope, so "alpha" is statically allocated object.
Work alpha; // this is also default initialization

void f() {
Work beta; // local object
// ...
}

When no parameterized constructor is declared and objects are initialized without initializer {} , these two rules are followed:

  • For statically allocated objects (objects defined in global/namespace scope or objects defined with static keyword), the rules are exactly as if you had used {}. So, the value of “alpha” is {“”, “”, 0}.
  • For local variables and free-store (memory allocated in heap) objects, the default initialization is done only for members of class type, and members of built-in type are left uninitialized, so the value of “beta” is {“”,””,unknown}. ‘Unknown’ because beta is of type “Work” which contains 2 string member variables (not built in types) and 1 int member variable (built-in type).

Let’s consider one more example:

class Buf {
public:
int count; // built in type
char buf[16∗1024]; // built in type
};

// statically allocated (global scope), so initialized by default. [Rule 1]
Buf buf0;

void f() {
Buf buf1; // leave elements uninitialized (local variable) [Rule 2]

// Using default initializer syntax. Rule 1,2 doesn't apply here.
Buf buf2 {}; // I really want to zero out those elements

int∗ p1 = new int; // *p1 is uninitialized [Rule 2]
int∗ p2 = new int{}; // *p2 == 0 [Rule 1, 2 doesn't apply here]
int∗ p3 = new int{7}; // *p3 == 7 [Rule 1, 2 doesn't apply here]
// ...
}

Initialization using constructors

The {} notation for initialization can also be used to provide arguments to a constructor wherever an object can be constructed. Also referred as universal initialization. However, = and () notations for initialization are not universal.

Following two initializations are considered same:

X obj{v};
X obj(X{v});

{} is called universal initialization because it can be used to initialize objects of any type, including built-in types, user-defined types, and standard library containers.

Let’s see the same example for both the cases:

// Case 1: {} notation for initialization
class X {
X(int);
};

class Y : X {
X m{0}; // provide default initializer for member m
// syntax error: can’t use = for member initialization
Y(int a) : X{a}, m={a} { };
Y() : X{0} { }; // initialize base and member
};

X g(1); // initialize global variable

void f(int a) {
X def {}; // error : no default value for X
Y de2 {}; // use default constructor
X∗ p {nullptr};
X var{2}; // initialize local variable
p = new X{4}; // initialize object on free store
X a[]{1,2,3}; // initialize array elements
vector<X> v{1,2,3,4}; // initialize vector elements
}

// ============================================================
// Case 2: = and () notation for initialization
class X {
X(int);
};

class Y : X {
X m;
// syntax error: can't use = for member initialization
Y(int a) : X(a), m=a { };
};

X g(1); // initialize global variable

void f(int a) {
X def(); // function returning an X (surprise!?)
X∗ p {nullptr};
X var = 2; // initialize local variable
p = new X=4; // syntax error: can't use = for new
X a[](1,2,3); // error : can't use () for array initialization
vector<X> v(1,2,3,4); // error : can't use () for list elements
}
  • The uniform use of {} initialization only became possible in C++11, so older C++ code uses () and = initialization.
  • the {}-initializer notation does not allow narrowing. That is another reason to prefer the {} style over () or =.

“Narrowing” refers to a type of implicit conversion that may result in the loss of information because the target type cannot represent all possible values of the source type without loss of precision. For ex: conversion from float to int.

Initializer list constructors

A constructor that takes a single argument of type std::initializer_list is called an initializer-list constructor.

An initializer-list constructor is used to construct objects using a {}-list as its initializer value. It can be of arbitrary length but must be homogeneous. That is, all elements must be of the template argument type, T, or implicitly convertible to T.

void f(initializer_list<int>);

f({1,2});
f({23,345,4567,56789});
f({}); // the empty list
f{1,2}; // error: function call () missing

When you have several constructors for a class, these constructor overload resolution rules are used:

  • Prefer the default constructor if either a default constructor or an initializer-list constructor could be invoked.
  • Prefer the initializer-list constructor if both an initializer-list constructor and an ‘‘ordinary constructor’’ could be invoked.
class X {
X(); // default constructor
X(initializer_list<int>); // initializer list constructor
X(int); // Parameterized constructor(in our context, ordinary constructor)
};

X x0 {}; // empty list: the default constructor [Rule 1]
X x1 {1}; // one integer: the initializer-list constructor [Rule 2]

Initializer_list doesn’t provide subscripting so you can iterate on it using begin(), end() methods, or using for (auto x: lst) syntax.

An initializer_list<T> is passed by value and its elements are immutable.

Member initialization

Arguments for a member’s constructor are specified in a member initializer list in the definition of the constructor of the containing class.

In a class constructor, the parameters for initializing a member are listed within a member initializer list, which is located in the class constructor’s definition.

What does “member initializer list” mean?

The member initializer list starts with a colon, and the individual member initializers are separated by commas.

What does “member’s constructor” mean?

In C++, when you create a class, you can declare member variables within it. Each member variable can have its own constructor, which is a unique member function designed to initialize objects of that variable’s type. For example, there’s a default constructor for int, a constructor for string, float, and so on.

class Club {
string name; // name declaration
vector<string> members; // members declaration
vector<string> officers;
Date founded;
// ...
Club(const string& n, Date fd); // constructor declaration
};

// This whole thing is constructor definition of the class "Club".
Club::Club(const string& n, Date fd)
: members{}, name{n}, officers{}, founded{fd} // member initializer list
{
// ...body of the constructor
}

Some important things to remember:

  • Member initializer list is executed first and then the body of the constructor is executed.
  • The order in which member variables are declared in the class, is the order in which they are initialized regardless of their initialization order in the member initialization list. In the example above, first name is declared and then members is declared. Even though in member initializer list first members is initialized, then name is initialized, but in reality name is initialized first and then members is initialized.
  • A constructor can initialize the members and bases of its own class but cannot initialize the members or bases of its own members or bases.
class B { B(int); /* ... */};
class BB : B { /* ... */ };
class BBB : BB {
BBB(int i) : B(i) { }; // error : trying to initialize base's base
// ...
};
  • A reference member or a const member must be initialized in the initialzer list only.
class X {
const int i;
Club cl;
Club& rc;
// ...
X(int ii, const string& n, Date d, Club& c) : i{ii}, cl{n,d}, rc{c} { }
};
  • Never call another constructor from one constructor’s body.
  • You cannot both delegate and explicitly initialize a member.
class X {
int a;
public:
X(int x) { if (0<x && x<=max) a=x; else throw Bad_X(x); }

// This is error
X()
: X{42}, // constructor delegation
a{56} // explicit member initialization
{ }
};

In-class initializer

You can specify an initializer for a non-static data member in the class declaration. C++ versions older than 11 doesn’t allow this syntax.

class A {
public:
int a {7}; // in class initializer
int b = 77; // in class initialize
};

The example above and the example below does the exact same thing. The example above uses in-class initializer and the example below uses member initializer listbut they achieve the same thing at the end of the day.

class A {
public:
int a;
int b;
A() : a{7}, b{77} {}
};

Copy

The difference between move and copy is that after a copy two objects must have the same value, whereas after a move the source of the move is not required to have its original value. Moves can be used when the source object will not be used again.

Copy for a class X is defined by two operations:

  • Copy constructor: X(const X&)
  • Copy assignment: X& operator=(const X&)

A copy constructor and a copy assignment differ in that a copy constructor initializes uninitialized memory, whereas the copy assignment operator deals with an object that has already been constructed and may own resources.

  • Usually a copy constructor must copy every non-static member. If a copy constructor cannot copy an element (e.g., because it needs to acquire an unavailable resource to do so), it can throw an exception.
  • When writing a copy operation, be sure to copy every base and member.
class X {
string s;
string s2;
vector<string> v;

X(const X&) // copy constructor
: s{a.s}, v{a.v} // probably sloppy and probably wrong because s2 is not being copied so it gets default initialized (to "").
{
// ...
}
// ...
};
  • To copy an object of a derived class you have to copy its bases. (If you don’t know about inheritance yet, just skip this point, read along and come back to it after reading about inheritance in part 5.)
class B1 {
B1();
B1(const B1&); // copy constructor
// ...
};

class B2 {
B2(int);
B2(const B2&); // copy constructor
// ...
};

class D : B1, B2 {
D(int i) :B1{}, B2{i}, m1{}, m2{2∗i} {}
D(const D& a) :B1{a}, B2{a}, m1{a.m1}, m2{a.m2} {} // copy constructor
B1 m1;
B2 m2;
};

D d {1}; // construct with int argument
D dd {d}; // copy construct

Copy operations must meet two criteria:

  • Equivalence: After x=y, operations on x and y should give the same result. In particular, if == is defined for their type, we should have x==y and f(x)==f(y) for any function f() that depends only on the values of x and y (as opposed to having its behavior depend on the addresses of x and y).
  • Independence: After x=y, operations on x should not implicitly change the state of y, that is f(x) does not change the value of y as long as f(x) doesn’t refer to y.

You generally don’t need to create a custom copy constructor or copy assignment operator yourself because the compiler generates default versions of these functions if they’re not explicitly defined. These default implementations perform a member-wise copy of the data members of the class. It performs shallow copy.

As long as your class isn’t managing resources like dynamically allocated memory (DMA) using pointers, the compiler-generated copy constructor and copy assignment operator usually suffice.

However, if your class contains pointers to dynamically allocated memory or other resources that require special handling during copying, then it’s advisable to define custom versions of both the copy constructor and the copy assignment operator. This allows you to implement proper resource management, such as deep copying, where new memory is allocated for the copied data, ensuring that each object manages its own independent copy of the resources.

This is how it looks:

Compiler v/s custom copy constructors

Move

The idea behind a move assignment is to handle lvalues separately from rvalues: copy assignment and copy constructors take lvalues whereas move assignment and move constructors take rvalues. For a return value, the move constructor is chosen.

template<class T>
Matrix<T>::Matrix(Matrix&& a) // move constructor
: dim{a.dim}, elem{a.elem} // grab a’s representation
{
a.dim = {0,0}; // clear a’s representation
a.elem = nullptr; // clear a’s representation
}

Move constructors and move assignments take non-const (rvalue) reference arguments: they can, and usually do, write to their argument. However, the argument of a move operation must always be left in a state that the destructor can cope with (and preferably deal with very cheaply and easily).

template<class T>
Matrix<T>& Matrix<T>::operator=(Matrix&& a) // move assignment
{
swap(dim,a.dim); // swap representations
swap(elem,a.elem);
return ∗this;
}

How does the compiler know when it can use a move operation rather than a copy operation?

In a few cases, such as for a return value of a function, the language rules say that it can (because the next action is defined to destroy the element). However, in general we have to tell it by giving an rvalue reference argument.

template<class T>
void swap(T& a, T& b) { // "perfect swap" (almost)
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}

move(x) means ‘‘give me an rvalue reference to x.’’ That is, std::move(x) does not move anything; instead, it allows a user to move x.

You have to be careful about data structures containing pointers. In particular, don’t assume that a moved-from pointer is set to nullptr.

Destructors

  • Just like a constructor, destructor also doesn’t have a return type and its name is also exactly same as the name of the class, but with ~ in the prefix.
  • There’s only one type of destructor (unlike constructors that are of 3 types), which runs automatically whenever the object goes out of scope.
  • Like constructors, it can never be static.
class Complex {
~Complex() {}
};
  • If you don’t create a destructor, compiler creates one for you, but the destructor created by the compiler is of empty body type. This isn’t a problem as long as our object isn’t storing a pointer, but the moment we start storing a pointer, not creating a destructor can result into memory leak because even after an object is destroyed, memory location it was pointing to would still exist in the memory. The only way to release that memory is by explictly releasing it in the destructor.
  • If a class has at least one virtual function or pure virtual function, it needs a virtual destructor because during run-time polymorphism, a class’s pointer can be used to point to the memory of any of its derived classes. Since the binding happens at run-time, the compiler won’t know beforehand which destructor to call exactly. (Don’t worry if you don’t understand this right now. Come back to it after learning more about polymorphism in part 6 of this series).

Quick overview of different initialization techniques

Default Initialization

class MyClass {
public:
MyClass() {
// Default constructor
std::cout << "Default constructor called" << std::endl;
}
};

int main() {
MyClass obj{}; // Default initialization
MyClass obj1; // This is also default initialization
return 0;
}

Initialization with Constructor Arguments / Direct Initialization

class MyClass {
public:
MyClass(int x, double y) {
// Constructor with arguments
std::cout << "Constructor with arguments called: " << x << ", " << y << std::endl;
}
};

int main() {
// Initialization with constructor arguments or Direct initialization
MyClass obj{10, 3.14};
MyClass obj = {10, 3.14};
MyClass obj = MyClass{10, 3.14}; // These three syntax are exactly same.

MyClass obj(10, 3.14);
MyClass obj = MyClass(10, 3.14); // These two syntax are exactly same.
return 0;
}

Copy Initialization

class MyClass {
public:
MyClass(const MyClass& other) {
// Copy constructor
std::cout << "Copy constructor called" << std::endl;
}
};

int main() {
MyClass obj1;
MyClass obj2 = obj1; // Copy initialization
MyClass obj3{obj2}; // Copy initialization
return 0;
}

Copy Assignment

class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}

// Copy assignment operator
MyClass& operator=(const MyClass& other) {
// This self assignment check is very important maybe not here, but
// there's one such scenario explained below.
if (this != &other) { // Check for self-assignment
data = other.data; // Copy data from the other object
}
return *this; // Return a reference to the modified object
}
void display() const {
std::cout << "Data: " << data << std::endl;
}
};

int main() {
MyClass obj1(10);
MyClass obj2(20);
std::cout << "Initial values:" << std::endl;
obj1.display(); // Output: Data: 10
obj2.display(); // Output: Data: 20

// For copy assignment to work, the type of the class on both sides of
// the assignment operator should be same.
obj1 = obj2; // Copy assignment: Assigning obj2's value to obj1

// Display updated values after copy assignment
std::cout << "Values after copy assignment:" << std::endl;
obj1.display(); // Output: Data: 20
obj2.display(); // Output: Data: 20 (unchanged)
return 0;
}

The self assignment check is very important especially when dealing with Dynamically allocated memory. Consider this example to understand this better:

class DynamicArray {
private:
int* arr;
int size;
public:
DynamicArray(int n) : size(n) {
arr = new int[size];
for (int i = 0; i < size; ++i) {
arr[i] = i;
}
}

// Copy assignment operator
DynamicArray& operator=(const DynamicArray& other) {
if (this != &other) { // Check for self-assignment
delete[] arr; // Deallocate existing memory.
size = other.size; // Update size
arr = new int[size]; // Allocate new memory
for (int i = 0; i < size; ++i) {
arr[i] = other.arr[i]; // Copy elements from other object
}
}
return *this; // Return a reference to the modified object
}
void display() const {
for (int i = 0; i < size; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
~DynamicArray() {
delete[] arr; // Destructor to deallocate memory
}
};
int main() {
DynamicArray arr1(5);
arr1.display(); // Output: 0 1 2 3 4
arr1 = arr1; // Self-assignment
arr1.display(); // Expected Output: 0 1 2 3 4 (unchanged)
return 0;
}

Without the self-assignment check, the destructor in the copy assignment operator would deallocate the memory before copying, leading to a dangling pointer and undefined behavior when accessing arr afterwards.

With the self-assignment check, the copy assignment operator recognizes the self-assignment and avoids unnecessary work, ensuring that arr1 remains unchanged after the assignment.

Dynamic Allocation (Using ‘new’)

class MyClass {
public:
MyClass() {
std::cout << "Constructor called" << std::endl;
}
~MyClass() {
std::cout << "Destructor called" << std::endl;
}
};

int main() {
MyClass* ptr = new MyClass(); // Dynamic allocation
delete ptr; // Freeing memory
return 0;
}

--

--

Lokesh Bihani

Software Engineer passionate about System Design, DevOps, and ML. I try to simplify complex tech concepts to help others learn while deepening my own knowledge.