Skip to content
C++
Language
since C++98
Beginner

Conditionals in C++: if, else, switch, and Compile-Time Branching

Master C++ conditional statements — if/else chains, switch, C++17 if-with-initializer, and if constexpr — and understand the scoping rules and pitfalls that trip up new programmers.

By the end of this page you will write correct if/else chains, use switch for multi-way branching, understand dangling-else scoping, and know when to reach for if constexpr (C++17).

What and Why

A conditional executes different code depending on whether a condition is true. C++ has three forms:

FormUse when
if / elseAny boolean condition
switchComparing one value against multiple constants
if constexprChoosing between branches at compile time

Step by Step

Basic if / else

cpp
#include <iostream>

int main() {
    int score = 75;

    if (score >= 90) {
        std::cout << "A\n";
    } else if (score >= 75) {
        std::cout << "B\n";   // this branch runs
    } else {
        std::cout << "C or below\n";
    }
}

Each condition is tested in order; the first true branch executes and the rest are skipped.

Always use braces, even for one-liners. The dangling-else problem is exactly this:

cpp
// Misleading indentation — the else binds to the inner if, not the outer
if (a > 0)
    if (b > 0)
        std::cout << "both positive\n";
else                           // this else belongs to if (b > 0)
    std::cout << "a is not positive\n";  // never prints what you expect

// Fixed: braces make the structure explicit
if (a > 0) {
    if (b > 0) {
        std::cout << "both positive\n";
    }
} else {
    std::cout << "a is not positive\n";
}

if with initializer (C++17)

You can declare a variable scoped to the if block in the same statement:

cpp
#include <map>
#include <string>
#include <iostream>

std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 75}};

if (auto it = scores.find("Alice"); it != scores.end()) {
    std::cout << "Alice scored: " << it->second << '\n';
}
// it is out of scope here — no name pollution

// Also useful with dynamic_cast
if (auto* derived = dynamic_cast<Derived*>(base_ptr); derived != nullptr) {
    derived->specific_method();
}

The variable lives only for the duration of the if/else block, which is exactly the lifetime you want.

switch

Use switch when comparing one integer (or enum) value against multiple compile-time constants:

cpp
#include <iostream>

enum class Direction { North, South, East, West };

void move(Direction d) {
    switch (d) {
        case Direction::North: std::cout << "Moving north\n"; break;
        case Direction::South: std::cout << "Moving south\n"; break;
        case Direction::East:  std::cout << "Moving east\n";  break;
        case Direction::West:  std::cout << "Moving west\n";  break;
    }
}

The break is mandatory at the end of each case unless you intentionally want fallthrough. Forgetting it causes execution to fall into the next case — a common bug.

The default label catches any value not matched by a case:

cpp
switch (error_code) {
    case 0: return "OK";
    case 1: return "Not found";
    case 2: return "Permission denied";
    default: return "Unknown error";
}

Intentional fallthrough (C++17)

When you want fallthrough on purpose, use [[fallthrough]] to suppress compiler warnings and document intent:

cpp
switch (level) {
    case 3:
        apply_gold_perks();
        [[fallthrough]];   // intentional: level 3 also gets silver and bronze perks
    case 2:
        apply_silver_perks();
        [[fallthrough]];
    case 1:
        apply_bronze_perks();
        break;
    default:
        break;
}

if constexpr (C++17)

if constexpr evaluates the condition at compile time and discards the branch that does not apply — including not compiling it:

cpp
#include <type_traits>
#include <iostream>

template<typename T>
void print_type_info(T value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "Integer: " << value << '\n';
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "Float: " << value << '\n';
    } else {
        std::cout << "Other type\n";
    }
}

int main() {
    print_type_info(42);    // "Integer: 42"
    print_type_info(3.14);  // "Float: 3.14"
}

The discarded branch is not instantiated — it can reference things that do not exist for that type without causing a compile error. This is what makes if constexpr different from a regular if inside a template.

Common Patterns

Pattern 1 — guard clause (early return)

Instead of deeply nested if/else, return early for invalid states:

cpp
// Nested — hard to read
void process(const std::string& input) {
    if (!input.empty()) {
        if (input.size() < 1024) {
            if (is_valid(input)) {
                do_work(input);
            }
        }
    }
}

// Guard clauses — flat, readable
void process(const std::string& input) {
    if (input.empty()) return;
    if (input.size() >= 1024) return;
    if (!is_valid(input)) return;
    do_work(input);
}

Pattern 2 — ternary for simple value selection

cpp
// Verbose
std::string label;
if (count == 1) label = "item";
else label = "items";

// Concise
std::string label = (count == 1) ? "item" : "items";

Use the ternary only when both branches are simple expressions. For anything complex, an if/else is clearer.

Pattern 3 — switch over enum (exhaustive)

cpp
enum class Color { Red, Green, Blue };

