I Have No Constructor, and I Must Initialize (Hacker News discussion) goes into the gnarly details of initialization in C++, and all the immense, immense complexity and edge cases around it. As the top-voted comment on HN put it:
After almost 20 years of experience with C++, there are still some gnarly details I wouldn’t have imagined. What a God awful language!
Interestingly, I do not recall ever running into such an issue myself. The strategy I follow and the guidelines I always give:
1. Always, always define your own constructor
As the article says: never leave it to the compiler to work it out. If you just have a plain-old-data style struct, then just put the initializers in-line:
struct Point2D {
float x = 0.0f;
float y = 0.0f;
};
This ensures that your values are always something expected, while retaining the ability to use designated initializers:
auto p = Point2D{
.x = 3.75f,
.y = 5.0f,
};
The same holds for other special functions, like the copy- and move constructors, as well as copy- and move-assignment operators. If they matter, explicitly declare them, and default them. This avoids other pitfalls, such as a class with a user-defined destructor getting copy constructors but not move constructors generated by the compiler.
2. Always initialize functional-style
Always use the following form for initializing your fields and variables:
auto foo = Foo();
auto bar = Bar(123);
Baz baz = make_baz();
This avoids the differences between T a;
and T a{};
, makes turns any constructor calls from implicit to explicit, and makes all of your variable initializations look uniform, regardless of whether it happens via a constructor call or a function call. You can see an example above: Baz baz = make_baz();
I also like to do this for copy constructors, for the same reason of explicitness:
auto foo1 = Foo();
auto foo2 = Foo(foo1);
Related: write out the type instead of auto
unless it the type is obvious e.g. because it is explicitly specified on the right hand side because it’s a constructor call or a cast – or if the type is really obnoxious to name, such as std::vector<std::unique_ptr<Foo>>::const_iterator
. Anybody who has to review your pull requests will thank you, as will anybody with a malfunctioning/crashing clangd
. (Yes, I have worked on code bases that killed clangd
. Be grateful if you have not.)
A Rust-y aside
One of the things I like about Rust is that it just avoids all of these issues. You always have to explicitly initialize all variables and fields. There are no compiler-generated special member functions. This can make things more verbose sometimes, but removes a huge amount of ambiguity and complexity.