Featured image of post C++20 Ranges and Views

C++20 Ranges and Views

Abstract

In questo post analizzeremo l’introduzione del C++20 di std::ranges e std::views e cercheremo di comprendere come questi elementi della STL modificano, semplificando, la stesura del codice.

Modello computazionale

Quesito

Vogliamo scrivere un algoritmo che prenda in input una collezione di numeri interi e in output restituisca solo quelli divisibili per 3 in ordine inverso.

Nella tabella sottostante troviamo alcuni esempi di input e output.

InputOutput
3 0 10 9 12 7 30 14 66 30 12 9 0 3
12 14 303 2525 303
15 17 21 0 1818 0 21 15

Soluzione pre C++20

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <algorithm>
#include <vector>
#include <iostream>

auto main() -> int {
    const std::vector<int> numbers{3, 0, 10, 9, 12, 7, 30, 14, 6};

    auto isDivisibleByThree = [](int const i) { return i % 3 == 0; };

    std::vector<int> tmp{};

    std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(tmp), isDivisibleByThree);
    std::reverse(tmp.begin(), tmp.end());

    for (auto& const i : tmp)
        std::cout << i << " ";

    return 0;
}

Questo piccolo frammento di codice esegue i seguenti passaggi:

  • Crea un std::vector tmp, di supporto.
  • Copia tutti gli elementi di v che rispettino il funtore isDivisibleByThree in tmp.
  • Inverte la sequenza di elementi di tmp.

Come si può constatare, il processo è lungo e tedioso, fortunatamente C++20 ha introdotto gli std::ranges.

Soluzione con std::ranges

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>

auto main() -> int {
    const std::vector<int> numbers{3, 0, 10, 9, 12, 7, 30, 14, 6};

    auto isDivisibleByThree = [](int const i) { return i % 3 == 0; };

    auto v{std::views::reverse(std::views::filter(numbers, isDivisibleByThree))};

    for (auto const& i : result)
        std::cout << i << " ";

    return 0;
}

La differenza è palese, tuttavia prima di entrare nello specifico, cerchiamo di approfondire il meglio concetto di ranges e di views.

Ranges

Range in italiano potrebbe essere tradotto con la parola intervallo.

Un range è l’astrazione di “una collezione di elementi” oppure “qualcosa di iterabile”.

Per definizione un range è una coppia di iteratori begin e end, il primo punta all’inizio di una collezione o sequenza e il secondo alla fine della predetta.

Possiamo quindi inferire che, per la precedente affermazione, ogni struttura dati (container) presente nella STL è un range.

Classificazione

I ranges possono essere classificati in modi differenti, tuttavia il più importante è in base alle capacità degli iteratori.

Avendo affrontato nel post precedente i concepts, possiamo riassumere i ranges nella tabella sottostante.

ConceptDescrizione
std::ranges::input_rangePuò essere iterato dall’inizio alla fine almeno una volta
std::ranges::forward_rangePuò essere iterato dall’inizio alla fine molteplici volte
std::ranges::bidirectional_rangeL’iteratore può effettuare l’operazione -- (vai all’elemento precedente)
std::ranges::random_access_rangeEsiste l’operatore [] che permette l’accesso agli elementi in tempo costante
std::ranges::contiguous_rangeGli elementi sono vincolati ad essere memorizzati contiguamente nella memoria

Si nota facilmente la similitudine con i rispettivi concepts degli iteratori (std::forward_iterator ecc…).

Views

Ci sono tre concetti molto importanti che riguardano le views:

  • Una view è un range.
  • Una view non possiede i dati a cui accede.
  • Una view applica le modifiche solo quando un elemento viene richiesto (lazy-evaluation).

Una view è un range

Per definizione, una view \(w\) è un range che viene definito su un’altro range \(r\). La view in questione può apportare modifiche sul range controllato tramite algoritmi e altre operazioni. (lazy-evaluation)

Una view non possiede i dati a cui accede

Quando si accede a un oggetto attraverso una view, si accede all’oggetto all’interno dell’indirizzo di memoria gestito dalla struttura datu su cui si basa la view.

Questo ha due implicazioni:

  • Le viste sono veloci da creare, perché non hanno bisogno di copiare i dati sottostanti.
  • Le modifiche apportate dalla view non si ripercuotono sul contenitore originale.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include<iostream>
#include<vector>
#include<ranges>

auto main() -> int {
    std::vector numbers{1, 2, 3, 4, 5};
    auto v{std::views::reverse(numbers)};

    for (auto const& i : numbers)
        std::cout << i << " ";
    return 0;
}

// Output: 1 2 3 4 5

Come si può notare la view non ha modificato il range numbers. Tuttavia però è vero il contrario, ossia, modificando il contenitore originale il cambiamento si ripercuote su tutte le view che usano questo range.

Avremo pertanto

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<iostream>
#include<vector>
#include<ranges>

auto main() -> int {
    std::vector numbers{1, 2, 3, 4, 5};
    auto v{std::views::reverse(numbers)};

    for (auto const& i : v)
        std::cout << i << " ";

    std::cout << std::endl;

    numbers[2] = 100;
    numbers[4] = 77;

    for (auto const& i : v)
        std::cout << i << " ";

    return 0;
}

// Output: 5 4 3 2 1
//         77 4 100 2 1

Lazy-evaluation

Altra concetto da tenere bene a mente è che una view applica le modifiche richieste solo nel momento in cui un elemento viene richiesto e non al momento della creazione della view stessa. Questo garantisce un’ottima flessibilità e permette che una view possa essere usata flessibilmente come un iteratore. Tuttavia se una view dovesse computare una trasformazione

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include<iostream>
#include<vector>
#include<ranges>

auto main() -> int {
    std::vector numbers{1, 2, 3, 4, 5};
    auto v{std::views::reverse(numbers)};
    std::cout << *v.begin() << std::endl; // la view viene valutata qua
    return 0;
}

// Output: 5

Composizioni e Pipelines

Alcuni potrebbero chiedersi il perchè abbia scritto

1
auto v{std::views::reverse(numbers)};

Anzichè utilizzare

1
std::views::reverse v{numbers};

Questo perchè std::views::reverse non è una view, bensì è un adattatore che prende il sottostante range, std::vector in questo caso, e restituisce un oggetto view sul std::vector. Il tipo esatto di view viene nascosto dietro la keyword auto, in questo modo otteniamo il vantaggio di non doversi preoccupare di compilare gli argomenti del template della view. Un ulteriore pregio di questa dichiarazione è la possibilità di concatenare adattatori multipli con dei pipe.

Per esempio anzichè utilizzare

1
auto v{std::views::reverse(std::views::filter(numbers, isDivisibleByThree))};

Potremo scrivere

1
auto v{numbers | std::views::filter(isDivisibleByThree) | std::views::reverse};

Esempi

Vogliamo creare una view dei primi 5 elementi di un std::vector e stampare il risultato.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include<iostream>
#include<vector>
#include<ranges>

auto main() -> int {
    std::vector numbers{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto v{numbers | std::views::take(5)};

    for (auto const& i : v)
        std::cout << i << " ";
}

// Output: 1 2 3 4 5

Vogliamo sfruttare un algoritmo range based per stampare un std::vector invertito e ripulito da tutti i valori negativi.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>

auto main() -> int {
    std::vector numbers{-1, 3, -100, -4, 0, 3, -7, 1};
    auto predicate = [](int const i) -> bool {
        return i >= 0;
    };
    auto printer = [](int const i) {
        std::cout << i << " ";
    };

    std::ranges::for_each(numbers | std::views::reverse | std::views::filter(predicate), printer);
}

// Output: 1 3 0 3

Concetti avanzati

Range Factories

La libreria standard mette a disposizione delle range factories, algoritmi, che forniscono la possibilità di creare delle view senza richiedere un range sottostante.

Una tra le svariate è std::views::iota, che crea una view incrementale di interi.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <iostream>
#include <ranges>

auto main() -> int {
    for (int const i : std::views::iota(1, 7)) {
        std::cout << x << "";
    }
}

// Output: 1 2 3 4 5 6

Zip Views

La funzionalità avanzata std::views::zip(), introdotta in C++23, permetta di combinare più range (o view) in un’unica view. Per ottenere ciò, la libreria sfrutta un’altra componente della STL, std::tuple. Ogni elemento nella zip view è una tupla, pertanto gli elementi di ciascuna view sono contenuti parallelamente.

Cerchiamo di capire meglio con un esempio.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <ranges>
#include <vector>

auto main() -> int {

    std::vector numbers{1, 2, 3, 4};
    std::vector english{"cat", "dog", "table", "sun"};
    std::vector italian{"gatto", "cane", "tavolo", "sole"};


    for (const auto& i : std::views::zip(numbers, english, italian)) {
        std::cout << std::get<0>(i) << ". "
                  << std::get<1>(i) << ": "
                  << std::get<2>(i) << '\n';
    }

    return 0;
}

Conclusione

Anche questo articolo è terminato, tuttavia per quanto io possa essere entrato nei dettagli, mi sembra di non essere stato esaustivo.

Per tutti coloro che fossero interessati ad approfondire questa ranges library, al seguente link troverete la conoscenza che tanto bramate.

Licensed under CC BY-NC-SA 4.0
Realizzato con Hugo
Tema Stack realizzato da Jimmy