Skip to content

Operators, Expressions & Conversions

Master operator precedence, arithmetic conversions, the dangers of signed/unsigned mixing, and the four named C++ casts.

Expressions: The Building Blocks of Computation

An expression in C++ is a sequence of operators and operands that computes a value. Even a simple statement like x = a + b; contains multiple sub-expressions: a, b, a + b, and x = a + b (the assignment itself is an expression that returns the assigned value).

Every expression has two properties: a type and a value category (lvalue, rvalue, etc.). Understanding these properties is essential because C++ applies implicit conversions between types, and the rules for these conversions can produce surprising results if you are not careful.

Operator precedence determines how operators are grouped (which binds tighter), while associativity determines the direction of grouping for operators at the same precedence level. Getting these wrong leads to subtle bugs that compile without warning.

Operator Precedence Surprises

Operator precedence is one of the most common sources of bugs. When in doubt, use parentheses to make your intent explicit:

precedence.cpp
#include <iostream>

int main() {
    // Precedence trap #1: bitwise vs comparison
    int x = 5;
    // This does NOT check if bit 1 is set:
    if (x & 1 == 0) {              // Parsed as: x & (1 == 0) → x & 0 → 0
        std::cout << "Never reached!\n";
    }
    // Correct:
    if ((x & 1) == 0) {            // Parentheses fix the grouping
        std::cout << "Even\n";
    }

    // Precedence trap #2: ternary vs assignment
    int a = 1, b = 2;
    int result = a > b ? a : b = 10;  // Parsed as: (a > b ? a : b) = 10 — ERROR!
    // Correct:
    int correct = (a > b) ? a : b;    // Parenthesize for clarity

    // Precedence trap #3: pointer dereference vs member access
    // (*ptr).member  vs  ptr->member  — both are equivalent
    // *ptr.member    is parsed as *(ptr.member) — usually a compile error

    // Short-circuit evaluation
    int* ptr = nullptr;
    // Safe: the second operand is NOT evaluated if the first is false
    if (ptr != nullptr && *ptr > 0) {
        std::cout << "Value: " << *ptr << '\n';
    }
    // The && and || operators guarantee left-to-right evaluation and short-circuit
    // Overloaded && and || do NOT short-circuit — avoid overloading them
}

Arithmetic Conversions & Integer Promotion

When you mix types in an expression, C++ applies implicit conversions following a set of rules called the usual arithmetic conversions:

1. Integer promotion: Types smaller than int (char, short, bool) are first promoted to int (or unsigned int if the value cannot fit in int). This means that char + char produces an int, not a char.

2. Balancing: If operands still differ in type after promotion, the "smaller" type is converted to the "larger" one following a hierarchy: intunsigned intlongunsigned longlong longunsigned long longfloatdoublelong double.

3. Signed/unsigned interaction: When a signed type meets an unsigned type of the same rank, the signed value is converted to unsigned. This is where the most dangerous implicit conversions occur.

Pitfall

Mixing signed and unsigned integers is one of the most common and dangerous pitfalls in C++. When a negative signed value is implicitly converted to unsigned, it wraps around to a very large positive number (since unsigned types use modular arithmetic).

This frequently occurs when comparing int values with size_t (which is unsigned) — for example, iterating over a std::vector with an int loop counter and comparing it with vector.size().

Compile with -Wsign-compare (included in -Wall) to catch these issues.

  • A negative int converted to unsigned becomes a very large number (e.g., -1 becomes 4294967295 for 32-bit)
  • Comparing signed and unsigned values converts the signed value to unsigned first
  • vector.size() returns size_t (unsigned) — comparing with a negative int gives wrong results
  • Use -Wsign-compare to catch signed/unsigned comparison issues at compile time

C++ Named Casts vs C-Style Casts

C++ provides four named cast operators that replace the dangerous C-style cast (Type)expr. Each has a specific purpose and different levels of safety:

casts.cpp
#include <iostream>
#include <cstdint>

class Base {
public:
    virtual ~Base() = default;
    virtual void speak() { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
    void speak() override { std::cout << "Derived\n"; }
    void only_in_derived() { std::cout << "Special!\n"; }
};

int main() {
    // static_cast — compile-time checked, for "natural" conversions
    double pi = 3.14159;
    int truncated = static_cast<int>(pi);    // Explicit: you acknowledge the truncation
    std::cout << truncated << '\n';          // 3

    // dynamic_cast — runtime-checked, for safe downcasting in class hierarchies
    Base* base_ptr = new Derived();
    Derived* derived_ptr = dynamic_cast<Derived*>(base_ptr);
    if (derived_ptr) {                       // Check for success!
        derived_ptr->only_in_derived();       // Safe
    }

    // const_cast — adds or removes const (use sparingly!)
    const int ci = 42;
    // int& mutable_ref = const_cast<int&>(ci);  // Legal but modifying ci is UB!
    // Only valid use: calling a non-const API with a const object you know won't be modified

    // reinterpret_cast — reinterprets the bit pattern (dangerous!)
    std::int64_t address = reinterpret_cast<std::int64_t>(base_ptr);
    std::cout << "Address: " << address << '\n';

    // C-style cast: (int)pi — NEVER use this in C++
    // It tries static_cast, then const_cast, then reinterpret_cast — 
    // you don't know which one was applied!

    delete base_ptr;
}
Best Practice

Use the named casts to communicate intent and get appropriate compiler checking:

- static_cast — For well-defined conversions: numeric type conversions, upcasts in class hierarchies, and void* to typed pointer. This is the cast you will use most often.
- dynamic_cast — For safe downcasting in polymorphic hierarchies. Requires at least one virtual function in the base class. Returns nullptr (for pointers) or throws std::bad_cast (for references) on failure. Has a runtime cost.
- const_cast — Only for interfacing with legacy APIs that are not const-correct. Never use it to modify a truly const object.
- reinterpret_cast — For low-level bit reinterpretation. Almost always indicates platform-specific code. Rarely needed in application code.

Never use C-style casts (Type)expr in C++ — they hide which conversion is being performed and bypass safety checks.

  • Use static_cast for numeric conversions and safe upcasts — it is checked at compile time
  • Use dynamic_cast for safe downcasts in polymorphic hierarchies — it is checked at runtime
  • Use const_cast only to interface with non-const-correct legacy APIs
  • Never use C-style casts in C++ — they hide which conversion is applied
Key Takeaways
  • Operator precedence determines grouping — when in doubt, use parentheses to make intent explicit
  • Short-circuit evaluation in && and || guarantees left-to-right order and skips unnecessary evaluation
  • Integer promotion converts small types (char, short, bool) to int before arithmetic
  • Mixing signed and unsigned types converts the signed value to unsigned — a negative int becomes a huge positive number
  • Use the four named casts (static_cast, dynamic_cast, const_cast, reinterpret_cast) instead of C-style casts
  • C-style casts are dangerous because they silently try multiple cast types without telling you which one was applied

Quiz — Test Your Knowledge

(15 XP)

1. What does the expression `x & 1 == 0` actually evaluate as?

2. What happens when you compare `-1 < 0u` (where `0u` is unsigned)?

3. Which C++ cast should you use for safe downcasting in a polymorphic class hierarchy?