Used only inside constructors to initialize data members before the constructor body executes.
class Foo {
int b;
double c;
public:
Foo(int i, double j) : b(i), c(j) {}
};A reasonable question: why write
Foo(int i, double j) : b(i), c(j) {}instead of just
Foo(int i, double j) { b = i; c = j; }The body version looks simpler — but it's either wrong, slower, or illegal depending on what b and c are. The reason traces back to one fact:
Members are always initialized before the constructor body runs. That phase is not optional. The initializer list is just the syntax for telling the compiler what value to use during that phase.
If you skip the initializer list, the compiler synthesises an empty one — every member gets default‑initialized first, and then your body runs assignments on already‑constructed objects. That gives you five concrete problems.
class Controller {
const double m_kp;
Logger& m_logger;
public:
Controller(double kp, Logger& logger) {
m_kp = kp; // error: assignment of read-only member
m_logger = logger; // error: 'operator=' is implicitly deleted (reference)
}
};const members and references can be set exactly once — at construction. They have no assignment operator. The initializer list is the only legal place to give them a value:
Controller(double kp, Logger& logger) : m_kp(kp), m_logger(logger) {}If a member type has no default constructor, the compiler can't default‑construct it before your body runs:
class Mutex { // no default ctor
public:
explicit Mutex(MutexAttr attr);
};
class RobotArm {
Mutex m_state_mutex;
public:
RobotArm() {
m_state_mutex = Mutex(MutexAttr::priority_inheritance); // error: no default ctor
}
};Same problem with types that have =deleted default ctors, std::reference_wrapper, or any type you've intentionally made non‑default‑constructible. The initializer list is again the only path:
RobotArm() : m_state_mutex(MutexAttr::priority_inheritance) {}For cheap types (int, double, raw pointers) the body version costs the same — assignment is one move instruction. But for any member with a non‑trivial constructor, the body version pays for two operations:
class Map {
std::string m_frame_id;
Eigen::MatrixXd m_occupancy;
std::vector<Obstacle> m_obstacles;
public:
Map(std::string frame, int rows, int cols) {
// What actually happens:
// 1. m_frame_id default-constructed (empty string)
// 2. m_occupancy default-constructed (0×0 matrix)
// 3. m_obstacles default-constructed (empty vector)
// 4. THEN body runs:
m_frame_id = std::move(frame); // assignment, may allocate
m_occupancy = Eigen::MatrixXd(rows, cols); // build temporary, move-assign
m_obstacles.reserve(1024);
}
};vs. the initializer‑list version, which constructs each member directly with the right value the first time:
Map(std::string frame, int rows, int cols)
: m_frame_id(std::move(frame)),
m_occupancy(rows, cols), // built in place, no temporary
m_obstacles{}
{
m_obstacles.reserve(1024);
}The cost difference is small for std::string but real for Eigen::MatrixXd (allocates a heap buffer in the default ctor, then reallocates in operator=), std::vector, std::map, std::function, ROS publishers, mutexes with attributes, file handles — anything that touches the heap or the OS in its constructor.
There is no other syntax for passing arguments to a base‑class constructor:
class Sensor {
public:
Sensor(const std::string& topic, double rate_hz);
};
class Lidar : public Sensor {
public:
Lidar() {
Sensor("/lidar", 10.0); // does NOT call the base ctor —
// creates a temporary Sensor and discards it
}
};That body line compiles but does the wrong thing — it builds a throw‑away temporary. The actual Lidar::Sensor subobject was already default‑constructed earlier (which won't even compile if Sensor has no default ctor). The only correct form:
Lidar() : Sensor("/lidar", 10.0) {}For int, double, raw pointers, etc., the body version has a subtler trap. Between the start of the body and your assignment line, the member holds an indeterminate value. Reading it before writing it is undefined behaviour:
class Counter {
int m_count; // no in-class initializer
public:
Counter(int initial) {
if (m_count < 0) { // UB: reads indeterminate value
m_count = 0;
} else {
m_count = initial;
}
}
};With the initializer list this is impossible — m_count is set before the body runs:
Counter(int initial) : m_count(initial < 0 ? 0 : initial) {}(The same effect is achieved with C++11 in‑class default initializers — int m_count = 0; — which apply regardless of which constructor runs. See section 9.)
| Member kind | Body version | Initializer list |
|---|---|---|
const or reference |
Compile error | Required |
| No default constructor | Compile error | Required |
| Base class arguments | Wrong (creates temporary) | Required |
| Non‑trivial type (string, vector, Eigen…) | Default‑construct + assign | One construction |
Built‑in (int, double, pointer) |
Indeterminate until assigned | Set immediately |
The body should be reserved for things that aren't initialization — invariant checks, logging, registering callbacks, kicking off async work. If a line in the body reads m_x = something;, it almost certainly belongs in the initializer list.
A common misconception is that the initializer list controls order, or that order is unspecified. Both are wrong. The C++ standard guarantees:
Non‑static data members are initialized in the order they are declared in the class body, regardless of the order they appear in the constructor's initializer list. Destruction happens in the reverse order.
The initializer list is how each member is initialized, not when. Writing the list out of declaration order doesn't reorder anything — it just makes the code lie to the reader.
template <typename T>
struct SensorBuffer {
T* m_data; // declared first → initialized first
std::size_t m_capacity; // declared second → initialized second
SensorBuffer(std::size_t capacity)
: m_capacity(capacity),
m_data(new T[m_capacity]) {} // BUG: reads m_capacity before it's set
};The initializer list reads left‑to‑right as written, so a casual reviewer assumes m_capacity is assigned first. It is not. m_data is initialized first, using m_capacity while it still holds an indeterminate value, so new T[<garbage>] either throws bad_alloc, allocates a huge block, or (in release builds with optimisation) silently does the wrong thing. Pure undefined behaviour.
The fix is one of:
// Option A — match declaration order to initializer list order
template <typename T>
struct SensorBuffer {
std::size_t m_capacity;
T* m_data;
SensorBuffer(std::size_t capacity)
: m_capacity(capacity),
m_data(new T[m_capacity]) {}
};
// Option B — depend on the constructor parameter, not the other member
template <typename T>
struct SensorBuffer {
T* m_data;
std::size_t m_capacity;
SensorBuffer(std::size_t capacity)
: m_data(new T[capacity]), // uses the parameter, no ordering trap
m_capacity(capacity) {}
};Option B is usually safer in practice — it removes the cross‑member dependency entirely.
Both gcc and clang emit a warning when the initializer list order disagrees with declaration order:
warning: field 'm_data' will be initialized after field 'm_capacity' [-Wreorder-ctor]
-Wreorder-ctor is part of -Wall. Treat it as an error (-Werror=reorder-ctor); it has effectively zero false positives and catches exactly this class of bug.
Two reasons. First, destruction must be the exact reverse of construction, and there's only one declaration order to reverse — so there must be only one construction order. Second, a class can have multiple constructors with different initializer lists; the destructor is shared, so it can't depend on which constructor ran.
- Virtual bases first (left‑to‑right in the inheritance graph).
- Then non‑virtual direct bases, in the order listed after
class X :. - Then non‑static data members, in declaration order.
- Then the constructor body runs.
This is also why a base‑class constructor cannot see derived‑class state — the derived members haven't been constructed yet, and a virtual call from the base constructor dispatches to the base version, not the override.
- Match the initializer list to declaration order. The code should read the way it executes.
- Don't read one member while initializing another unless the dependency is obvious from declaration order. Prefer constructor parameters.
- Enable
-Wreorder-ctoras an error. It's already on with-Wall; promote it. - Group declarations by initialization dependency, not by type or alignment. If
m_dataneedsm_capacity, declarem_capacityfirst. - For
const/reference members, the initializer list is the only option — direct assignment in the body won't compile.
Aggregate initialization uses brace‑enclosed lists to initialize aggregates:
An aggregate is a type with:
- no user‑declared constructors
- no private or protected non‑static data members
- no virtual functions
- no base classes
struct S {
int i;
};
S s{10};This is aggregate initialization.
Conceptually equivalent to:
S s;
s.i = 10;but performed during initialization, not assignment.
S s = {10};Still aggregate initialization.
S s{3.14}; // compile‑time errorInitializes an object from another object or expression using =.
int a = 5;
int b = a;Triggers copy or conversion constructors when applicable.
Also occurs when:
void f(Foo x);
f(obj); // copy or move
Foo make();
Foo x = make(); // copy elided or movedInitializes an object directly, usually via a constructor.
Foo f(10);
std::string s("abc");
int x(5);Occurs when no initializer is provided.
int x; // indeterminate value
Foo f; // calls default constructor (if any)
new int; // indeterminateFor built‑in types: no initialization.
Forces zero‑initialization for fundamental types.
int x{}; // zero
int y = int(); // zero
Foo f{}; // default constructorCommon interview pitfall: T() ≠ default initialization.
Automatically applied in specific contexts:
- static storage duration
- thread‑local storage
static int x; // zeroAlso part of value initialization.
General brace‑based syntax applicable to everything.
int a{42};
std::vector<int> v{1,2,3};
MyClass m{1, 3.14};Benefits:
- prevents narrowing
- uniform syntax
- works for aggregates and constructors
Provides default values at declaration site.
class MyClass {
int x = 10;
double y{3.14};
};Used if constructor does not override them.
std::initializer_list enables custom brace handling.
class IntArray {
int size;
int* data;
public:
IntArray(std::initializer_list<int> list)
: size(list.size()), data(new int[size]) {
int i = 0;
for (int v : list) data[i++] = v;
}
};
IntArray a{1,2,3,4};This is list initialization via constructor, not aggregate initialization.
| Syntax | Meaning |
|---|---|
T x; |
default initialization |
T x{}; |
value initialization |
T x = y; |
copy initialization |
T x(y); |
direct initialization |
T x{y}; |
list initialization (aggregate or ctor) |
S s{10};is called:
- brace initialization
- specifically aggregate initialization
when S is an aggregate.
References:
- cppreference — initialization
- modernescpp.com
- C++ Core Guidelines