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 β surprising