std::string color_name(Color c) {
    switch (c) {
        case Color::Red:   return "red";
        case Color::Green: return "green";
        case Color::Blue:  return "blue";
        // no default: compiler warns if a new enum value is added
    }
    __builtin_unreachable();  // or std::unreachable() in C++23
}

Omitting default lets the compiler warn when you add a new enum value and forget to handle it.

What Can Go Wrong

Missing break in switch

cpp
switch (x) {
    case 1: std::cout << "one\n";   // falls through to case 2!
    case 2: std::cout << "two\n";
}
// If x==1, prints both "one" and "two"

Always end each case with break, return, or [[fallthrough]].

Comparing floating-point for equality

cpp
double d = 0.1 + 0.2;
if (d == 0.3) { }   // false! floating-point arithmetic is not exact

// Use an epsilon comparison
if (std::abs(d - 0.3) < 1e-9) { }

Assignment in condition

cpp
if (x = 5) { }   // assigns 5 to x, then checks if x is truthy — probably a typo

// Modern compilers warn about this; use == for comparison
if (x == 5) { }

Some style guides place the constant first (if (5 == x)) to turn the accidental assignment into a compile error, though modern compilers catch this anyway.

switch on non-integer types

switch requires an integer or enum type. You cannot switch on std::string, float, or custom types:

cpp
// Wrong — does not compile
switch (my_string) {
    case "hello": ...
}

// Right — use if/else or a lookup map
if (my_string == "hello") { ... }
else if (my_string == "world") { ... }

Quick Reference

TaskSyntax
Two-way branchif (cond) { } else { }
Multi-way branchif ... else if ... else
Branch with scoped variableif (auto x = f(); x != nullptr) { } (C++17)
Multi-value matchswitch (val) { case A: ... break; }
Intentional fallthrough[[fallthrough]]; (C++17)
Compile-time branchif constexpr (condition) { } (C++17)
Short value selectioncondition ? value_true : value_false

Ternary Operator Deep Dive

The ternary operator is an expression, not a statement — it produces a value where if/else cannot. When you need to initialize a const variable or pass a value inline, the ternary lets you do it in one line. However, nesting ternaries quickly becomes unreadable, and the type rules have a few surprises.

cpp
#include <string>
#include <iostream>

int main() {
    int count = 3;

    // Good use: single-level, both branches are simple literals
    const std::string label = (count == 1) ? "item" : "items";
    std::cout << count << ' ' << label << '\n';

    // Bad use: nested ternaries — hard to parse
    int x = 7;
    std::string tier = (x >= 9) ? "gold" : (x >= 6) ? "silver" : "bronze";
    // Prefer if/else for anything with more than one level
}

Type rules: both branches must have a common type. If they differ, the compiler applies the usual arithmetic conversions. This can silently change what you get:

cpp
int a = 1;
double b = 2.5;
auto result = (true) ? a : b;   // result is double (1.0), not int
// The int branch is implicitly converted to double

Use if/else when the branches are complex, have side effects, or differ in type in a way that might surprise a reader.

Short-Circuit Evaluation

&& and || guarantee that the right operand is only evaluated if the left operand does not already determine the result. This is not just an optimization — it is a safety guarantee written into the standard, and you can rely on it in production code.

cpp
#include <iostream>

struct Node {
    int value;
    Node* next = nullptr;
};

bool has_positive(Node* p) {
    // Safe: p->value is only evaluated when p is not null
    return p != nullptr && p->value > 0;
}

int main() {
    Node n{42};
    std::cout << has_positive(&n) << '\n';  // 1
    std::cout << has_positive(nullptr) << '\n'; // 0 — no dereference
}

The same applies to ||: if the left side is true, the right side is skipped. This matters when the right side has a side effect (like a function call) that you only want to happen conditionally:

cpp
bool logged_in = false;
bool admin    = false;

// open_dashboard() is never called when logged_in is false
if (logged_in && open_dashboard()) {
    // ...
}

// fallback() is only called when primary() returns false
if (primary() || fallback()) {
    // ...
}

Sequence rules (C++17 and later) guarantee that the left operand is fully evaluated — and all its side effects complete — before the right operand is even started.

Structured Bindings in Conditions (C++17)

Combining a structured binding with the if-with-initializer syntax lets you unpack a result and test its validity in a single line. This is especially useful with standard library functions that return std::pair or std::tuple as success/failure signals.

cpp
#include <map>
#include <string>
#include <iostream>

int main() {
    std::map<std::string, int> inventory = {{"apple", 5}};

    // emplace returns pair<iterator, bool>
    // The bool says whether the insertion happened
    if (auto [it, inserted] = inventory.emplace("banana", 3); inserted) {
        std::cout << "Inserted: " << it->first << " -> " << it->second << '\n';
    } else {
        std::cout << it->first << " already existed\n";
    }

    // Works with try_emplace too
    if (auto [pos, ok] = inventory.try_emplace("apple", 99); !ok) {
        std::cout << "apple already at " << pos->second << '\n';
    }
}

