Conditions
Any non-trivial program makes choices. Whether to load a file if it exists. Whether to call a method if the pointer is non-null. Whether to apply an algorithm if the type is already known at compile time. Behind each of these choices stands one of the forms of conditional branching.
C++ has several of them, and each has its own role:
if / else— universal branching on any expressionswitch— multi-way selection on an integer value- the ternary operator
?:— choosing a value in a single expression if constexpr— branching that happens not at run time but at compile time
Understanding when to use each form is the essence of the topic.
if / else
The basic branching construct. The condition can be any expression convertible to bool:
int x = 42;
if (x > 0) {
std::cout << "positive\n";
} else if (x < 0) {
std::cout << "negative\n";
} else {
std::cout << "zero\n";
}
With a single statement the braces are optional, but recommended. Without them, one extra statement during an edit and you get a "dangling else" or an accidental logic bug:
// Dangerous: adding a line breaks the logic
if (flag)
do_a();
do_b(); // always runs, despite the indentation
// Safe
if (flag) {
do_a();
do_b();
}
if with an initializer (C++17)
Lets you declare a variable right in the condition, scoping it to the if block. This reduces pollution of the surrounding scope:
if (int result = compute(); result > 0) {
use(result);
} else {
log_error(result);
}
// result is not accessible here — an intent fixed by the syntax
Especially handy when searching in containers:
if (auto it = map.find(key); it != map.end()) {
process(it->second);
}
The same works for switch:
switch (auto status = getStatus(); status) {
case Status::Ok: handle_ok(); break;
case Status::Error: handle_error(); break;
default: handle_unknown(); break;
}
switch
switch compares an integer (or enum) expression against a set of constants. For a dense set of values the compiler can generate a jump table — faster than a chain of if-else:
int day = 3;
switch (day) {
case 1: std::cout << "Mon"; break;
case 2: std::cout << "Tue"; break;
case 3: std::cout << "Wed"; break;
default: std::cout << "Unknown day"; break;
}
Fallthrough
Without break, execution "falls through" into the next case. This is a source of popular bugs, but sometimes it's exactly what you want:
switch (val) {
case 1:
case 2:
std::cout << "1 or 2\n"; // both cases land here
break;
case 3:
std::cout << "3\n";
[[fallthrough]]; // explicit marker — intentional fallthrough (C++17)
case 4:
std::cout << "3 or 4\n";
break;
}
The [[fallthrough]] attribute (C++17) tells the compiler and the reader that the fallthrough is intentional, not a forgotten break. Without it, many compilers with -Wall will emit a warning.
switch vs if-else
- Many cases of one value
- Integer type or enum
- The compiler can generate a jump table
- Range comparisons (x > 0)
- Complex conditions with &&, ||
- Different types in different branches
The ternary operator
condition ? value_if_true : value_if_false is an expression, not a statement. The result can be assigned, passed into a function, or returned from one:
int x = 10;
std::string sign = (x >= 0) ? "non-negative" : "negative";
int abs_val = (x < 0) ? -x : x;
std::cout << (flag ? "yes" : "no");
The ternary operator is appropriate for a concise choice of value. Avoid nesting — two levels of nesting already make the code unreadable:
// Bad: nested ternary
std::string label = (x > 0) ? "pos" : (x < 0) ? "neg" : "zero";
// Good: if-else is clearer
std::string label;
if (x > 0) label = "pos";
else if (x < 0) label = "neg";
else label = "zero";
Short-circuit evaluation
The logical operators && and || use lazy evaluation: the right-hand operand is evaluated only if necessary.
a && b— ifais false,bis not evaluateda || b— ifais true,bis not evaluated
This is not just an optimization — it is a guarantee of the standard. The order of evaluation is strictly left to right.
The main practical use is a null check before dereferencing:
int* ptr = getPointer(); // may return nullptr
// Safe: ptr != nullptr is evaluated first
if (ptr != nullptr && *ptr > 0) {
use(*ptr);
}
// Without short-circuit, *ptr would dereference nullptr → UB
The same works for || as an "early exit":
// find_user() is not called if ptr == nullptr
if (ptr == nullptr || find_user(ptr->id).valid()) {
handle();
}
Short-circuit also guards against expensive computations in conditions:
// scanDirectory() runs only if cache.has(key) returned false
if (!cache.has(key) && scanDirectory(path).contains(key)) {
cache.insert(key);
}
Careful: & and | (the bitwise operators) are not short-circuit — they always evaluate both operands. Don't confuse them with && and || in conditions.
Working with pointers and nullptr
Always check a pointer before dereferencing it. A common pattern is the early return:
void process(const std::string* name) {
if (name == nullptr) return;
std::cout << *name;
}
Since C++11 use nullptr instead of NULL or 0 — it is type-safe and explicit:
int* p = nullptr; // good
int* q = NULL; // deprecated, a macro
int* r = 0; // works, but implicitly converts int
Best of all are std::optional or smart pointers, which remove the very need to check "is there a value" by hand.
if constexpr (C++17)
This is branching at the compiler level. Unlike an ordinary if, the branch whose condition is false is not compiled at all — it may contain code that is syntactically valid but semantically impossible for the given type.
The classic case is a template function that behaves differently depending on the type:
template <typename T>
void print(const 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 << "Floating-point: " << std::fixed << value << "\n";
} else {
std::cout << "Other: " << value << "\n"; // requires operator<<
}
}
print(42); // → is_integral branch
print(3.14); // → is_floating_point branch
print("hello"); // → else branch
If this were an ordinary if, the compiler would try to compile every branch for each instantiation — and would fail when value does not support the required operations.
if constexpr — what gets compiled
if constexpr requires C++17. C++23 added if consteval — branching on "are we inside a constant evaluation" — but that is already a narrowly specialized feature.
Branch prediction and the likely/unlikely attributes
Modern processors predict which branch of an if will run and load the instructions ahead of time. A misprediction flushes the pipeline, and that costs tens of cycles. If one branch is clearly rare, you can give the compiler a hint:
int value = getValue();
if (value == 0) [[unlikely]] {
handle_error(); // happens rarely
}
if (value > 0) [[likely]] {
process(value); // the main path
}
The [[likely]] and [[unlikely]] attributes (C++20) affect code layout — the main path stays "inline", the rare path is moved out. Don't add them without profiling: a wrong hint is worse than no hint.
std::visit as an alternative to if-else on types
Long chains of if (std::holds_alternative<T>(v)) are a sign that std::visit with pattern matching on the types of a std::variant would fit better:
std::variant<int, double, std::string> v = 42;
std::visit([](auto&& val) {
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << val;
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << val;
} else {
std::cout << "string: " << val;
}
}, v);
std::visit guarantees that all alternatives are handled — if you add a new type to the variant and don't update the lambda, you get a compile error rather than a silent bug.
Common mistakes
Assignment instead of comparison:
int x = 5;
if (x = 10) { ... } // always true — assigns 10 rather than comparing
if (x == 10) { ... } // correct
The compiler usually warns about this. Some people write 10 == x (a "Yoda condition") to turn the mistake into a syntax error. Better to simply not ignore warnings.
Forgotten break in a switch:
switch (state) {
case A: doA(); // falls through into B — a bug
case B: doB(); break;
}
An ordinary if instead of if constexpr in a template:
template <typename T>
void bad(T val) {
if (std::is_integral_v<T>) {
val.to_string(); // compile error for T=int, even though the branch doesn't run
}
}
An ordinary if does not exclude a branch from compilation — both branches must be valid for any T. if constexpr solves exactly this.
Interview relevance
Conditions are standard material for checking language understanding at every level: juniors are asked about short-circuit, middles about if constexpr in templates, seniors about branch prediction.
Typical checks:
- Whether you understand the difference between
&&and&(and that short-circuit is a guarantee of the standard) - Whether you know that
if constexpris compile-time branching, not run-time - Whether you can explain fallthrough and
[[fallthrough]] - Whether you understand
[[likely]]/[[unlikely]]and when they make sense - Whether you can use
ifwith an initializer to limit a variable's scope
Common question directions:
- "What is short-circuit evaluation and what is it used for?" — they expect the null guard, not just a definition
- "How does
if constexprdiffer from an ordinaryifin templates?" - "What happens if you forget
breakin a switch?" - "How do you hint the compiler about a branch's probability?"
- "When a condition has
if (auto it = m.find(k); it != m.end())— why do this, and how does it differ from two separate lines?"
Common wrong answer: treating if constexpr as a "fast if" — a run-time speedup. In reality it is a mechanism for selecting a branch at compile time, so that the discarded branch is not compiled at all.