Initialization in C++
C++ initialization taxonomy — value-init, default-init, zero-init, list-init, aggregate-init, and the rules that govern each form.
Initializationsince C++98Initialization gives a newly declared variable its first value; C++ defines at least eight distinct initialization categories whose rules differ by storage duration, type, and syntactic form.
Overview
C++ initialization is governed by a dense ruleset that has grown across standards. The forms are not interchangeable — they differ in which constructors are selected, whether narrowing is permitted, and what value built-in types receive. Understanding the taxonomy prevents subtle bugs like indeterminate values, unexpected overload selection, and the static initialization order fiasco.
Syntax
The six surface syntaxes and the initialization categories they invoke:
T x; // default-init
T x{}; // value-init (C++11 list-init form)
T x{a, b}; // direct-list-init (C++11)
T x(a, b); // direct-init
T x = expr; // copy-init
T x = {a, b}; // copy-list-init (C++11)Examples
Default initialization
A declaration with no initializer. For built-in types at block scope, the value is indeterminate — reading it is undefined behavior. Static and thread-local variables are zero-initialized before anything else.
void f() {
int x; // indeterminate — UB to read
int* p; // indeterminate pointer
std::string s; // "" — default constructor called
int arr[4]; // each element indeterminate
}
static int g; // 0 — zero-initialized at program start
thread_local int t; // 0 — zero-initialized per threadZero initialization
Zero-initialization sets scalars to 0, pointers to nullptr, and recursively zeroes aggregates. It occurs automatically for static/thread-local objects and as the first step of value-initialization for types with no user-provided constructor.
static double d; // 0.0
int arr[8] = {}; // all zeros — C++98 aggregate init
int* p{}; // nullptr (C++11)
unsigned char buf[1024]{}; // zero-filled buffer (C++11)Value initialization (C++11)
T{} or new T{} value-initializes: POD types get zeroed, class types with a user-provided default constructor have it called, class types without one are zero-initialized then default-initialized.
int x{}; // 0
double d{}; // 0.0
int arr[3]{}; // {0, 0, 0}
std::vector<int> v{}; // empty vector, default ctor
struct Pod { int a; double b; };
Pod p{}; // {0, 0.0} — zero-initialized (no user ctor)
int* heap = new int{}; // 0 — value-init
int* raw = new int; // indeterminate — default-initDirect initialization
T(args) selects the best matching constructor via normal overload resolution. No narrowing restriction.
int x(42);
std::string s("hello");
std::vector<int> v(5, 0); // 5 elements, each 0
double d(3.14f); // float → double: fine
int n(3.14); // 3 — silent truncation (no error)Copy initialization
T x = expr — constructs from the right-hand side. For class types, an implicit conversion sequence is applied; explicit constructors are excluded.
int x = 42;
std::string s = "hello"; // implicit const char* → string
double d = 3.14f; // widening OK
// explicit constructors are not considered in copy-init:
struct Wrap { explicit Wrap(int); };
Wrap w = 42; // error: Wrap(int) is explicit
Wrap w2(42); // OK: direct-init uses explicit ctorsSince C++17, copy-elision is guaranteed for prvalues, so T x = T(args) never copies even if the copy constructor is deleted.
List initialization (C++11)
Braced initialization ({}). The key guarantees: narrowing conversions are ill-formed, and std::initializer_list constructors are preferred over other constructors when the type has one.
int x{42};
int bad{3.14}; // error: narrowing conversion
int also_bad{1LL}; // error: narrowing (even if value fits)
// Narrowing check is on the value category, not just the value:
constexpr int k = 42;
int ok{k}; // OK: k is a constant expression that fits
std::vector<int> a{1, 2, 3, 4}; // initializer_list<int> ctorAggregate initialization
Aggregates (arrays, and classes with no user-provided constructors, no private/protected non-static data members, no virtual functions — C++11; restrictions relaxed in C++14 and further in C++17) can be brace-initialized element by element.
struct Point { int x; int y; double z; };
Point p{1, 2, 3.0}; // x=1, y=2, z=3.0
Point q{1}; // x=1, y=0, z=0.0 — trailing zeros
// C++20: designated initializers for aggregates
Point r{.x = 10, .z = 2.5}; // y=0, order must match declaration // C++20Designated initializers in C++20 require that fields appear in declaration order; unlike C, you cannot reorder them.
The Most Vexing Parse
When a declaration looks like a function call, the parser chooses the function interpretation:
std::string s(); // FUNCTION DECLARATION — not an object
std::vector<int> v(); // same problem
// Fixes:
std::string s{}; // value-init (C++11)
auto s = std::string{}; // explicit, clearThe parse applies wherever a type name and () appear without a braced body. It also affects temporaries passed as arguments:
// Parses as a function declaration taking a function pointer:
Widget w(Widget()); // most vexing parse
// Fix:
Widget w{Widget{}}; // unambiguous (C++11)
Widget w((Widget())); // extra parens also work (ugly)initializer_list Constructor Priority
When a class has an initializer_list<T> constructor and you use braces, that constructor is strongly preferred — even if another constructor would be a better match:
std::vector<int> a(5); // 5 elements, value 0 — size constructor
std::vector<int> b{5}; // 1 element: {5} — initializer_list wins!
std::vector<int> c(5, 1); // 5 elements, value 1
std::vector<int> d{5, 1}; // 2 elements: {5, 1}
struct S {
S(int n, double v); // ctor A
S(std::initializer_list<double> il); // ctor B
};
S x{3, 1.5}; // ctor B: initializer_list<double>{3.0, 1.5}
S y(3, 1.5); // ctor A: n=3, v=1.5The only escape from initializer_list priority when using {} is if the arguments cannot be converted to the list's element type at all.
Member Initialization Order
The order of initialization is always declaration order, regardless of the order in the member initializer list. A compiler with -Wreorder (GCC/Clang) flags mismatches.
struct Bad {
int a;
int b;
Bad(int v) : b{v}, a{b + 1} {} // UB: b uninitialized when a is set
};
struct Good {
int a;
int b;
Good(int v) : a{v}, b{a + 1} {} // OK: a initialized first
};In-class member initializers (C++11) serve as defaults that any constructor can override:
struct Config {
int port = 8080; // C++11
std::string host = "localhost"; // C++11
bool tls = false; // C++11
Config() = default; // all defaults
Config(int p) : port{p} {} // host, tls from defaults
Config(std::string h, int p, bool t)
: port{p}, host{std::move(h)}, tls{t} {} // all overridden
};Static Initialization and the Order Fiasco
Objects with static storage duration are initialized in two phases:
- Constant initialization — if the initializer is a constant expression, done at compile time (or link time).
constexprvariables always go through this path. - Dynamic initialization — everything else, before
main(). Order between translation units is unspecified.
// TU 1:
int base = 10; // dynamic-init
int derived = base * 2; // dynamic-init — safe if in same TU
// TU 2 — undefined order relative to TU 1:
extern int base;
int other = base * 3; // UB if TU 2 initializes before TU 1The canonical fix is the construct-on-first-use idiom, which C++11 guarantees is thread-safe:
const Config& get_config() {
static Config cfg{"prod.example.com", 443, true}; // initialized on first call, thread-safe (C++11)
return cfg;
}Best Practices
- Prefer
{}for all initialization — it catches narrowing at compile time and avoids the most vexing parse. - When a class has an
initializer_listconstructor, document it clearly; callers must use()to reach other constructors. - Always list member initializers in declaration order to avoid
-Wreordersurprises. - Use in-class member initializers (C++11) for defaults that most constructors share — reduces duplication and keeps defaults visible at the declaration site.
- Avoid global variables with dynamic initialization; prefer
constexpr(C++11) or the construct-on-first-use idiom. - Use designated initializers (C++20) for aggregates with many fields to make call sites self-documenting.
Common Pitfalls
Reading an uninitialized variable. int x; at block scope is indeterminate. Even if the machine reliably produces zero for a particular build, the optimizer may assume the value is never read before written and eliminate surrounding code.
new T vs new T{}. The former default-initializes (indeterminate for scalars); the latter value-initializes (zero for scalars). Prefer new T{} unless the overhead is measured and matters.
Brace-init selecting initializer_list unexpectedly. Widget{1, 2} may call a completely different constructor than Widget(1, 2) if Widget has an initializer_list<int> constructor.
Static initialization order fiasco. A global that depends on another global in a different TU may see an uninitialized value. Compilers cannot warn about this in general.
C++20 designated initializer ordering. Unlike C99, designated initializers must appear in the same order as the struct declaration. Reordering is an error.
Value initialization vs zero initialization
These two categories overlap but are not identical. Zero initialization always sets bytes to zero; value initialization is a higher-level rule that may or may not invoke a constructor after zeroing.
// Zero initialization — no constructor, bytes become 0
static int a; // zero-init at program start
int b = 0; // copy-init, but scalar = 0
// Value initialization — rule depends on the type // C++11
int x{}; // scalar: zero-init → 0
int* p{}; // pointer: zero-init → nullptr
struct Plain { int n; double d; }; // no user-provided ctor
Plain pl{}; // zero-init → {0, 0.0}
struct WithCtor {
int n;
WithCtor() : n{42} {}
};
WithCtor w{}; // default ctor called (NOT zero-init first for n)For types with a user-provided default constructor, value-init calls that constructor directly — the zero-pass is skipped. For types without one (including all plain aggregates), zero-init runs first, which is why aggregate members are reliably 0/nullptr/0.0 when you write T{}.
Brace initialization and narrowing
Narrowing conversions are ill-formed in any braced initializer. This is the single biggest behavioral difference between {} and ().
int a = 3.9; // OK: silent truncation to 3
int b(3.9); // OK: silent truncation to 3
int c{3.9}; // error: narrowing conversion // C++11
long long big = 1LL << 40;
int d{big}; // error: value may not fit // C++11
// Constant expressions are an exception — the value must fit
constexpr int k = 7;
int e{k}; // OK: k fits in int // C++11
// Floating-point → integer is always narrowing, even 1.0
int f{1.0}; // error: floating-point is narrowing // C++11
int g{1}; // OKThe narrowing check is purely static — if the compiler cannot prove the value fits, it rejects the code. This makes {} the preferred default for all new code: bugs that would silently truncate become compile errors.
Default member initializers (C++11)
Non-static data member initializers (NSDMIs) let you set defaults at the point of declaration. Any constructor that does not explicitly initialize a member uses its NSDMI.
struct Server { // C++11
std::string host = "localhost";
int port = 8080;
bool tls = false;
int retry = 3;
Server() = default; // all four defaults apply
Server(std::string h, int p)
: host{std::move(h)}, port{p} {} // tls and retry use defaults
Server(std::string h, int p, bool t, int r)
: host{std::move(h)}, port{p}, tls{t}, retry{r} {}
};
Server a; // {"localhost", 8080, false, 3}
Server b{"example.com", 443}; // {"example.com", 443, false, 3}NSDMIs can use any expression, including function calls and lambdas, but they cannot refer to other members (the member being initialized is not yet constructed). The NSDMI expression is re-evaluated for each object — it is not shared.
Delegating constructors
A constructor can delegate its entire initialization to another constructor in the same class. This avoids repeating shared setup logic.
struct Connection { // C++11
std::string host;
int port;
int timeout_ms;
bool tls;
// Target constructor — does the real work
Connection(std::string h, int p, int t, bool s)
: host{std::move(h)}, port{p}, timeout_ms{t}, tls{s} {}
// Delegating constructors
Connection(std::string h, int p)
: Connection{std::move(h), p, 5000, false} {}
Connection()
: Connection{"localhost", 8080} {}
};Once a delegating constructor passes control to the target, the object is considered fully constructed — even before the delegating constructor's body runs. If the target constructor throws, the destructor is called; if the delegating constructor's body throws after delegation succeeds, the destructor is also called. A constructor may not both delegate and initialize a member: Connection() : Connection{...}, port{9} is ill-formed.
Constant initialization (constinit C++20)
constinit guarantees that a variable's initialization is constant (compile-time) without making it const. It prevents accidental dynamic initialization of globals that were intended to be zero-cost at startup.
// constexpr: const AND constant-initialized
constexpr int max_threads = 16; // C++11
// constinit: constant-initialized but MUTABLE // C++20
constinit int active_threads = 0;
// active_threads = 4; // OK — it is not const
// constinit on a static local forces compile-time initialization
void worker() {
constinit static int call_count = 0; // C++20
++call_count;
}
// constinit rejects dynamic initialization:
int compute();
constinit int bad = compute(); // error: initializer not constant // C++20constinit is especially useful on global/static variables that are read across translation units: it guarantees the variable is ready before any dynamic initialization runs in any TU, eliminating one class of static-init-order bugs.
Dynamic vs static initialization order
Static initialization is divided into two phases; the second phase (dynamic) is where the order fiasco lives. Knowing which phase a variable falls into determines whether cross-TU dependencies are safe.
// Phase 1: constant initialization — compile/link time, always safe
constexpr double PI = 3.14159265358979; // C++11
constinit int pool_size = 64; // C++20
// Phase 2: dynamic initialization — runtime, before main()
// Order within a TU is top-to-bottom; order ACROSS TUs is unspecified.
// --- translation_unit_a.cpp ---
int base = 100; // dynamic: initialized before main()
// --- translation_unit_b.cpp ---
extern int base;
int derived = base + 1; // UB if TU_b initializes before TU_a
// Safe alternative: construct-on-first-use idiom
int& get_base() {
static int val = 100; // initialized on first call, once, thread-safe (C++11)
return val;
}
int derived_safe = get_base() + 1; // safe: get_base() forces TU_a initThe "fiasco" arises only for dynamic initialization across TUs. Variables in the constant-initialization phase are immune because they are ready before any runtime code executes. Moving global state to constexpr/constinit is the most reliable cure.
NSDMI and aggregate types
Providing a default member initializer does not by itself prevent a class from being an aggregate in C++14 and later. The rules changed across standards, so the version matters.
// C++11: any NSDMI made the class a non-aggregate
struct C11 { int x = 0; }; // NOT an aggregate in C++11
// C11 c{42}; // error in C++11: no aggregate init
// C++14: NSDMIs are allowed in aggregates // C++14
struct C14 { int x = 0; int y = 0; };
C14 a{}; // {0, 0} — aggregate init, NSDMIs as defaults
C14 b{10}; // {10, 0} — x overridden, y from NSDMI // C++14
C14 c{10, 20}; // {10, 20} — both overridden
// C++20: user-declared constructors (even =default) disqualify
struct C20 { // C++20
int x = 0;
C20() = default; // user-declared → no longer an aggregate
};
// C20 d{42}; // error in C++20: aggregate init disallowedThe practical rule: keep aggregates constructor-free. As soon as you add any constructor (including = default in C++20), brace-based aggregate initialization is gone. Designated initializers ({.x = 1}) require an aggregate, so the same constraint applies.