C++ / FUNDAMENTAL TYPES AND VARIABLES
Narrowing conversions and brace initialisation safety
Predict and control which conversions brace initialisation rejects, so value-losing initialisations become compile errors instead of silent bugs.
What you will learn
- Name the four narrowing conversions and spot them inside an initialiser
- Use {} so lossy initialisations fail to compile instead of changing the value
- Apply the constant-expression exemption: in-range constants pass, variables do not
- Mark intentional truncation with static_cast inside the braces
Understanding Narrowing conversions and brace initialisation safety
Copy-initialisation with = runs the ordinary implicit conversion rules, and those rules happily lose information: int n = 3.99; stores 3, short s = 40000; stores -25536 on a 16-bit short, and unsigned u = -1; stores 4294967295. The compiler may warn about some of these, and only with the right flags, but nothing in the language stops the conversion. List-initialisation with braces adds one extra rule on top of the very same conversions: if a required conversion is a narrowing conversion, the program is ill-formed and you get a diagnostic. So {} reads as "this value, in this type", and it fails the moment those two cannot both be true.
The standard lists the narrowing conversions explicitly, and the details decide what compiles. Floating-point to integer is narrowing with no exception at all, so int x{8.0}; is rejected even though 8 is exact and visible to the compiler. The other three cases, double to float, integer to floating-point, and integer to an integer type that cannot represent all source values, are exempt when the initialiser is a constant expression whose value survives the conversion. That is why short s{32767}; compiles and short s{32768}; does not, and why the check falls back to comparing the ranges of the types as soon as the value is not a compile-time constant.
The check follows the braces wherever they appear: scalar variables, each member of an aggregate, every element passed to an initializer_list constructor, arguments to a constructor chosen by list-initialisation, and the operand of return {...}. That makes braces a cheap audit of existing code, because int n{v.size()}; refuses to compile, and the truncation it names is exactly the bug you wanted to hear about. When the loss really is deliberate, write static_cast<int>(v.size()) instead; the rule exists to stop conversions happening by accident, not to forbid them.
<iostream>
int main() {
// Assumes 16-bit short and 32-bit unsigned int.
int truncated = 3.99; // fraction dropped
short wrapped = 40000; // does not fit in short
unsigned int wrapped_neg = -1; // wraps to the largest unsigned value
std::cout << truncated << ' ' << wrapped << ' ' << wrapped_neg << '\n';
// The same three initialisations in braces do not compile:
// int a{3.99}; // floating point to integer is always narrowing
// short b{40000}; // constant, but 40000 does not fit in short
// unsigned int c{-1}; // constant, but -1 does not fit in unsigned
short fits{32767}; // constant expression inside short's range
int widened{'A'}; // char to int loses nothing
double promoted{7}; // 7 converts to double and back unchanged
std::cout << fits << ' ' << widened << ' ' << promoted << '\n';
}
Braces reject any initialisation that could change the value, judging by the source type unless the initialiser is a constant expression the compiler can prove survives the conversion.
Worked examples
Constant expressions get an exemption
Two initialisers holding the same value behave differently because only one is a constant expression.
<iostream>
int main() {
const int small = 100; // constant expression
short a{small}; // accepted: the value 100 fits in short
int runtime = 100; // same value, not a constant expression
// short bad{runtime}; // error: narrowing conversion of 'runtime'
short b{static_cast<short>(runtime)};
float f{0.1}; // accepted: within float's range
// int n{0.1}; // error: no exemption for floating to integer
std::cout << a << ' ' << b << ' ' << f << '\n';
}
Example explained
Line 1short a{small}: small is a const int initialised by a constant expression, so the rule inspects the value 100 instead of int's range and accepts it.
Line 2short bad{runtime}: the value is identical, but a plain int variable carries no compile-time value, so int's range is compared with short's and the line is ill-formed.
Line 3float f{0.1}: the double-to-float exemption asks only whether the constant lies within float's range, so an inexact value like 0.1 still passes.
Line 4int n{0.1}: floating point to integer is narrowing unconditionally, so no constant, however exact, gets through.
The check reaches into aggregates and containers
Narrowing is diagnosed for aggregate members and for elements of an initializer_list, not just for plain variables.
<iostream>
<vector>
struct Pixel { int x; int y; };
int main() {
std::vector<int> v{4, 8, 15};
// int n{v.size()}; // error: size_t to int is narrowing
int n{static_cast<int>(v.size())};
// Pixel p{4, 8.0}; // error: double to int in a member
Pixel p{4, 8};
// std::vector<int> w{1, 2, 3.5}; // error: element of initializer_list<int>
std::vector<double> w{1, 2, 3.5};
std::cout << n << ' ' << p.x + p.y << ' ' << w.back() << '\n';
}
Example explained
Line 1v.size() returns std::size_t, and no int can hold every size_t value, so the brace form reports the truncation that int n = v.size(); would perform in silence.
Line 2Pixel p{4, 8.0}: aggregate members are list-initialised one by one, so the double is checked and rejected even though 8.0 is exact.
Line 3std::vector<int> w{1, 2, 3.5}: each element is converted to the initializer_list element type int, and 3.5 cannot convert back unchanged.
Line 4std::vector<double> w{1, 2, 3.5}: here int to double is exempt because 1 and 2 are constants that round-trip through double.
Important notes
Whether a conversion narrows depends on the platform's type widths: int i{someLong}; compiles where long is 32 bits, as on 64-bit Windows, and fails where long is 64 bits, as on 64-bit Linux.
Signed and unsigned partners narrow in both directions, since neither int nor unsigned int can represent all of the other's values, so int a{u}; and unsigned b{i}; are both rejected for non-constant operands.
Common mistakes
Expecting the runtime value to be checked: with int i = 5;, char c{i}; is rejected while char c{5}; compiles, because only constant expressions are examined by value.
Adding static_cast just to silence the error without checking the range, which turns a compile-time diagnostic into a wrapped value, as static_cast<short>(40000) gives -25536.
Rewriting a failing int n{v.size()}; as int n = v.size();, which keeps the truncation and discards the only diagnostic you were given.
Try it yourself
Change, predict, then run
In a browser editor write constexpr int big = 70000; then try short a{big};, short b{static_cast<short>(big)}; and short c = big;. Comment out the line that refuses to compile and print the values the other two hold.
Open the C++ workspaceCheck your understanding
Both variables hold 200 where they are used: int a = 200; and constexpr int b = 200;. Why does unsigned char x{b}; compile while unsigned char y{a}; does not?
- A constexpr variable is stored in the smallest type that fits, so no conversion is needed for b
- The narrowing rule exempts constant expressions whose converted value fits and converts back unchanged, while a can only be judged by its type
- a is not const, so it might change before the initialisation runs and its value cannot be trusted
- Braced initialisation checks only floating-point conversions, so an int source is rejected on principle
Show answer
The integer-to-narrower-integer case is exempt when the initialiser is a constant expression whose value fits the target, and 200 as a constexpr int fits unsigned char, so unsigned char x{b}; is well-formed; for a there is no compile-time value, leaving the compiler to compare int's range with unsigned char's and reject. Option 3 sounds reasonable but is wrong: const int a = 200; also compiles even though the object still exists at runtime, because what matters is that the initialiser is a constant expression, not whether the variable could be modified.