Table of Contents

Working with data members and class design is essential to almost any project in C++. In this article, I gathered five topics that I hope will get you curious about the internals of C++.

1. Changing status of aggregates  

Intuitively a simple class type, or an array should be treated as “aggregate” type. This means that we can initialize it with braces {}:

#include <iostream>
#include <array>
#include <type_traits>
#include <utility>
#include <tuple>

struct Point {
    double x {0.0};
    double y {0.0};

int main() {
    std::array<int, 4> numbers { 1, 2, 3, 4 };    
    std::array statuses { "error", "warning", "ok" };  // CTAD
    Point pt { 100.0, 100.0 };
    std::pair vals { "hello", 10.5f };
    std::tuple pack { 10, true, "important" };

    // not an aggregate...

Run @Compiler Explorer

But what is a simple class type? Over the years, the definition changed a bit in C++.

Currently, as of C++20, we have the following definition:

From latest C++20 draft dcl.init.aggr:

An aggregate is an array or a class with

  • no user-declared or inherited constructors,
  • no private or protected direct non-static data members,
  • no virtual functions and
  • no virtual, private, or protected base classes.

However, for example, until C++14, non-static data member initializers (NSDMI or in-class member init) were prohibited. In C++11, the Point class from the previous example wasn’t an aggregate, but it is since C++14.

C++17 enabled base classes, along with extended brace support. You can now reuse some handy aggregates as your base classes without the need to write constructors:

#include <string>
#include <type_traits>

enum class EventType { Err, Warning, Ok};

struct Event {
    EventType evt;

struct DataEvent : Event {
    std::string msg;

int main() {
    DataEvent hello { EventType::Ok, "hello world"};


Run @Compiler Explorer

If you compile with the std=c++14 flag, you’ll get:

no matching constructor for initialization of 'DataEvent'
    DataEvent hello { EventType::Ok, "hello world"};

Run at

We also have some more minor changes like:

  • user-declared constructor vs user-defined or explicit,
  • inherited constructors

See more at:

2. No parens for direct initialization and NSDMI  

Let’s take a simple class with a default member set to `“empty”:

class DataPacket {
    std::string data_ {"empty"};
    // ... the rest...

What if I want data_ to be initialized with 40 stars *? I can write the long string or use one of the std::string constructors taking a count and a character. Yet, because of a constructor with the std::initializer_list in std::string which takes precedence, you need to use direct initialization with parens to call the correct version::

#include <iostream>

int main() {
    std::string stars(40, '*');     // parens
    std::string moreStars{40, '*'}; // <<
    std::cout << stars << '\n';
    std::cout << moreStars << '\n';

Run @Compiler Explorer

If you run the code, you’ll see:


It’s because {40, '*'} converts 40 into a character ( (using its) ASCI code) and passes those two characters through std::initializer_list to create a string with two characters only. The problem is that direct initialization with parens (parentheses) won’t work inside a class member declaration:

class DataPacket {
    std::string data_ (40, '*'); // syntax error!
    /* rest of the code*/

The code doesn’t compile and to fix this you can rely on copy initialization:

class DataPacket {
    std::string data_ = std::string(40, '*'); // fine
    /* rest of the code*/

This limitation might be related to the fact that the syntax parens might quickly run into the most vexing parse/parsing issues, which might be even worse for class members.

3. No deduction for NSDMI  

You can use auto for static variables:

class Type {
    static inline auto theMeaningOfLife = 42; // int deduced

However, you cannot use it as a class non-static member:

class Type {
    auto myField { 0 };   // error
    auto param { 10.5f }; // error  

The alternative syntax also fails:

class Type {
    auto myField = int { 10 };  

Similarly for CTAD (from C++17). it works fine for static data members of a class:

class Type {
    static inline std::vector ints { 1, 2, 3, 4, 5 }; // deduced vector<int>

However, it does not work as a non-static member:

class Type {
    std::vector ints { 1, 2, 3, 4, 5 }; // syntax error!

Same happens for arrays, the compiler cannot deduce the number of elements nor the type:

struct Wrapper {
    int numbers[] = {1, 2, 3, 4}; // syntax error!
    std::array nums { 0.1f, 0.2f, 0.3f }; // error...

4. List initialization. Is it uniform?  

Since C++11, we have a new way of initialization, called list initialization {}. Sometimes called brace initialization or even uniform initialization.

Is it really uniform?

In most places, you can use it… and with each C++ standard, the rules are less confusing… unless you have an exception.

For example:

int x0 { 78.5f }; // error, narrowing conversion
auto x1 = { 1, 2 }; // decltype(x1) is std::initializer_list<int>
auto x2 = { 1, 2.0 }; // error: cannot deduce element type
auto x3{ 1, 2 }; // error: not a single element (since C++17)
auto x4 = { 3 }; // decltype(x4) is std::initializer_list<int>
auto x5{ 3 }; // decltype(x5) is int (since C++17)

Additionally there’s this famous issue with a vector:

std::vector<int> vec1 { 1, 2 }; // holds two values, 1 and 2
std::vector<int> vec2 ( 1, 2 ); // holds one value, 2

For data members, there’s no auto type deduction nor CTAD, so we have to specify the exact type of a member. I think list initialization is more uniform and less problematic in this case.

Some summary:

In the book about data members, I follow the rule to use {} in most places unless it’s obvious to use () to call some proper constructor.

5. std::initializer_list is greedy  

All containers from the Standard Library have constructors supporting initializer_list. For instance:

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

// map:
map( std::initializer_list<value_type> init,
     const Compare& comp = Compare(),
     const Allocator& alloc = Allocator() );

We can create our own class and similate this behaviour:

#include <iostream>
#include <initializer_list>

struct X {
    X(std::initializer_list<int> list) 
    : count{list.size()} { puts("X(init_list)"); }
    X(size_t cnt) : count{cnt} { puts("X(cnt)"); }
    X() { puts("X()"); }
    size_t count {};

int main() {
    X x;
    std::cout << "x.count = " << x.count << '\n';
    X y { 1 };
    std::cout << "y.count = " << y.count << '\n';
    X z { 1, 2, 3, 4 };
    std::cout << "z.count = " << z.count << '\n';
    X w ( 3 );
    std::cout << "w.count = " << w.count << '\n';

Run @Compiler Explorer

The X class defines three constructors, and one of them takes initializer_list. If we run the program, you’ll see the following output:

x.count = 0
y.count = 1
z.count = 4
w.count = 3

As you can see, writing X x; invokes a default constructor. Similarly, if you write X x{};, the compiler won’t call a constructor with the empty initializer list. But in other cases, the list constructor is “greedy” and will take precedence over the regular constructor taking one argument. To call the exact constructor, you need to use direct initialization with parens ().


In the article, we touched on important topics like aggregates, non-static data member initialization, and a few others. This is definitely not all; for example, C++20 allows using parentheses lists (...) to initialize aggregates, and C++17 added inline variables.

  • Do you use in-class member initialization?
  • Have you got any tricks for handling data members?

Share your opinions in the comments below.