C++ Value Initialization: What Empty () and {} Do for Scalars and Classes

Key takeaways

Value initialization uses empty () or {}. Scalars become zero-like; classes call the default constructor. Differs from default initialization for locals; compares with zero initialization.

The Initialization Problem

C++ has multiple initialization rules, and the wrong one leads to undefined behavior from reading indeterminate values. Understanding when you get a guaranteed zero vs garbage is essential.

int a;    // default initialization — indeterminate value (garbage)!
int b{};  // value initialization — guaranteed 0
int c = 0; // copy initialization — 0

// Reading a is undefined behavior if it was never assigned
std::cout << a;  // could print anything, crash, or worse
std::cout << b;  // always prints 0

Value initialization is the mechanism that gives you a safe, predictable starting value.

Why does C++ leave int a; uninitialized at all? Because zeroing costs something, and C++ inherited C’s rule of not paying for what you don’t ask for: a local buffer that is about to be filled by read() doesn’t need to be cleared first. The price is that “forgot to initialize” is not a compile error. Compilers warn when they can prove the problem — GCC and Clang with -Wuninitialized / -Wmaybe-uninitialized (enabled by -Wall with optimization), MSVC with warning C4700 “uninitialized local variable ‘a’ used” — but they cannot see through every branch or pointer. In practice the symptom is a program that behaves differently between debug and release builds, or starts failing after an unrelated change moves things around on the stack, because the “garbage” is simply whatever an earlier function left there. Toolchains now offer mitigations such as -ftrivial-auto-var-init=zero in GCC and Clang, and C++26 reclassifies reading an uninitialized local as “erroneous behavior” rather than full undefined behavior, but neither replaces writing {}.


When Value Initialization Happens

Value initialization is triggered by empty parentheses or empty braces:

// Variable declaration with empty braces
int x{};          // value init → 0
double d{};       // value init → 0.0
int* ptr{};       // value init → nullptr
bool flag{};      // value init → false

// Temporary (prvalue) with empty parens or braces
int temp = int(); // value init → 0
int temp2 = int{}; // value init → 0 (same result)

// new expression with empty parens
int* heap = new int();   // value init → 0
int* heap2 = new int{};  // value init → 0

// Arrays
int arr[5]{};   // all elements value-initialized → all zero
int* dynArr = new int[5]();  // all zero

// Class member with default member initializer using {}
class Counters {
    int hits{};        // value-initialized when Counters is constructed
    int misses{};      // value-initialized
    double ratio{};    // 0.0
public:
    Counters() = default;  // uses the member initializers above
};

Strictly, the member initializers in Counters are not value initialization of the object — hits{} is list-initialization of that member from an empty list, which for an int has the same effect. The distinction matters only in one sense: those initializers run for every way of constructing Counters, including Counters c; without braces, which is exactly why they are the most robust tool on this list. Note also that int arr[5]{} and new int[5]() zero the whole array, while int arr[5]{1} sets the first element to 1 and value-initializes the remaining four to 0 — a well-known idiom, and a well-known misreading when people expect int arr[5]{-1} to fill the array with -1 (it gives {-1, 0, 0, 0, 0}).


Value Initialization Rules by Type

The behavior depends on the type:

TypeValue initialization result
Scalar (int, double, pointer, bool)Zero (0, 0.0, nullptr, false)
ArrayEach element is value-initialized
Class with user-provided constructorDefault constructor is called
Aggregate with {}Aggregate initialization: every member is initialized from an empty list, so scalars become zero
Class with no user-provided constructorZero-initialized first, then the implicit default constructor runs
// Scalar
int n{};        // 0
double d{};     // 0.0
char* p{};      // nullptr
bool b{};       // false

// Aggregate
struct Point { int x, y; };
Point pt{};     // x=0, y=0 — both zero-initialized

// Class with constructor
class Widget {
    int id_;
    std::string name_;
public:
    Widget() : id_(0), name_("default") {}
};
Widget w{};  // default constructor called: id=0, name="default"

// Array
int arr[4]{};  // {0, 0, 0, 0}

The row that surprises people is “class with a user-provided constructor”. Value initialization does not zero such an object first — it just calls the constructor. If Widget() had been written as Widget() : name_("default") {}, then Widget w{}; would leave id_ indeterminate, even though the braces look like they promise zeros. The zeroing step only happens when the compiler supplies (or you = default on first declaration) the default constructor, as the FAQ below explains. That rule makes the result of T{} depend on a detail of T’s definition that a caller cannot see, which is the strongest argument for initializing every member where it is declared.

Point pt{} looks like value initialization but is technically aggregate initialization with an empty list; since C++11 the effect is the same — all members zeroed — so you rarely need to care. It does matter for aggregates with members that have default member initializers (those values are used) and for reference members (an aggregate with a reference member cannot be initialized from {}).


Default Initialization vs Value Initialization

This is the most practically important distinction:

// Local variables — default initialization
int local;            // indeterminate — do NOT read without assigning
double ratio;         // indeterminate
int* ptr;             // indeterminate (not nullptr!)

// Local variables — value initialization
int safeLocal{};      // 0
double safeRatio{};   // 0.0
int* safePtr{};       // nullptr

// Static/global variables — always default-initialized to zero
static int count;     // 0 — static storage is zero-initialized
int globalCount;      // 0 — same

The rule to remember: for local scalar variables, T x; is indeterminate; T x{}; is zero.

The difference comes from storage duration, not syntax. Objects with static storage duration (globals, static locals, static class members, and thread_local variables) are zero-initialized before anything else happens — the memory comes from the zero-filled .bss section of the executable — and only then does their declared initialization run. Automatic (stack) and dynamic (heap via new) objects get no such step. This is also why a bug caused by a missing initializer can vanish when you move a variable to global scope while debugging, which is a misleading “fix”.

void process() {
    int counter;      // WARNING: indeterminate
    counter++;        // undefined behavior — reading uninitialized
    
    int safeCounter{};  // 0
    safeCounter++;      // OK — well-defined: 1
}

{} vs () for Value Initialization

Both T{} and T() trigger value initialization, but they differ in two ways:

Narrowing Conversions

double pi = 3.14159;

int a(pi);   // OK — narrowing truncates: a = 3 (compiles with warning)
int b{pi};   // Error — narrowing conversion rejected at compile time

{} catches narrowing at compile time, which prevents accidental data loss. (Whether int a(pi); warns depends on flags; GCC and Clang only warn with -Wconversion or similar.) The check applies to non-constant sources: int b{3.0} is still an error, but char c{65} compiles because the constant 65 fits in a char. The error message reads along the lines of narrowing conversion of 'pi' from 'double' to 'int' [-Wnarrowing]; GCC reports it as an error for braces in C++11 and later.

Most Vexing Parse

struct Widget { Widget() {} };

Widget w1();   // PROBLEM: this is a function declaration, not a variable!
               // "w1 is a function that takes no args and returns Widget"

Widget w2{};   // Correct: value-initialized Widget object
Widget w3;     // Also OK: default-initialized Widget object (same here, constructor called)

The Most Vexing Parse is a notorious C++ ambiguity. Using {} eliminates it.

The rule behind it is “anything that can be parsed as a declaration is a declaration”. Widget w1(); compiles without complaint, and the error only appears when you use w1 as an object — request for member 'foo' in 'w1', which is of non-class type 'Widget()' — which sends people looking in the wrong place. Clang even has a dedicated warning, “empty parentheses interpreted as a function declaration”, with a fix-it suggesting braces. The temporary form int() or Widget() in an expression is not affected; only declarations are.

initializer_list Ambiguity

std::vector<int> v1(5, 0);   // 5 elements, all zero: {0,0,0,0,0}
std::vector<int> v2{5, 0};   // 2 elements: {5, 0}

When a class has an initializer_list constructor, {} prefers it. For std::vector, {5, 0} means a vector with elements 5 and 0, not 5 zeros. Use () when you want the non-initializer_list constructor.

Empty braces are the exception to that preference: std::vector<int> v{}; calls the default constructor (an empty vector), not the initializer_list constructor with an empty list. This is the rule that keeps “prefer {}” safe for value initialization specifically. The ambiguity only appears once you put arguments inside the braces, so a pragmatic style is: {} for “give me a default/zero value”, () for “call the constructor with these arguments” on container-like types, and {...} with values when you mean a list of elements.


new T() vs new T

For heap allocations, the distinction between value and default initialization matters:

// Default initialization — value is indeterminate for scalars
int* p1 = new int;    // *p1 is indeterminate
int* p2 = new int[5]; // all 5 elements indeterminate

// Value initialization — scalars become zero
int* p3 = new int();     // *p3 == 0
int* p4 = new int{};     // *p4 == 0 (same)
int* p5 = new int[5]();  // all zero
int* p6 = new int[5]{};  // all zero

// For class types with user constructors — same either way
Widget* w1 = new Widget;   // default constructor called
Widget* w2 = new Widget(); // default constructor called

Use new T() or new T{} consistently when you want zero-initialized memory, even if you’ll overwrite it immediately — it avoids accidentally reading indeterminate values.

In modern C++, prefer smart pointers:

auto p = std::make_unique<int>();    // *p == 0
auto arr = std::make_unique<int[]>(5); // all 5 elements zero

std::make_unique always value-initializes, which is the safe default. When you are about to overwrite a large buffer anyway — reading a file into it, for instance — the zeroing is wasted work, and C++20 added std::make_unique_for_overwrite<int[]>(n) (and make_shared_for_overwrite) to request default initialization explicitly. The name is deliberate: it documents at the call site that the contents are garbage until written. Be careful with the new T / new T() distinction in templates, too: new T in generic code is fine for class types but leaves scalars indeterminate, so generic code that needs a known value should use new T() or T{}.


Value Initialization in Containers

When standard containers create new elements, they value-initialize them:

#include <vector>
#include <iostream>

int main() {
    // resize adds value-initialized elements
    std::vector<int> v;
    v.resize(5);
    for (int x : v) std::cout << x << ' ';  // 0 0 0 0 0
    std::cout << '\n';

    // Constructor with count — also value-initializes
    std::vector<double> d(3);  // {0.0, 0.0, 0.0}

    // push_back copies the value you pass; emplace_back() with no args value-initializes
    v.push_back(42);  // adds 42
}

Containers value-initialize because they cannot know what a “meaningful” element is, and leaving elements indeterminate would make std::vector<int>(n) a trap. The cost shows up in performance-sensitive code: std::vector<char> buf(1 << 20) writes a megabyte of zeros before you read data into it. Profilers occasionally surface this as memset near the top of the profile. Common workarounds are to reserve() and then append, to use a std::unique_ptr<char[]> from make_unique_for_overwrite, or (in specialized code) a custom allocator whose construct default-initializes. For most code the zeroing is worth its cost, because it removes an entire class of bugs.


Member Default Member Initializers

C++11 lets you provide default values for members at the declaration site. These are used during value initialization:

class Connection {
    int fd_ = -1;            // default member initializer
    bool connected_ = false;
    std::string host_;       // default-constructed (empty string)
    int retries_ = 3;

public:
    Connection() = default;  // uses all default member initializers
    explicit Connection(std::string host, int fd)
        : fd_(fd), host_(std::move(host)), connected_(true) {}
};

Connection c1;           // fd=-1, connected=false, host="", retries=3
Connection c2("db", 5);  // fd=5, connected=true, host="db", retries=3

Default member initializers with {} (or = value) are the modern way to ensure members are always initialized to a known state.

When a constructor’s member initializer list mentions a member, that entry replaces the default member initializer for that constructor only; members it does not mention (retries_ here) still get their defaults. One detail in Connection is a common source of warnings: members are always initialized in declaration order, not in the order written in the initializer list. The list writes fd_, host_, connected_, but the declarations are fd_, connected_, host_, retries_, so GCC and Clang warn with -Wreorder (“‘Connection::host_’ will be initialized after ‘Connection::connected_’”). Here it is harmless, but if one member’s initializer used another member, the order would decide whether it read an initialized value or garbage. Keep initializer lists in declaration order.

In my experience, adopting default member initializers for every scalar member is the single change that eliminates most uninitialized-member bugs in older codebases. It makes every constructor — including ones added years later by someone who forgot a field — start from a known state, and code reviews stop needing to check each constructor’s initializer list for completeness.


Comparison Table

FormLocal intLocal classComment
int x;IndeterminateDefault ctorUnsafe for scalars
int x{};0Default ctorSafe, preferred
int x = 0;0Copy initClear intent for scalars
int x(0);0Matching ctorOK but avoid MVP with no args
new int;IndeterminateDefault ctorUnsafe for scalars
new int();0Default ctorSafe

Frequently Asked Questions (FAQ)

Q. Why does T x{} zero my members with = default but not with an empty constructor T() {}?

A. A constructor defaulted on its first declaration is not user-provided, so value initialization zero-initializes the object first and then runs the default constructor. T() {} is user-provided, so value initialization just calls it, and members without default member initializers stay indeterminate. Defining the constructor out of line as T::T() = default; also counts as user-provided, which surprises people; default member initializers make the result independent of these rules.