Modern C++

Choosing the Right Constructors to Write

In part 3 of this series on the C++ Core Guidelines, we explore constructors and why, rather than writing a default constructor, you should use in-class member initializers, a feature added in C++ 11.

In my continuing series on the C++ Core Guidelines, I want to simplify the job of designing constructors for a class, and highlight a language feature you may not have noticed.

There are a lot of guidelines in the Constructors section, known as C.ctor in the table of contents, though some of them are actually about destructors and assignment operators. I want to focus on C.45: Don't define a default constructor that only initializes data members; use in-class member initializers instead.

Before I show you an example of what this means, let's back up and look at some of the different ways you can choose constructors for a class you're writing. Here's a simple class:

class Simple
{
public:
private:
  int a;
  int b;
  int c;
};

It doesn't do anything, but that's not super relevant for this demonstration. And at the moment, it doesn't have any user-provided constructors.

If you took a course on object-oriented design or hang out with people who talk as though they could write them, you might know that you need constructors to set up your invariants. For example, in a bank account, an invariant is "the balance is always equal to the total of all the transactions in the log" and you set that up by creating an empty log and setting the balance to zero when you first create an instance of the object -- in other words, in the constructor.

You also use constructors to set non-optional fields, like a person's name or birthdate. When you don't provide any constructors that don't take a name or birthdate, then you ensure that the object is always valid -- it's not possible to construct one that isn't valid. That can save a ton of checking later on.

But what about classes that don't have non-optional fields? For these, providing a default constructor is very useful. That's why the compiler generates one (that doesn't do anything) even if you don't write one. I can use this class like this:

Simple s;
std::vector<Simple> v2(5);

(I won't like what I get -- the integers in the Simple instances are uninitialized -- I can't count on them. But it will compile and run.) But the moment you write a constructor, the compiler stops generating one for you. So if I add this constructor:

class Simple
{
public:
  Simple(int aa, int bb, int cc) : a(aa), b(bb), c(cc) {}
private:
  int a;
  int b;
  int c;
};

Suddenly the line Simple s; stops compiling. There are a whole pile of ways to deal with this -- C++ is the language that gives you many ways to do almost everything. You could add a default constructor that sets all the variables to default values:

Simple() : a(1), b(2), c(3) {}

Or you could give default values to all the parameters on that three-argument constructor:

Simple(int aa=1, int bb=2, int cc=3) : a(aa), b(bb), c(cc) {}

You can't do both, you'll get a compiler error about ambiguity. Using default values lets people provide values for a and b, but not c -- whether that's a bug or a feature is up to you. What sometimes happens, as a code base grows, is that you get a mixture of different ways of initializing the members, depending on who added them. So you could end up with something like this:

class Simple
{
public:
  Simple() : a(1), b(2), c(3) {}
  Simple(int aa, int bb, int cc=-1) : a(aa), b(bb), c(cc) {}
  Simple(int aa) { a = aa; b = 0; c=0; }
private:
  int a;
  int b;
  int c;
};

The third constructor here isn't following good practice -- if the member variables weren't simple integers, they would be default-initialized and then assigned new values. It's always better to use initializer syntax as the first two constructors do. But there's a bigger problem: Imagine trying to read and understand this class. Why is the default value for a always 1, but for b it might be 0 or 2? And for c, it might be 3, -1 or 0? Is this deliberate? In my experience, it usually isn't -- people just make numbers up when they need them without putting in a lot of thought. There might even be a subtle bug lurking here when someone who uses -1 as a signal value to mean "uninitialized" doesn't realize some of the constructors use 0 for that.

So the guideline says, don't do that. Use in-class member initializers. These were added in C++ 11 and if you've programmed in some other languages (especially C#) you've probably tried to use them in C++ without even thinking twice. When you declare the member variable, you can provide an initial value for it. Then the class gets a lot simpler and more consistent:

class Simple
{
public:
  Simple()  {}
  Simple(int aa, int bb, int cc) : a(aa), b(bb), c(cc) {}
  Simple(int aa) : a(aa) { }
private:
  int a=-1;
  int b=-1;
  int c=-1;
};

Now anyone reading this code can see the default values for the three member variables. The rest of the code is shorter -- less to write, read, understand and test. And there's a chance that the compiler and optimizer will be able to speed things up a little because you're not writing the default constructor yourself, though that's not my main motivation for following this guideline.

I can make a small tweak to the first constructor, which is to ask the compiler to make a do-nothing one for me, instead of writing the empty braces surrounding nothing myself:

Simple() = default;

This makes it clear to anyone reading the code that you're not doing anything in this constructor. And it also means that the first constructor no longer counts as a "user-provided" constructor, though for this class it doesn't matter because there are two other user-provided constructors. The =default notation is very useful for copy- and-move constructors and assignment operators, because the compiler will generate useful code for you, but it's a good habit to use whenever you would've used empty braces, because it stands out well and tells other people what you're doing.

Having lots of ways to do things isn't always great: It leaves you having to make choices, and can lead to inconsistency. One of the great benefits of following the Core Guidelines is that you don't have to argue for hours about which of two (or three, or four) ways to do something is better -- you just do the one the guideline says to do and move on.

In some code bases, you'll get an added benefit that things are more consistent and easier to read. That's what this guideline offers, in my opinion: It simplifies the job of designing your constructors, and it might improve your code if you go back and apply it to older code, especially code written before member initializers were added to the language. Certainly, this is the way to design your classes from now on.

About the Author

Kate Gregory has been using C++ since before Microsoft had a C++ compiler, and has been paid to program since 1979. Kate runs a small consulting firm in rural Ontario, Canada, and provides mentoring and management consultant services, as well as writing code every week. She has spoken all over the world, written over a dozen books, and helped thousands of developers to be better at what they do. Kate is a Microsoft Regional Director, a Visual C++ MVP, an Imagine Cup judge and mentor, and an active contributor to StackOverflow and StackExchange sites. She develops courses for Pluralsight, primarily on C++ and Visual Studio. Since its founding in 2014 she is the Open Content Chair and speaker for CppCon, the definitive C++ conference.

comments powered by Disqus

Featured

  • Diving Deep into .NET MAUI

    Ever since someone figured out that fiddling bits results in source code, developers have sought one codebase for all types of apps on all platforms, with Microsoft's latest attempt to further that effort being .NET MAUI.

  • Copilot AI Boosts Abound in New VS Code v1.96

    Microsoft improved on its new "Copilot Edit" functionality in the latest release of Visual Studio Code, v1.96, its open-source based code editor that has become the most popular in the world according to many surveys.

  • AdaBoost Regression Using C#

    Dr. James McCaffrey from Microsoft Research presents a complete end-to-end demonstration of the AdaBoost.R2 algorithm for regression problems (where the goal is to predict a single numeric value). The implementation follows the original source research paper closely, so you can use it as a guide for customization for specific scenarios.

  • Versioning and Documenting ASP.NET Core Services

    Building an API with ASP.NET Core is only half the job. If your API is going to live more than one release cycle, you're going to need to version it. If you have other people building clients for it, you're going to need to document it.

  • TypeScript Tops New JetBrains 'Language Promise Index'

    In its latest annual developer ecosystem report, JetBrains introduced a new "Language Promise Index" topped by Microsoft's TypeScript programming language.

Subscribe on YouTube