C++ Type Casting: Implicit Conversions and the Four Casts
C++ converts types implicitly at every assignment, operand and argument. Narrowing, signed vs unsigned traps, and when to use each of the four named casts.
- Course: C++ study plan
- Module: Fundamental types
- Kind: Lesson
- Reading time: 14 min
- Runtime: Clang 18 · C++20
What are the four casts in C++?
C++ has four named casts. static_cast performs ordinary value conversions, such as int to double or an integer to an enum; reinterpret_cast reinterprets a pointer's bits as another pointer type; const_cast adds or removes const; and dynamic_cast is a run-time-checked cast down a polymorphic class hierarchy. Each does one job and is easy to search for, unlike a C-style (int)x.
Lesson
C++ converts between types constantly and silently. Every mixed-type expression, every argument passed to a differently typed parameter, every if on a number is a conversion. Most are harmless promotions; a handful lose information, and two of those — narrowing a floating value to an integer, and mixing signed with unsigned — are behind a large share of real bugs. This lesson lists the implicit conversions and what each does to a value, shows how brace-initialisation refuses the lossy ones, and explains the four named casts and why the C-style cast has no place in new code.
Where implicit conversions happen
double half = 1 / 2; // 0.0: the int division happens first, then 0 converts to double
int n = 2.9; // 2: truncated, no warning under -Wall
if (n) { } // n converts to bool
void f(long long x);
f(n); // n widens to long long — a promotion, value preserved
Initialisation and assignment, the operands of an operator, arguments, return values and conditions are all conversion sites. A promotion goes to a wider type and preserves the value; a conversion may not. The compiler reports neither unless you ask (-Wconversion is not part of -Wall).
Narrowing conversions
| Written | Stored | Why |
|---|---|---|
int i = 3.99; | 3 | floating → integer truncates toward zero |
int i = 3e10; | undefined | out of range for int |
unsigned u = -1; | 4294967295 | signed → unsigned is modular, always defined |
int k = 3000000000LL; | -1294967296 | wider → narrower keeps the low bits (modular since C++20) |
char c = 300; | 44 | 300 mod 256 |
float f = 16777217; | 16777216.0f | integer → float rounds to the nearest representable |
bool b = 0.1; | true | anything non-zero |
Only one row is undefined behaviour — a floating value that does not fit the target integer — but every row loses something. Braces make the compiler say so:
int i{3.99}; // error: narrowing conversion of '3.99' from 'double' to 'int'
unsigned u{-1}; // error
char c{300}; // error
char d{65}; // fine: a constant that fits is not narrowing
long long big = 5;
int j{big}; // error: a run-time long long might not fit, even though this one does
When you want the conversion, say so with static_cast; the reader then knows the loss was considered and -Wall stops second-guessing you.
bool
Zero and null are false; everything else is true. if (x = 5) compiles — assignment, then the 5 converts to true — which is why -Wall warns about an assignment used as a condition and why some people write 5 == x. Going the other way, true is 1 in arithmetic and false is 0; std::cout << std::boolalpha prints the words instead.
char and int
A char in arithmetic becomes an int, which is the whole basis of text processing by hand:
char c = 'q';
int code = c; // 113
int digit = '7' - '0'; // 7
char next = static_cast<char>(c + 1); // 'r' — c + 1 is an int and must be narrowed back on purpose
bool lower = c >= 'a' && c <= 'z';
Because char is signed here, a byte ≥ 128 is a negative int; before passing one to <cctype> or using it as an index, convert through unsigned char (Module 5 makes this a habit).
Signed and unsigned
int → unsigned wraps modulo 2³², and since C++20 so does the reverse. The trouble is not the conversion itself but that it happens inside comparisons without asking:
unsigned count = 3;
int delta = -5;
std::cout << count + delta << '\n'; // 4294967294 — delta became unsigned
std::cout << (delta < count) << '\n'; // 0 — "-5 < 3" is false, because -5 became 4294967291
The usual arithmetic conversions (lesson 2) convert the signed operand, and GCC's -Wall warns with -Wsign-compare (Clang, the judge's compiler, puts it under -Wextra). The classic sighting is for (int i = 0; i < v.size(); ++i). Fixes, in order of preference:
for (std::size_t i = 0; i < v.size(); ++i) // match the type
for (int i = 0; i < std::ssize(v); ++i) // C++20: a signed size
std::cmp_less(delta, count) // C++20 <utility>: compares mathematical values — true
std::in_range<int>(someUnsigned) // does the value fit the target type?
std::cmp_equal, cmp_not_equal, cmp_less, cmp_greater, cmp_less_equal and cmp_greater_equal are the whole family. Casting the unsigned side to long long also works when it is known to fit.
Floating to integer
static_cast<int>(2.9) is 2 and static_cast<int>(-2.9) is -2 — truncation toward zero, the same as std::trunc. If the truncated value does not fit the target, the behaviour is undefined, so static_cast<int>(1e10) is not "some big number", it is a bug. Range-check first, use std::llround when rounding is wanted, and reach for long long when the magnitude is uncertain.
The four casts
double ratio = static_cast<double>(hits) / total; // numeric; forces floating division
auto level = static_cast<Level>(2); // int → enum (lesson 6)
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(&ratio); // view the object's bytes
void legacyPrint(char* text);
legacyPrint(const_cast<char*>(name.c_str())); // only if legacyPrint really does not write
static_castis the everyday cast: every numeric conversion, enum ↔ integer,void*→T*, and up or down a class hierarchy without a run-time check. If a conversion is plausible,static_castdoes it.reinterpret_castreinterprets the bits of a pointer or an integer as another pointer type. It is for byte-level I/O (Module 18) and talking to hardware; almost every other use is undefined behaviour. For values — the bits of afloatas auint32_t— usestd::bit_cast.const_castadds or removesconst. Removing it to call a badly declared legacy function is legitimate; writing through the result to an object that was bornconstis undefined behaviour.dynamic_castis a checked cast down a polymorphic hierarchy, in Module 10.
Each cast does one thing, is easy to search for, and refuses conversions outside its job.
Why not (int)x
A C-style cast (T)x — and its functional twin T(x) — tries const_cast, then static_cast, then static_cast plus const_cast, then reinterpret_cast, and silently uses the first that compiles. (int)somePointer, (char*)someConstString and (Base*)unrelatedPointer all compile and all mean something different from what a reader expects. It cannot be grepped for, and it keeps compiling when the types around it change into something the cast should have refused. Treat one in new code as a review comment waiting to happen.
Pitfalls
double avg = sum / n;with integer operands: the division is integral before the conversion. Cast one operand first.long long area = width * height;where both areint: the product overflows inintbefore the widening. Cast one operand first.if (index < v.size())with a negativeindex: the comparison is unsigned and the bounds check passes.static_cast<int>(hugeDouble)is undefined, not saturated.(int)xwherexturns out to be a pointer.
Key takeaways
- Conversions happen at every assignment, operand, argument, return and condition; promotions preserve values, conversions may not, and
-Wallstays quiet. - Brace-initialisation rejects narrowing;
int x{3.7}andunsigned u{-1}are compile errors. - Mixed signed/unsigned expressions convert the signed side; use matching types,
std::ssize, orstd::cmp_lessand friends. - Floating → integer truncates toward zero and is undefined when out of range.
static_castfor value conversions,reinterpret_castfor bytes and hardware,const_castfor legacy signatures,std::bit_castfor bit views; never a C-style cast.
Common questions
Why should you avoid C-style casts in C++?
A C-style cast (T)x tries const_cast, then static_cast, then reinterpret_cast, and silently uses the first that compiles, so (int)somePointer or (char*)someConstString compile and mean something surprising. It cannot be searched for, and it keeps compiling when the surrounding types change into something a named cast would refuse.
What is a narrowing conversion in C++?
A narrowing conversion is one that can lose information: a floating value to an integer, a wider integer to a narrower one, a signed value to unsigned, or an integer to a floating type that cannot hold it exactly. int i = 3.99; stores 3 without complaint, but brace initialisation, int i{3.99};, makes it a compile error.
Why is -1 < 1u false in C++?
When a signed and an unsigned operand of the same width meet, the usual arithmetic conversions turn the signed one into unsigned, so -1 becomes 4294967295, which is greater than 1. -Wall warns with -Wsign-compare; C++20's std::cmp_less(-1, 1u) compares the mathematical values and returns true.
What happens when you cast a double to an int in C++?
The value is truncated toward zero, so static_cast<int>(2.9) is 2 and static_cast<int>(-2.9) is -2. If the truncated value does not fit in an int, the behaviour is undefined — not saturated — so range-check first, and use std::llround when you want rounding instead.
Why does double avg = sum / n; give a whole number?
When sum and n are integers, the division happens in integer arithmetic first and only the truncated result is converted to double. Cast one operand before dividing: static_cast<double>(sum) / n. The same trap makes long long area = width * height; overflow in int before the widening.
Exercises
Caesar shift in char arithmetic
Read an integer k — any sign, any size — then one line of text. Shift every letter k places along the alphabet, wrapping (z + 1 is a) and keeping the case; leave every other character unchanged. Print the shifted line.
Do the arithmetic in int (c - 'a' gives the letter's index) and narrow back to char with static_cast<char>, never a C-style cast. Normalise k to 0…25 first with the floor-modulo idiom ((k % 26) + 26) % 26. Read k with >>, then std::cin.ignore(); before std::getline — the pattern from Module 1.
Input: k, then one line. Output: the shifted line.
Example: input 3 / Hello, World! → Khoor, Zruog!
Compare across the sign divide
Read an integer n, then n lines each holding an int a (possibly negative) and an unsigned b (possibly above 2147483647). For each pair print <a> vs <b>: naive <r1>, correct <r2>, where each result is less, equal or greater.
The naive result is what the built-in < does — it converts a to unsigned first, so compute it with static_cast<unsigned>(a) against b. The correct result compares the mathematical values with std::cmp_less and std::cmp_equal from <utility> (C++20).
Input: n, then n pairs. Output: n lines.
Example: input 2 / -1 1 / 5 5 →
-1 vs 1: naive greater, correct less
5 vs 5: naive equal, correct equalIn this module: Fundamental types
← Floating point — representation, error and printing · const, constexpr and auto →