Chapter 1.3. C.45: Don’t define a default constructor that only initializes data members; use in-class member initializers instead

Why have default constructors anyway?

We will start this chapter with a scenic detour. Consider Core Guideline NR.5: “Don’t use two-phase initialization.” This is referring to the habit of constructing an object and then calling an initialization function. This practice dates from the last century, when C was the very thing, and one would declare an object on the stack or allocate some memory from the free store and then initialize it. If you were really on top of things, you would define a function that took a pointer to your struct and call it my_struct_init or something like that.

The two phases were allocate, then initialize. All sorts of things could go wrong: you could insert more and more code between the allocation and the initialization and suddenly find you were using your object prior to initialization. Then along came C++ and constructors and this problem vanished forever.

For objects with static duration, the linker would create a list of their constructors for execution prior to main() and a function to iterate through them. The linker would have full knowledge about how much space they would take up, so that function might allocate some address space for those objects, initialize them all by constructing them in that address space, and then invoke main().

For objects with automatic duration, the compiler would allocate some stack space and initialize the object within that memory. For objects with dynamic duration, the new operator would invoke operator new to allocate some memory, followed by the constructor to initialize the object within that memory. The thread-local duration class arrived in C++11 and behaves in much the same way as static duration, except there is a per-thread instance of the object rather than a per-program instance.

We hope you can see a clear and consistent pattern here that eliminates an entire class of bugs: that of using an object before it is ready for use. By combining allocation and initialization into a single operation, the problem of two-phase initialization vanished entirely.

Except, of course, that the problem did not vanish at all. Engineers were still in the habit of instantiating an object and then modifying it after construction. Classes would be designed with a default value and clients would specialize that value according to context.

This just moves the problem around. A default constructor is not always appropriate for a class. Sadly, for a long time, the containers provided by some C++ vendor implementations would not work unless the contained class was default-constructible. A default constructor would be provided not as part of the problem domain but as part of the solution domain. Of course, that meant that it would be used in the problem domain as well, muddying the waters about what the correct use of the class should be.

Some classes should have a default constructor. For example, how would you declare an empty string? There is no meaningful API for that, unless you decide you are going to have a constructor overload especially for empty strings with a special tag parameter. The std::string API recognizes this and provides a default constructor that creates a string of zero length. A default constructor is the obvious solution. Indeed, all the standard containers provide default constructors that signify that the container starts life empty.

However, do not assume that your class needs to be default-constructible. Make sure you know what it means to allow your users to create an instance of your class without any kind of specification.

How do you initialize a data member?

Let’s rejoin the main road and look at the process of initialization. When an object is constructed, the memory is reserved as appropriate for the storage class. The constructor is then invoked, although the rules are a little different for objects of built-in type. If no constructor is defined, then the members of the class are default-initialized. If there are any members of built-in type, then they are not initialized by default.

This is a bad thing: if you don’t ensure every member of a class is initialized, then you run the risk of introducing nondeterministic behavior into your program. Good luck debugging that. I worked on a game many years ago using a C++ implementation that was very helpful when it came to dynamic storage duration. It came with two run-time libraries, one for development and one for retail. The development version of the run-time library was built with NDEBUG undefined, so asserts would fire, and all sorts of debug information was available for the standard library. When operator new was invoked, it would initialize the memory with the value 0xcd. When operator delete was invoked, it would overwrite the memory with the value 0xdd. This was very useful for identifying dereferencing of dangling pointers. For reasons of speed, the retail library did not do this, and simply left the memory untouched after allocation and after deallocation.

The game was a multiplayer game. Each player’s machine would send their moves over the internet in the blink of an eye and each machine would have to resolve them identically. This required each machine to be in an identical state as far as the model of the game was concerned; otherwise, the models on each machine would diverge, giving confusing results and broken games. Such inconsistencies would appear less frequently in versions of the game that were built with the development version of the run-time library, because they all had the same underlying memory values for uninitialized data,

0xcd. This led to retail-version-only crashes, which were incredibly hard to debug since any divergences would not be noticed by players until long after they occurred.

Until this point, persuading the team of the importance of initializing every data member in every constructor had been an uphill battle. When this particular penny dropped, no more persuasion was required. Determinism is your ally when it comes to debugging, so ensure determinism with deterministic construction of all objects and initialize every item of member data.

There are three places where you can initialize member data. The first place we’ll take a look at is the constructor function body. Consider this class:

class piano
{
public:
  piano();
private:
  int number_of_keys;
  bool mechanical;
  std::string manufacturer;
};

We can define the constructor like this:

piano::piano()
{
  number_of_keys = 88;
  mechanical = true;
  manufacturer = "Yamaha";
}

This is perfectly adequate. Every member is initialized, and the order of initialization matches the order of declaration. This is function-body-initialization. However, it is suboptimal. Prior to the function body being executed, the members of the class were default-initialized. This meant that the std::string default constructor was invoked, and then the assignment operator was invoked with a char const*. In fact, this is overwriting, not initializing.

