So far with structs we’ve covered data members, member functions, and overloaded operators. Now let’s talk about the main parts of their lifecycle: constructors and destructors. Destructors in particular are very different from C# and represent a signature feature of C++ that has wide-ranging effects on how we write and design code for the language. Read on to learn all about them!

Table of Contents

General Constructors

First things first, we’re not going to deeply discuss actually calling any of these constructors today. Initialization is a complex topic that requires a full article of its own. So we’ll write the constructors in this week’s article and call them in next week’s article.

Basic C++ constructors are so similar to constructors in C# that the syntax is identical and has the same meaning!

struct Vector2
{
    float X;
    float Y;
 
    Vector2(float x, float y)
    {
        X = x;
        Y = y;
    }
};

Anything more advanced than this simple example is going to diverge a lot between the languages. First, as with member functions, we can split the constructor declaration from the definition by placing the definition outside the struct. This is commonly used to put the declaration in a header file (.h) and the definition in a translation unit (.cpp) to reduce compile times.

struct Vector2
{
    float X;
    float Y;
 
    Vector2(float x, float y);
};
 
Vector2::Vector2(float x, float y)
{
    X = x;
    Y = y;
}

C++ provides a way to initialize data members before the function body runs. These are called “initializer lists.” They are placed between the constructor’s signature and its body.

struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    Ray2(float originX, float originY, float directionX, float directionY)
        : Origin(originX, originY), Direction{directionX, directionY}
    {
    }
};

The initializer list starts with a : and then lists a comma-delimited list of data members. Each has its initialization arguments in either parentheses (Origin(originX, originY)) or curly braces (Direction{directionX, directionY}). The order doesn’t matter since the order the data members are declared in the struct is always used.

We can also use an initializer list to initialize primitive types. Here’s an alternate version of Vector2 that does that:

struct Vector2
{
    float X;
    float Y;
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

The initializer list overrides data members’ default initializers. This means the following version of Vector2 has the x and y arguments initialized to the constructor arguments, not 0:

struct Vector2
{
    float X = 0;
    float Y = 0;
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

An initializer list can also be used to call another constructor, which helps reduce code duplication and “helper” functions (typically named Init or Setup). Here’s one in Ray2 that defaults the origin to (0, 0):

struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    Ray2(float originX, float originY, float directionX, float directionY)
        : Origin(originX, originY), Direction{directionX, directionY}
    {
    }
 
    Ray2(float directionX, float directionY)
        : Ray2(0, 0, directionX, directionY)
    {
    }
};

If an initializer list calls another constructor, it can only call that constructor. It can’t also initialize data members:

Ray2(float directionX, float directionY)
    // Compiler error: constructor call must stand alone
    : Origin(0, 0), Ray2(0, 0, directionX, directionY)
{
}
Default Constructors

A “default constructor” has no arguments. In C#, the default constructor for a struct is always available and can’t even be defined by our code. C++ does allow us to write default constructors for our structs:

struct Vector2
{
    float X;
    float Y;
 
    Vector2()
    {
        X = 0;
        Y = 0;
    }
};

In C#, this constructor is always generated for all structs by the compiler. It simply initializes all fields to their default values, 0 in this case.

// C#
Vector2 vecA = new Vector2();    // 0, 0
Vector2 vecB = default(Vector2); // 0, 0
Vector2 vecC = default;          // 0, 0

C++ compilers also generate a default constructor for us. Like C#, it also initializes all fields to their default values.

C++ structs also behave in the same way that C# classes behave: if a struct defines a constructor then the compiler won’t generate a default constructor. That means that this version of Vector2 doesn’t get a compiler-generated default constructor:

struct Vector2
{
    float X;
    float Y;
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

If we try to create an instance of this Vector2 without providing the two float arguments, we’ll get a compiler error:

// Compiler error: no default constructor so we need to provide two floats
Vector2 vec;

If we want to get the default constructor back, we have two options. First, we can define it ourselves:

struct Vector2
{
    float X;
    float Y;
 
    Vector2()
    {
    }
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

Second, we can use = default to tell the compiler to generate it for us:

struct Vector2
{
    float X;
    float Y;
 
    Vector2() = default;
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

We can also put = default outside the struct, usually in a translation unit (.cpp file):

struct Vector2
{
    float X;
    float Y;
 
    Vector2();
 
    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};
 
Vector2::Vector2() = default;

Sometimes we want to do the reverse and stop the compiler from generating a default constructor. Normally we do this by writing a constructor of our own, but if we don’t want to do that then we can use = delete:

struct Vector2
{
    float X;
    float Y;
 
    Vector2() = delete;
};

This can’t be put outside the struct:

struct Vector2
{
    float X;
    float Y;
 
    Vector2();
};
 
// Compiler error
// Must be inside the struct
Vector2::Vector2() = delete;

If there’s no default constructor, either generated by the compiler or written by hand, then the compiler also won’t generate a default constructor for structs that have that kind of data member:

// Compiler doesn't generate a default constructor
// because Vector2 doesn't have a default constructor
struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
};

As we saw above, initializer lists are particularly useful when writing constructors for types like Ray2. Without them, we get a compiler error:

struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    Ray2(float originX, float originY, float directionX, float directionY)
        // Compiler error
        // Origin and Direction don't have a default constructor
        // The (float, float) constructor needs to be called
        // That needs to be done here in the initializer list
    {
        // Don't have Vector2 objects to initialize
        // They needed to be initialized in the initializer list
        Origin.X = originX;
        Origin.Y = originY;
        Origin.X = directionX;
        Origin.Y = directionY;
    }
};

With initializer lists, we can call the non-default constructor to initialize these data members just before the constructor body runs:

struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    Ray2(float originX, float originY, float directionX, float directionY)
        : Origin(originX, originY), Direction{directionX, directionY}
    {
    }
};
Copy and Move Constructors

A copy constructor is a constructor that takes an lvalue reference to the same type of struct. This is typically a const reference. We’ll cover const more later in the series, but for now it can be thought of as “read only.”

Similarly, a move constructor takes an rvalue reference to the same type of struct. Here’s all four of these in Vector2:

struct Vector2
{
    float X;
    float Y;
 
    // Default constructor
    Vector2()
    {
        X = 0;
        Y = 0;
    }
 
    // Copy constructor
    Vector2(const Vector2& other)
    {
        X = other.X;
        Y = other.Y;
    }
 
    // Copy constructor (argument is not const)
    Vector2(Vector2& other)
    {
        X = other.X;
        Y = other.Y;
    }
 
    // Move constructor
    Vector2(const Vector2&& other)
    {
        X = other.X;
        Y = other.Y;
    }
 
    // Move constructor (argument is not const)
    Vector2(Vector2&& other)
    {
        X = other.X;
        Y = other.Y;
    }
};

Unlike C#, the C++ compilers will generate a copy constructor if we don’t define any copy or move constructors and all the data members can be copy-constructed. Likewise, the compiler will generate a move constructor if we don’t define any copy or move constructors and all the data members can be move-constructed. So the compiler will generate both a copy and a move constructor for Vector2 and Ray2 here:

struct Vector2
{
    float X;
    float Y;
 
    // Compiler generates copy constructor:
    // Vector2(const Vector2& other)
    //     : X(other.X), Y(other.Y)
    // {
    // }
 
    // Compiler generates move constructor:
    // Vector2(const Vector2&& other)
    //     : X(other.X), Y(other.Y)
    // {
    // }
};
 
struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    // Compiler generates copy constructor:
    // Ray2(const Ray2& other)
    //     : Origin(other.Origin), Direction(other.Direction)
    // {
    // }
 
    // Compiler generates move constructor:
    // Ray2(const Ray2&& other)
    //     : Origin(other.Origin), Direction(other.Direction)
    // {
    // }
};

The argument to these compiler-generated copy and move constructors is const if there are const copy and move constructors available to call and non-const if there aren’t.

As with default constructors, we can use = default to tell the compiler to generate copy and move constructors:

struct Vector2
{
    float X;
    float Y;
 
    // Inside struct
    Vector2(const Vector2& other) = default;
};
 
struct Ray2
{
    Vector2 Origin;
    Vector2 Direction;
 
    Ray2(Ray2&& other);
};
 
// Outside struct
// Explicitly defaulted move constructor can't take const
Ray2::Ray2(Ray2&& other) = default;

We can also use =delete to disable compiler-generated copy and move constructors:

struct Vector2
{
    float X;
    float Y;
 
    Vector2(const Vector2& other) = delete;
    Vector2(const Vector2&& other) = delete;
};
Destructors

C# classes can have finalizers, often called destructors. C# structs cannot, but C++ structs can.

Unlike constructors, which are pretty similar between the two languages, C++ destructors are extremely different. These differences have huge impacts on how C++ code is designed and written.

Syntactically, C++ destructors look the same as C# class finalizers/destructors: we just put a ~ before the struct name and take no arguments.

struct File
{
    FILE* handle;
 
    // Constructor
    File(const char* path)
    {
        // fopen() opens a file
        handle = fopen(path, "r");
    }
 
    // Destructor
    ~File()
    {
        // fclose() closes the file
        fclose(handle);
    }
};

We can also put the definition outside the struct:

struct File
{
    FILE* handle;
 
    // Constructor
    File(const char* path)
    {
        // fopen() opens a file
        handle = fopen(path, "r");
    }
 
    // Destructor declaration
    ~File();
};
 
// Destructor definition
File::~File()
{
    // fclose() closes the file
    fclose(handle);
}

The destructor is usually called implicitly, but it can be called explicitly:

File file("myfile.txt");
file.~File(); // Call destructor

The basic purpose of both C# finalizers and C++ destructors is the same: do some cleanup when the object goes away. In C#, an object “goes away” after it’s garbage-collected. The timing of when the finalizer is called, if it is called at all, is highly complicated, non-deterministic, and multi-threaded.

In C++, an object’s destructor is simply called when its lifetime ends:

void OpenCloseFile()
{
    File file("myfile.txt");
    DebugLog("file opened");
    // Compiler generates: file.~File();
}

This is ironclad. The language guarantees that the destructor gets called no matter what. Consider an exception, which we’ll cover in more depth later in the series but acts similarly to C# exceptions:

void OpenCloseFile()
{
    File file("myfile.txt");
    if (file.handle == nullptr)
    {
        DebugLog("file filed to open");
        // Compiler generates: file.~File();
        throw IOException();
    }
    DebugLog("file opened");
    // Compiler generates: file.~File();
}

No matter how file goes out of scope, its destructor is called first.

Even a goto based on runtime computation can’t get around the destructor:

void Foo()
{
    label:
    File file("myfile.txt");
    if (RollRandomNumber() == 3)
    {
        // Compiler generates: file.~File();
        return;
    }
    shouldReturn = true;
    // Compiler generates: file.~File();
    goto label;
}

To briefly see how this impacts the design of C++ code, let’s add a GetSize member function to File so it can do something useful. Let’s also add some exception-based error handling:

struct File
{
    FILE* handle;
 
    File(const char* path)
    {
        handle = fopen(path, "r");
        if (handle == nullptr)
        {
            throw IOException();
        }
    }
 
    long GetSize()
    {
        long oldPos = ftell(handle);
        if (oldPos == -1)
        {
            throw IOException();
        }
 
        int fseekRet = fseek(handle, 0, SEEK_END);
        if (fseekRet != 0)
        {
            throw IOException();
        }
 
        long size = ftell(handle);
        if (size == -1)
        {
            throw IOException();
        }
 
        fseekRet = fseek(handle, oldPos, SEEK_SET);
        if (fseekRet != 0)
        {
            throw IOException();
        }
 
        return size;
    }
 
