It is relatively easy to get your for-loops wrong. Luckily, C++ offers more and more bug-proof alternatives.

Technically, one could iterate over a sequence of elements like this:

int i = 0;
ITERATION_STEP: 
if (i != records.size())
{
  std::println("{}", records[i]);
  ++i;
  goto ITERATION_STEP;
}

Counter to some incorrect beliefs, there is no undefined behavior here. Jumping back never skips any initialization or clean-up. We are advised against using goto for a different reason. It is very low-level, very versatile and very flexible. While the last two adjectives may sound positive, they are actually not. When it comes to correctness and language-safety, the two properties that we are after are:

  1. Is it easy to implement an unintended behavior?
  2. Is it easy to spot the unintended behavior in code?

With goto, even in medium-sized examples it is too easy to implement something unintended, and then spotting the bug is even harder. In contrast, a for-loop offers a structure: we are saying, “this is an iteration; expect a repetition, a condition and the increment statement”. These components have even their syntactic place:

for (SETUP ; COND ; INCR)
  REPEATED_STATEMENT

This reflects the intention of performing an iteration directly. Whoever gets to read this code, they immediately understand what statement is being repeated, they can estimate if and when the iteration stops. Plus a new scope for the control variable is introduced.

This good old for-loop is a step in the right direction, but it is still too low level, and it is still too easy to plant and then overlook a bug. Consider:

for (auto i = 0; i <= vec.size(); ++i)
  use(vec[i]);

or:

for (auto i = 0; i != widths.size(); ++i)
  for (auto j = 0; j != heights.size(); ++i)
    use(widths[i], heights[j]);

or:

for (auto i = vec.size() - 1; i >= 0; --i)
  use(vec[i]);

The reason for these bugs is similar: the classic for-loop is too flexible:

  • you can put a condition not related to the iteration statement,
  • you can put a condition that never causes the loop to terminate,
  • you can put a condition that changes the program state,
  • what not.

And the compiler cannot really prevent you from doing all these things, because maybe you have exercised all this flexibility intentionally.

These problems do not occur when you use the range-based for-loop:

for (Record const& rec : records)
  use(rec);

This C++11 addition, apart from other conveniences, is a safety feature (as in language-safety): it is very hard to use it incorrectly. It is not flexible, or versatile. You have to pass it a range, you have to give a name to an element referenced in each iteration step. There is no “control variable” (like i), so you cannot get the operations on it wrong. A number of bugs are prevented simply by employing a range-based loop.

But what if my iteration is more complicated? What if I need to visit my elements in reverse? For this purpose since C++20 we have ranges. You still use the rigid for-loop, but provide a different range, by adopting the original one:

using std::views::reverse;

for (Record const& rec : reverse(records))
  use(rec);

Ranges do come with their own problems, but when used judiciously — without complicated, nested constructs — they can reduce the chances of your planting a bug.

But what if I need an index, because I need to iterate over two sequences simultaneously?

You do not need an index for that. Since C++23 we have the zip view that enables exactly this use case:

using std::views::zip;

for (auto [name, rec] : zip(names, records))
  use(name, rec);

If you pass to zip a number of equally-sized containers, it will give you a range whose value type is tuple<T1, T2, ...> where each Tn comes from a corresponding range, and reference type tuple<T1&, T2&, ...>. It is an interesting case that demonstrates why containers, iterators and ranges have associated type reference even though seemingly value_type& could do the job. Well, it couldn’t: in the case of a zip_view the reference type is not a reference!

But there is also a bug-prone aspect of this usage. We use the structured binding (the square braces) to give names to individual tuple references. We are getting the tuple by value but because it is a tuple of references there is no copying of elements involved. The problem is that while we have the names of individual elements, we do not have their types, so this is possible to assign the wrong names to the wrong elements of the tuple. If you are a fan of “almost always auto” philosophy, you will not see this as a problem. I myself prefer “almost never auto” philosophy. It requires more typing but prevents more bugs.

Going back to the idea of replacing all your index-based loops with range-based alternatives, what if I really need an index because I need to record it or store it?

for (int i = 0; i < vec.size(); ++i)
  use(i, vec[i]);

C++ has an answer to that. We have to think in terms of ranges. Being able to see the next integer value in each iteration step is equivalent to iterating over a range {0, 1, 2, …}. C++ has a view for representing this: iota. It is a generator: it doesn’t really store all the numbers anywhere: it generates the next number on the fly. For our case, we need both the index and the element with data, so we need to employ both iota and zip:

using std::views::iota;
using std::views::zip;

for (auto [i, rec] : zip(iota(0), records)) 
  use(i, rec);

The first argument passed to iota determines both the type and the initial value of the elements. Next values are obtained by applying operator ++. iota is a practically infinite range, while records will be something small. We use the property of zip here that it only sees as many elements as the shortest range that it is passed. Range records is shorter than iota, so it will determine the size of the zipped view.

In C++23 there is a shorthand for the above:

using std::views::enumerate;

for (auto [i, rec] : enumerate(records)) 
  use(i, rec);

In that case the initial value (0) and the type of the index (difference_type of records) is fixed.

But what if my use case is more complicated? For instance, when I determine the next index value from the state of the object inspected in the current step?

for (int i = 0; i != records.size(); i = records[i])
  use(records[i]);

In that case we have no dedicated tool for you, and you have to resort to a more bug-prone but also more flexible regular for-loop. In fact, in rare cases, you may need to resort to a goto. This is what they are for. But the point is, they should be only used as the last resort, when you have run out of more bug-proof tools.