Now, any smart compiler will optimize away the construct-assign pattern. std::string is a class template, and there is a good chance that the entire execution is available to the compiler. It will see that there is a redundancy and eliminate it. However, you cannot rely on this being the case for every class. You should prefer initializing in the initializer list to initializing in the function body.

Let us change the constructor appropriately:

piano::piano()
  : number_of_keys(88)
  , mechanical(true)
  , manufacturer("Yamaha")
{}

Aside from the problem of remembering to maintain the default constructor when you add member data, this looks a lot like boilerplate code that is just bloating your source file.

There is a third place you can provide default definitions that is even closer to the action: in the definition of the class itself. Default member initializers provide a default value for an object when no other is provided in a constructor. Let’s return to our class definition to take a look at this in action:

class piano
{
public:
  // piano(); // no longer needed
private: int number_of_keys = 88; bool mechanical = true; std::string manufacturer = "Yamaha"; };

This is much better. You have managed to get rid of a surplus member function and you have also specified what you expect a default piano to look like: an 88-note mechanical Yamaha piano. There is a cost that cannot be ignored, though, which is that these default values are exposed in a class declaration that is likely to be a dependency of other source files. Making a change to any of these values may require recompilation of an unknown number of files. There are, however, good reasons for paying this price.

What happens when two people maintain a class?

It is to be hoped that in the normal run of things, one person will maintain a class. They will have identified the abstraction, encoded it in a class, designed the API, and will have full knowledge of what is going on.

Of course, Things Happen. A maintainer might be moved to another project temporarily, or worse, suddenly leave without the opportunity for a proper handover. Several things can complicate matters without the strict discipline of thorough communication via documentation, meetings, and all the other time-sinks that plague the typical engineer.

Hotch-potch of constructors

When several people work on a class, inconsistencies start to creep in. A lot of the Core Guidelines material is about reducing the opportunity to be inconsistent. Consistent code is easier to read and contains fewer surprises. Consider what might happen to the piano class were three maintainers let loose upon it:

class piano
{
public:
    piano()
    : number_of_keys(88)
    , mechanical(true)
    , manufacturer("Yamaha")
    {}
    piano(int number_of_keys_, bool mechanical_,
    std::string manufacturer_ = "Yamaha")
    : number_of_keys(number_of_keys_)
    , mechanical(mechanical_)
    , manufacturer(std::move(manufacturer_))
    {}
    piano(int number_of_keys_) {
    number_of_keys = number_of_keys_;
    mechanical = false;
    manufacturer = "";
    }
private:
    int number_of_keys;
    bool mechanical;
    std::string manufacturer;
};

This is a sample class, but I have seen things like this in the wild. Usually, the constructors are separated by many lines. Perhaps they are all defined in the class definition, so it is not immediately obvious that there are three very similar constructors because they are obscured by many lines of implementation. Indeed, you can tell a couple of things about the different maintainers. The implementer of the third constructor does not appear to know about initialization lists. Also, assigning the empty string to the manufacturer member is redundant, so they are possibly unaware of how constructors and default initialization works.

More importantly, though, the first and third constructors have different defaults. While you may have spotted that in this simple example, we are sure you can imagine circumstances where it would not be so obvious. Calling code can pass in one, two, or three arguments with unexpectedly different behavior, which is not what any user wants. The presence of default arguments in the constructor overloads should also worry you.

What happens if we adopt the in-class member initializers? The code becomes this:

class piano
{
public:
    piano() = default;
    piano(int number_of_keys_, bool mechanical_, std::string manufacturer_)
    : number_of_keys(number_of_keys_)
    , mechanical(mechanical_)
    , manufacturer(manufacturer_)
    {}
    piano(int number_of_keys_) {
    number_of_keys = number_of_keys_;
    }
private:
    int number_of_keys = 88;
    bool mechanical = true;
    std::string manufacturer = "Yamaha";
};

We now have consistent defaults. The constructor authors have been told by the presence of in-class member initializers that defaults have been chosen and they do not need to, nor should they, choose their own.

Default parameters can confuse matters in overloaded functions

Default parameters in constructors are confusing beasts. They imply a default value for something, but there is a cognitive distance between that default and the member

declaration. There exist plausible reasons for adding a default parameter to a constructor; perhaps a member has been added, and rather than change all the client code you decide to take on the technical debt of maintaining a default value. However, you need to recognize it as technical debt that needs to be repaid, ideally by adding an additional constructor that takes all the required parameters and deprecating the existing constructor.

Summary

Default constructors should be an active choice. Not all classes have meaningful default values. Member data initialization can happen in three places: the constructor function body, the constructor initialization list, and at the point of declaration of the member data, known as default member initializers.

Default member initializers define a default value at the point of declaration. If there is a member that cannot be defined in such a way, it suggests that there may be no legal mechanism by which a default constructor can be defined. This is fine. As remarked earlier, there is no necessity for default constructors.

Constructors provide a variation from the default. Providing default member initializers gives each variation increased specificity. This is a bonus for the client as they have fewer things to worry about regarding the state of the object: it can be more closely tailored to their requirements.

Default constructors should be an active choice.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.224.246.203