Usual Arithmetic Conversions
The implicit type coercion rules applied to both operands of binary arithmetic operators to yield a common result type.
Usual Arithmetic Conversionssince C++98A multi-stage sequence of implicit type conversions applied to both operands of a binary arithmetic, bitwise, or comparison operator to yield a single common type, which becomes the type of the result.
Overview
Usual arithmetic conversions (UAC) fire on binary operators including +, -, *, /, %, &, |, ^, ~ (unary, via promotion), and comparison operators <, >, <=, >=, ==, !=. They also apply to the conditional operator ?:. Assignment operators are excluded β those use separate conversion rules.
The process is compiler-driven and silent. Knowing the stages lets you predict result types, avoid signed/unsigned bugs, and write portable numeric code.
Stage 1: lvalue-to-rvalue
Both operands undergo lvalue-to-rvalue conversion. The resulting prvalues feed the remaining stages.
Stage 2: Scoped enumeration guard (C++11)
If either operand is a scoped enumeration (enum class or enum struct), no conversions are applied. If the operands differ in type, the expression is ill-formed. Scoped enumerations were intentionally designed to opt out of implicit numeric coercion.
enum class Status { Ok, Error };
Status s = Status::Ok;
// int n = s + 1; // error β ill-formed since C++11
int n = static_cast<int>(s) + 1; // explicit cast requiredStage 3: Unscoped enum / floating-point mix (C++26)
Since C++26, mixing an unscoped enumeration with a floating-point type is ill-formed. Before C++26 the enumeration was converted to its underlying integer type and then the floating-point rules applied β a latent source of surprising implicit promotions.
Stage 4: Floating-point types
When at least one operand is a floating-point type:
- Same type on both sides: no conversion.
- One operand is non-floating-point: it converts to the floating-point type.
- Both floating-point, different ranks: the operand of lesser floating-point conversion rank converts to the higher-ranked type.
The rank order is float < double < long double. Since C++23, the standard also defines ranks for extended floating-point types (std::float16_t, std::float32_t, std::float64_t, std::float128_t, std::bfloat16_t from <stdfloat>). When ranks are equal but types differ (e.g., a vendor _Float32 and float), the operand of lesser subrank converts.
float f = 1.0f;
double d = 2.0;
auto r1 = f + d; // double β f converts to double
auto r2 = f + 1; // float β int 1 converts to float
auto r3 = 1 + 2.0L; // long double β int converts to long doubleStage 5: Integer types
When both operands are integer types, UAC first applies integral promotions to each independently, producing promoted types T1 and T2. Then it selects common type C by the following rules applied in order:
- T1 == T2 β C = T1.
- Both signed or both unsigned β C = the one with the greater conversion rank.
- Unsigned rank β₯ signed rank β C = the unsigned type.
- Signed type can represent all values of the unsigned type β C = the signed type.
- Otherwise β C = the unsigned version of the signed type.
Integral promotions widen narrow types to int (or unsigned int when int cannot represent all source values):
| Source | Promoted to |
|---|---|
bool | int (false β 0, true β 1) |
char, signed char, unsigned char | int or unsigned int |
short, unsigned short | int or unsigned int |
| Unscoped enum | Underlying integer type, then promoted |
| Bit-field | int if values fit, unsigned int otherwise |
The integer conversion rank order: bool < signed char = char = unsigned char < short = unsigned short < int = unsigned int < long = unsigned long < long long = unsigned long long.
Examples
Observing result types
#include <type_traits> // C++11
static_assert(std::is_same_v<decltype(1 + 2u), unsigned int>); // C++17
static_assert(std::is_same_v<decltype(1 + 2.0f), float>);
static_assert(std::is_same_v<decltype(1 + 2.0), double>);
static_assert(std::is_same_v<decltype(true + 1), int>); // bool β int first
static_assert(std::is_same_v<decltype(1LL + 2u), long long>); // ll holds all uint valuesFor 1 + 2u: T1 = int, T2 = unsigned int. Same rank, unsigned wins (rule 3). For 1LL + 2u: T1 = long long, T2 = unsigned int. long long rank is greater and it can hold all unsigned int values, so rule 4 applies: C = long long.
The signed/unsigned comparison trap
#include <vector>
void scan(const std::vector<int>& v) {
int i = -1;
// UAC converts i to size_t; -1 wraps to SIZE_MAX β comparison always false
if (i < v.size()) { // -Wsign-compare fires here
// unreachable when i is negative
}
// Safe: use std::ssize (C++20) to get a signed size
if (i < std::ssize(v)) { // C++20 β both signed, no UAC trap
// correct
}
}uint8_t arithmetic always produces int
#include <cstdint>
uint8_t a = 200, b = 100;
auto diff = a - b; // int(100), not uint8_t(100)
auto wrap = b - a; // int(-100), not uint8_t(156) β no wrap in promoted int
// To stay in 8-bit domain, cast explicitly
uint8_t result = static_cast<uint8_t>(b - a); // 156
// XOR of two uint8_t values produces int, not uint8_t
auto x = a ^ b; // int
uint8_t y = static_cast<uint8_t>(a ^ b);char signedness is implementation-defined
char c = '\xFF';
// If char is signed: c == -1, promoted to int(-1)
// If char is unsigned: c == 255, promoted to int(255)
int val = c + 0; // platform-dependent result
// Portable alternatives:
unsigned char uc = '\xFF'; // always 255 before promotion
signed char sc = '\xFF'; // always -1 before promotionBitwise operations widen operands
#include <cstdint>
uint16_t flags = 0xA5A5;
// Both operands promoted to int before shift; result is int
auto shifted = flags << 8; // int, not uint16_t
// safe here since the value fits in int
// Danger: shifting a signed int left into the sign bit is UB until C++20
// In C++20 and later, left shift of signed integers is well-defined (two's complement)
uint32_t safe = static_cast<uint32_t>(flags) << 8; // stays unsigned throughoutBest Practices
- Use
std::ssize(C++20) when comparing a signed loop index against a container size. It returnsptrdiff_t, eliminating signed/unsigned UAC in the comparison. - Never compare
intandunsigned intwithout an explicit cast. Negative signed values wrap to large unsigned values, producing silent logic bugs. - Cast explicitly before narrow arithmetic. Operations on
uint8_t,uint16_t, and similar types produceint; assign back withstatic_castto make the narrowing intentional and visible. - Prefer
int64_toverlongfor portable wide arithmetic. The rank oflongis 32 bits on Windows (LLP64) and 64 bits on Linux/macOS (LP64), solongvs.unsigned intcan yield different common types on different platforms. - Enable
-Wsign-compare(GCC/Clang) and/W4(MSVC). These warn on signed/unsigned comparisons introduced by UAC before the bug ships.
Common Pitfalls
Loop bound with .size()
std::vector<int> v = {1, 2, 3};
// If n is ever negative, this loops forever β UAC makes n unsigned
for (int n = v.size() - 1; n >= 0; --n) { } // BUG when size_t wraps
// C++20 fix
for (auto n = std::ssize(v) - 1; n >= 0; --n) { } // C++20Ternary operator applies UAC too
bool flag = true;
auto x = flag ? 1 : 2.0; // UAC: int 1 converts to double; x is doubleMixing long long and unsigned long
long long ll = -1;
unsigned long ul = 1;
// On LP64 (Linux): long long rank == unsigned long rank
// long long cannot represent ULONG_MAX β C = unsigned long long (rule 5)
// ll converts to unsigned long long β wraps to huge value
bool b = (ll < ul); // false β surprisingQuick-Reference: The UAC Algorithm at a Glance
The five-stage process collapses to a single decision table once you know where both operands land:
| Situation | Common type chosen |
|---|---|
Either operand is long double | long double |
Either operand is double | double |
Either operand is float | float |
| Both integer, same type after promotion | that type |
| Both signed (or both unsigned), different rank | higher-rank type |
| Unsigned rank β₯ signed rank | unsigned type |
| Signed type holds all unsigned values | signed type |
| Neither of the above | unsigned version of the signed type |
Read top-to-bottom; the first matching row wins. Floating-point rows short-circuit before any integer promotion runs.
Signed/Unsigned Pitfall Gallery
These are the four most common UAC-induced bugs in production C++ code.
(-1 > 0u) is true
// UAC: int(-1) converts to unsigned int β wraps to UINT_MAX
bool b = (-1 > 0u); // true β unsigned comparison
// std::cmp_greater is the C++20 fix (see below)The signed operand -1 converts to unsigned int because the unsigned rank is not less than the signed rank (Stage 5, sub-rule 1). On a 32-bit unsigned int, -1 wraps to 4294967295.
size_t subtraction underflow
#include <cstddef>
std::size_t a = 2, b = 5;
std::size_t diff = a - b; // wraps to SIZE_MAX - 2, not -3
// Common in bounds checks:
if (a - b > 0) { /* always true when a < b β size_t never goes negative */ }
// Fix: check order before subtracting
if (a > b && a - b > 0) { /* safe */ }
// Or use signed arithmetic explicitly:
auto sdiff = static_cast<std::ptrdiff_t>(a) - static_cast<std::ptrdiff_t>(b);Signed loop index vs. .size()
#include <vector>
#include <string>
std::vector<int> v = {1, 2, 3};
for (int i = 0; i < v.size(); ++i) { // -Wsign-compare: int vs size_t
// ...
}
// Also: comparing against string::find result
std::string s = "hello";
int pos = s.find("ell"); // find() returns size_t; pos may be int
if (pos != std::string::npos) { // comparing int to size_t npos (SIZE_MAX)
// Correct only by luck when pos >= 0; breaks if find returns npos
// and int is narrower than size_t
}Platform-dependent result: (-1L < 1U)
// LP64 (Linux/macOS 64-bit): long is 64-bit, fits all unsigned int values
// β 1U converts to long β (-1L < 1L) β true (prints 1)
//
// ILP32 (32-bit): long and unsigned int have equal rank; long can't
// represent UINT_MAX β both convert to unsigned long
// β (-1UL < 1UL) β (4294967295UL < 1UL) β false (prints 0)
#include <iostream>
std::cout << (-1L < 1U) << "\n"; // 1 on LP64, 0 on ILP32 β same code, different answersThis is a real portability hazard: code that passes all tests on a 64-bit build server may silently misbehave in a 32-bit embedded target.
Unsigned narrow multiplication β signed overflow UB
unsigned short x = 0xFFFF; // 65535
unsigned short y = 0xFFFF; // 65535
// Both promoted to int (Stage 5 integral promotion)
// 65535 * 65535 = 4294836225 > INT_MAX β signed integer overflow β UB
auto z = x * y; // undefined behavior despite both operands being unsigned!Integral promotion converts unsigned short to int, not to unsigned int, because int can hold all unsigned short values. The subsequent multiplication then overflows a signed int. UBSan reports: signed integer overflow: 65535 Γ 65535 cannot be represented in type 'int'.
Fix: cast to unsigned int before multiplying.
auto z = static_cast<unsigned int>(x) * static_cast<unsigned int>(y); // well-definedC++20 Safe Comparison: std::cmp_*
The <utility> header (C++20) provides a family of integer comparison functions that handle mixed signedness correctly without UAC pitfalls:
#include <utility> // C++20
int a = -6;
unsigned int b = 3;
// Built-in: UAC converts a to unsigned int β -6 wraps β false comparison
bool wrong = (a < b); // false β bug
// std::cmp_less performs mathematically correct comparison
bool correct = std::cmp_less(a, b); // true β a is genuinely less than bThe full family:
| Function | Equivalent intent |
|---|---|
std::cmp_equal(a, b) | a == b with correct sign handling |
std::cmp_not_equal(a, b) | a != b |
std::cmp_less(a, b) | a < b |
std::cmp_greater(a, b) | a > b |
std::cmp_less_equal(a, b) | a <= b |
std::cmp_greater_equal(a, b) | a >= b |
All six functions are constexpr, work on any combination of signed/unsigned integer types, and never invoke UAC. They are defined in terms of the mathematical values of their arguments, not the bit patterns.
std::in_range<T>(value) (also C++20, <utility>) checks whether a value fits in type T without UAC traps:
int n = -1;
bool fits = std::in_range<std::size_t>(n); // false β no implicit conversionInteger Promotion in Variadic Function Arguments
Variadic functions such as printf are the one place where integral promotions apply outside of a binary operator. When a value of a narrow integer type is passed to the ... parameter list, the compiler applies the default argument promotions: char, signed char, unsigned char, short, and unsigned short all widen to int (or unsigned int), and float widens to double. The callee has no type information for variadic arguments, so mismatching the format specifier with the promoted type is undefined behaviour.
#include <cstdio> // C++11
char c = 'A';
short s = 32767;
float f = 3.14f;
// All three undergo default argument promotion before the call:
// c β int('A') = 65
// s β int(32767)
// f β double(3.14...)
printf("%d %d %f\n", c, s, f); // well-defined β format matches promoted types
// Common mistake: passing a promoted int and using %hhd
// The byte value is still in the promoted int, but the standard only
// guarantees reading back exactly what you wrote with the right specifier.
printf("%hhd\n", c); // defined β %hhd re-narrows the int argument to signed charThe same promotion rules also fire for non-variadic calls that go through a function pointer whose parameter type is int or wider. This means char/short arithmetic inside the call body operates on already-promoted values, which is almost always what you want β but it can surprise you when you expected a modular uint8_t wrap.
Conversion Rank and std::common_type
std::common_type<T, U> exposes the UAC common-type selection as a compile-time metafunction. Including <type_traits> gives you std::common_type_t<T, U>, which mirrors exactly what the compiler would choose as the result type of T{} + U{}. This is the idiomatic way to write numeric utility functions whose return type should follow the same rules as built-in arithmetic.
#include <type_traits> // C++11
template <typename T, typename U>
std::common_type_t<T, U> add(T a, U b) {
return a + b; // return type matches the UAC common type
}
static_assert(std::is_same_v<std::common_type_t<int, unsigned int>, unsigned int>);
static_assert(std::is_same_v<std::common_type_t<int, double>, double>);
static_assert(std::is_same_v<std::common_type_t<float, long double>, long double>);
static_assert(std::is_same_v<std::common_type_t<short, short>, int>); // promoted!Note that std::common_type applies integral promotions before selecting the common type, so std::common_type_t<short, short> is int, not short. This mirrors the real behaviour of short{} + short{}. In generic code, use std::common_type_t rather than hand-rolling rank comparisons β it is standardised and handles extended floating-point types (C++23) automatically.
#include <type_traits> // C++20
// Clamp a value of type U into the range [lo, hi] of type T,
// using the UAC-common type for intermediate arithmetic.
template <typename T, typename U>
T safe_clamp(U val, T lo, T hi) {
using C = std::common_type_t<T, U>;
return static_cast<T>(
std::clamp(static_cast<C>(val), static_cast<C>(lo), static_cast<C>(hi))
); // C++20
}Arithmetic with bool
bool participates in UAC by first promoting to int (false β 0, true β 1), so any arithmetic on a bool operand produces int, not bool. This is correct in the narrow sense β true + true equals 2 β but it leads to surprising outcomes when the result is assigned back to a bool or used in a context where only 0/1 is expected.
// C++11
bool a = true, b = true;
auto sum = a + b; // int(2), not bool
auto diff = a - b; // int(0)
auto prod = a * 5; // int(5)
// Assigning back to bool re-applies the boolean conversion (nonzero β true)
bool back = a + b; // true β int(2) converts to bool(true)
bool zero = a - a; // false β int(0) converts to bool(false)The most dangerous pattern is accumulating a bool inside a counter or mask. Because the promoted int can exceed 1, bitwise operations and comparisons against true (which is int(1)) can break.
// C++11
bool flags[4] = {true, false, true, true};
int count = 0;
for (bool f : flags) count += f; // int(1) added per true element β fine here
// But mixing bool and bitwise OR:
unsigned mask = 0u;
for (bool f : flags) mask |= f; // each iteration: mask |= int(f) β int
// mask ends up as 1, not 3 β OR of 0/1 values never exceeds 1
// Correct accumulation into a bitmask requires explicit shifting:
unsigned mask2 = 0u;
for (int i = 0; i < 4; ++i) mask2 |= (static_cast<unsigned>(flags[i]) << i);UAC and std::byte / std::uint8_t
std::byte deliberately does not participate in arithmetic at all. Introduced in C++17 (<cstddef>), std::byte is a distinct type modelled as an enumeration with underlying type unsigned char. It supports only bitwise operators and the std::to_integer conversion function β no +, -, *, or /. This makes accidental arithmetic on raw byte buffers a compile-time error rather than a silent promotion to int.
#include <cstddef> // C++17
std::byte x{0xAB};
std::byte y{0x0F};
auto masked = x & y; // std::byte β bitwise AND is permitted
// auto sum = x + y; // error: no operator+ for std::byte
// To do arithmetic, extract the integer value explicitly:
auto val = std::to_integer<unsigned int>(x) + std::to_integer<unsigned int>(y);
// val is unsigned int β UAC rules then apply to any further mixed arithmetic
// Round-trip back to byte (assuming result fits):
std::byte result{static_cast<unsigned char>(val)};std::uint8_t, by contrast, is just a typedef for unsigned char on virtually all platforms, and it participates fully in UAC. Every arithmetic operation on two uint8_t values promotes both to int first, so the result type is int, not uint8_t. This is the source of the "XOR of bytes produces int" surprise shown earlier in this page.
#include <cstdint> // C++11
std::uint8_t a = 0xFF;
std::uint8_t b = 0x01;
auto r1 = a + b; // int(256) β promoted, no uint8_t wrap
auto r2 = a ^ b; // int(0xFE)
auto r3 = a & b; // int(0x01)
// All three have type int; wrap-around requires explicit cast:
std::uint8_t wrapped = static_cast<std::uint8_t>(a + b); // 0x00
// std::byte prevents the confusion at the cost of requiring explicit conversion:
std::byte ba{0xFF};
std::byte bb{0x01};
// auto rb = ba + bb; // compile error β intent must be stated
auto rb = std::byte{static_cast<unsigned char>(
std::to_integer<unsigned int>(ba) + std::to_integer<unsigned int>(bb))}; // C++17When you need a type that is both ergonomic for arithmetic and unambiguously 8-bit, std::uint8_t with explicit casts is the idiomatic choice; when you need a type that models raw memory without arithmetic semantics, prefer std::byte.