C++ Module 2 – Types, values and expressions
Variables and initialisation: =, () and {}
Declare C++ variables with =, () and {}, see why braces reject narrowing conversions, and stop reading variables that never got a value.
What you will learn
- Declare and initialise variables in the three common forms
- Explain why brace initialisation rejects narrowing
- Avoid reading variables that were never initialised
Before you start
What you’ll learn · Before you start
What you will learn
- Declare and initialise variables in the three common forms
- Explain why brace initialisation rejects narrowing
- Avoid reading variables that were never initialised
Before you start
On this page
A variable in C++ is a named object of a fixed type, and its declaration can give it a first value in three ways:
int a = 1;, int b(2); and int c{3};. For a plain number all three store the value, so the choice looks like a
matter of taste. The differences show at the edges. Braces refuse a conversion that would lose information, so
int d{2.5}; does not compile, while int e = 2.5; compiles and stores 2. Empty parentheses, as in std::string s();,
declare a function instead of a string. And a local variable declared with no initialiser, int n;, starts with no
defined value at all: reading it is undefined behaviour in C++23. This lesson runs each case through two compilers,
Apple clang 21 and GCC 16, and ends with one habit that avoids all of them: give every variable its value where you
declare it, and use braces when you want the compiler to check the conversion.
Three ways to write the first value
A declaration names a type, then the variable, then its initialiser, the expression that gives the first value. This program uses every form:
#include <print>
#include <string>
int main() {
int apples = 4; // copy-initialisation: = and a value
int pears(7); // direct-initialisation: the value in parentheses
int plums{9}; // list-initialisation: the value in braces
int figs = {2}; // copy-list-initialisation: = and braces
double price{12.5};
std::string city("Pune");
std::string fruit{"mango"};
std::println("{} {} {} {}", apples, pears, plums, figs);
std::println("{} {} {}", price, city, fruit);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra three_forms.cpp -o three_forms
./three_forms
g++-16 -std=c++23 -Wall -Wextra three_forms.cpp -o three_forms
./three_forms
""") Output
$ clang++ -std=c++23 -Wall -Wextra three_forms.cpp -o three_forms $ ./three_forms 4 7 9 2 12.5 Pune mango $ g++-16 -std=c++23 -Wall -Wextra three_forms.cpp -o three_forms $ ./three_forms 4 7 9 2 12.5 Pune mango
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_forms_session.py
Each session on this page shows the commands as you would type them in a terminal, followed by what they printed.
Here both compilers build the program without a single warning, even with -Wall -Wextra switched on, and it prints
the same two lines. The standard names the forms after their syntax:
| Written as | Name in the standard |
|---|---|
int apples = 4; |
copy-initialisation |
int pears(7); |
direct-initialisation |
int plums{9}; |
direct-list-initialisation |
int figs = {2}; |
copy-list-initialisation |
“Copy” here describes the syntax, not the work done: int apples = 4; copies nothing, it stores 4. For a class type
the forms can choose different constructors, which the last sections of this lesson show. The two forms with braces,
together called list-initialisation, add one check that the others lack, and that check is the main reason style
guides recommend them.
Braces reject narrowing conversions
A narrowing conversion is one that can lose information. The standard lists them: a floating-point value to an
integer type; a double to a float; an integer to a floating-point type; and an integer to an integer type that
cannot hold all of its values, such as long long to int or int to unsigned. Copy-initialisation and
direct-initialisation perform all of these without complaint. List-initialisation makes the program ill-formed:
#include <print>
int main() {
double price = 2.5;
int a = price; // copy-initialisation converts silently: the .5 is dropped
int b(price); // direct-initialisation does the same
int c{price}; // braces: a narrowing conversion, which the language forbids
std::println("a = {}, b = {}, c = {}", a, b, c);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra narrowing.cpp -o narrowing
g++-16 -std=c++23 -Wall -Wextra narrowing.cpp -o narrowing
./narrowing
g++-16 -std=c++23 -Wall -Wextra -pedantic-errors narrowing.cpp -o narrowing
""") Output
$ clang++ -std=c++23 -Wall -Wextra narrowing.cpp -o narrowing
narrowing.cpp:7:11: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
7 | int c{price}; // braces: a narrowing conversion, which the language forbids
| ^~~~~
narrowing.cpp:7:11: note: insert an explicit cast to silence this issue
7 | int c{price}; // braces: a narrowing conversion, which the language forbids
| ^~~~~
| static_cast<int>( )
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra narrowing.cpp -o narrowing
narrowing.cpp: In function 'int main()':
narrowing.cpp:7:11: warning: narrowing conversion of 'price' from 'double' to 'int' [-Wnarrowing]
7 | int c{price}; // braces: a narrowing conversion, which the language forbids
| ^~~~~
$ ./narrowing
a = 2, b = 2, c = 2
$ g++-16 -std=c++23 -Wall -Wextra -pedantic-errors narrowing.cpp -o narrowing
narrowing.cpp: In function 'int main()':
narrowing.cpp:7:11: error: narrowing conversion of 'price' from 'double' to 'int' [-Wnarrowing]
7 | int c{price}; // braces: a narrowing conversion, which the language forbids
| ^~~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 narrowing_session.py
Apple clang refuses to build the file. It points at the braces in line 7 and suggests the fix itself: write
static_cast<int>(price) if a conversion is what you mean. GCC only warns, builds the program, and the program
prints a = 2, b = 2, c = 2. That is not a gap in the rule. The standard says the program is ill-formed, and GCC’s
manual describes its own choice: narrowing from a constant is an error, while narrowing from a value that is not a
constant only produces a warning, so that older code keeps building. With -pedantic-errors, the last command,
GCC refuses as well. Notice also what neither compiler said: lines 5 and 6 drop the .5 with no warning at all.
Only -Wconversion, which neither -Wall nor -Wextra turns on, reports them.
Which conversions count as narrowing is decided by the types, not by whether the value happens to fit. This program
asks the compiler about pairs of types, using a requires expression that is true when To{value} compiles for a
value of type From that the compiler cannot know in advance:
#include <print>
// braces_accept<To, From> is true when To{value} compiles for a value of type From
// that is not a constant: the compiler cannot see what the value will be.
template <class To, class From>
constexpr bool braces_accept = requires(From value) { To{value}; };
constexpr const char* verdict(bool ok) { return ok ? "compiles" : "rejected: narrowing"; }
int main() {
std::println("long long{{int}} {}", verdict(braces_accept<long long, int>));
std::println("double{{float}} {}", verdict(braces_accept<double, float>));
std::println("int{{double}} {}", verdict(braces_accept<int, double>));
std::println("float{{double}} {}", verdict(braces_accept<float, double>));
std::println("double{{int}} {}", verdict(braces_accept<double, int>));
std::println("int{{long long}} {}", verdict(braces_accept<int, long long>));
std::println("unsigned{{int}} {}", verdict(braces_accept<unsigned, int>));
std::println("char{{int}} {}", verdict(braces_accept<char, int>));
std::println("bool{{int}} {}", verdict(braces_accept<bool, int>));
// Constants are checked by value: these fit, so they compile.
char letter{65};
float ratio{0.1};
double whole{7};
std::println("char{{65}} = {}, float{{0.1}} = {}, double{{7}} = {}", letter, ratio, whole);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra narrowing_rules.cpp -o narrowing_rules
./narrowing_rules
g++-16 -std=c++23 -Wall -Wextra narrowing_rules.cpp -o narrowing_rules
./narrowing_rules
""") Output
$ clang++ -std=c++23 -Wall -Wextra narrowing_rules.cpp -o narrowing_rules
$ ./narrowing_rules
long long{int} compiles
double{float} compiles
int{double} rejected: narrowing
float{double} rejected: narrowing
double{int} rejected: narrowing
int{long long} rejected: narrowing
unsigned{int} rejected: narrowing
char{int} rejected: narrowing
bool{int} rejected: narrowing
char{65} = A, float{0.1} = 0.1, double{7} = 7
$ g++-16 -std=c++23 -Wall -Wextra narrowing_rules.cpp -o narrowing_rules
$ ./narrowing_rules
long long{int} compiles
double{float} compiles
int{double} rejected: narrowing
float{double} rejected: narrowing
double{int} rejected: narrowing
int{long long} rejected: narrowing
unsigned{int} rejected: narrowing
char{int} rejected: narrowing
bool{int} rejected: narrowing
char{65} = A, float{0.1} = 0.1, double{7} = 7
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 narrowing_rules_session.py
The two compilers agree on every line; inside a requires expression GCC treats narrowing as an error too. Widening
conversions such as long long{int} and double{float} are fine. double{int} is rejected although every 32-bit
int fits exactly in a double: the rule says an integer type to a floating-point type is narrowing, whatever the
sizes. Constants are different, because the compiler can see the value. char{65}, float{0.1} and double{7}
compile, since each value fits its target, while char{300}, unsigned{-1} and int{2.0} do not; a
floating-point value to an integer type is narrowing even when it is a whole number.
When you do want a lossy conversion, say so. static_cast<int>(price) truncates towards zero, and std::lround(price)
from <cmath> rounds to the nearest whole number, halfway cases away from zero, so 2.5 becomes 3. Either one tells
the next reader that losing the fraction was the plan.
A variable without an initialiser
What value int n starts with
Text description of the diagram
The diagram has four rows. Each row shows a way to declare int n on the left and, after an arrow, the value n starts with on the right.
- int n = 5;, int n(5); or int n{5}; start as 5. The braces form also checks the conversion for narrowing.
- int n{}; starts as 0.
- int n; inside a function has an indeterminate value: reading it is undefined behaviour, and erroneous behaviour in C++26.
- int n; outside any function, or static int n;, starts as 0, before main() runs.
When a declaration has no initialiser, the variable is default-initialised. For a class type that runs its
default constructor: std::string city; is an empty string. For int, double, a pointer, or a struct made only of
such members, default-initialisation does nothing, and a variable declared inside a function holds an indeterminate
value: whatever bits that memory held before. Reading an indeterminate value is undefined behaviour in C++23. The
compiler may assume that a correct program never does it, so the symptom can change with the compiler, the
optimisation level or an unrelated edit nearby. This function forgets to start its total at zero:
#include <print>
int total_of(const int* prices, int count) {
int total; // no initialiser: the bug
for (int i = 0; i < count; ++i) {
total += prices[i];
}
return total;
}
int main() {
const int prices[] = {120, 80, 45};
std::println("total: {}", total_of(prices, 3));
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -c uninitialised.cpp
g++-16 -std=c++23 -Wall -Wextra -c uninitialised.cpp
g++-16 -std=c++23 -Wall -Wextra -O2 -c uninitialised.cpp
g++-16 -std=c++26 -Wall -Wextra uninitialised.cpp -o uninitialised
./uninitialised
""") Output
$ clang++ -std=c++23 -Wall -Wextra -c uninitialised.cpp
uninitialised.cpp:6:9: warning: variable 'total' is uninitialized when used here [-Wuninitialized]
6 | total += prices[i];
| ^~~~~
uninitialised.cpp:4:14: note: initialize the variable 'total' to silence this warning
4 | int total; // no initialiser: the bug
| ^
| = 0
1 warning generated.
$ g++-16 -std=c++23 -Wall -Wextra -c uninitialised.cpp
$ g++-16 -std=c++23 -Wall -Wextra -O2 -c uninitialised.cpp
uninitialised.cpp: In function 'int total_of(const int*, int)':
uninitialised.cpp:6:15: warning: 'total' may be used uninitialized [-Wmaybe-uninitialized]
6 | total += prices[i];
| ~~~~~~^~~~~~~~~~~~
uninitialised.cpp:4:9: note: 'total' was declared here
4 | int total; // no initialiser: the bug
| ^~~~~
In function 'int total_of(const int*, int)',
inlined from 'int main()' at uninitialised.cpp:13:39:
uninitialised.cpp:6:15: warning: 'total' is used uninitialized [-Wuninitialized]
6 | total += prices[i];
| ~~~~~~^~~~~~~~~~~~
uninitialised.cpp: In function 'int main()':
uninitialised.cpp:4:9: note: 'total' was declared here
4 | int total; // no initialiser: the bug
| ^~~~~
$ g++-16 -std=c++26 -Wall -Wextra uninitialised.cpp -o uninitialised
$ ./uninitialised
total: 245
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 uninitialised_session.py
The session shows three different answers to one bug:
- Apple clang warns as soon as it compiles the file, without any optimisation, and even suggests
= 0. - GCC says nothing at the default level,
-O0. Its uninitialised-variable warnings come from analysis done while optimising, and its manual notes that which variables are flagged depends on the optimisation options. With-O2the same file produces two warnings. Building with optimisation now and then catches warnings like these. - GCC 16 in C++26 mode prints
total: 245, the right answer, by luck.
C++26 changes the rules for this mistake. Proposal P2795R5 makes reading an uninitialised local variable
erroneous behaviour instead of undefined behaviour: the variable holds a value that the implementation chooses,
the program goes on in a predictable way, and the compiler is encouraged to diagnose the read. The code is still a
bug, and the standard says so. GCC 16 implements it: in C++26 mode it fills such variables with zeros unless
-ftrivial-auto-var-init says otherwise, and the attribute [[indeterminate]] on a variable opts out where speed
matters. Apple clang 21 does not implement erroneous behaviour yet, and neither does upstream Clang, whose status page
lists P2795R5 as not supported.
Two related facts complete the picture. A variable declared outside any function, or declared static, is
zero-initialised before main starts, so int counter; at namespace scope is 0; the rule follows where the variable
lives, and relying on it hides intent. And at run time, Clang’s MemorySanitizer (-fsanitize=memory) detects reads of
uninitialised memory, but it runs only on Linux, NetBSD and FreeBSD, not on macOS. The reliable fix is the
initialiser: int total{}; or int total = 0;.
Empty parentheses declare a function
#include <print>
#include <string>
int main() {
std::string name(); // meant as an empty string: declares a function instead
std::string title{}; // an empty string
std::string city; // also an empty string: std::string has a default constructor
std::println("title: [{}], city: [{}]", title, city);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra vexing_parse.cpp -o vexing_parse
./vexing_parse
g++-16 -std=c++23 -Wall -Wextra vexing_parse.cpp -o vexing_parse
./vexing_parse
""") Output
$ clang++ -std=c++23 -Wall -Wextra vexing_parse.cpp -o vexing_parse
vexing_parse.cpp:5:21: warning: empty parentheses interpreted as a function declaration [-Wvexing-parse]
5 | std::string name(); // meant as an empty string: declares a function instead
| ^~
vexing_parse.cpp:5:21: note: remove parentheses to declare a variable
5 | std::string name(); // meant as an empty string: declares a function instead
| ^~
1 warning generated.
$ ./vexing_parse
title: [], city: []
$ g++-16 -std=c++23 -Wall -Wextra vexing_parse.cpp -o vexing_parse
vexing_parse.cpp: In function 'int main()':
vexing_parse.cpp:5:21: warning: empty parentheses were disambiguated as a function declaration [-Wvexing-parse]
5 | std::string name(); // meant as an empty string: declares a function instead
| ^~
vexing_parse.cpp:5:21: note: remove parentheses to default-initialize a variable
5 | std::string name(); // meant as an empty string: declares a function instead
| ^~
| --
$ ./vexing_parse
title: [], city: []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vexing_parse_session.py
std::string name(); reads like “a string called name, made with no arguments”, but C++ treats anything that can be a
declaration as one, and this line can declare a function called name that takes no arguments and returns a
std::string. The rule is nicknamed the most vexing parse. Both compilers warn about it by default, and the moment
you use name as a string you get an error that seems to have nothing to do with the line that caused it. Write
std::string name; or std::string name{}; instead. A pair of braces can never be read as a parameter list, which
is the second reason braces are recommended.
Braces with auto, and with constructors
auto gives a variable the type of its initialiser, and braces interact with it in a way worth knowing. Since C++17,
which adopted proposal N3922, auto b{5}; makes an int. But auto c = {5};, with the equals sign, still makes a
std::initializer_list<int>, a list holding one value, and auto e{1, 2}; does not compile. The usual style is
therefore auto count = 5;.
Braces also prefer a constructor that takes a std::initializer_list whenever the class has one, and the standard
containers do. For a std::vector, the parentheses in (3, 7) mean “three elements, each 7”, while the braces in
{3, 7} mean “the elements 3 and 7”:
auto_braces.cpp
#include <initializer_list>
#include <print>
#include <type_traits>
int main() {
auto a = 5; // int
auto b{5}; // int as well (since C++17)
auto c = {5}; // std::initializer_list<int>: a list holding one int
auto d = {1, 2, 3}; // std::initializer_list<int> with three values
static_assert(std::is_same_v<decltype(a), int>);
static_assert(std::is_same_v<decltype(b), int>);
static_assert(std::is_same_v<decltype(c), std::initializer_list<int>>);
std::println("a = {}, b = {}, c holds {} value, d holds {} values", a, b, c.size(), d.size());
} vector_forms.cpp
#include <print>
#include <vector>
int main() {
std::vector<int> a(3, 7); // parentheses: 3 elements, each 7
std::vector<int> b{3, 7}; // braces: the list 3, 7
std::vector<int> c(3); // 3 elements, each 0
std::vector<int> d{3}; // the list 3
std::println("a = {}", a);
std::println("b = {}", b);
std::println("c = {}", c);
std::println("d = {}", d);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra auto_braces.cpp -o auto_braces
./auto_braces
clang++ -std=c++23 -Wall -Wextra vector_forms.cpp -o vector_forms
./vector_forms
g++-16 -std=c++23 -Wall -Wextra auto_braces.cpp -o auto_braces
./auto_braces
g++-16 -std=c++23 -Wall -Wextra vector_forms.cpp -o vector_forms
./vector_forms
""") Output
$ clang++ -std=c++23 -Wall -Wextra auto_braces.cpp -o auto_braces $ ./auto_braces a = 5, b = 5, c holds 1 value, d holds 3 values $ clang++ -std=c++23 -Wall -Wextra vector_forms.cpp -o vector_forms $ ./vector_forms a = [7, 7, 7] b = [3, 7] c = [0, 0, 0] d = [3] $ g++-16 -std=c++23 -Wall -Wextra auto_braces.cpp -o auto_braces $ ./auto_braces a = 5, b = 5, c holds 1 value, d holds 3 values $ g++-16 -std=c++23 -Wall -Wextra vector_forms.cpp -o vector_forms $ ./vector_forms a = [7, 7, 7] b = [3, 7] c = [0, 0, 0] d = [3]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 braces_session.py
The static_assert lines in auto_braces.cpp check the deduced types while the file compiles, so the build itself
proves that b is an int and c a list. For containers, use parentheses for a count or a size, and braces for the
elements themselves.
Which form to use
Most of this lesson reduces to a few habits, which match rules ES.20 and ES.23 of the C++ Core Guidelines:
- Initialise every variable where you declare it. If there is no sensible value yet, declare the variable later, where the value is known.
- Prefer braces for values,
int seats{40};, so that the compiler rejects narrowing, and writeint seats{};for zero. - With
auto, use the equals sign:auto seats = 40;. - For constructors that take a count or a size, such as
std::vector<int> rows(3, 0);, use parentheses. - When you mean to lose information, write
static_castorstd::lround, so the conversion is visible.
Try it: fix six declarations
Each of the six declarations in main below has one mistake. Fix them so that the file builds with
-Wall -Wextra -Werror on both compilers and prints
seats 0, per row 7, rows [0, 0, 0], hall size 0, discount 10, label Gate A.
#include <print>
#include <string>
#include <vector>
// Each of the six declarations in main() has one mistake. Fix them so that the file
// builds with -Wall -Wextra -Werror on both compilers and prints:
// seats 0, per row 7, rows [0, 0, 0], hall size 0, discount 10, label Gate A
int main() {
int seats;
int per_row{7.5};
std::vector<int> rows{3, 0};
std::string hall();
unsigned discount{-10};
auto label = {"Gate A"};
std::println("seats {}, per row {}, rows {}, hall size {}, discount {}, label {}",
seats, per_row, rows, hall.size(), discount, label);
} The compilers report four of the mistakes; the other two compile and print the wrong thing:
from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -Werror fix_me.cpp -o fix_me
g++-16 -std=c++23 -Wall -Wextra -Werror fix_me.cpp -o fix_me
""", max_lines=60) Output
$ clang++ -std=c++23 -Wall -Wextra -Werror fix_me.cpp -o fix_me
fix_me.cpp:10:17: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
10 | int per_row{7.5};
| ^~~
fix_me.cpp:10:17: note: insert an explicit cast to silence this issue
10 | int per_row{7.5};
| ^~~
| static_cast<int>( )
fix_me.cpp:10:17: error: implicit conversion from 'double' to 'int' changes value from 7.5 to 7 [-Werror,-Wliteral-conversion]
10 | int per_row{7.5};
| ~^~~
fix_me.cpp:12:21: error: empty parentheses interpreted as a function declaration [-Werror,-Wvexing-parse]
12 | std::string hall();
| ^~
fix_me.cpp:12:21: note: remove parentheses to declare a variable
12 | std::string hall();
| ^~
fix_me.cpp:13:23: error: constant expression evaluates to -10 which cannot be narrowed to type 'unsigned int' [-Wc++11-narrowing]
13 | unsigned discount{-10};
| ^~~
fix_me.cpp:13:23: note: insert an explicit cast to silence this issue
13 | unsigned discount{-10};
| ^~~
| static_cast<unsigned int>( )
fix_me.cpp:16:40: error: base of member reference is a function; perhaps you meant to call it with no arguments?
16 | seats, per_row, rows, hall.size(), discount, label);
| ^~~~
| ()
5 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -Werror fix_me.cpp -o fix_me
fix_me.cpp: In function 'int main()':
fix_me.cpp:10:17: error: narrowing conversion of '7.5e+0' from 'double' to 'int' [-Wnarrowing]
10 | int per_row{7.5};
| ^~~
fix_me.cpp:12:21: error: empty parentheses were disambiguated as a function declaration [-Werror=vexing-parse]
12 | std::string hall();
| ^~
fix_me.cpp:12:21: note: remove parentheses to default-initialize a variable
12 | std::string hall();
| ^~
| --
fix_me.cpp:13:23: error: narrowing conversion of '-10' from 'int' to 'unsigned int' [-Wnarrowing]
13 | unsigned discount{-10};
| ^~~
fix_me.cpp:16:45: error: request for member 'size' in 'hall', which is of non-class type 'std::string()' {aka 'std::__cxx11::basic_string<char>()'}
16 | seats, per_row, rows, hall.size(), discount, label);
| ^~~~
cc1plus: all warnings being treated as errors
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fix_me_session.py
One way to fix it
Read the errors from the top: the narrowing 7.5, the empty parentheses after hall, and the narrowing -10; the
error about hall.size() disappears once hall is a variable. Two mistakes compile: rows{3, 0} holds the two
elements 3 and 0, and auto label = {"Gate A"} makes a list rather than a string. The sixth, int seats;, is the
quietest of all:
#include <print>
#include <string>
#include <vector>
int main() {
int seats{}; // value-initialised: 0
int per_row{7}; // a whole number to begin with (or static_cast<int>(7.5))
std::vector<int> rows(3, 0); // parentheses: 3 elements, each 0
std::string hall; // a variable: no empty parentheses
unsigned discount{10}; // a value that fits an unsigned type
std::string label{"Gate A"}; // a string, not an initializer_list
std::println("seats {}, per row {}, rows {}, hall size {}, discount {}, label {}",
seats, per_row, rows, hall.size(), discount, label);
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -Werror fixed.cpp -o fixed
./fixed
g++-16 -std=c++23 -Wall -Wextra -Werror -O2 fixed.cpp -o fixed
./fixed
""") Output
$ clang++ -std=c++23 -Wall -Wextra -Werror fixed.cpp -o fixed $ ./fixed seats 0, per row 7, rows [0, 0, 0], hall size 0, discount 10, label Gate A $ g++-16 -std=c++23 -Wall -Wextra -Werror -O2 fixed.cpp -o fixed $ ./fixed seats 0, per row 7, rows [0, 0, 0], hall size 0, discount 10, label Gate A
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fixed_session.py
Had seats kept its missing initialiser, the warnings would not have caught it. std::println takes its arguments
by reference, so inside main the compilers see seats passed along, not read. Here is that one line on its own:
#include <print>
int main() {
int seats; // still no initialiser
std::println("seats {}", seats); // println takes its arguments by reference
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -c seats_bug.cpp
clang++ -std=c++23 -Wall -Wextra -O2 -c seats_bug.cpp
g++-16 -std=c++23 -Wall -Wextra -c seats_bug.cpp
g++-16 -std=c++23 -Wall -Wextra -O2 -c seats_bug.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra -c seats_bug.cpp
$ clang++ -std=c++23 -Wall -Wextra -O2 -c seats_bug.cpp
$ g++-16 -std=c++23 -Wall -Wextra -c seats_bug.cpp
$ g++-16 -std=c++23 -Wall -Wextra -O2 -c seats_bug.cpp
In file included from seats_bug.cpp:1:
In function 'void std::println(format_string<_Args ...>, _Args&& ...) [with _Args = {int&}]',
inlined from 'int main()' at seats_bug.cpp:5:17:
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/print:164:19: warning: 'seats' is used uninitialized [-Wuninitialized]
164 | { std::println(stdout, __fmt, std::forward<_Args>(__args)...); }
| ~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
seats_bug.cpp: In function 'int main()':
seats_bug.cpp:4:9: note: 'seats' was declared here
4 | int seats; // still no initialiser
| ^~~~~
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 seats_session.py
Only GCC with -O2 reports it, from inside the <print> header, after inlining std::println into main. Warnings
are a safety net with holes; the initialiser is the fix.
Interview questions
Fresher · What is the difference between int x(2.9); and int x{2.9};?
Both try to initialise an int from a double, but only the first is valid. int x(2.9); is direct-initialisation,
which allows the conversion and drops the fraction, so x is 2; a compiler may warn, but the program builds.
int x{2.9}; is list-initialisation, and converting a floating-point value to an integer type is a narrowing
conversion, which list-initialisation forbids, so the program is ill-formed and both Clang and GCC reject it. If the
truncation is intended, int x{static_cast<int>(2.9)}; states it. A strong answer adds that GCC reports narrowing
from a non-constant only as a warning unless -pedantic-errors or -Werror=narrowing is given.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Key takeaways
int a = 1;,int b(2);andint c{3};all initialise; the forms with braces are called list-initialisation.- Braces reject narrowing conversions such as
doubletoint,inttodoubleandinttounsigned, unless the value is a constant that fits. GCC only warns for a non-constant;-pedantic-errorsmakes it an error. - A local
int,doubleor pointer declared with no initialiser has an indeterminate value, and reading it is undefined behaviour in C++23. Apple clang warns about a direct read at once, GCC only when optimising, and neither sees a read that happens through a reference. - C++26 makes that read erroneous behaviour; GCC 16 implements it by zero-filling, Clang does not yet. Write
int total{};and stop worrying about either rule. std::string name();declares a function. Writestd::string name;orstd::string name{};.auto x{5}is anint,auto y = {5}is astd::initializer_list<int>; with containers,(3, 7)and{3, 7}mean different things.
References
- Working Draft, Programming Languages: C++ (N4950): [dcl.init] Initializers, [dcl.init.list] List-initialization, [basic.indet] (ISO/IEC JTC1/SC22/WG21)
- P2795R5: Erroneous behaviour for uninitialized reads (ISO/IEC JTC1/SC22/WG21)
- N3922: New Rules for auto deduction from braced-init-list (ISO/IEC JTC1/SC22/WG21)
- Options Controlling C++ Dialect (-Wnarrowing) (GNU Project)
- Options That Control Optimization (-ftrivial-auto-var-init) (GNU Project)
- Options to Request or Suppress Warnings (-Wuninitialized) (GNU Project)
- GCC 16 Release Series, Changes, New Features, and Fixes (GNU Project)
- C++ Support in Clang (LLVM Project)
- MemorySanitizer (LLVM Project)
- C++ Core Guidelines: ES.20, Always initialize an object (Standard C++ Foundation)
- C++ Core Guidelines: ES.23, Prefer the {}-initializer syntax (Standard C++ Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress