C++20 for-loops: Range-based initialization explained

beginner recent 9 min read updated 17 Aug 2026
On this page 5

Range-for evolution: Why C++20 improved iteration

C++11 introduced range-based for-loops to simplify iterating over collections. This syntax, for (declaration : range_expression), significantly reduced boilerplate compared to explicit iterator-based loops. For many common scenarios, it provided a clear and concise way to process elements within a container.

However, the original design had a key limitation: it only allowed for the range_expression itself. Any setup code, resource acquisition, or temporary objects needed for the loop’s duration had to be declared in the surrounding scope. This could lead to variables outliving their intended use or requiring artificial scope blocks.

Consider a situation where a range needs to be accessed under a lock, or when a temporary view object defines the iteration. Without an init-statement, such dependencies had to be managed externally to the loop. This reduces the locality of code that is logically tied to the iteration.

// C++17 example: mutex locked outside the loop's direct context
std::mutex mtx;
{ // Artificial scope limits lock_guard's lifetime
    std::lock_guard<std::mutex> lock(mtx);
    for (const auto& item : shared_data) {
        // Process item; shared_data is protected by 'lock'
    }
} // 'lock' is released here, scope ends
// 'mtx' is still in scope, but the lock is gone.

Another scenario involves creating a temporary object that generates or filters the range itself. If this object is complex or should only exist for the loop, placing its declaration outside the loop’s direct context obscures intent and can make code harder to refactor. The C++20 init-statement addresses these cases by allowing loop-specific initialization directly within the for construct. This enhances locality and encapsulation.

C++20 Range-for: How to compile and enable

C++20 features, including the new range-for loop enhancements, are not enabled by default in most compilers. To use them, you must explicitly instruct your compiler to target the C++20 standard. Failing to do so results in compilation errors, as the compiler will not recognize the C++20 syntax.

For GCC and Clang compilers, use the -std=c++20 or -std=gnu++20 flag. The gnu++20 option enables C++20 features along with GNU-specific extensions, which often include minor convenience features or platform-specific APIs. For standard-compliant code and maximum portability, -std=c++20 is the preferred choice.

g++ -std=c++20 -o program_name source_file.cpp
clang++ -std=c++20 -o program_name source_file.cpp

Microsoft Visual C++ (MSVC) compilers use a different flag for standard selection. Specify /std:c++20 to enable C++20 features. Ensure you are using a recent version of Visual Studio (e.g., Visual Studio 2019 version 16.10 or later) for complete C++20 support.

cl.exe /std:c++20 /EHsc /Fe:program_name source_file.cpp

Consider a simple program, range_init_example.cpp, that uses a C++20 range-for loop with an initializer. Attempting to compile this code without the C++20 flag will produce an error, as the compiler will not understand the initializer within the for loop syntax. For instance, with GCC 11.2 and no flag, you might see errors related to expected semicolons or invalid syntax.

// range_init_example.cpp
#include <vector>
#include <iostream>

int main() {
    for (std::vector<int> v = {1, 2, 3}; int x : v) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
    return 0;
}
g++ range_init_example.cpp -o range_init_example
range_init_example.cpp: In function 'int main()':
range_init_example.cpp:6:38: error: expected ';' before 'int'
    6 |     for (std::vector<int> v = {1, 2, 3}; int x : v) {
      |                                      ^~
      |                                      ;

Compiling with the correct flag resolves this. The program will then compile successfully and execute as expected.

g++ -std=c++20 range_init_example.cpp -o range_init_example
./range_init_example
1 2 3

The primary tradeoff of using C++20 features is compatibility with older environments. Projects compiled with C++20 flags will not build on systems with compilers that do not support C++20 or are not configured for it. This can affect build times for very large projects due to the increased complexity of newer standard library components.

C++20 Range-for: Structured bindings in practice

Iterating over collections of std::pair, std::tuple, or aggregate types often requires decomposing each element. Before C++20, range-based for-loops accessed elements as a single entity, needing item.first, item.second, or std::get<N>(item). This approach could lead to verbose code when accessing multiple components of each element. C++20 integrates structured bindings directly into the range-for declaration, streamlining this process significantly.

Structured bindings, introduced in C++17, allow decomposing an object into individual named variables based on its members or elements. When applied to a C++20 range-for loop, this means each iteration directly provides the constituent parts of the iterated element as distinct variables. This improves readability by giving meaningful names to components, and reduces boilerplate compared to manual member access.

Consider a std::map<std::string, int>. Iterating through it yields std::pair<const std::string, int> elements.

#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> scores = {
        {"Alice", 95},
        {"Bob", 88},
        {"Charlie", 92}
    };

    // C++17 style: manual member access
    for (auto const& entry : scores) {
        std::cout << "Player: " << entry.first << ", Score: " << entry.second << '\n';
    }
    std::cout << "---\n";

    // C++20 with structured bindings: direct decomposition
    for (auto const& [name, score] : scores) {
        std::cout << "Player: " << name << ", Score: " << score << '\n';
    }
    return 0;
}

