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.
ranges::to
szerződéseA 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:
std::ranges::to<C>(r, args...) — az
r range-ből C típusú konténert készít, a
C konstruktorának további args argumentumaival
(jellemzően allocator).std::ranges::to<C<>>(r, args...) — a
konténer típusát a range value_type-jából vezeti le.r | std::ranges::to<C>(args...) — a pipe alak,
ahol a to<C>(args...) egy range adaptor closure.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.
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.
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 , a kezdeti kapacitás , és tegyük fel a szokásos duplázó növekedést. Az újraallokációk száma legfeljebb
az összes elemáthelyezés pedig legfeljebb
(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
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.
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ő szintA 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.
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:
v | views::filter(...)) ref_view lesz: a view
csak hivatkozik a v-re, és addig érvényes, amíg a
v él;make() | views::filter(...)) owning_view
lesz: a view mozgatással átveszi az ideiglenes vektort,
és birtokolja, így a teljes kifejezés után is biztonságosan
bejárható;views::all másolja.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.
to-függvénnyelHa 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.
ranges::to a <ranges>-ben és a
std::ranges névtérben van, nem a
<algorithm>-ban és nem std::to
néven.to<std::vector> hibás; helyesen
to<std::vector<int>>() vagy
to<std::vector>().to<std::map>() nem fordul; a kulcs típusát explicit
meg kell adni.to<vector<vector<T>>> nem materializálja
a belső range-eket; a belső szinten külön
transform(to<vector<T>>()) kell.from_range tag elhagyása. A
vector<int> v(r) nem a range-konstruktort hívja; a
std::from_range tag kötelező.insert_range forrása
nem hivatkozhat a célkonténer saját elemeire.input_range esetén a
range csak egyszer járható be; a to ezt betartja, de a
körülötte lévő pipeline-nak is tekintettel kell lennie erre.std::vector<int>-ben adja vissza az
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.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>>>.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.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
(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
elemáthelyezést; szűrt pipeline esetén a méret nem ismert, ezért a
növekedés amortizáltan
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ő.
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.