Notes on C++ initialization

After getting back into hands-on programming with C++ after a long break, I reached for the shiny new way of doing aggregate initialization, called designated initializers. In two short weeks of writing code, I managed to shoot myself in the foot due to incorrect assumptions about the ways that initialization work.

I reviewed some of the details of initialization, and developed some guidelines which should hopefully help prevent some footguns in the future.

I shall avoid designated initializers.

Designated initializers are a form of aggregate initialization. As an aside, aggregate initialization is the initialization syntax used for C-like structs, where all members are public, and there are no user-provided constructors or in-class initializers. Here’s what designated initializers look like:

1
T obj{.des1 = arg1, des2 = arg2, ...};

On the surface, the explicit designation of which fields are being initialized looks great. And like aggregate initialization, narrowing conversions are prohibited. However, designated initializers have the following additional rules:

  • Designators must appear in the same order as the members are declared.
  • Designators can be omitted. 💣

Due to that latter rule, it’s possible to accidentally forget to initialize a field if you are not careful. Consider the RenderParameters struct.

1
2
3
4
5
6
7
8
9
struct RenderParameters
{
    Camera camera;
};

// Elsewhere in the codebase. This compiles and everything is fine.
const RenderParameters renderParams{
    .camera = cameraController.getCamera(),
};

Then, a new field is added to RenderParameters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct RenderParameters
{
    Camera       camera;
    Extent2u  framebufferSize;
};

// Elsewhere in the codebase, `renderParams` designated initializer
// list is not updated!
RenderParameters renderParams{
    .camera = cameraController.getCamera(),
};

Extent2u is default constructible, so what happens? The code compiles fine, because fields can be omitted from designated initializers. Whether intentional or not, framebufferSize gets initialized via the default constructor. Usually, I want to ensure that my instances are fully initialized. Choosing regular aggregate initializer list syntax here would have been the correct way to ensure I don’t accidentally leave anything out.

Except…

Designated initializers are useful when you intentionally don’t want to initialize all members of your aggregate class. There are a few limited places where this can happen. For example, I have a number of “plain old data” aggregate classes which are intended to be uploaded to the GPU. They contain dummy members for memory alignment purposes:

1
2
3
4
5
6
7
8
9
struct Triangle
{
    glm::vec3 v0;
    float     pad0;
    glm::vec3 v1;
    float     pad1;
    glm::vec3 v2;
    float     pad2;
};

It’s useful to be able to initialize the triangle as Triangle tri{.v0 = p0, .v1 = p1, .v2 = p2}; That way, even if the memory layout requirement changes, the initializers in the code do not need to be updated. This is desirable and acceptable in this use-case, since these “layout” types are usually local to a translation unit, and the friction of testing a different memory layout is reduced when the initializers can remain untouched.

I shall use parentheses for initializing standard library types, and braces for initializing user-defined types.

Brace-initialization syntax has great benefits:

  • A consistent syntax which can be used to call constructors and perform initialization of aggregate classes.
  • Narrowing conversions are prohibited.

Why not use it everywhere then? The drawback is the fact that if a constructor accepting std::initializer_list is defined, that’s the one that the compiler is going to match against first, even using narrowing conversions to find a match.

std::vector, and many other STL types, have such constructors defined. For instance:

1
2
3
4
5
6
constexpr vector( size_type count,
                  const T& value,
                  const Allocator& alloc = Allocator() );

constexpr vector( std::initializer_list<T> init,
                  const Allocator& alloc = Allocator() );

The fact that narrowing conversions are allowed means that initializing an std::vector containing integers with a count, value pair is dangerous.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <cstdio>

std::size_t operator "" _sz (unsigned long long int x)
{
    return x;
}

int main() {
    std::vector<bool> v1(10_sz, false);
    std::vector<bool> v2{10_sz, false}; // 💣

    std::printf("v1.size(): %zu\n", v1.size());
    std::printf("v2.size(): %zu\n", v2.size());

    return 0;
}

Running the program, we obtain

1
2
v1.size(): 10
v2.size(): 2

v1 was initialized with 10 falses. v2 was initialized with {true, false}, after narrowing 10_sz to a boolean and matching against the list initializer constructor. Thus using parenthesis with STL container constructors to guard against parameters being narrowed is a good idea.

I shall use in-class initializers for types where I want some kind of reasonable “default” value.

It’s very useful for a type to have some kind of usable default. Rust has the Default trait which you usually use to obtain reasonable defaults.

Instead of defining a constructor which just sets the default values in the constructor member initializers, there is a C++ core guideline recommendation to use in-class initializers. This way, the type definition and it’s default values are guaranteed to be in one place.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct FlyCameraController
{
    // A usable camera orientation for many different kinds of scenes.

    glm::vec3 position = glm::vec3(1.22f, 1.25f, -1.25f);
    Angle           yaw = Angle::degrees(129.64f);
    Angle           pitch = Angle::degrees(-13.73f);
    Angle           vfov = Angle::degrees(80.0f);
    float           aperture = 0.8f;
    float           focusDistance = 10.0f;

    // ...
};

In Rust, you can use pattern matching against the default initializer to explicitly initialize only some fields:

1
2
3
4
let config = RendererConfig {
    texture_format: context.surface_config.format,
    ..Default::default()
};

In C++, you can use designated initializers to explicitly override a field which has an in-class initializer, in a similar way to Rust. This also removes the need to provide distinct constructors.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <cstdio>

struct S {
    int f1 = 1;
    int f2 = 2;
    int f3 = 3;
};

int main() {
    const S s {
        .f2 = 4,
    };
    
    // Prints S(1,4, 3)
    std::printf("S(%i, %i, %i)", s.f1, s.f2, s.f3);

    return 0;
}

Note that this doesn’t work if in-class initializers are not used. The following code using a constructor no longer compiles, because the struct is no longer an aggregate type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct S {
    int f1, f2, f3;
    
    // S is not an aggregate because of this constructor
    S() : f1(1), f2(2), f3(3) {}
};

int main() {
    const S s {
        .f2 = 4,
    };

    // ...

Contents