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:
| Form | Use when |
|---|---|
if / else | Any boolean condition |
switch | Comparing one value against multiple constants |
if constexpr | Choosing between branches at compile time |
Step by Step
Basic if / else
#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:
// 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:
#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:
#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:
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:
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:
#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:
// 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
// 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)
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
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
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
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:
// 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
| Task | Syntax |
|---|---|
| Two-way branch | if (cond) { } else { } |
| Multi-way branch | if ... else if ... else |
| Branch with scoped variable | if (auto x = f(); x != nullptr) { } (C++17) |
| Multi-value match | switch (val) { case A: ... break; } |
| Intentional fallthrough | [[fallthrough]]; (C++17) |
| Compile-time branch | if constexpr (condition) { } (C++17) |
| Short value selection | condition ? 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.
#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:
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 doubleUse 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.
#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:
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.
#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.
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.
#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.
#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.
#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.
#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, anddo-whileto 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.