44.2 std::generator mélyebben: rekurzió, allokáció és ranges

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.

44.2.1 A generator típusparaméterei és szerződése

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"
#endif

44.2.2 Yieldelt referencia és érték élettartama

A 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.

44.2.3 Rekurzív generátor ranges::elements_of-fal

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.

44.2.4 Allokáció, frame és költségek

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.

44.2.5 Ranges pipeline és input range korlátok

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.

44.2.6 Kivétel és korai leállás

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:

44.2.7 Feladatok

44.2.7.1 1. feladat: Referencia vagy érték?

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.

44.2.7.2 2. feladat: Rekurzív fa bejárása

Í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.

44.2.7.3 3. feladat: Élettartam- és pipeline-diagnosztika

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.

44.2.8 Megoldások

44.2.8.1 1. megoldás

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.

44.2.8.2 2. megoldás

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.

44.2.8.3 3. megoldás

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.

44.2.9 Összefoglaló

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.