The structured binding variables (it, inserted) are scoped to the if/else block, just like any variable in the initializer. Once the block ends, they are gone — no name leakage into the surrounding scope.

Common Pitfalls

Assignment instead of comparison

A single = inside an if condition assigns, not compares, and almost always compiles without error. The assignment itself evaluates to the assigned value, so the condition becomes true for any non-zero value.

cpp
int x = 0;

if (x = 5) {               // assigns 5, then tests 5 — always true
    std::cout << "oops\n"; // always prints
}

if (x == 5) {              // correct: comparison
    std::cout << "x is 5\n";
}

Modern compilers (-Wall) warn about this. Some teams write if (5 == x) (Yoda condition) so a typo if (5 = x) becomes a compile error, but the compiler warning is usually sufficient.

Comparing floats with ==

Floating-point numbers are represented in binary, so most decimal fractions are only approximations. Testing for exact equality almost never does what you intend.

cpp
#include <cmath>
#include <iostream>

int main() {
    double d = 0.1 + 0.2;
    std::cout << (d == 0.3) << '\n';        // prints 0 (false)
    std::cout << (d - 0.3) << '\n';         // prints something like 5.55e-17

    // Correct: test within a tolerance
    constexpr double kEps = 1e-9;
    if (std::abs(d - 0.3) < kEps) {
        std::cout << "close enough\n";
    }
}

The right epsilon depends on the magnitude of your numbers. For values around 1, 1e-9 is a common choice. For larger values, scale the epsilon accordingly.

Signed/unsigned comparison

Comparing a signed integer to an unsigned one can produce surprising results because the signed value is implicitly converted to unsigned. A negative signed number becomes a very large unsigned number.

cpp
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3};
    int n = -1;

    // signed/unsigned mismatch: n is converted to a huge size_t
    if (n < v.size()) {          // -Wall warns here
        std::cout << "less\n";   // may or may not print — undefined territory
    }

    // Fix option 1: cast the signed value
    if (static_cast<std::size_t>(n) < v.size()) { /* still wrong for negatives */ }

    // Fix option 2: check sign first
    if (n >= 0 && static_cast<std::size_t>(n) < v.size()) {
        std::cout << "in range\n";
    }

    // Fix option 3 (C++20): use std::ssize for signed size
    if (n < std::ssize(v)) {
        std::cout << "in range\n";
    }
}

Enable -Wsign-compare (included in -Wall) to catch these at compile time.

if constexpr vs. Runtime if — The Key Difference

if constexpr is evaluated entirely at compile time: the discarded branch is not just skipped at runtime — it is never instantiated. This matters inside templates, where a branch that would be invalid for a given type would otherwise cause a compile error even if it never runs.

cpp
#include <type_traits>
#include <string>
#include <iostream>

// This would fail to compile with a plain `if`:
// the else-branch calls .to_string() which does not exist for int
template<typename T>
std::string describe(T v) {
    if constexpr (std::is_arithmetic_v<T>) {
        return "number: " + std::to_string(v);
    } else {
        // Only compiled when T is NOT arithmetic.
        // For T=int this branch does not exist in the binary at all.
        return "other: " + v.to_string();
    }
}

int main() {
    std::cout << describe(42) << '\n';         // "number: 42"
    // describe("hi") would pick the else branch — needs .to_string()
}

With a plain if, both branches must be syntactically and semantically valid for every instantiation. With if constexpr, only the branch that matches is compiled for that instantiation.

A second difference: a constexpr condition must be a constant expression. You cannot use a runtime variable in if constexpr.

Branchless Programming Preview

Every branch is a potential pipeline stall on modern CPUs; replacing a simple if/else with arithmetic or standard library functions can eliminate the branch entirely. This is called branchless programming, and it matters most in hot loops on large data.

cpp
#include <algorithm>
#include <iostream>

int main() {
    int a = 5, b = 3;

    // Branch version — CPU must predict which path to take
    int max_val;
    if (a > b) max_val = a;
    else        max_val = b;

    // Branchless version — compiles to a conditional move (cmov) on x86
    int max_std = std::max(a, b);

    // Ternary also typically compiles to cmov for simple integer types
    int max_ternary = (a > b) ? a : b;

    std::cout << max_val << ' ' << max_std << ' ' << max_ternary << '\n';
}

std::min and std::max are the idiomatic C++ way to get branchless min/max. The compiler's optimizer turns them into cmov (conditional move) instructions on x86, which avoids the branch predictor entirely.

Do not reach for branchless tricks prematurely. Profile first — a branch that is almost always taken will be predicted correctly and is essentially free.

What's Next

  • Loops — for, while, and do-while to repeat code.
  • Types and Variables — the values you'll be testing in conditions.
  • Functions — once you have conditionals and loops, functions let you name and reuse those patterns.