Control Flow + Functions, the C++ Way
Range-for, lambdas, trailing return types, and the constexpr machine that runs at compile time
Why it matters
Control flow looks like every other C-family language: if, else,
while, for. The interesting parts are what C++ added on top:
range-for for iterating containers without index arithmetic, lambdas
for passing behavior as values, trailing return types for templates that
return whatever the compiler can figure out, and constexpr for asking
the compiler to do work at compile time so the program doesn’t have to do it
at run time. Together they’re 60% of what makes modern C++ feel different
from C.
The boring stuff, briefly
C-style control flow works the way you expect:
if (x > 0) { /* … */ } else if (x == 0) { /* … */ } else { /* … */ }
while (running) { /* … */ }
for (int i = 0; i < n; i++) { /* … */ }
switch (kind) {
case 1: foo(); break; // forget break, you get fallthrough
case 2: bar(); break;
default: baz();
}
Two C++17+ additions to keep in your back pocket:
if (auto v = expr; cond)— declare a variable scoped to theifand itselse. Useful whenexprreturns a “maybe” value.[[fallthrough]];— explicit fall-through inswitch. Compilers warn on accidental fallthrough; this attribute says “yes, on purpose.”
if (auto it = map.find(key); it != map.end()) {
use(it->second); // it is scoped to the if/else
} else {
insert(key);
}
Range-for: stop typing indices
The single most common C++ loop in modern code:
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> words{"hello", "world", "from", "C++"};
// By value — copies each string into the loop variable.
for (std::string w : words) {
std::cout << "copy: " << w << "\n";
}
// By reference — no copy, can't modify.
for (const auto& w : words) {
std::cout << "cref: " << w << "\n";
}
// By mutable reference — modify in place.
for (auto& w : words) {
w += "!";
}
std::cout << "after: " << words[0] << " " << words[1] << "\n";
return 0;
}
The same loop syntax, three different cost+intent profiles. Read your colleagues' range-for loops for the &.
copy: hello
copy: world
copy: from
copy: C++
cref: hello
cref: world
cref: from
cref: C++
after: hello! world! Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out The mnemonic: default to const auto& when reading, auto& when
mutating, plain auto only when you actively want a copy (rare, usually a
mistake). The Python equivalent is for w in words — there’s only one
flavor and it always shares. C++ asks you to be explicit.
Functions: declaration is a promise
A function declaration tells the compiler about a name, its parameter types, and its return type. The compiler can then call it from any translation unit that’s seen the declaration — even before the definition exists, because the linker will resolve it later.
int add(int a, int b); // declaration: a promise
int add(int a, int b) { return a + b; } // definition: keeps the promise
A few flavors worth knowing:
// Default arguments (caller may omit)
void log(const std::string& msg, int level = 0);
// Overloading: same name, different parameter types
int abs(int x);
long abs(long x);
double abs(double x);
// Trailing return type — for when the return type depends on the parameters
template <class T, class U>
auto add(T a, U b) -> decltype(a + b) { return a + b; }
The trailing return type (-> decltype(a + b)) reads weirdly the first
time but solves a real problem: you can’t say decltype(a + b) add(T a, U b) because a and b aren’t in scope yet. By the time the trailing form
shows up, they are.
In modern C++ you can usually just say auto add(T a, U b) { return a + b; }
and let the compiler deduce. Trailing types are still useful in templates
that need to be SFINAE-friendly, but that’s a lesson 10 topic.
Lambdas: behavior as a value
A lambda is an anonymous function you can pass around like an int. The
compiler implements each one as an invisible struct with an operator() —
which is why lambdas can be stored, copied, and called like any other
object.
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
int main() {
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6};
// Pass a lambda to a standard-library algorithm.
std::sort(v.begin(), v.end(), [](int a, int b) {
return a > b; // sort descending
});
std::cout << "sorted: ";
for (int x : v) std::cout << x << ' ';
std::cout << "\n";
// Capture by value: take a snapshot of 'threshold' at lambda-creation time.
int threshold = 4;
auto big = std::count_if(v.begin(), v.end(),
[threshold](int x) { return x > threshold; });
std::cout << "count > 4: " << big << "\n";
// Sum.
int sum = std::accumulate(v.begin(), v.end(), 0);
std::cout << "sum: " << sum << "\n";
return 0;
}
A lambda is the syntax for `[capture](params) { body }`. The standard library was waiting for it.
sorted: 9 6 5 4 3 2 1 1
count > 4: 3
sum: 31 Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out The capture list is the only part with new syntax:
[]— capture nothing.[x]— capturexby value (snapshot at lambda creation).[&x]— capturexby reference. Be careful: the lambda may outlivex.[=]— capture everything used by value. Convenient, scary in templates.[&]— capture everything used by reference. Convenient, scarier.[this]— capture the enclosing object’sthispointer (for methods).[x = expr]— init capture — invent a new lambda-local name.
The capture rule of thumb: be explicit about what you capture and how.
A [&] lambda that escapes its scope is a dangling reference, and dangling
references are how production C++ services crash.
constexpr: doing work at compile time
constexpr is a request to the compiler: if you can run this at compile
time, please do, so we don’t have to at run time.
#include <iostream>
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
int main() {
// Computed at compile time — the binary literally contains 120.
constexpr int five_bang = factorial(5);
std::cout << "5! = " << five_bang << "\n";
// The same function still works at run time too.
int n;
std::cout << "(n hard-coded to 7 for this demo)\n";
n = 7;
std::cout << "7! = " << factorial(n) << "\n";
return 0;
}
Same function. Constant inputs → compile-time evaluation. Runtime inputs → runtime evaluation. Zero overhead either way.
5! = 120
(n hard-coded to 7 for this demo)
7! = 5040 Or run locally
g++ -std=c++23 -O2 snippet.cpp && ./a.out constexpr propagates: a constexpr function can be called from another
constexpr context. This means you can build up surprisingly elaborate
compile-time machinery — lookup tables generated at build time, parser
combinators evaluated by the compiler, even prime sieves run during the
build.
C++20 added consteval (must run at compile time, never at runtime) and
constinit (initialized at compile time, mutable at runtime). The basic
constexpr is still the workhorse.
Why this matters for performance
Every range-for is sugar for a pair of iterator calls — exactly the same
machine code as a hand-rolled for with begin() and ++. Every lambda
compiles to a struct + operator() that the optimizer inlines like any
other function. Every constexpr call you can resolve at compile time
removes work from the runtime.
This is the zero-cost abstraction promise: prettier syntax that the compiler peels away to the same machine instructions a careful C programmer would write by hand. The Python equivalents — list comps, generators, decorators — each cost extra dictionary lookups and bytecode dispatch. In C++, sugar is free.
Key takeaways
- Use range-for with
const auto&to read,auto&to mutate, plainautoonly when you specifically want a copy. - Lambdas are values. Pass them to
<algorithm>functions. Be explicit about captures, especially with[&]. - Trailing return types (
auto f(…) -> T) solve scoping problems in templates; in non-templates,auto f(…) { … }is usually enough. constexprasks the compiler to run code at build time. Costs you nothing at runtime when it works.
What’s next
Lesson 03 is the one C++ courses usually rush through and shouldn’t: stack vs heap, automatic vs dynamic storage, lifetimes, scope. We’ll draw the memory diagram on every example until the picture is automatic.