std::ranges en C++20/23: pipelines de datos sin bucles manuales

Durante años, procesar una colección en C++ significaba escribir bucles explícitos, variables temporales y condiciones dispersas por el código. Con std::ranges de C++20 ese patrón cambia: describes qué quieres hacer con los datos, no cómo iterar sobre ellos.

El resultado no es solo más legible. Al componerse como pipelines perezosos, las vistas evitan crear contenedores intermedios, lo que en colecciones grandes puede marcar la diferencia en memoria y en caché.

El operador pipe y las vistas

La interfaz central de std::ranges son las vistas (views), que viven en el namespace std::views. Se encadenan con el operador | igual que en Unix:

#include <ranges>
#include <vector>
#include <print>

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

    auto pipeline = nums
        | std::views::filter([](int n) { return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; })
        | std::views::take(3);

    for (int n : pipeline) {
        std::println("{}", n);  // 4, 16, 36
    }
}

Nada de esto construye vectores intermedios. El pipeline es perezoso: solo procesa los elementos que el bucle final consume. Si take(3) se satisface antes de recorrer todo el vector, los elementos restantes no se procesan.

Vistas esenciales de C++20

std::views::filter

// Solo strings no vacíos
auto no_vacios = palabras | std::views::filter([](const std::string& s) {
    return !s.empty();
});

std::views::transform

// Convertir a mayúsculas (simplificado)
auto upper = strs | std::views::transform([](std::string s) {
    for (char& c : s) c = std::toupper(c);
    return s;
});

std::views::take y std::views::drop

auto primeros_5  = rango | std::views::take(5);
auto sin_primero = rango | std::views::drop(1);
auto pagina      = rango | std::views::drop(20) | std::views::take(10);

std::views::reverse e iota

#include <ranges>

// Iterar al revés sin modificar el contenedor
for (int n : nums | std::views::reverse) { /* ... */ }

// Generar secuencias numéricas sin vector
for (int i : std::views::iota(0, 100)) { /* ... */ }

std::views::split y std::views::join

#include <ranges>
#include <string_view>

std::string_view csv = "uno,dos,tres,cuatro";
for (auto token : csv | std::views::split(',')) {
    // token es un subrange de chars
    std::println("{}", std::string_view(token));
}

Algoritmos de rango: sin iteradores begin/end

Además de las vistas, std::ranges incluye versiones de todos los algoritmos clásicos que aceptan directamente un rango completo, sin necesidad de pasar .begin() y .end():

#include <algorithm>
#include <vector>

std::vector<int> v = {5, 3, 1, 4, 2};

std::ranges::sort(v);                                   // ordena in-place
std::ranges::sort(v, std::greater<>{});                 // descendente
auto it = std::ranges::find(v, 3);                      // busca el 3
bool ok = std::ranges::all_of(v, [](int n){ return n>0; });

La mayoría también admiten una proyección, que es una función que se aplica a cada elemento antes de comparar:

struct Persona { std::string nombre; int edad; };
std::vector<Persona> personas = { {"Ana", 30}, {"Carlos", 25}, {"Bea", 28} };

// Ordenar por edad sin lambda de comparación compleja
std::ranges::sort(personas, {}, &Persona::edad);

Novedades de C++23 en rangos

std::views::zip

Itera sobre varias colecciones al mismo tiempo, produciendo tuplas de elementos:

#include <ranges>
#include <print>

std::vector<std::string> nombres = {"Ana", "Bob", "Carlos"};
std::vector<int>         edades  = {30, 25, 28};

for (auto [nombre, edad] : std::views::zip(nombres, edades)) {
    std::println("{} tiene {} años", nombre, edad);
}

std::views::enumerate

for (auto [i, val] : std::views::enumerate(nombres)) {
    std::println("[{}] {}", i, val);
}

std::views::chunk y std::views::slide

// Dividir en grupos de 3
for (auto grupo : nums | std::views::chunk(3)) {
    // grupo es un subrange de 3 elementos
}

// Ventana deslizante de tamaño 2
for (auto ventana : nums | std::views::slide(2)) {
    // ventana tiene los elementos [i, i+1]
}

std::views::cartesian_product

std::vector<int> filas = {0, 1, 2};
std::vector<int> cols  = {0, 1, 2};

for (auto [f, c] : std::views::cartesian_product(filas, cols)) {
    std::println("({}, {})", f, c);  // todos los pares
}

Rangos y std::generator juntos

Con std::generator de C++23 puedes crear rangos propios mediante corrutinas y encadenarlos con las vistas estándar. El artículo sobre corrutinas de esta serie lo cubre en detalle, pero el patrón básico es:

#include <generator>
#include <ranges>

std::generator<int> primos() {
    // generador de números primos
    co_yield 2;
    for (int n = 3; ; n += 2) {
        bool es_primo = true;
        for (int d = 3; d * d <= n; d += 2)
            if (n % d == 0) { es_primo = false; break; }
        if (es_primo) co_yield n;
    }
}

// Los primeros 10 primos mayores que 100
auto resultado = primos()
    | std::views::drop_while([](int n){ return n <= 100; })
    | std::views::take(10);

Cuándo usar rangos y cuándo no

Los rangos brillan cuando el pipeline de transformaciones es claro y las colecciones son grandes. Para bucles simples de una sola operación, un for clásico sigue siendo perfectamente legible. Y hay casos donde los rangos tienen limitaciones: las vistas de escritura (output ranges) aún no están completas en C++23, y algunos adaptadores no funcionan sobre rangos no-bidireccionales.

Si vienes del mundo funcional o conoces las pipelines de Rust con iteradores, te encontrarás en terreno familiar. La diferencia principal es que en C++ las vistas no consumen el rango original.

Para operaciones más complejas sobre datos, la combinación de std::ranges con std::format o std::print (cubiertos en el artículo de novedades de C++23) da como resultado código muy compacto y sin boilerplate.

Imagen: Pexels / Myburgh Roux

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP