CodeGym /Corsi /C++ SELF /std::views::filter / transform / take

std::views::filter / transform / take

C++ SELF
Livello 62, Lezione 1
Disponibile

1. view e pipeline ranges

Quando si inizia a programmare, un ciclo è come un coltellino svizzero: con esso si può fare quasi tutto. È vero. Ma a lungo andare emerge un'altra verità: lo stesso «coltello» inizia a sembrare un insieme di copie leggermente diverse, e nel codice compare rumore — dove filtriamo, dove trasformiamo, dove limitiamo il numero di risultati.

Immaginate di avere una semplice applicazione console «TaskLite» — una mini-lista di task. Conserviamo i task in un std::vector, sappiamo come stamparli, e ora vogliamo mostrare «i primi 3 task importanti non completati». Con i cicli si finisce con diversi if, un contatore, un break e un po' di gestione minuta. Con le views si può scrivere in modo che il codice si legga come una frase: «prendi i task → filtra → trasforma → prendi i primi N».

Che cos'è una view

È importante iniziare con il modello mentale corretto. std::vector è una scatola con oggetti: possiede la memoria, contiene gli elementi e ne gestisce il ciclo di vita. Una view è più una «finestra intelligente» o uno «scenario di visualizzazione»: di solito non possiede i dati, ma conserva solo le regole su come attraversarli e cosa mostrare.

Se piace l'analogia con la cucina, il contenitore è la pentola con la zuppa, mentre la view è il mestolo e l'istruzione «prendi solo i pezzi di patata». La zuppa non scompare e non viene copiata, semplicemente la guardi da un'angolazione differente.

Una piccola tabella per fissare la differenza:

Entità Esempio Possiede i dati? Dove "vivono" gli elementi? Quando viene eseguito il lavoro?
Contenitore
std::vector<Task>
Sì All'interno del contenitore All'aggiunta/rimozione/modifica
View
tasks | std::views::filter(...)
Di solito no Nel contenitore sorgente Durante l'iterazione della view

Conclusione chiave: una view non è «una nuova lista», ma «un nuovo modo di guardare la lista esistente».

Operatore pipeline |

Le pipeline in ranges/views appaiono insolite: lì si usa l'operatore |. Storicamente in C++ questo è l'operatore «bitwise OR», e la mente del principiante cerca onestamente i bit. Ma nel mondo dei ranges questo operatore è sovraccaricato e lo si legge convenzionalmente come «passa il range attraverso l'adattatore».

L'idea della «pipe» per i range adaptors non è magia casuale, ma una parte di design intenzionale dei ranges. Nei materiali WG21 questo è stato discusso chiaramente (in particolare, come «Pipe support for user-defined range adaptors»).

Quindi l'espressione:

tasks | std::views::filter(pred)

si legge come:

«Prendi tasks e passalo attraverso filter(pred)».

E la catena:

tasks | filter(...) | transform(...) | take(...)

si legge come una linea di montaggio: a ogni passo non «costruiamo un nuovo vettore», ma creiamo un'altra view.

3. Modello dati: TaskLite

Per mantenere coerenti gli esempi, prendiamo un piccolo modello di task. Supponiamo che negli argomenti precedenti siate già familiari con struct e enum class. Usiamo la versione più semplice possibile.

#include <string>

enum class Priority { Low, Normal, High };

struct Task {
    int id = 0;
    std::string title;
    Priority priority = Priority::Normal;
    bool done = false;
};

E creiamo dati di esempio:

#include <vector>

std::vector<Task> makeSampleTasks() {
    return {
        {1, "Consegnare il lab", Priority::High, false},
        {2, "Comprare il pane", Priority::Low,  true},
        {3, "Riparare la build", Priority::High, false},
        {4, "Accarezzare il gatto", Priority::Normal, false},
        {5, "Controllare gli errori del compilatore", Priority::High, true},
    };
}

Successivamente costruiremo views sopra questo std::vector<Task>.

4. Adattatori di base: filter, transform, take

std::views::filter

Quando si scrive il ciclo tradizionale, si fa spesso un controllo if (condizione) continue;. std::views::filter è quel «medesimo if», ma espresso in stile dichiarativo: descrivi la regola di selezione, invece di controllare manualmente ogni passo dell'iterazione.

La firma a livello concettuale è: views::filter(pred) accetta un predicato (di solito una lambda), e restituisce una view che durante l'iterazione salta solo gli elementi per i quali pred(x) ritorna true.

Esempio: prendiamo solo i task non completati.

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

