C++ For C# Developers: Part 12 – Constructors and Destructors
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrapup
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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!
#1 by vct on August 3rd, 2020 ·
Beware though that primitives have no default value in C++ so the default constructor for UninitializedVector2 won’t set X and Y to 0:
This is not true.
In your example all data members (X and Y) are value initialized .
See below:
https://en.cppreference.com/w/cpp/language/value_initialization
#2 by jackson on August 3rd, 2020 ·
You’re correct! I’ve updated the article to drop that sentence and the example right after it.
I was thinking of a related case:
I’ll cover these topics more thoroughly in next week’s article about initialization.
#3 by Chris on August 3rd, 2020 ·
You write that we can call a destructor in C++. In the past, call a destructor was legal but a bad idea. If you called the destructor on a stack variable, it would get “destructed” twice, once by you and once by the compiler. If you called the destructor of something on the heap, then you’d destruct the members but not reclaim the object’s memory. Has this changed?
#4 by jackson on August 3rd, 2020 ·
Calling a destructor doesn’t prevent any future calls to the destructor, either compiler-generated or manual. Calling a destructor also doesn’t free any memory it happens to be using on the heap. It’s just a function call like any other.
Whether it’s a bad idea to call it manually or not really depends on what the destructor does. Most destructors are written assuming they’ll be called only once, but for many it’s perfectly fine to call them many times.
#5 by Domen on September 21st, 2020 ·
Quote: “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.”
This is not true for C# structs, they always have a default constructor :).
#6 by jackson on September 21st, 2020 ·
Correct, and that constructor is always generated by the compiler to set all fields to zero. We can’t write our own default constructors for structs in C#. Classes in C#, as this quote begins, behave more like C++ classes/structs: if we define any constructor, including a default constructor, then the compiler will no longer generate a default constructor for us. We can override this and force it to generate one by using
= default
in C++, but not C#.#7 by Domen on September 21st, 2020 ·
Quote: “The basic purpose of both C# finalizers and C++ destructors is the same: do some cleanup when the object goes away.”
Although this is true, I think it’s worth mentioning, that in C# destructors should only be used to release native resources. Defining destructors for other purposes (like unsubscribing from an event for example) is a bad practice, because every object which has a destructor has a longer life time due to how garbage collection works.
#8 by jackson on September 21st, 2020 ·
Yes, I’d really recommend the two articles I linked in the next sentence after that quote as good reading about the complexities of C# destructors/finalizers and why they should be used very sparingly.
#9 by Em on February 6th, 2023 ·
Small question! Why are they called “copy” and “move” constructors?
My assumption is that with copy constructors, the constructor is copying the underlying data into a new spot in memory, whereas move constructors are yoinking the data from one spot into another? Or maybe move constructors are moving the reference to that data?
Clarity would be appreciated, thanks!!
#10 by jackson on February 7th, 2023 ·
Let’s take
std::vector
, C++’s equivalent to C#’sList
covered in part 45, as an example. It holds a pointer to a dynamically-allocated array of elements. The copy constructor will allocate enough memory to hold all the elements of thestd::vector
being copied and then copy every element. That can be very expensive because dynamic allocation can be slow, this can use a lot of memory, there may be a lot of memory to copy, and the cost to copy an element may be expensive such as if it too is astd::vector
.In contrast, the move constructor is allowed to treat the
std::vector
being moved from as if it were never going to be used again. It’s allowed to do this because the parameter is an rvalue, meaning it’s impossible for any other code to have a reference to it after the move constructor returns. See part 8 for more on that. The move constructor is free to “suck out the guts” of thestd::vector
. To do this, all the move constructor needs to do is copy the pointer to the array of elements and then set it tonullptr
so the movedstd::vector
’s destructor doesn’t try to delete it. These are super cheap operations: no memory is allocated and no elements are copied.Here’s a pseudo-code summary:
If you can “move” instead of “copy” then it’s almost always at least as fast as the “copy” and sometimes, like with
std::vector
, way faster.