42.1 ranges::to és tárolókonstrukció range-ből

A lazy range-pipeline egy ponton túllép a nézeteken: a feldolgozás eredményét tárolóba kell tenni, hogy tulajdonolt, továbbadható, a forrástól független adatunk legyen. Ezt a műveletet nevezzük materializálásnak. C++20-ban erre kézzel kellett std::vector-t és push_back-et vagy std::ranges::copy-t írni; C++23-ban a szabvány két egymást kiegészítő eszközt ad: a std::ranges::to konverziós függvényt és a konténerek range-alapú tagfüggvényeit, illetve from_range_t konstruktorait. Ez a rész ezeket a szerződéseket tárgyalja, különös tekintettel az allokációs költségre és arra, hogy a materializálás mikor véd a dangling ellen — és mikor nem. Az alapokat, a lazy pipeline viselkedését és a borrowed range fogalmát a Lazy view pipeline és mellékhatások, illetve a Borrowed range, dangling és iterator lifetime részben rögzítettük; itt ezekre építünk, nem ismételjük meg őket.

A fejezet nem végzett forrásellenőrzést. A C++23 státuszt, a pontos feature-test értékeket és az implementációs lefedettséget a megadott kiindulópontokon (például az N4950 munkatervezeten és a cppreference C++23 oldalán) kell ellenőrizni; ezek a linkek kiindulópontok, nem bizonyítékok.

42.1.1 A ranges::to szerződése

A std::ranges::to a <ranges> fejlécben található, a std::ranges névtérben. Nem egyetlen függvény, hanem túlterhelés-halmaz, amelynek két használati alakja van: közvetlen hívás és pipe-ba illeszthető closure. A fő alakok:

A visszatérési típus mindig C, érték szerint. A bemenet egy input_range, amelynek value_type-ja konvertálható a C value_type-jává (a szabvány container-compatible-range fogalma); ez a fordítási feltétel, nem futásidejű ellenőrzés. Ha a konverzió nem lehetséges, a program nem fordul — ez a kívánt viselkedés, mert a hibát a típusrendszer fogja meg.

A to<C<>> levezetés lényege: a konténer típusa C<range_value_t<R>> lesz, a többi template paraméter a default értékét kapja. Ezért működik a to<std::vector>() (vector<int>, vector<std::string> stb.) és a to<std::string>(), de a to<std::map>() nem, mert a map első paramétere a kulcs típusa, amely nem vezethető le a value_type-ból; ott to<std::map<K, V>>() kell. A C tehát template-név, nem típus: a to<std::vector> (típus nélkül) szintaktikailag hibás.

42.1.2 Konténer oldali range-konstrukciók

A C++23 a konténereket is ellátta range-fogadó felülettel. A std::from_range_t tag és a std::from_range inline constexpr érték a <ranges> fejlécben él; a konstruktorok és tagfüggvények a konténer saját fejlécében (<vector>, <list>, <string> stb.) vannak.

std::vector<int> v(std::from_range, r);          // konstruktor range-ből
v.assign_range(r2);                              // teljes tartalom cseréje
v.insert_range(v.begin() + 2, r3);               // beszúrás pozíció elé
v.append_range(r4);                              // hozzáfűzés (sequence containereknél)

A from_range tag kötelező: a std::vector<int> v(r) alak nem a range-konstruktort hívja, hanem más, esetleg nem is létező túlterhelést — ez gyakori félreértés. Az insert_range és az assign_range a legtöbb konténeren elérhető, az append_range csak azokon, amelyeknek van push_back-je (vector, deque, list, forward_list, string); a std::array fix mérete miatt eleve kimarad. Az insert_range előfeltétele, hogy a forrás range ne hivatkozzon a célkonténer saját elemeire; az ilyen önbeszúrás (v.insert_range(v.end(), v)) nem megengedett, és a konténer invalidációja miatt nem definiált viselkedéshez vezet. A pontos előfeltételt mindig az adott tagfüggvény leírásában ellenőrizzük.

A ranges::to és a from_range viszonya: a szabvány a to megfigyelhető eredményét írja elő, nem az implementációját. A könyvtárak jellemzően a from_range konstruktort vagy emplace/insert műveleteket használnak; számunkra a létrejövő konténer tartalma és a value_type konvertálhatósága a szerződés.

42.1.3 Sized range és előzetes foglalás

A materializálás költsége elsősorban attól függ, hogy a forrás sized range-e, azaz a std::ranges::sized_range koncepció teljesül-e, és a std::ranges::size(r) O(1)-e. Ha igen, a konténer előre foglalhat, és a növekedés újraallokációi elmaradnak. Ha nem (tipikus input_range, szűrő pipeline), a konténer növekedni fog.

A költséget számszerűsíthetjük. Legyen a végső elemszám nn, a kezdeti kapacitás c0c_0, és tegyük fel a szokásos duplázó növekedést. Az újraallokációk száma legfeljebb

⌈log2nc0⌉+1, \left\lceil \log_2 \frac{n}{c_0} \right\rceil + 1,

az összes elemáthelyezés pedig legfeljebb 2n2n (a mértani sor miatt). Előzetes reserve(n) esetén egy allokáció történik, és a range elemei egyszer másolódnak vagy mozgatódnak a végleges bufferbe. A csúcs-memóriahasználat szempontjából is jelentős a különbség: duplázásnál a régi és az új buffer egyszerre él, ami átmenetileg mintegy 3n3n elemnyi tárhelyet igényelhet.

A ranges::to sized range esetén a gyakorlatban előre foglal, ha a célkonténernek van reserve tagfüggvénye; ezt azonban a szabványos szöveg pontos megfogalmazása szerint kell ellenőrizni, és nem szabad rá építeni minden implementációnál. Ha biztos kontrollt akarunk, a to előtt magunk is hívhatunk reserve-ot egy köztes konténeren, vagy saját materializáló függvényt írhatunk (lásd a C++20 fallbacket). A std::list-nek nincs reserve-ja, tehát a to<std::list<T>> esetén az előfoglalás lehetősége eleve elvész; itt a csomóponti allokáció dominál, ami más költségprofil.

42.1.4 Beágyazott konverzió

A to nem rekurzív: a range elemeit alakítja a konténer value_type-jává, de a beágyazott range-eket nem materializálja automatikusan. Ez a vector<vector<T>> esetén azonnal látszik.

namespace v = std::views;
auto flat = v::iota(0, 12) | std::ranges::to<std::vector<int>>();

// Ez nem fordul: a chunk elemei subrange-ok, nem vector<int>-ek.
// auto bad = flat | v::chunk(3) | std::ranges::to<std::vector<std::vector<int>>>();

auto nested = flat
    | v::chunk(3)
    | v::transform(std::ranges::to<std::vector<int>>())   // belső szint
    | std::ranges::to<std::vector<std::vector<int>>>();   // külső szint

A kommentelt sor fordítási hibát ad, mert a chunk elemeinek típusa subrange, amely nem konvertálható vector<int>-té. A helyes megoldás a belső szinten egy transform-mal elvégzett materializálás; ezt követően a külső to már vector<int> elemeket másol. A chunk részleteit a Szeletelő és átalakító view-k tárgyalja; itt csak a beágyazott konverzió eszközeként jelenik meg. A tanulság általános: minden olyan szinten, ahol a value_type nem konvertálható közvetlenül, külön materializálás kell.

42.1.5 Materializálás mint dangling-elleni eszköz

Hogy egy view tulajdonolja-e a forrását, az a konkrét alap-view típusán múlik ([C++20] [range.all], a C++20-ra visszamenőleg alkalmazott P2415 szerint). A views::all – és így minden pipeline első lépése – a forrás értékkategóriája szerint választ:

A valódi dangling helyzet ezért nem a make() eredményén épített view, hanem a függvényből visszaadott, lokális lvalue forrásra épülő view: a ref_view a megszűnt lokális vektorra mutat.

// owning_filter.cpp — C++20: rvalue forrás -> owning_view, lvalue -> ref_view
#include <cstdio>
#include <ranges>
#include <span>
#include <type_traits>
#include <vector>

std::vector<int> make() { return {-2, 1, -3, 4, 5}; }

auto positive = [](int x) { return x > 0; };

// NEGATÍV: lokális lvalue vektor -> ref_view; a visszaadott view dangling.
auto dangling_filter() {
    std::vector<int> local = make();
    return local | std::views::filter(positive);
}

// NEGATÍV: a span borrowed_range, de a lokális háttértárolót nem tartja életben.
auto dangling_span() {
    std::vector<int> local = make();
    return std::span<const int>(local) | std::views::take(2);
}

int main(int argc, char**) {
    // A make() eredménye rvalue: a views::all owning_view-ba mozgatja,
    // a view a saját vektorát birtokolja.
    auto owned = make() | std::views::filter(positive);
    static_assert(std::is_same_v<decltype(owned),
        std::ranges::filter_view<std::ranges::owning_view<std::vector<int>>,
                                 decltype(positive)>>);

    std::vector<int> v = make();
    auto borrowed = v | std::views::filter(positive);   // ref_view: v-re hivatkozik
    static_assert(std::is_same_v<decltype(borrowed),
        std::ranges::filter_view<std::ranges::ref_view<std::vector<int>>,
                                 decltype(positive)>>);

    int sum = 0;
    for (int x : owned) sum += x;                        // későbbi utasításban is OK
    std::printf("owned sum=%d\n", sum);

    if (argc > 1) {                                      // negatív próbák
        for (int x : dangling_filter()) std::printf("%d\n", x);
        for (int x : dangling_span()) std::printf("%d\n", x);
    }
}

Buildjegyzőkönyv (lab-gcc profil, g++ -std=c++20 -Wall -Wextra -Wpedantic -fsanitize=address,undefined). A program figyelmeztetés nélkül fordult; a két static_assert igazolja, hogy a make()-ből owning_view, a v-ből ref_view lesz. Normál futásban owned sum=10. Negatív módban (./owning_filter neg) a dangling_filter() bejárása ASan alatt stack-use-after-return, a csak dangling_span()-t futtató változat heap-use-after-free hibát ad.

A to ezzel szemben mindig új, a forrástól független tárolót hoz létre; ott hasznos, ahol a view-t a forrás élettartamán túl kellene megőrizni, vagy ahol az elemeket egyszer, rögzítve akarjuk látni.

std::vector<int> make();

auto owned = make() | std::views::filter([](int x) { return x > 0; });
// Biztonságos: az owning_view birtokolja a make() eredményét.

auto snapshot = make()
              | std::views::filter([](int x) { return x > 0; })
              | std::ranges::to<std::vector<int>>();
// Önálló vector<int>: a szűrt elemek materializált másolata.

A két változat élettartamában nem különbözik, a költségében igen: az owned minden bejáráskor újra szűr, a snapshot egyszer. A materializálás akkor véd a danglingtől, ha a view egy olyan lvalue forrásra épül, amely hamarabb megszűnik, mint ahogy az eredményre szükség van: ilyenkor a | to<...>()-t a forrás élettartamán belül kell meghívni.

Egy másik gyakori hiba: a materializált konténerre később view-t nyitunk, majd a konténert módosítjuk (például push_back-kel). A vector újraallokációja érvényteleníti a view iteratorait — erről a std::array, std::vector és folytonos tárolás invalidációs szabályai szólnak.

42.1.6 C++20 fallback saját to-függvénnyel

Ha a laborprofil C++20, a ranges::to nem elérhető. A materializálás magja saját függvénnyel is megvalósítható, sized range esetén előfoglalással:

#include <ranges>
#include <utility>

template <class C, std::ranges::input_range R>
C to_container(R&& r) {
    C c;
    if constexpr (std::ranges::sized_range<R> &&
                  requires(C& c, std::ranges::range_size_t<R> n) { c.reserve(n); }) {
        c.reserve(static_cast<std::ranges::range_size_t<R>>(std::ranges::size(r)));
    }
    for (auto&& e : r) {
        c.push_back(std::forward<decltype(e)>(e));   // sequence containerekhez
    }
    return c;
}

Ez a változat nem azonos a szabványos to teljes szerződésével: nincs benne a to<C<>> levezetés, a from_range konstruktor preferálása és a beágyazott eset kezelése. A materializálás és az előfoglalás lényegét viszont megmutatja, és C++20-on is használható. Asszociatív konténerhez insert-et kell használni push_back helyett — ezt a feladat megoldása mutatja.

42.1.7 Gyakori hibák és javításuk

42.1.8 Feladatok

  1. Alap. Írj függvényt, amely std::vector<int>-ben adja vissza az 1..n1..n egészek négyzeteit views::iota, views::transform és ranges::to használatával. A visszatérési típust static_assert-tel ellenőrizd. Készítsd el ugyanezt a C++20-as fallbackkel is.
  2. Közepes. Adott egy std::vector<std::string> naplósorokkal. Szűrd ki az "ERROR" előtagú sorokat, materializálj std::vector<std::string>-be, majd csoportosítsd kettesével, és az eredményt materializáld std::vector<std::vector<std::string>>-be. Magyarázd meg, miért nem működik a közvetlen to<std::vector<std::vector<std::string>>>.
  3. Haladó. Egészítsd ki a to_container fallbacket úgy, hogy sized range esetén előfoglaljon, és asszociatív konténerhez is működjön. Használj feature-test makrót annak eldöntésére, hogy a szabványos ranges::to elérhető-e, és indokold az allokációs különbséget egy sized és egy szűrt (nem sized) range esetén.

42.1.9 Megoldások

1. megoldás. A lényeg a típus és a két változat összevetése.

#include <ranges>
#include <vector>
#include <type_traits>

std::vector<int> squares(int n) {
    namespace v = std::views;
    auto r = v::iota(1, n + 1) | v::transform([](int x) { return x * x; });
    auto out = std::ranges::to<std::vector<int>>(r);
    static_assert(std::is_same_v<decltype(out), std::vector<int>>);
    return out;
}

A C++20-as változatban a to_container<std::vector<int>>(r) hívás helyettesíti a to-t. Ellenőrzés: a static_assert forduljon le, és a v::iota(1, 6) bemenetre a várt eredmény 1,4,9,16,251, 4, 9, 16, 25 (nem mért, hanem a szemantikából következő érték).

2. megoldás. A belső szintet transform-mal kell materializálni.

#include <ranges>
#include <string>
#include <vector>
#include <algorithm>

std::vector<std::vector<std::string>> groups(const std::vector<std::string>& lines) {
    namespace v = std::views;
    return lines
        | v::filter([](const std::string& s) { return s.rfind("ERROR", 0) == 0; })
        | std::ranges::to<std::vector<std::string>>()
        | v::chunk(2)
        | v::transform(std::ranges::to<std::vector<std::string>>())
        | std::ranges::to<std::vector<std::vector<std::string>>>();
}

A közvetlen to<std::vector<std::vector<std::string>>> azért nem működik, mert a chunk elemei subrange-ok, amelyek nem konvertálhatók std::vector<std::string>-té. Ellenőrzés: páratlan elemszámnál az utolsó csoport egyelemű; a függvény a bemenetet nem módosítja.

3. megoldás. A fallback általánosítása és a feature-test.

#include <ranges>
#include <version>

#if defined(__cpp_lib_ranges_to_container) && __cpp_lib_ranges_to_container >= 202202L
#  define HAVE_RANGES_TO 1
#else
#  define HAVE_RANGES_TO 0
#endif

template <class C, std::ranges::input_range R>
C to_container(R&& r) {
    C c;
    if constexpr (std::ranges::sized_range<R> &&
                  requires(C& c, std::ranges::range_size_t<R> n) { c.reserve(n); }) {
        c.reserve(static_cast<std::ranges::range_size_t<R>>(std::ranges::size(r)));
    }
    for (auto&& e : r) {
        if constexpr (requires { c.push_back(std::forward<decltype(e)>(e)); }) {
            c.push_back(std::forward<decltype(e)>(e));
        } else {
            c.insert(c.end(), std::forward<decltype(e)>(e));
        }
    }
    return c;
}

Indoklás: sized range esetén a reserve egy allokációra csökkenti a növekedést, és elkerüli a legfeljebb 2n2n elemáthelyezést; szűrt pipeline esetén a méret nem ismert, ezért a növekedés amortizáltan O(n)O(n) marad, de az újraallokációk és a csúcs-memória nem küszöbölhetők ki. Ellenőrzés: a kód forduljon sized (vector) és nem sized (szűrt) bemenetre is; a HAVE_RANGES_TO értékét a fordító <version> fejlécéből olvassuk, és ha 0, a fallbacket használjuk. A feature-test konkrét számértéke implementáció- és verziófüggő, ezért a saját fejlécben ellenőrizendő.

42.1.10 Összefoglaló

A std::ranges::to a C++23 szabványos materializáló eszköze: a lazy pipeline eredményét tulajdonolt konténerbe másolja, ezzel a forrás megszűnése után is érvényes adatot adva. A to<C> explicit típust vár, a to<C<>> a value_type-ból vezeti le a konténer típusát; utóbbi nem működik olyan sablonoknál, ahol további típusparaméter (például kulcs) nem dedukálható. A konténerek from_range_t konstruktorai és insert_range/append_range/assign_range tagfüggvényei ugyanezt a range-fogadó szerződést valósítják meg közvetlenül. A költség sized range esetén előfoglalással egy allokációra és lineáris másolásra csökkenthető; nem sized range esetén az amortizált növekedés marad. A to nem rekurzív: beágyazott konténerhez szintenkénti materializálás kell. A materializálás a teljes kifejezésen belül véd a dangling ellen, de a view külön változóba mentése továbbra is hibás. C++20-on a bemutatott to_container fallback adja a magot, a teljes szabványos szerződés nélkül.