int main() {
    std::vector<Task> tasks = makeSampleTasks();

    auto notDone = tasks | std::views::filter([](const Task& t) { return !t.done; });

    for (const Task& t : notDone) {
        std::cout << t.id << ": " << t.title << '\n';
    }
}

Nota il const Task& t nel range-for: non vogliamo copiare il Task (c'è una std::string dentro), vogliamo leggere gli elementi per riferimento.

Altro esempio: filtriamo «importanti e non completati».

#include <ranges>

bool isImportantAndNotDone(const Task& t) {
    return t.priority == Priority::High && !t.done;
}

int main() {
    auto tasks = makeSampleTasks();

    auto important = tasks | std::views::filter(isImportantAndNotDone);

    for (const Task& t : important) {
        std::cout << t.title << '\n';
    }
}

Qui abbiamo passato una funzione normale invece di una lambda. Per filter va bene: l'importante è che sia qualcosa di «callable» (callable).

std::views::transform

Dopo la filtrazione spesso si vuole «prendere non l'intero oggetto», ma solo la parte utile: per esempio, stampare solo i titoli, oppure calcolare una rappresentazione breve della stringa, o estrarre l'id.

Nel ciclo tipicamente si fa: «prendi l'elemento → calcola qualcosa → stampa». std::views::transform rende questo un passo separato della pipeline: descrivi la funzione di trasformazione f(x), e la view durante l'iterazione restituisce i valori f(x).

Esempio: trasformiamo i task nei loro titoli.

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

int main() {
    auto tasks = makeSampleTasks();

    auto titles = tasks | std::views::transform([](const Task& t) { return t.title; });

    for (const std::string& s : titles) {
        std::cout << s << '\n';
    }
}

C'è un punto importante da considerare: ritorniamo t.title — che è una std::string. A seconda dei dettagli, transform può restituire valori per riferimento o copie. Per non trasformare la lezione in un «giallo sui template», manteniamo una regola pratica: se dentro Task ci sono campi pesanti, non sorprende che a volte sia meglio restituire non la stringa completa, ma ad esempio un int id o qualcosa di leggero.

Esempio: trasformiamo i task nei loro id.

#include <iostream>
#include <ranges>

int main() {
    auto tasks = makeSampleTasks();

    auto ids = tasks | std::views::transform([](const Task& t) { return t.id; });

    for (int id : ids) {
        std::cout << id << ' ';
    }
    std::cout << '\n'; // 1 2 3 4 5
}

Questo è un transform «pulito»: in uscita numeri leggeri.

std::views::take

Limitare il numero di risultati è classico: vuoi mostrare all'utente non tutta la lista, ma i primi 3–10 elementi. Nel ciclo questo di solito appare come int shown = 0; ... if (++shown == N) break;. Funziona, ma leggere questo è come una istruzione per il forno a microonde: tutto logico, ma poco piacevole.

std::views::take(n) risolve il problema in modo dichiarativo: «prendi i primi n elementi del range». Anche questo è una view ed è lazy: nessun nuovo contenitore viene creato.

Esempio: prendiamo i primi 2 task dalla lista.

#include <iostream>
#include <ranges>

int main() {
    auto tasks = makeSampleTasks();

    auto first2 = tasks | std::views::take(2);

    for (const Task& t : first2) {
        std::cout << t.id << ": " << t.title << '\n';
    }
}

Anche se ci fossero 1000 task, itereremo solo i primi 2 durante la stampa.

Componiamo tutto: filter | transform | take

Ora la parte più interessante: comporre la frase «i primi 3 task importanti non completati», scrivendo il codice in modo che si legga quasi come una frase italiana (per quanto possibile in C++).

Prima filtriamo importanti e non completati, poi trasformiamo nei titoli, poi prendiamo i primi 3.

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

int main() {
    auto tasks = makeSampleTasks();

    auto pipeline =
        tasks
        | std::views::filter([](const Task& t) { return t.priority == Priority::High && !t.done; })
        | std::views::transform([](const Task& t) { return t.title; })
        | std::views::take(3);

    for (const std::string& title : pipeline) {
        std::cout << title << '\n';
    }
}

Se avete pensato «sembra le pipe di Unix» — complimenti, avete colto la sensazione giusta. In C++ lo stesso concetto di «pipe» per i range adaptors è trattato con la stessa logica.

Per fissare, ecco una semplice schema della pipeline:

flowchart LR
    A[std::vector<Task> tasks] --> B[views::filter
importanti e non done] B --> C[views::transform
Task -> title] C --> D[views::take
primi 3] D --> E[range-for + cout]

Notate che il «calcolo» non avviene al momento della dichiarazione di pipeline. Avviene durante l'iterazione nel for.

5. Lazy evaluation e leggibilità delle pipeline

Quando vengono effettivamente chiamate le lambda

La parola «lazy» suona come la caratteristica di un gatto che ignora il vostro std::cout. Ma nei ranges è una caratteristica importante: una view di solito non calcola nulla in anticipo, fa il lavoro solo quando si tenta di ottenere il prossimo elemento durante l'iterazione.

Il modo più semplice per sentirlo è aggiungere un contatore di chiamate dentro la lambda. Sì, è un trucco «sporco» (effetto collaterale), ma come dimostrazione è perfetto.

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

int main() {
    std::vector<int> v{1, 2, 3, 4, 5};
    int calls = 0;

    auto evens = v | std::views::filter([&](int x) { ++calls; return x % 2 == 0; });

    std::cout << calls << '\n';      // 0 (ancora non abbiamo iterato)
    for (int x : evens) { (void)x; }
    std::cout << calls << '\n';      // 5 (il predicato è stato chiamato durante l'iterazione)
}

Regola pratica: una view è una «descrizione», non un «risultato». Perciò filter, transform e take si manifestano quando si itera.

Quando è meglio spezzare in passi

Una pipeline in un'unica «colonna» è bella da leggere... finché non appaiono tre condizioni, due trasformazioni e mezza pagina di logica. A quel punto il codice diventa una «noodle funzionale»: alla moda, ma difficile da gestire.

Buona pratica ingegneristica: se la catena non si legge facilmente da sinistra a destra in circa 5–10 secondi, date dei nomi ai passaggi. Non è una debolezza, è cura per il futuro voi stessi (che tra due settimane guarderà il codice come se fosse di un altro).

Esempio:

#include <ranges>
#include <vector>

int main() {
    auto tasks = makeSampleTasks();

    auto important = tasks | std::views::filter([](const Task& t) {
        return t.priority == Priority::High && !t.done;
    });

    auto titles = important | std::views::transform([](const Task& t) {
        return t.title;
    });

    for (const std::string& s : titles | std::views::take(3)) {
        // stampa
    }
}

Lo scopo è lo stesso, ma la mente legge più facilmente: «important» → «titles» → «take(3)».

6. Errori tipici con views::filter / transform / take

Errore #1: aspettarsi che filter/transform/take creino un nuovo std::vector.
Confusione molto comune: lo studente scrive auto x = ... e pensa «x è una lista». In realtà x è una view, cioè una rappresentazione. Non è detto che possegga i dati e nella maggior parte dei casi non copia nulla. Quindi se serve un contenitore vero, bisogna crearlo esplicitamente (ma questo sarà argomento della prossima lezione).

Errore #2: scrivere for (auto t : view) e copiare involontariamente elementi pesanti.
Se Task contiene una std::string, allora for (auto t : someView) può iniziare a copiare elementi (o i risultati di transform) e rallentare il codice. Abitudine pratica: per leggere oggetti sorgente utilizzare const auto&, e se si vuole una copia farlo in modo consapevole ed esplicito.

Errore #3: cercare di «vedere il risultato» senza iterare.
La view è lazy: finché non la si attraversa (range-for, std::ranges::copy, un algoritmo, ecc.), potrebbe non calcolare nulla. Quindi std::cout << calls prima dell'iterazione può mostrare «0», e questo non è un bug ma comportamento atteso. Bisogna separare mentalmente «descrizione» e «esecuzione».

Errore #4: nascondere logica di business complessa dentro la lambda e ottenere codice-puzzle.
Le views sono leggibili quando il predicato e la trasformazione sono brevi e chiare. Se in filter ci sono 8 condizioni, 3 variabili locali e un pezzo di «politica aziendale», il lettore soffrirà. In questi casi è meglio estrarre la logica in una funzione separata con un buon nome e lasciare nella pipeline solo la chiamata a quella funzione.

Errore #5: confondere std::views::... con std::ranges::....
Sono scaffali diversi della libreria. Le views costruiscono rappresentazioni (cioè descrivono come guardare i dati), mentre gli algoritmi di ranges eseguono il lavoro (ordinano, copiano, cercano). Ricordate questa frase: views descrivono, ranges-algorithms eseguono — e la vita sarà più tranquilla.

1
Compito
C++ SELF, livello 62, lezione 1
Bloccato
Filtro degli input
Filtro degli input
1
Compito
C++ SELF, livello 62, lezione 1
Bloccato
Lunghezze dei nickname
Lunghezze dei nickname
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION