Start Kontakt

C++20 - zakresy (ranges)

W tym artykule chciałbym dokładniej zaprezentować zakresy. Mówiąc w skrócie, zakres to obiekt, który potrafi obsługiwać sekwencję elementów. To coś w rodzaju iteratorów, które znamy z poprzednich wersji C++, ale o wyższym poziomie abstrakcji. Dzięki zakresom użytkownicy mogą używać prostszej składni.

Przykładowe kody prezentowane w tym artykule można pobrać z Google Drive.

Konfiguracja Visual Studio

Okazuje się, że aby przetestować zakresy, nie wystarczy w Visual Studio wybrać ISO C++20 Standard. To niestety nie zadziała. Trzeba pójść na całość i wybrać Preview - Features from the Latest C++ Working Draft.

konfiguracja visual studio dla ranges

Po wybraniu tej opcji program się kompiluje, ale IntelliSense przestaje zupełnie działać. Jak widać, zakresy to na razie dość eksperymentalna opcja w Visual Studio! No cóż, rozpocznijmy więc testy.

Zakresy a iteratory

Jak już wspomniałem, zakresy zastępują iteratory i pozwalają uzyskać prostszą składnię. Sprawdźmy najprostszy program. Oto wersja tradycyjna, z iteratorami:

import <vector>;
import <iostream>;

int main()
{
    std::vector<int> vec = { 5, 7, 11, 12, 25, 28, 33, 36 };

    auto cmp = [](int val) -> bool { return val % 3 == 0; }; // sprawdzamy podzielność przez 3
    auto result1 = std::find_if(std::begin(vec), std::end(vec), cmp); // znaleziono pierwszy element o wartości 12
    
    std::cout << *result1 << std::endl;
}

Najpierw deklarujemy wektor liczb całkowitych, a następnie za pomocą algorytmu find_if wyszukujemy w nim pierwszy element podzielny przez 3.

A teraz zróbmy to samo za pomocą zakresów.

import <vector>;
import <iostream>;
import <ranges>;

int main()
{
    std::vector<int> vec = { 5, 7, 11, 12, 25, 28, 33, 36 };

    auto cmp = [](int val) -> bool { return val % 3 == 0; }; // sprawdzamy podzielność przez 3
    auto result2 = std::ranges::find_if(vec, cmp); // znaleziono pierwszy element o wartości 12

    std::cout << *result2 << std::endl;
}

Jak widać, przede wszystkim musieliśmy zaimportować moduł ranges. Po drugie, kod wyszukujący element podzielny przez 3 ładnie się uprościł. Nie trzeba było używać trochę sztucznego zapisu std::begin(vec), std::end(vec), ale po prostu wystarczyło podać jako argument wektor vec.

Warto również zapamiętać, że oprócz kontenerów jako parametru można użyć na przykład "zwykłych" tablic statycznych czy też łańcuchów - ogólnie mówiąc takich struktur danych, które można wykorzystywać z metodami std::begin i std::end.

Widoki (views)

W C++20 pojawiła się też koncepcja podobna do zakresów, a mianowicie widoki (ang. views). W rzeczywistości zakresy i widoki są ze sobą ściśle powiązane. Można powiedzieć, że każdy widok jest zakresem, ale nie każdy zakres jest widokiem.

Widok to takie sprytne rozwiązanie, które "w tle" przetwarza pierwotny zakres wykorzystując pewien algorytm i udostępnia wynik w postaci zakresu docelowego. Warto również dodać, że koszt operacji usuwania, kopiowania czy też przenoszenia jest w przypadku widoków stały i niezależny od liczby jego elementów. Poza tym widoki są wartościowane leniwie, czyli wyznaczane dopiero wówczas, gdy ma miejsce realna iteracja po elementach zakresu, a nie wtedy, gdy widok jest tworzony.

Biorąc to pod uwagę możemy stwierdzić, że zwykły kontener jest co prawda zakresem, ale już nie widokiem, ponieważ koszty obsługi elementów zależą od ich liczby.

Moglibyśmy sobie na przykład zażyczyć utworzenia widoku, który zwraca jedynie liczby parzyste. Ponieważ widok jest zakresem, jego elementy należy wyświetlić iteracyjnie.

auto cmp2 = [](const int& val) { return val % 2 == 0; };
auto result3 = std::ranges::filter_view{ vec,  cmp2};

for (const auto& val : result3)
    std::cout << val << std::endl;

Oto uzyskany wynik:

12
28
36

Adaptery zakresów

Takie wykorzystywanie widoków, jak zaprezentowano powyżej, nie jest jednak zalecane. Zamiast tego w praktyce powinno się używać tak zwanych adapterów zakresów. Za pomocą nich oraz przeciążonego operatora | (alternatywy logicznej) uzyskuje się coś w rodzaju zapisu stosowanego często w poleceniach systemu Unix, a mianowicie przekierowywania wyników jednego polecenia na wejście drugiego (tzw. potok).

Powiedzmy, że z wyfiltrowanego widoku z liczbami parzystymi chcemy wziąć tylko dwa ostatnie elementy, a następnie pomnożyć je przez 3.

auto cmp2 = [](const int& val) { return val % 2 == 0; };
auto f = [](const int& val) { return val * 3; };
auto result4 = vec | std::ranges::views::filter(cmp2) | std::ranges::views::reverse | std::ranges::views::take(2) | std::ranges::views::reverse | std::ranges::views::transform(f);
std::ranges::copy(result4, std::ostream_iterator<int>(std::cout, "\n"));

Bierzemy wektor vec i filtrujemy go za pomocą wyrażenia lambda cmp2, aby uzyskać liczby parzyste. Następnie odwracamy kolejność liczb (reverse), bierzemy dwie pierwsze (take), ponieważ nie można wziąć dwóch ostatnich, ponownie odwracamy kolejność i wreszcie mnożmy przez 3 za pomocą wyrażenia lambda f. Jak widać, jest to typowe przetwarzanie potokowe. W końcu za pomocą metody std::ranges::copy, która wykorzystuje wyjściowy iterator std::ostream_iterator z separatorem końca wiersza, wyświetlamy jeden pod drugim elementy wektora wynikowego.

Jak przekształcić zakres na kontener?

Wydawałoby się, że zakresy można bezpośrednio przekształcać na kontenery, jednakże nie jest to aż takie intuicyjne. Należy w tym celu użyć zapisu wykorzystującego iteratory begin i end, których udawało się nam do tej pory unikać:

std::vector<int> vecResult;
vecResult.assign(begin(result4), end(result4));

W powyższym kodzie zdefiniowaliśmy kontener vecResult, a następnie umieściliśmy w nim zakres result4.

Można też zrobić tak prosto jak na poniższym przykładzie:

for (auto x : result4)
   vecResult.push_back(x);

Można także posłużyć się zewnętrzną biblioteką range-v3 napisaną przez Erica Niebler'a i zastosować następujące polecenie:

auto vecResult = std::ranges::to<std::vector>(result4);

Fabryki zakresów

Fabryki zakresów (ang. range factories) są algorytmami, które zwracają zakres, ale jako danych wejściowych nie wykorzystują żadnego zakresu. Istnieją cztery fabryki zakresów:

Ponieważ dwie pierwsze fabryki są trywialne, spróbujmy przetestować bardziej ciekawą, tzn. iota. Pamiętajmy, by wcześniej zaimportować moduł numeric!

Powiedzmy, że mamy wektor 10 liczb całkowitych i chcemy go wypełnić kolejnymi liczbami poczynając od 5. Oto odpowiedni kod:

std::vector<int> result5(10);
std::iota(result5.begin(), result5.end(), 5);

Wektor zostanie wypełniony liczbami od 5 do 14. Gdybyśmy chcieli wypełnić jedynie trzy początkowe elementy tego wektora, moglibyśmy użyć takiego kodu:

std::iota(result5.begin(), result5.begin() + 3, 5);

Pierwszymi trzema elementami wektora będą 5, 6, 7, a pozostałe nie zostaną zmienione (będą równe 0).

A teraz przetestujmy fabrykę istream_view:

auto numbers = std::istringstream{ "1 2 3 4 5" };
    auto result6 = std::ranges::istream_view<int>(numbers);
    for (const auto& n : result6)
        std::cout << n << std::endl;

Najpierw tworzymy wejściowy strumień łańcuchowy numbers, w którym zawarte są liczby całkowite. Na podstawie tego strumienia generujemy odpowiedni zakres liczb całkowitych result6. Jeśli w strumieniu numbers pojawiłby się znak niebędący liczbą całkowitą, tworzenie zakresu zakończyłoby się na ostatniej poprawnie odczytanej liczbie.