C++ / CONTROL FLOW
for loops and idiomatic bounds
Write three-clause for loops with half-open bounds, pick the right counter type, and explain why i < n beats i <= n - 1.
What you will learn
- Know when each for clause runs: init once, test before each pass, step after the body.
- Write bounds as half-open [0, n) so n is both the count and the exclusive end.
- Index containers with std::size_t to avoid signed/unsigned comparison warnings.
- Write countdowns as i-- > 0, because i >= 0 never ends an unsigned loop.
Understanding for loops and idiomatic bounds
A for header packs three separate jobs into one line: the init clause runs once before anything else, the condition is evaluated before every pass including the first, and the iteration expression runs after the body finishes and before the next test. Because the condition comes first, a for loop can execute its body zero times, which is what makes it safe on empty data. Names declared in the init clause are scoped to the whole loop and destroyed when it ends, so for (std::size_t i = 0; ...) keeps i out of the surrounding function and lets the next loop reuse the name.
The idiomatic bound is half-open: start at 0 and keep going while i < n, treating n as one past the last valid index. That spelling pays for itself three ways. n - 0 is the iteration count with no correction term, adjacent ranges [a, b) and [b, c) join into [a, c), and n == 0 already means do nothing instead of needing a guard. Writing i <= n - 1 describes the same range but computes a huge wrong value when n is 0 and unsigned, and it fights the begin/end convention the rest of the standard library uses.
The counter's type matters as much as the comparison. v.size() returns an unsigned type, std::size_t in practice, so int i < v.size() converts i to unsigned before comparing and earns a -Wsign-compare warning; declaring std::size_t i matches the container and indexes it without a conversion. The cost is that an unsigned counter has no values below zero: i >= 0 is always true, and --i at zero wraps to the largest std::size_t instead of going negative, so a countdown must test before it decrements. Writing ++i rather than i++ costs nothing for integers and avoids a pointless copy the day the counter becomes a heavier iterator type.
<cstddef>
<iostream>
<vector>
int main() {
std::vector<int> v{10, 20, 30, 40, 50};
// Half-open range [0, v.size()): exactly v.size() passes.
long long sum = 0;
for (std::size_t i = 0; i < v.size(); ++i) {
sum += v[i];
}
std::cout << "count " << v.size() << ", sum " << sum << '\n';
// Any subrange [first, last) holds last - first elements.
const std::size_t first = 1;
const std::size_t last = 4;
std::cout << last - first << " indices:";
for (std::size_t i = first; i < last; ++i) {
std::cout << ' ' << i;
}
std::cout << '\n';
// An empty range needs no guard: the first test already fails.
std::vector<int> nothing;
for (std::size_t i = 0; i < nothing.size(); ++i) {
std::cout << "unreachable";
}
std::cout << "done\n";
}
A for loop's clauses run at fixed times, and a half-open bound [0, n) makes the iteration count, the empty case, and the valid index range all agree.
Worked examples
Counting down with unsigned indices
Shows the i-- > 0 countdown idiom and the modular arithmetic that makes i >= 0 useless.
<cstddef>
<iostream>
<limits>
<vector>
int main() {
std::vector<char> v{'a', 'b', 'c'};
for (std::size_t i = v.size(); i-- > 0; ) {
std::cout << v[i];
}
std::cout << '\n';
std::size_t zero = 0;
std::cout << std::boolalpha
<< "zero - 1 == max size_t: "
<< (zero - 1 == std::numeric_limits<std::size_t>::max()) << '\n';
}
Example explained
Line 1i-- compares the old value and decrements as a side effect, so the first test sees 3 while the body sees index 2.
Line 2The iteration clause is left empty because the decrement already happened inside the condition.
Line 3The final test compares 0 > 0, fails, and wraps i to the maximum, which is harmless because i is never read again and dies with the loop.
Line 4zero - 1 is not negative: unsigned arithmetic is modular, which is exactly why i >= 0 can never stop a std::size_t countdown.
Two counters and the comma operator
Reverses a string in place with a rising index and a one-past-the-end index managed in the same header.
<cstddef>
<iostream>
<string>
<utility>
int main() {
std::string s = "stressed";
for (std::size_t lo = 0, hi = s.size(); lo + 1 < hi; ++lo, --hi) {
std::swap(s[lo], s[hi - 1]);
}
std::cout << s << '\n';
}
Example explained
Line 1The init clause holds one declaration with two declarators, since it may contain only a single statement.
Line 2hi stays one past the element it refers to, matching the half-open convention, which is why the swap uses hi - 1.
Line 3lo + 1 < hi is written instead of lo < hi - 1 so an empty string, where hi is 0, does not underflow to a huge value.
Line 4The comma in ++lo, --hi is the comma operator: both increments run once per pass, left to right, after the body.
Freezing the bound before the body grows the vector
Demonstrates that the condition is re-evaluated every pass, so a live v.size() bound would never be reached.
<cstddef>
<iostream>
<vector>
int main() {
std::vector<int> v{1, 2, 3};
const std::size_t n = v.size();
for (std::size_t i = 0; i < n; ++i) {
v.push_back(v[i] * 10);
}
std::cout << "size " << v.size() << "\nvalues:";
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << ' ' << v[i];
}
std::cout << '\n';
}
Example explained
Line 1const std::size_t n = v.size() records the finish line once, so the loop means the three original elements.
Line 2Using i < v.size() here instead would be re-read every pass and never catch up with push_back, looping until memory runs out.
Line 3v[i] * 10 is computed into a temporary before push_back is called, so a reallocation cannot invalidate the value being appended.
Line 4The printing loop uses i < v.size() on purpose: nothing changes the size there, so re-reading it is the clearest bound.
Important notes
Every clause is optional: for (;;) is a legal endless loop, and an empty iteration clause is normal when the condition or body already advances the counter.
Unsigned wraparound is well defined, not something the compiler traps at runtime, so a broken countdown misbehaves later, when the huge index is used to read memory.
Common mistakes
Writing i <= v.size() instead of i < v.size(): the extra pass evaluates v[v.size()], which is out of bounds undefined behaviour that usually prints a junk number rather than crashing, so the bug survives testing.
Counting down with for (std::size_t i = v.size() - 1; i >= 0; --i): the condition is always true, so after index 0 the decrement wraps i to the largest std::size_t and the next v[i] reads far outside the vector; on an empty vector the wrap happens before the first pass.
Leaving a stray semicolon after the header, as in for (std::size_t i = 0; i < n; ++i); followed by a braced block: the loop spins with an empty body and the block runs exactly once, or fails to compile because i is out of scope there.
Try it yourself
Change, predict, then run
Fill a std::vector<int> with ten values, print the even indices using a half-open bound with i += 2, then print all elements backwards with the i-- > 0 form. Call v.clear() and rerun both loops to confirm they print nothing without any added guard.
Open the C++ workspaceCheck your understanding
For an empty std::vector<int> v, why does for (std::size_t i = v.size() - 1; i >= 0; --i) std::cout << v[i]; read out of bounds on its very first pass?
- std::vector::size() returns -1 when the vector is empty, so v[-1] is read.
- A for loop checks its condition only after the first pass through the body.
- size() is unsigned, so 0 - 1 wraps to the largest std::size_t, and that value satisfies i >= 0.
- operator[] on an empty vector throws before the condition can be evaluated.
Show answer
size() returns an unsigned type and unsigned arithmetic is modular, so 0 - 1 is the maximum std::size_t rather than -1; the condition is checked before the first pass, but for an unsigned i the test i >= 0 is a tautology, so that enormous index reaches v[i]. Option 1 is tempting because do-while does test after the body, but a for loop always tests first, which is precisely how zero-iteration loops are possible; and operator[] never throws, since only at() is checked.