C++ · lesson 6 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Control Flow in C++
C++ control flow with real output: if with initializers, switch and [[fallthrough]], loops over stdin, if constexpr, break/continue, and std::optional.
By SkillByExample · Updated
if / else
if works as in most languages. C++17 adds an init-statement — if (init; condition) — that scopes a variable to the if/else alone.
#include <iostream>
#include <map>
#include <string>
char grade(int score) {
if (score >= 90) return 'A';
else if (score >= 80) return 'B';
else if (score >= 70) return 'C';
else return 'F';
}
int main() {
for (int s : {95, 85, 70, 69}) std::cout << s << " -> " << grade(s) << "\n";
std::map<std::string, int> config = {{"timeout", 30}, {"retries", 3}};
if (auto it = config.find("timeout"); it != config.end()) // C++17 init-statement
std::cout << "timeout: " << it->second << "\n";
// 'it' does not exist here
for (int attempt = 0; attempt < 2; ++attempt) {
if (auto [pos, inserted] = config.emplace("max_conn", 100); inserted)
std::cout << "added max_conn = " << pos->second << "\n";
else
std::cout << "max_conn already " << pos->second << "\n";
}
}
$ g++ -std=c++20 -Wall -Wextra -o grades grades.cpp
$ ./grades
95 -> A
85 -> B
70 -> C
69 -> F
timeout: 30
added max_conn = 100
max_conn already 100
The else if chain checks top to bottom and stops at the first match, so 85 is a B even though it’s also >= 70. The iterator it exists only inside the if. The second loop reruns the same emplace: the first time inserted is true, the second time the key already exists, nothing is inserted, and pos points at the existing entry.
switch
switch dispatches on an integer or enum value. Cases fall through into the next one unless you break.
#include <iostream>
enum class Direction { North, South, East, West };
void move(Direction dir) {
switch (dir) {
case Direction::North: std::cout << "north\n"; break;
case Direction::South: std::cout << "south\n"; break;
case Direction::East: // two labels, one body
case Direction::West: std::cout << "east or west\n"; break;
}
}
void process_event(int event) {
std::cout << "event " << event << ":";
switch (event) {
case 1:
std::cout << " pre-process";
[[fallthrough]]; // intentional: run case 2 too
case 2:
std::cout << " process";
break;
default:
std::cout << " ignored";
}
std::cout << "\n";
}
int main() {
move(Direction::North);
move(Direction::West);
for (int e : {1, 2, 3}) process_event(e);
}
$ g++ -std=c++20 -Wall -Wextra -o switch switch.cpp
$ ./switch
north
east or west
event 1: pre-process process
event 2: process
event 3: ignored
East and West share one body by stacking labels. Event 1 prints both “pre-process” and “process” because [[fallthrough]] lets execution continue into case 2; event 3 matches no case and hits default. move has no default, deliberately — every Direction is handled, so -Wall would warn if someone added a fifth enumerator.
Forget a break without the attribute and -Wextra catches it:
#include <iostream>
int main() {
int level = 1;
switch (level) {
case 1:
std::cout << "debug\n"; // forgot break
case 2:
std::cout << "info\n";
break;
}
}
$ g++ -std=c++20 -Wall -Wextra -o forgot_break forgot_break.cpp
forgot_break.cpp: In function 'int main()':
forgot_break.cpp:7:26: warning: this statement may fall through [-Wimplicit-fallthrough=]
7 | std::cout << "debug\n"; // forgot break
| ^~~~~~~~~
forgot_break.cpp:8:9: note: here
8 | case 2:
| ^~~~
$ ./forgot_break
debug
info
The program still compiles and runs, printing both lines — the warning is the only thing standing between you and the bug.
while and do-while
while checks before each pass; do-while checks after, so its body runs at least once. Here the program reads from a pipe instead of the keyboard.
#include <iostream>
#include <string>
int main() {
int countdown = 3;
while (countdown > 0) std::cout << countdown-- << " ";
std::cout << "liftoff\n";
int input;
do { // body runs at least once
std::cout << "positive number? ";
std::cin >> input;
std::cout << "got " << input << "\n";
} while (input <= 0);
std::string line;
std::getline(std::cin, line); // eat the rest of the number's line
while (std::getline(std::cin, line)) { // stops at end of input
if (line == "quit") break;
std::cout << "you said: " << line << "\n";
}
}
$ g++ -std=c++20 -Wall -Wextra -o loops loops.cpp
$ printf '%s\n' -5 0 7 hello quit ignored | ./loops
3 2 1 liftoff
positive number? got -5
positive number? got 0
positive number? got 7
you said: hello
The prompt appears three times because do-while keeps asking until the number is positive. The typed input doesn’t echo because it came from printf, not a terminal. Two details matter: after std::cin >> input the newline is still in the buffer, so one getline discards it — without that, the first line read would be empty. And break on quit means ignored is never read; if the input ran out first, getline would return false and end the loop just the same.
for Loops
The three-part for gives full control over the index; the range-for iterates a container directly.
#include <iostream>
#include <string>
#include <vector>
int main() {
for (int i = 0; i < 5; ++i) std::cout << i << " ";
std::cout << "\n";
for (int i = 0, j = 9; i < j; i += 2, j -= 2) // two indices, moving inward
std::cout << "(" << i << "," << j << ") ";
std::cout << "\n";
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
for (std::size_t i = 0; i < names.size(); ++i)
std::cout << i << ": " << names[i] << "\n";
std::vector<int> nums = {10, 20, 30};
for (int n : nums) n += 1; // copy: no effect
for (int& n : nums) n *= 2; // reference: modifies
for (const int& n : nums) std::cout << n << " "; // read-only, no copy
std::cout << "\n";
for (int i = 3; i >= 0; --i) std::cout << i << " "; // counting down with int
std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o for_loops for_loops.cpp
$ ./for_loops
0 1 2 3 4
(0,9) (2,7) (4,5)
0: Alice
1: Bob
2: Carol
20 40 60
3 2 1 0
The comma operator lets one for update two indices. In the range-fors, int n is a copy (the += 1 did nothing), int& n modifies in place (10, 20, 30 doubled), and const int& reads without copying — the default for anything bigger than a number.
The counting-down loop used a signed int. With std::size_t it breaks:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
int printed = 0;
for (std::size_t i = v.size() - 1; i >= 0; --i) { // i >= 0 is always true
std::cout << "i = " << i << "\n";
if (++printed == 5) break; // safety valve for the demo
}
}
$ g++ -std=c++20 -Wall -Wextra -o unsigned_down unsigned_down.cpp
unsigned_down.cpp: In function 'int main()':
unsigned_down.cpp:7:42: warning: comparison of unsigned expression in '>= 0' is always true [-Wtype-limits]
7 | for (std::size_t i = v.size() - 1; i >= 0; --i) { // i >= 0 is always true
| ~~^~~~
$ ./unsigned_down
i = 2
i = 1
i = 0
i = 18446744073709551615
i = 18446744073709551614
i >= 0 is always true for an unsigned type, and GCC says so. After i = 0, --i wraps to 18446744073709551615 and the loop would run (and index out of bounds) forever; the break after five lines is only there to stop the demo.
if constexpr (C++17)
In a template, both branches of a regular if must compile for every T. if constexpr discards the branch that doesn’t apply at compile time.
#include <iostream>
#include <string>
#include <type_traits>
template <typename T>
std::string describe(T value) {
if constexpr (std::is_integral_v<T>)
return "integer: " + std::to_string(value);
else if constexpr (std::is_floating_point_v<T>)
return "float: " + std::to_string(value);
else if constexpr (std::is_same_v<T, std::string>)
return "string: " + value; // to_string(value) wouldn't compile here
else
return "unknown type";
}
int main() {
std::cout << describe(42) << "\n";
std::cout << describe(3.14) << "\n";
std::cout << describe(std::string{"hi"}) << "\n";
std::cout << describe("hi") << "\n"; // const char*, not std::string
std::cout << describe('x') << "\n"; // char is an integral type
}
$ g++ -std=c++20 -Wall -Wextra -o describe describe.cpp
$ ./describe
integer: 42
float: 3.140000
string: hi
unknown type
integer: 120
std::to_string(value) would not compile for a std::string, and "string: " + value would not compile for an int — but each only exists in the instantiation where it’s valid. The last two lines are the surprises: "hi" is a const char*, not a std::string, so it falls to “unknown type”; and char is an integral type, so 'x' prints as its character code, 120.
break, continue and Early Return
break leaves the loop, continue skips to the next iteration, and guard clauses return before the happy path.
#include <iostream>
#include <string>
#include <vector>
bool valid_user(const std::string& name, int age) {
if (name.empty()) return false; // guard clauses: fail fast,
if (age < 0 || age > 150) return false; // keep the happy path flat
return true;
}
int main() {
std::vector<int> data = {3, 7, 2, 9, 4, 9};
int found = -1;
for (int i = 0; i < static_cast<int>(data.size()); ++i) {
if (data[i] == 9) { found = i; break; } // first match only
}
std::cout << "first 9 at index " << found << "\n";
for (int n : data) {
if (n % 2 == 0) continue; // skip evens
std::cout << n << " ";
}
std::cout << "\n" << std::boolalpha
<< valid_user("ada", 36) << " " << valid_user("", 36) << " "
<< valid_user("bob", 200) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o jumps jumps.cpp
$ ./jumps
first 9 at index 3
3 7 9 9
true false false
break stops at the first 9 (index 3) even though there’s another at index 5. continue skips 2 and 4, so both 9s print. valid_user rejects the empty name and the age of 200 in one-line guards, keeping the success path at the bottom without nesting.
std::optional (C++17)
std::optional<T> makes “maybe no result” part of the type, instead of a sentinel like -1.
#include <iostream>
#include <map>
#include <optional>
#include <string>
std::optional<int> find_score(const std::map<std::string, int>& db,
const std::string& user) {
auto it = db.find(user);
if (it == db.end()) return std::nullopt;
return it->second;
}
int main() {
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 0}};
for (const auto& user : {"Alice", "Bob", "Dave"}) {
if (auto s = find_score(scores, user)) // true if it holds a value
std::cout << user << " scored " << *s << "\n";
else
std::cout << user << " not found\n";
}
std::cout << "Carol: " << find_score(scores, "Carol").value_or(-1) << "\n";
try {
find_score(scores, "Carol").value(); // empty: throws
} catch (const std::bad_optional_access& e) {
std::cout << "value() threw: " << e.what() << "\n";
}
}
$ g++ -std=c++20 -Wall -Wextra -o optional optional.cpp
$ ./optional
Alice scored 95
Bob scored 0
Dave not found
Carol: -1
value() threw: bad optional access
Bob’s score is 0 but he’s still found — an optional distinguishes “present and zero” from “absent”, which a sentinel of 0 or -1 can’t. if (auto s = ...) tests for presence and *s reads the value; value_or(-1) supplies a default. Calling value() on an empty optional throws std::bad_optional_access, while *s on an empty one is undefined behavior — so dereference only after checking.
[[nodiscard]]
[[nodiscard]] makes the compiler warn when a caller ignores a return value.
#include <optional>
#include <string>
[[nodiscard]] std::optional<std::string> read_config(const std::string& path) {
if (path.empty()) return std::nullopt;
return "port=8080";
}
int main() {
read_config("app.cfg"); // result dropped on the floor
auto cfg = read_config("app.cfg"); // fine
return cfg ? 0 : 1;
}
$ g++ -std=c++20 -Wall -Wextra -o nodiscard nodiscard.cpp 2>&1 | grep warning
nodiscard.cpp:10:27: warning: ignoring return value of 'std::optional<std::__cxx11::basic_string<char> > read_config(const std::string&)', declared with attribute 'nodiscard' [-Wunused-result]
Only the first call warns; the second uses the result. Put [[nodiscard]] on functions whose return value is the whole point — optionals, error codes, factory functions.
Practice
-
Rewrite
gradewith aswitchonscore / 10, using stacked labels for 10 and 9.Solution
char grade(int score) { switch (score / 10) { case 10: case 9: return 'A'; case 8: return 'B'; case 7: return 'C'; default: return 'F'; } }Integer division maps 95 to 9, 85 to 8 and 69 to 6. A
returnends the case, so nobreakis needed. -
Fix
unsigned_down.cppso it prints 2, 1, 0 while keepingstd::size_t.Solution
for (std::size_t i = v.size(); i-- > 0;) std::cout << "i = " << i << "\n";The test
i-- > 0compares first and decrements second, so the body sees 2, 1, 0 and the loop stops wheniwas 0 — it never needs a negative value. -
Remove the extra
std::getlineinloops.cppand run it with the same input. What changes?Solution
An extra
you said:line with nothing after it appears first.cin >> inputleft the newline after7in the buffer, and the firstgetlinereads that empty remainder of the line. -
Add a
booloverload todescribesodescribe(true)printsbool: trueinstead ofinteger: 1.Solution
if constexpr (std::is_same_v<T, bool>) return std::string("bool: ") + (value ? "true" : "false"); else if constexpr (std::is_integral_v<T>) return "integer: " + std::to_string(value);boolis an integral type, so theboolcheck must come before the integral one. -
Write
std::optional<int> parse_int(const std::string& s)that returnsstd::nulloptinstead of throwing whenstd::stoifails.Solution
std::optional<int> parse_int(const std::string& s) { try { std::size_t used = 0; int n = std::stoi(s, &used); if (used != s.size()) return std::nullopt; // trailing junk like "42abc" return n; } catch (const std::exception&) { return std::nullopt; } }Checking
usedalso rejects"42abc", which plainstoiwould accept as 42.