Skip to content
C++
Language
Intermediate

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++98

A 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.

cpp
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 required

Stage 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.

cpp
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 double

Stage 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:

  1. T1 == T2 β†’ C = T1.
  2. Both signed or both unsigned β†’ C = the one with the greater conversion rank.
  3. Unsigned rank β‰₯ signed rank β†’ C = the unsigned type.
  4. Signed type can represent all values of the unsigned type β†’ C = the signed type.
  5. 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):

SourcePromoted to
boolint (false β†’ 0, true β†’ 1)
char, signed char, unsigned charint or unsigned int
short, unsigned shortint or unsigned int
Unscoped enumUnderlying integer type, then promoted
Bit-fieldint 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

cpp
#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 values

For 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

cpp
#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

cpp
#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

cpp
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 promotion

Bitwise operations widen operands

cpp
#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 throughout

Best Practices

  • Use std::ssize (C++20) when comparing a signed loop index against a container size. It returns ptrdiff_t, eliminating signed/unsigned UAC in the comparison.
  • Never compare int and unsigned int without 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 produce int; assign back with static_cast to make the narrowing intentional and visible.
  • Prefer int64_t over long for portable wide arithmetic. The rank of long is 32 bits on Windows (LLP64) and 64 bits on Linux/macOS (LP64), so long vs. unsigned int can 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()

cpp
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++20

Ternary operator applies UAC too

cpp
bool flag = true;
auto x = flag ? 1 : 2.0;  // UAC: int 1 converts to double; x is double

Mixing long long and unsigned long

cpp
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

See Also