    ~File()
    {
        fclose(handle);
    }
};

We can use this to get the size of the file like so:

long GetTotalSize()
{
    File fileA("myfileA.txt");
    File fileB("myfileB.txt");
    long sizeA = fileA.GetSize();
    long sizeB = fileA.GetSize();
    long totalSize = sizeA + sizeB;
    return totalSize;
}

The compiler generates several destructor calls for this. To see them all, let’s see a pseudo-code version of what the constructor generates:

long GetTotalSize()
{
    File fileA("myfileA.txt");
 
    try
    {
        File fileB("myfileB.txt");
        try
        {
            long sizeA = fileA.GetSize();
            long sizeB = fileA.GetSize();
            long totalSize = sizeA + sizeB;
            fileB.~File();
            fileA.~File();
            return totalSize;
        }
        catch (...) // Catch all types of exceptions
        {
            fileB.~File();
            throw; // Re-throw the exception to the outer catch
        }
    }
    catch (...) // Catch all types of exceptions
    {
        fileA.~File();
        throw; // Re-throw the exception
    }
}

In this expanded view, we see that the compiler generates destructor calls in every possible place where fileA or fileB could end their lifetimes. It’s impossible for us to forget to call the destructor because the compiler thoroughly adds all the destructor calls for us. We know by design that neither file handle will ever leak.

Another aspect of destructors is also visible here: they’re called on objects in the reverse order that the constructors are called. Because we declared fileA first and fileB second, the constructor order is fileA then fileB and the destructor order is fileB then fileA.

The same ordering goes for the data members of a struct:

struct TwoFiles
{
    File FileA;
    File FileB;
};
 
void Foo()
{
    // If we write this code...
    TwoFiles tf;
 
    // The compiler generates constructor calls: A then B
    // Pseudo-code: can't really call a constructor directly
    tf.FileA();
    tf.FileB();
 
    // Then destructor calls: B then A
    tf.~FileB();
    tf.~FileA();
}

This explains why we can’t change the order of data members in an initializer list: the compiler needs to be able to generate the reverse order of destructor calls no matter what the constructor does.

Finally, the compiler generates a destructor implicitly:

struct TwoFiles
{
    File FileA;
    File FileB;
 
    // Compiler-generated destructor
    ~TwoFiles()
    {
        FileB.~File();
        FileA.~File();
    }
};

We can use = default to explicitly tell it to do this:

// Inside the struct
struct TwoFiles
{
    File FileA;
    File FileB;
 
    ~TwoFiles() = default;
};
 
// Outside the struct
struct TwoFiles
{
    File FileA;
    File FileB;
 
    ~TwoFiles();
};
TwoFiles::~TwoFiles() = default;

And we can stop the compiler from generating one with = delete:

struct TwoFiles
{
    File FileA;
    File FileB;
 
    ~TwoFiles() = delete;
};

The compiler generates a destructor as long as we haven’t written one and all of the data members can be destructed.

Conclusion

At their most basic, constructors are the same in C# and C++. The two languages quickly depart though with implicitly or explicitly compiler-generated default, copy, and move constructors, support for writing custom default constructors, strict initialization ordering, and initializer lists.

Destructors are starkly different from C# finalizers/destructors. They’re called predictibly as soon as the object’s lifetime ends, rather than on another thread long after the object is released or perhaps never called at all. The paradigm is similar to C#’s using (IDisposable), but there’s no need to add the using part and no way to forget it. They also strictly order destruction in the reverse of construction and provide us the option to generate or not generate destructors for us.

Now that we know how to define all these constructors and destructors, we’ll dive into actually putting them to use next week when we discuss initialization!