A Generator és streaming feldolgozás (35.4)
részben a std::generator alapvető használatát már
rögzítettük: a coroutine felfüggeszthető, a fogyasztó pedig egy input
range-en keresztül kéri le az elemeket. Itt a teljesebb típus- és
élettartam-modellt vesszük elő: hogyan függ a
generator<Ref, V, Allocator> viselkedése a
sablonparaméterektől, mikor biztonságos a referencia szerinti yieldelés,
hogyan delegálunk rekurzívan ranges::elements_of-fal, és
milyen korlátokat jelent a ranges pipeline-okban, hogy a generátor input
range.
A tárgyalás feltételezi a promise_type, awaiter
és coroutine_handle (35.2) és a Kombináló
view-k: zip, adjacent, enumerate, cartesian_product (42.2)
ismeretét. A C++20-as coroutine-alapokat nem tanítjuk újra; a hangsúly a
C++23-as std::generator szerződésén és a gyakorlati
hibamódokon van. A példák C++23-at igényelnek; a konkrét fordító- és
könyvtártámogatást nem tekintjük automatikusan adottnak, ezért
feature-test makrót és compile probe-ot használunk.
A fejlécben deklarált típus alakja:
template<class Ref, class V = void, class Allocator = void>
class generator;A paraméterek szerepe:
| Paraméter | Szerep | Tipikus választás |
|---|---|---|
Ref |
ebből képződik a reference típus (lásd lent) |
const T&, T&, T |
V |
a generátor value_type-ja; ha void, a
Ref-ből remove_cvref_t-tel képződik |
legtöbbször elhagyható |
Allocator |
a coroutine frame allokátora; void esetén a könyvtár
alapértelmezett allokátora |
speciális allokációs politika esetén |
A std::generator move-only, view és
input_range. Az iterator input iterator, a
lezáró pedig default_sentinel_t; a bejárás single-pass,
azaz a sorozatot nem lehet visszatekerés nélkül, párhuzamosan vagy több
iterátorral újraolvasni. A begin() indítja a coroutine-t,
az end() pedig azt jelzi, hogy a coroutine
co_return-nal vagy a függvény végére érve befejeződött. A
generator nem tárolja el az összes elemet: a coroutine
frame-ben felfüggesztett állapot termeli őket igény szerint.
A Ref megválasztása nem esztétikai kérdés, hanem
tulajdonlási döntés. A három származtatott típus képlete ([C++23]
[coro.generator.class]); a két utolsó oszlop a
generator<std::string> és a
generator<const std::string&> esete (a
V mindkettőnél void):
| Típus | Képlet | Ref = std::string |
Ref = const std::string& |
|---|---|---|---|
value |
V void esetén remove_cvref_t<Ref>,
különben V |
std::string |
std::string |
reference |
V void esetén Ref&&, különben
Ref |
std::string&& |
const std::string& |
yielded |
ha reference referencia, akkor az, különben
const reference& |
std::string&& |
const std::string& |
Az iterátor operator*-a reference-t ad
([coro.generator.iterator]): a coroutine frame-ben a felfüggesztés
idejére megtartott objektumra mutató hivatkozást. Ez
generator<T> esetén is így van: a *it
eredménye T&&, nem másolat. A
V akkor kap szerepet, ha a value_type nem
vezethető le egyszerűen a Ref-ből; a legtöbb gyakorlati
esetben elég a Ref-et megadni.
A C++23-as elérhetőséget a <generator> include és
a __cpp_lib_generator feature-test makró jelzi. A következő
fordítószelet ezt ellenőrzi; a makró pontos értékét a használt standard
könyvtár dokumentációja rögzíti, ezért azt ne kezeld örökre rögzített
konstansként:
#include <version>
#if !defined(__cpp_lib_generator)
#error "A std::generator nem erheto el ebben a standard konyvtarban"
#endifA legfontosabb szabály: sem a co_yield, sem a
dereferálás nem másol automatikusan; az, hogy a fogyasztónál másolat
keletkezik-e, a fogyasztó változójának deklarációján múlik. A
for (std::string x : g) minden elemből saját x
objektumot konstruál: generator<std::string>-nél a
std::string&&-ből mozgatással,
generator<const std::string&>-nél másolással. Ez
az x a továbblépés után is érvényes. A
for (auto&& x : g) és a
const auto& r = *it viszont csak hivatkozik az
aktuálisan yieldelt objektumra, és ez a hivatkozás a következő
++it (a coroutine folytatása) után nem használható, mindkét
generátorfajtánál.
A coroutine frame élettartama segít megérteni a látszólag meglepő eseteket. A coroutine lokális változói nem a natív stacken, hanem a frame-ben élnek, és a frame addig marad életben, amíg a generátor le nem fut vagy meg nem semmisül. Ezért az alábbi kód első ránézésre ijesztő, de a generátor élettartamán belül helyes lehet:
std::generator<const std::string&> local_owner() {
std::string s = "hello";
co_yield s; // a frame-ben élő s-re hivatkozik
}A hivatkozás addig érvényes, amíg a coroutine fel nem ébred és be nem
fejeződik. Ha a fogyasztó továbblépteti az iterátort, a coroutine eléri
a függvény végét, a frame lokalisa, köztük s, megsemmisül;
a korábban eltárolt referencia ekkor dangling lesz. Ezért a
generator<const T&> tipikus használata nem
önmagában a „lokális változó”, hanem az, hogy a hivatkozott objektum
tulajdonosa egyértelműen túlél.
A generator<T> és a
generator<const T&> különbsége tehát nem az, hogy
az egyik másolatot ad a dereferáláskor, hanem az, hogy ki tartja életben
a yieldelt objektumot. A generator<T>-nél a yieldelt
érték a coroutine frame-ben él a felfüggesztés alatt, így a
forrásobjektumtól független. A
generator<const T&>-nél a generátor csak a
hivatkozást adja tovább, a hivatkozott objektum élettartamáért a
yieldelő kód felel. Mindkettőnél igaz: a dereferálással kapott
hivatkozás csak az aktuális felfüggesztésig érvényes, aki tovább akarja
tartani, annak saját értéket kell deklarálnia. A
std::string_view például önmagában olcsó, de a mögötte lévő
karaktertömb élettartamát nem hosszabbítja meg.
Egy temporális közvetlen yieldelése
generator<const T&>-be önmagában
nem hiba:
std::generator<const std::string&> temp() {
co_yield std::string{"temporary"}; // a temporális a co_yield teljes
} // kifejezésének végéig él
void consume() {
auto g = temp();
auto it = g.begin();
std::string copy = *it; // helyes: a felfüggesztés alatt olvasunk
const std::string& kept = *it; // csak a következő ++it-ig érvényes
++it; // a coroutine folytatódik: a teljes
// kifejezés véget ér, a temporális megszűnik
// kept itt már dangling; copy továbbra is érvényes
}A temporális élettartama a co_yield-et tartalmazó teljes
kifejezéshez kötődik. A coroutine ennek a kifejezésnek a kiértékelése
közben függeszti fel magát, így a fogyasztó a felfüggesztés alatt
biztonságosan olvashatja a temporálist. A teljes kifejezés a
folytatáskor, a következő ++it hatására ér véget. A valódi
veszély tehát az, ha a fogyasztó a hivatkozást a továbblépés után is
megtartja, vagy ha a yieldelt hivatkozás egy a coroutine-nál rövidebb
élettartamú külső objektumra mutat. Ilyenkor a fogyasztónak saját
értéket kell deklarálnia, a generátornak pedig
generator<std::string>-re vagy stabil tulajdonosra
kell váltania. Hasonló a helyzet akkor, ha egy vektorra hivatkozó
generátort készítünk, majd a vektort módosítjuk: a
push_back vagy reserve érvénytelenítheti az
elemekre adott referenciákat, és a generátor minden további dereferálása
hibás lehet.
Ellenőrzési megjegyzés. A lab-gcc
profil GCC 13.3-as libstdc++-ában nincs <generator>
(__cpp_lib_generator nincs definiálva), ezért a fejezet
std::generator-os példáit ott nem fordítottuk le; a
lab-next profilban ellenőrizendők. A fenti típusképletek és
az élettartam-szabályok forrása az N4950 [coro.generator.class] és
[coro.generator.iterator] szakasza.
A std::ranges::elements_of egy C++23-as delegáló eszköz.
A
co_yield std::ranges::elements_of(range);utasítás azt jelenti, hogy a generátor nem egyetlen elemet, hanem a
megadott range összes elemét yieldeli, mégpedig a range bejárásával. Ez
különösen rekurzív generátoroknál hasznos: a szülő generátor egy
gyermekgenerátorra delegálhat anélkül, hogy kézzel írnánk egy belső
for ciklust.
Tekintsünk egy fájtstruktúrát leíró fát. A preorder bejárás rekurzív generátorral így néz ki:
#include <version>
#if !defined(__cpp_lib_generator)
#error "A std::generator nem erheto el ebben a standard konyvtarban"
#endif
#include <generator>
#include <ranges>
#include <string>
#include <vector>
#include <iostream>
struct Node {
std::string name;
std::vector<Node> children;
};
std::generator<const Node&> walk(const Node& n) {
co_yield n;
for (const auto& child : n.children) {
co_yield std::ranges::elements_of(walk(child));
}
}
int main() {
Node root{"root", {
{"etc", {{"nginx.conf", {}}}},
{"var", {{"log", {{"app.log", {}}}}}}
}};
auto names = walk(root)
| std::views::filter([](const Node& n) { return !n.name.empty(); })
| std::views::transform([](const Node& n) -> const std::string& {
return n.name;
});
for (const std::string& name : names) {
std::cout << name << '\n';
}
}A példa preorder sorrendben adja a csomópontokat. A
walk(child) egy ideiglenes
generator<const Node&>, amelyet az
elements_of átvesz, és a szülő generátor a gyermek elemeit
delegálva yieldeli. A Ref = const Node& választás azért
megfelelő, mert a fa a bejárás teljes ideje alatt él; a generátor nem
másolja a csomópontokat.
A rekurzió mélysége nem feltétlenül a natív hívási verem mélysége. A
szülő coroutine a delegálás alatt felfüggesztve marad, a gyermek
frame-je pedig a heapen lévő coroutine-lánc része. A fogyasztó léptetése
a láncot járja be; a frame-ek élettartama a generátor-lánc
élettartamához kötődik. Ez fontos különbség a hagyományos rekurzív
függvényhíváshoz képest, ahol a mélység a stacket fogyasztja. A pontos
garanciát az elements_of szabványos szemantikája és a
könyvtár implementációja adja; a stack- és allokációs viselkedést a
laborban külön kell mérni.
Bizonyításvázlat (vázlat). Tegyük fel, hogy a
fogyasztó egy elem kérésével léptet. A szülő generátor
co_yield elements_of(walk(child)) pontján felfüggesztett, a
gyermek pedig a delegált range-et szolgálja ki. A fogyasztó a gyermek
aktuális elemét kapja, a szülő frame nem aktiválódik újra, amíg a
gyermek ki nem merül. Így a bejárás mélysége a felfüggesztett frame-ek
láncában, nem a hívási veremben jelenik meg. Ez a vázlat a delegálás
szerkezetére támaszkodik; a tényleges memóriahasználatot és a mély
rekurzió korlátait mérni kell.
A std::ranges::elements_of opcionális
allokátorargumentumot is elfogadhat, amely a delegált generátor
frame-jének allokációs politikájához kapcsolódhat. Ennek pontos
támogatása és típusillesztése könyvtárfüggő; csak akkor használd, ha a
fogadó generátor promise-típusa ezt kifejezetten támogatja, és a
laborprofilban ellenőrizted.
A generator<Ref, V, Allocator> harmadik paramétere
a coroutine frame allokátora. A coroutine frame tartalmazza a promise-t,
a paramétereket, a lokális változókat és a yieldelt érték tárolását. A
frame a coroutine első hívásakor jön létre, és a generátor
megsemmisülésekor szabadul fel. A generator tehát nem
allokál külön tárolót az elemek számára; a „tároló” a coroutine
állapota, amelynek mérete a coroutine lokálisaitól és a yieldelt érték
típusától függ.
Az Allocator = void alapértelmezés a könyvtár
alapallokátorát használja. Egyedi allokátorral például arena- vagy
pool-alapú coroutine-frame kezelés valósítható meg, de az allokátor
típusa a generátor típusának része lesz, ami a pipeline-okban és a
függvényszignatúrákban megjelenik. A gyakorlatban először azt érdemes
eldönteni, hogy a generátor rövid életű-e, és a frame-allokáció mérhető
költséget jelent-e; csak utána érdemes egyedi allokátorhoz nyúlni.
A rekurzív elements_of használatakor minden delegált
generátor saját frame-mel rendelkezhet. Ez kényelmes, de mély fáknál sok
kicsi allokációt jelenthet. A generator<T> érték
szerinti yieldelése további másolási költséget adhat; a
generator<const T&> referenciával dolgozik, de
szigorúbb élettartamszerződést követel. A választás tehát nem csak
nyelvi, hanem költség- és biztonsági döntés.
A std::generator view, ezért ranges
pipeline-ba illeszthető. A szűrő, az átalakító, a levágó és az összefűző
adapter (views::filter, views::transform,
views::take, views::join) jellemzően működik
vele, mert csak input range-et igényelnek. A
views::reverse, a ranges::sort vagy bármely
random access / bidirectional művelet viszont nem, mert a generátor nem
teszi lehetővé a visszafelé lépkedést és a többszöri bejárást.
auto first_three = walk(root)
| std::views::filter([](const Node& n) { return n.name.starts_with("a"); })
| std::views::take(3);A take(3) korai leállást eredményez: a generátor nem
feltétlenül fut a végéig. Amikor a range-for vagy a take
abbahagyja a fogyasztást, a generátor megsemmisül, a coroutine frame
felszabadul, és a frame-ben élő lokális objektumok destruktorai
lefutnak. Ez a korai leállás nem külön „cancel” hívás, hanem a generátor
élettartamának vége.
Ha a teljes sorozatra többször vagy véletlen hozzáféréssel van
szükség, materializálni kell. C++23-ban erre a
std::ranges::to szolgálhat, de a könyvtártámogatást
feature-testtel kell ellenőrizni; fallbackként egy
std::vector feltöltése is megfelel:
std::vector<const Node*> nodes;
for (const Node& n : walk(root)) {
nodes.push_back(&n);
}
std::ranges::reverse(nodes);Itt a nodes a csomópontokra mutató pointereket tárolja;
a fa élettartama továbbra is a hívó felelőssége. A pointerek
érvényessége akkor szűnik meg, ha a fa módosul és a csomópontok
megsemmisülnek.
Ha a coroutine teste kivételt dob, a generátor azt a fogyasztó felé
továbbítja a következő léptetés során. A kivétel nem tűnik el csendben,
és nem lesz belőle „csak egy hiányzó elem”. A fogyasztónak a bejárást
try/catch blokkban kell végeznie, ha a
generátor teste dobhat:
std::generator<int> risky() {
co_yield 1;
throw std::runtime_error("boom");
}
try {
for (int x : risky()) {
std::cout << x << '\n';
}
} catch (const std::runtime_error& e) {
std::cerr << "generator hiba: " << e.what() << '\n';
}A kivétel általában a léptetéskor, az operator++
hívásakor jelenik meg, mert a coroutine ekkor folytatódik. A generátor
destruktora ekkor is felszabadítja a frame-et. Ha a fogyasztó
break-kel áll le, a coroutine nem kap külön értesítést; a
frame destruktora takarít. Ezért a generátor belsejében használt
RAII-erőforrások (fájlleíró, lock, allokált buffer) akkor is
felszabadulnak, ha a fogyasztó korán kilép, feltéve, hogy a coroutine
frame destruktora lefut.
Gyakori hibák:
generator<const T&>-be lokális vagy
temporális objektumot yieldelni, majd a generátor léptetése után
használni a referenciát.views::reverse-vel, ranges::sort-tal
használni.generator<T> érték szerinti yieldelését
„ingyenesnek” tekinteni nagy objektumoknál.elements_of-nak átadott range élettartamát nem
ellenőrizni; a delegált range-nek a delegálás idejéig érvényesnek kell
lennie.Adott egy std::string_view input-ot feldolgozó függvény,
amely a bemenet szeleteit szeretné yieldelni. Válaszd ki a helyes
generátortípust: std::generator<std::string_view>
vagy std::generator<std::string_view&>? Indokold
a választ, és nevezd meg, kinek kell életben tartania a bemeneti
karaktereket.
Írj
std::generator<const Node&> walk(const Node&)
függvényt, amely preorder sorrendben adja a csomópontokat, és a
gyermekekre std::ranges::elements_of-fal delegál. Egészítsd
ki egy pipeline-nal, amely csak a log szót tartalmazó
neveket adja vissza, majd take(2)-vel korlátozd.
Az alábbi kódban két problémát kell találnod: egy élettartamhibát és egy ranges-korlátot.
std::generator<const std::string&> bad() {
std::string s = "hello";
co_yield s;
}
auto g = bad();
auto it = g.begin();
const std::string& r = *it;
++it;
std::cout << r << '\n'; // ?
auto reversed = bad() | std::views::reverse; // ?Magyarázd el, miért hibás a r használata a léptetés
után, és miért nem fordul a views::reverse. Adj javítást
mindkettőre.
A helyes választás a
std::generator<std::string_view>. A
std::string_view kis, olcsón másolható nézet; érték
szerinti yieldelése nem okoz jelentős másolási költséget, és elkerüli,
hogy egy lokális string_view objektumra mutató referenciát
adjunk ki. A std::generator<std::string_view&>
esetében a generátor egy string_view objektumra hivatkozó
referenciát adna; ha ez a string_view a coroutine frame
lokálisa vagy egy temporális, a fogyasztó a felfüggesztés után dangling
referenciát kaphat.
A mögöttes karaktertömb tulajdonosa továbbra is a hívó: a
string_view nem hosszabbítja meg a bemenet élettartamát. A
generátort csak addig szabad fogyasztani, amíg az input
érvényes. Ellenőrzési feltétel: a generátor típusa
std::generator<std::string_view>, a bemenet
élettartama a bejárás teljes idejét lefedi, és a kód nem tárol a
string_view-nál hosszabb életű referenciát a bemenetre.
std::generator<const Node&> walk(const Node& n) {
co_yield n;
for (const auto& child : n.children) {
co_yield std::ranges::elements_of(walk(child));
}
}
auto log_names = walk(root)
| std::views::filter([](const Node& n) {
return n.name.find("log") != std::string::npos;
})
| std::views::take(2);A walk preorder sorrendben adja a csomópontokat. A
elements_of a gyermekgenerátor elemeit delegálja, így nem
kell kézi belső ciklust írni. A filter és a
take input range-en működik; a take(2) korai
leállást okoz, ami a generátor destruktorán keresztül takarít.
Ellenőrzési feltétel: a kimenet preorder sorrendű, a log
szűrés helyes, és a take(2) után a generátor nem fut
tovább. A root élettartama a bejárás teljes idejét fedje
le.
A bad() coroutine frame-jében élő s addig
érvényes, amíg a coroutine fel nem ébred. Az it
léptetésekor a coroutine eléri a függvény végét, s
megsemmisül; a korábban eltárolt r ekkor dangling
referenciává válik. Az std::cout << r viselkedése nem
definiált. A javítás vagy std::generator<std::string>
érték szerinti yieldelés, vagy olyan tulajdonos biztosítása, amely a
referencia használatáig él.
A views::reverse azért nem fordul, mert a
std::generator csak input range: nincs bidirectional
iterátora, nem lehet visszafelé lépkedni. A javítás az, hogy előbb
materializáljuk a sorozatot egy std::vector-ba, és azon
végezzük a fordítást:
std::vector<std::string> values;
for (const std::string& s : bad()) {
values.push_back(s);
}
std::ranges::reverse(values);Ellenőrzési feltétel: a módosított generátor nem ad ki a frame
lokalisa után használható referenciát; a reverse-t csak materializált,
random access tárolón használjuk. A fordító által jelzett „no matching
function” hiba a reverse-nél várható, és nem javítható
views::reverse-vel közvetlenül a generátoron.
A std::generator<Ref, V, Allocator> legfontosabb
döntése a Ref: érték szerinti yieldelés biztonságosabb,
referencia szerinti yieldelés olcsóbb, de a hivatkozott objektum
élettartamáért a hívó felel. A coroutine frame a generátor
élettartamához kötött; a co_yield utáni léptetés vagy a
generátor megsemmisülése lefuttatja a frame lokális destruktorait. A
ranges::elements_of rekurzív delegálást tesz lehetővé, és a
felfüggesztett frame-ek láncát használja a natív rekurzió helyett. A
generátor input range: pipeline-ban filter,
transform, take használható, de reverse, sort
vagy többszöri bejárás nem. Kivétel esetén a hiba a fogyasztó felé
továbbítódik; korai break esetén a generátor destruktora
takarít.