Rangos y Vistas: Pipelines de Datos en C++20

Un rango es una abstracción que unifica un par de iteradores (begin y end) en un único objeto, permitiendo tratar a una colección como una entidad completa. En lugar de pasar constantemente container.begin(), container.end() a las funciones, ahora pasamos el contenedor directamente. Esto es posible gracias al concept std::ranges::range, que garantiza que un tipo proporciona los métodos necesarios para ser iterado.

Esta arquitectura existe para permitir la composición de operaciones mediante el uso de vistas (std::views). A diferencia de los algoritmos tradicionales que modifican un contenedor o crean copias, las vistas son objetos ligeros que no poseen los datos; solo describen una forma de acceder a ellos. Esto se basa en la evaluación perezosa (lazy evaluation): los elementos no se filtran ni se transforman en el momento en que creas la vista, sino en el preciso instante en que los solicitas al iterar sobre ella.

Debes usar este paradigma cuando necesites procesar secuencias de datos (filtrar, transformar, recortar) de forma eficiente, evitando asignaciones de memoria en el heap y la creación de contenedores temporales intermedios. Sin embargo, si intentas guardar una vista que apunta a un contenedor temporal, el programa caerá en un error crítico de memoria, ya que la vista intentará acceder a objetos que ya han sido destruidos.

#include <iostream>
#include <vector>
#include <ranges>    // Para std::views y std::ranges
#include <algorithm> // Para std::ranges::sort
#include <string>

struct Producto {
    int id;
    double precio;
    std::string nombre;
};

int main() {
    // Un contenedor que actúa como fuente de datos (Range)
    std::vector<Producto> inventario = {
        {1, 12.5, "Cuaderno"}, 
        {2, 550.0, "Portátil"}, 
        {3, 15.0, "Ratón"},
        {4, 1200.0, "Monitor"}, 
        {5, 25.0, "Teclado"}, 
        {6, 850.0, "GPU"}
    };

    // Composición de vistas usando el operador de tubería '|'
    // 1. Filtramos productos caros (> 30.0)
    // 2. Transformamos el resultado para obtener solo el nombre (std::string)
    // 3. Tomamos solo los primeros 2 resultados
    auto nombres_premium = inventario 
        | std::views::filter([](const Producto& p) { return p.precio > 30.0; })
        | std::views::transform([](const Producto& p) { return p.nombre; })
        | std::views::take(2);

    std::cout << "Productos premium detectados:\n";
    for (const auto& nombre : nombres_premium) {
        std::cout << "- " << nombre << "\n";
    }

    // std::views::iota genera una secuencia lógica sin ocupar memoria
    std::cout << "\nSecuencia generada con iota (1 a 5):\n";
    for (int i : std::views::iota(1, 6)) {
        std::cout << i << " ";
    }
    std::cout << "\n";

    // std::ranges::sort opera directamente sobre el contenedor
    // Es preferible sobre std::sort(v.begin(), v.end()) por claridad y seguridad
    std::ranges::sort(inventario, {}, &Producto::precio);

    return 0;
}

Desglose del concepto

En el ejemplo anterior, inventario es el contenedor base. Cuando aplicamos std::views::filter, no se crea un nuevo std::vector<Producto>. En su lugar, se crea un objeto view que almacena un iterador al inicio del vector y un centinela (sentinel), que es el objeto que marca el fin del rango. Un centinela no tiene por qué ser del mismo tipo que el iterador; por ejemplo, un centinela puede ser simplemente un valor nulo (\0) en una cadena de C, lo que optimiza el rendimiento al no tener que calcular el tamaño de la cadena antes de buscar.

La línea auto nombres_premium = ... es una construcción de un pipeline. Gracias a la evaluación perezosa, la transformación de Producto a std::string mediante std::views::transform solo ocurre cuando el bucle for pide el siguiente elemento. Si el take(2) decide que ya tiene suficientes elementos, el resto de la lista de inventario ni siquiera se procesa.

El uso del operador | (pipe) permite que el código se lea de izquierda a derecha, siguiendo el flujo de los datos, evitando el anidamiento de funciones estilo Lisp como transform(filter(inventario, pred), func). Finalmente, notarás que std::ranges::sort recibe inventario directamente. Esto es parte de la mejora de la biblioteca estándar para evitar errores de “iterator mismatch” (mezclar iteradores de dos contenedores distintos), ya que el algoritmo recibe el rango completo y extrae los iteradores internamente.

El error frecuente

El error más peligroso con las vistas es la gestión de la vida de los objetos. Una vista es un objeto que almacena iteradores (punteros lógicos) a un contenedor. Si el contenedor es un objeto temporal, la vista quedará con iteradores colgantes (dangling iterators).

// ERROR FATAL: El vector se destruye al terminar la línea, 
// pero 'v' intenta apuntar a su memoria.
auto get_nombres() {
    std::vector<std::string> nombres = {"A", "B", "C"};
    return nombres | std::views::filter([](const auto& s){ return s.length() > 0; });
}

// Al usar 'v' aquí, el programa fallará o tendrá un comportamiento indefinido.
auto v = get_nombres(); 
for (auto s : v) { std::cout << s << "\n"; } 

Para detectar esto, es imprescindible compilar con -Wdangling-via-range en versiones recientes de GCC o usar AddressSanitizer (-fsanitize=address) en Clang/GCC, lo cual detectará el acceso a memoria liberada inmediatamente.

75

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio