Foundations · #2 of 13

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 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;
}
idle

The same loop syntax, three different cost+intent profiles. Read your colleagues' range-for loops for the &.

expected output
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;
}
idle

A lambda is the syntax for `[capture](params) { body }`. The standard library was waiting for it.

expected output
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:

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;
}
idle

Same function. Constant inputs → compile-time evaluation. Runtime inputs → runtime evaluation. Zero overhead either way.

expected output
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

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.