Flatten Nested Collections Using std::views::join

The std::views::join range adaptor, introduced in C++20, provides a clean, declarative way to flatten nested sequences without allocating intermediate containers or copying underlying data. This article explores how std::views::join works, its primary mechanics, lazy evaluation semantics, and practical applications in modern C++ codebases.

Understanding std::views::join

In modern C++, nested collections—such as a std::vector<std::vector<T>> or a collection of strings—are common. Historically, flattening these structures into a single continuous stream of elements required manual nested loops or combining elements into an explicitly allocated secondary container via algorithms like std::copy or std::for_each.

The std::views::join adaptor (defined in the <ranges> header) operates on a "range of ranges." It transforms a collection containing sub-ranges into a single, linear view. It acts as an abstraction layer that iterates through the outer range, accesses each inner range in sequence, and yields each inner element one by one.

Lazy Evaluation and Memory Efficiency

A key feature of std::views::join is that it is a view. Views in C++20 Ranges exhibit the following properties:

  1. Zero Allocations: std::views::join does not allocate memory on the heap. It does not create a new, flattened container; rather, it stores an iterator pointing into the nested structure.
  2. Lazy Evaluation: Elements are flattened on demand. Iteration occurs only when an algorithm or range-based for loop requests the next element. This makes it suitable for working with large collections or streaming pipelines where creating a flattened copy would be prohibitively expensive.
  3. Reference Semantics: The yielded elements are references to the elements inside the original nested containers. Modifying non-const elements through the joined view modifies the underlying source data.

Basic Syntax and Usage

The adaptor can be applied using standard function-call syntax or the pipe operator (|), which is idiomatic for range composition.

#include <iostream>
#include <vector>
#include <ranges>

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

    // Flattening the 2D vector using the pipe operator
    auto flat_view = matrix | std::views::join;

    for (int value : flat_view) {
        std::cout << value << ' ';
    }
    // Output: 1 2 3 4 5 6 7 8 9
}

Flattening Strings

Because a std::string is itself a range of characters, applying std::views::join to a container of strings flattens them into a continuous sequence of characters:

#include <iostream>
#include <vector>
#include <string>
#include <ranges>

int main() {
    std::vector<std::string> words = {"Hello", " ", "World"};

    for (char c : words | std::views::join) {
        std::cout << c;
    }
    // Output: Hello World
}

Composition with Other Range Adaptors

Because std::views::join returns a range, it can be combined with other adaptors in a pipeline. For example, filtering elements or transforming data before or after flattening:

#include <iostream>
#include <vector>
#include <ranges>

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

    // Flatten the ranges, filter for even numbers, and multiply by 2
    auto pipeline = nested 
                  | std::views::join 
                  | std::views::filter([](int n) { return n % 2 == 0; })
                  | std::views::transform([](int n) { return n * 2; });

    for (int n : pipeline) {
        std::cout << n << ' ';
    }
    // Output: 4 8 12 16
}

Constraints and Considerations

While std::views::join simplifies code, certain constraints apply:

  • Input Requirements: The input must satisfy the std::ranges::input_range concept, and its value type must also satisfy std::ranges::input_range. It cannot be used directly on non-range elements.
  • Iterator Category: The resulting iterator category depends on the properties of both the outer and inner ranges. If either the outer or inner range is an input-only range, the resulting joined range is also an input-only range. Bidirectional iteration is only supported if both the outer range and inner ranges support bidirectional traversal and the inner range does not yield elements by value.
  • Separators: Standard std::views::join does not insert delimiters between inner ranges. For cases where a delimiter is needed (such as joining strings with a comma), C++23 introduces std::views::join_with.