Last Update:
Five tricky topics for data members in C++20
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" };
static_assert(std::is_aggregate_v<decltype(numbers)>);
static_assert(std::is_aggregate_v<decltype(statuses)>);
static_assert(std::is_aggregate_v<decltype(pt)>);
// not an aggregate...
static_assert(!std::is_aggregate_v<decltype(vals)>);
static_assert(!std::is_aggregate_v<decltype(pack)>);
}
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"};
static_assert(std::is_aggregate_v<decltype(hello)>);
}
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 https://godbolt.org/z/8oK1ree7r
We also have some more minor changes like:
- user-declared constructor vs user-defined or explicit,
- inherited constructors
See more at:
- Aggregate initialization - cppreference.com
- What are Aggregates and PODs, and how/why are they special? - Stack Overflow
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';
}
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:
- Initialization in C++ is bonkers - a famous article where it listed eighteen different forms of initialization (as of C++14).
- In Item 7 for Effective Modern C++, Scott Meyers said that “braced initialization is the most widely usable initialization syntax, it prevents narrowing conversions, and it’s immune to C++’s most vexing parse.
- Nicolai Josuttis had an excellent presentation about all corner cases: CppCon 2018: Nicolai Josuttis “The Nightmare of Initialization in C++” - YouTube, and suggests using
{}
- Core Guidelines: C++ Core Guidelines - ES.23: Prefer the
{}
-initializer syntax. Exception: For containers, there is a tradition for using{...}
for a list of elements and(...)
for sizes. Initialization of a variable declared usingauto
with a single value, e.g.,{v}
, had surprising results until C++17. The C++17 rules are somewhat less surprising. - Only abseil / Tip of the Week #88: Initialization: =, (), and {} - prefers the old style. This guideline was updated in 2015, so many things were updated as of C++17 and C++20.
- In Core C++ 2019 :: Timur Doumler :: Initialisation in modern C++ - YouTube - Timur suggests {} for all, but if you want to be sure about the constructor being called then use (). As () performs regular overload resolution.
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';
}
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()
x.count = 0
X(init_list)
y.count = 1
X(init_list)
z.count = 4
X(cnt)
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 ()
.
Summary
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.
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here: