Le ranges di C++20

Le ranges di C++20

La libreria delle ranges, introdotta con C++20, rappresenta una delle evoluzioni più profonde nel modo di manipolare sequenze di dati in C++. Costruita sopra i tradizionali algoritmi e iteratori, offre un'interfaccia più espressiva, componibile e sicura. Permette di descrivere trasformazioni sui dati in modo dichiarativo, concatenando operazioni in pipeline leggibili, e introduce il concetto di vista, che elabora i dati senza copiarli e in modo pigro.

Il limite degli algoritmi classici

Gli algoritmi tradizionali della libreria standard operano su intervalli espressi da coppie di iteratori, richiedendo di indicare esplicitamente inizio e fine di ogni sequenza. Questo rende il codice verboso e, quando si vogliono concatenare più operazioni, costringe a creare contenitori intermedi per conservare i risultati parziali. Le ranges superano entrambi i problemi trattando un intervallo come un'unica entità e componendo le operazioni senza allocazioni superflue.

Algoritmi che operano su ranges

Il primo passo verso le ranges è la versione degli algoritmi contenuta nello spazio dei nomi std::ranges, che accetta direttamente un contenitore anziché una coppia di iteratori. La chiamata risulta più concisa e meno soggetta a errori, poiché non è più possibile passare per sbaglio iteratori di contenitori diversi.

#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> values = {40, 10, 30, 20};

    // Si passa direttamente il contenitore, senza begin ed end
    std::ranges::sort(values);

    for (int v : values) {
        std::cout << v << std::endl;
    }
    return 0;
}

Le viste

Il vero cuore delle ranges sono le viste, oggetti leggeri che rappresentano una trasformazione applicata a un intervallo senza copiarne i dati. Una vista non possiede gli elementi: li osserva e li elabora su richiesta, in modo pigro, calcolando ogni valore soltanto quando viene effettivamente attraversato. Le viste risiedono nello spazio dei nomi std::views. Due tra le più usate sono filter, che seleziona gli elementi che soddisfano una condizione, e transform, che applica una funzione a ciascun elemento.

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

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

    // Seleziona i pari e li eleva al quadrato
    auto result = numbers
        | std::views::filter([](int n) { return n % 2 == 0; })
        | std::views::transform([](int n) { return n * n; });

    for (int v : result) {
        std::cout << v << std::endl;   // Stampa 4, 16, 36
    }
    return 0;
}

L'operatore di composizione

L'elemento che rende le ranges tanto espressive è l'operatore di pipe, il simbolo di barra verticale, che concatena le viste in una pipeline. Ogni vista riceve in ingresso il risultato della precedente e lo trasforma, esattamente come i comandi collegati da una pipe nel terminale. Il flusso di dati si legge da sinistra a destra, riflettendo l'ordine logico delle operazioni e rendendo l'intento immediatamente comprensibile.

La valutazione pigra

Un aspetto fondamentale delle viste è la valutazione pigra. Le operazioni descritte nella pipeline non vengono eseguite al momento della definizione, ma solo quando si scorre effettivamente il risultato, un elemento alla volta. Questo significa che nessun contenitore intermedio viene creato e che, se si consuma solo una parte del risultato, il lavoro sui restanti elementi non viene mai svolto. Ne derivano efficienza e la possibilità di lavorare persino con sequenze potenzialmente infinite.

#include <ranges>
#include <iostream>

int main() {
    // Genera i quadrati dei primi cinque interi positivi
    auto squares = std::views::iota(1)
        | std::views::transform([](int n) { return n * n; })
        | std::views::take(5);

    for (int s : squares) {
        std::cout << s << std::endl;   // 1, 4, 9, 16, 25
    }
    return 0;
}

La vista iota genera una sequenza crescente di numeri che, in questo caso, sarebbe infinita; è la vista take, che ne preleva soltanto i primi cinque, a renderla utilizzabile. Grazie alla valutazione pigra, l'infinità della sorgente non è un problema, perché vengono calcolati solo gli elementi effettivamente richiesti.

Un nuovo stile di programmazione

Le ranges incoraggiano uno stile dichiarativo, in cui si descrive cosa si vuole ottenere anziché come iterare passo per passo. Il risultato è codice più conciso, più leggibile e meno incline a errori come gli indici fuori intervallo o gli iteratori non validi. Pur poggiando sui meccanismi classici degli iteratori e degli algoritmi, le ranges ne offrono una veste moderna che avvicina il C++ all'eleganza dei linguaggi funzionali, senza sacrificare l'efficienza che da sempre lo contraddistingue.