The auto const& [name, score] syntax directly binds name to the key and score to the value of each map entry. Using const auto& is generally preferred for efficiency, avoiding copies and preventing accidental modification of the collection elements. This syntax makes the intent clearer, especially when dealing with more complex std::tuple types.

C++20 range-for loops also support an init-statement that executes once before the loop begins. Variables declared within this init-statement are in scope for both the range-expression and the loop body. Combining this with structured bindings allows for setup or temporary range creation, offering more control over the loop’s context.

For instance, if a function returns a temporary collection, the init-statement can manage its lifetime. This makes the temporary collection available for iteration and structured decomposition within the loop’s scope, without needing to declare it outside.

#include <iostream>
#include <vector>
#include <string>
#include <utility> // For std::pair

// A function returning a temporary vector of pairs
std::vector<std::pair<std::string, int>> get_filtered_items() {
    // In a real scenario, this might perform filtering or transformation
    return {{"apple", 10}, {"banana", 20}, {"cherry", 30}};
}

int main() {
    // C++20 range-for with initializer and structured bindings
    for (auto&& items_range = get_filtered_items(); // Initializer: creates temporary vector
         auto const& [item_name, quantity] : items_range) { // Structured binding for iteration
        std::cout << "Item: " << item_name << ", Quantity: " << quantity << '\n';
    }
    return 0;
}

Expected output:

Item: apple, Quantity: 10
Item: banana, Quantity: 20
Item: cherry, Quantity: 30

In this example, items_range is declared and initialized by get_filtered_items() within the init-statement. This temporary vector’s lifetime extends for the duration of the loop. The subsequent auto const& [item_name, quantity] then uses structured bindings to decompose each std::pair from items_range. This pattern is useful for processing transient data or results from functions that produce ranges, ensuring proper resource management and concise loop definitions.

C++20 Range-for: Debugging initialization issues

C++20 range-based for-loops introduce an optional init-statement before the range expression. This feature extends the lifetime of temporary objects or sets up resources for the loop, but it often leads to misunderstandings regarding scope and execution timing. Identifying and resolving these issues requires a clear understanding of the init-statement’s behavior.

The init-statement executes exactly once, before the range expression is evaluated and before any loop iterations begin. It is not re-evaluated for each iteration. This is a common point of confusion; do not use it for per-iteration setup or for generating new data for each element.

Variables declared within the init-statement are scoped exclusively to the for loop. They are accessible within the range-expression, the loop-variable declaration, and the loop-statement. Attempting to use these variables outside the loop’s scope will result in a compilation error.

Consider a scenario where the init-statement is mistakenly used to generate a new string for each character processed:

// Incorrect use: init-statement runs only once
std::string generate_unique_string() {
    static int counter = 0;
    return "string_" + std::to_string(counter++);
}

for (std::string s = generate_unique_string(); char c : s) {
    // 's' here is always "string_0" for all characters.
    // The init-statement does not execute again for subsequent iterations.
    std::cout << c;
}
std::cout << std::endl;
// Output: s t r i n g _ 0

This loop generates s only once, as “string_0”. If generate_unique_string() was intended to produce a new string for each character iteration, the logic is flawed. The init-statement is for setting up the entire range, not for per-element operations.

A correct use case involves extending the lifetime of a temporary object that provides the range. This prevents dangling references if the range expression itself produces a temporary.

// Correct use: extends lifetime of a temporary view
std::string_view get_view_from_temporary() {
    return std::string_view("hello world"); // Returns a temporary view
}

for (auto&& view = get_view_from_temporary(); char c : view) {
    std::cout << c;
}
std::cout << std::endl;
// Output: hello world

Here, get_view_from_temporary() returns a temporary std::string_view. Without the init-statement, this temporary would expire immediately after the range expression’s evaluation, leading to a dangling view during the loop iterations. The init-statement ensures view remains valid for the entire loop duration.

Attempting to access view after the loop demonstrates its limited scope:

// Error: 'view' is not declared in this scope
// for (auto&& view = get_view_from_temporary(); char c : view) { /* ... */ }
// std::cout << view << std::endl; // Compile error: 'view' was not declared in this scope

The compiler prevents access to view outside its defined scope. This reinforces that variables declared in the init-statement are truly local to the for loop construct.

C++20 Range-for: Implement a custom view

Iterating over a subset of a collection or a transformed version often requires creating temporary containers or complex manual loops. C++ views address this by providing lightweight, non-owning ranges that abstract the iteration logic. While std::views offers many standard transformations, custom iteration patterns require defining your own view.

A custom view type needs to provide begin() and end() methods, which return suitable iterator objects. These iterators encapsulate the specific logic for traversing the underlying data, such as skipping elements or applying a transformation. The C++20 range-for loop’s initializer clause allows direct construction of such a view within the loop’s scope, confining its lifetime to the loop and improving code clarity.

Consider a scenario where only even numbers from a std::vector<int> are needed. A custom EvenNumbersView can achieve this without allocating a new vector. The view holds a reference to the original range, and its begin() and end() methods return EvenIterator instances.

The EvenIterator checks each element. If the current element is odd, it advances until an even number is found or the end of the range is reached. This logic occurs during iterator construction and after each increment operation (operator++). The operator* simply returns the even number at the current position.

#include <vector>
#include <iostream>
#include <iterator> // For std::iterator_traits, std::forward_iterator_tag

// EvenIterator definition (simplified for demonstration)
template <typename Iter>
class EvenIterator {
    Iter current_position;
    Iter range_end;

    void advance_to_next_even() {
        while (current_position != range_end && (*current_position % 2 != 0)) {
            ++current_position;
        }
    }

public:
    using value_type = typename std::iterator_traits<Iter>::value_type;
    using iterator_category = std::forward_iterator_tag;

    EvenIterator(Iter begin_it, Iter end_it) : current_position(begin_it), range_end(end_it) {
        advance_to_next_even();
    }

    value_type operator*() const { return *current_position; }

    EvenIterator& operator++() {
        ++current_position;
        advance_to_next_even();
        return *this;
    }

    bool operator!=(const EvenIterator& other) const {
        return current_position != other.current_position;
    }
};

// EvenNumbersView definition
template <typename Range>
class EvenNumbersView {
    Range& underlying_range;

public:
    EvenNumbersView(Range& r) : underlying_range(r) {}

    auto begin() {
        return EvenIterator(std::begin(underlying_range), std::end(underlying_range));
    }

    auto end() {
        return EvenIterator(std::end(underlying_range), std::end(underlying_range));
    }
};

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // Use C++20 range-for with an initializer to create and use the custom view
    for (EvenNumbersView even_view(numbers); int num : even_view) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    return 0;
}

The output of this program is:

2 4 6 8 10 

This pattern provides precise control over iteration logic. While standard library views (std::views) are often preferred for their convenience and compositionality, a custom view is suitable when the required transformation or filtering logic is unique and not easily expressed with existing view adaptors.