30.1 Lambda, capture és closure lifetime

Ebben az alfejezetben a C callback+context minta típusos megfelelőjét vizsgáljuk. A Függvénypointerek, callbackek és context pointer (6.5) részben a C-ben megszokott void (*)(void *) és struct ctx * párost láttuk: a hívó ad egy függvénycímet és egy külön átadott context pointert, a kontextus érvényességéért pedig sem a nyelv, sem a fordító nem felel — az a programozó szerződése. A modern C++ closure (lambda kifejezés eredménye) ezt a szerződést típusba, nevesíthető kódba és a fordító által ellenőrzött capture-listába emeli. Ez erősebb eszköz, de nem varázslat: a closure egy közönséges objektum, élettartam-, másolási és mozgatási szabályai ugyanazok, mint bármely más osztálypéldányé. A lambda capture itt tanultakat később közvetlenül felhasználjuk a std::function, a std::invoke és a ranges pipeline (invoke, function wrapper és type erasure (30.2), Ranges algoritmusok, projection és sentinel (30.3)) tárgyalásakor; itt a capture-policy és a closure-élettartam jelenti a fókuszt.

30.1.1 Lambda mint closure: definíció és feltételek

Egy lambda-kifejezés (lambda expression) olyan elsődleges kifejezés, amelynek kiértékelése egy closure objektumot hoz létre. A closure objektum típusa egyedi, névtelen closure típus (closure type), amelyet a program nem tud nevén megnevezni, de auto, decltype és template paraméter formájában hozzáférhető. A closure típus az alábbi, a szabvány által előírt elemekkel rendelkezik: publikus operator() (hívási operátor), másoló és mozgató konstruktor a rögzített tagoktól függően, továbbá — ha a lambda nem rögzít semmit — egy konverziós operátor függvénypointerre.

A [] záradék a capture-lista (capture list). Pontosan azt mondja meg, hogy a lambda törzse mely, a bezáró hatókörben automatikus tárolási idejű változókat használhat, és milyen formában: érték szerint (by value) vagy referenciaként (by reference). A capture-lista három alakja fordul elő:

A fordító csak a tényleg odr-use formában használt neveket rögzíti, és a capture-lista érvényességi feltétele, hogy a rögzített név a lambda előtti hatókörben látható automatikus változó legyen. A [=] és [&] alapértelmezett mód nem rögzít this-t érték szerint tagfüggvényben: a this pointert minden esetben pointerként (tehát implicit referencia-jellegűen) fogadja el, ami a későbbi csapdák egyik fő forrása. A C++20 óta a [=] implicit this-rögzítése deprecált; a közvetlen, explicit [this] vagy [*this] forma javasolt.

Fontos, hogy a lambda típusa minden forráshelyen egyedi, ezért két, szó szerint azonos lambda kifejezés nem azonos típus. Ez indokolja, hogy closure-t csak auto-val, template paraméterrel vagy típus-erasure wrapperrel (std::function) tároljunk, soha ne írjuk ki a típusát.

30.1.2 Capture-policy élettartam szerint

A capture-policy helyes megválasztásának egyetlen rendező elve van: a closure objektum élettartama és a rögzített entitás élettartama közötti viszony. Érték szerinti capture esetén a closure a változó pillanatnyi értékének saját másolatát birtokolja — ez önálló élettartamot ad. Referencia szerinti capture esetén a closure csak hivatkozik az eredeti objektumra; ha a closure tovább él, mint a hivatkozott objektum, a hozzáférés nem definiált viselkedés (Temporary, value category és dangling (20.4)).

Capture Mit tárol a closure Kié az élettartam Mikor biztonságos
[x] x másolata (a closure tagja) a closure-é a closure bármilyen élettartamára
[&x] hivatkozás x-re a külső x-é a closure nem él túl x-et
[this] this pointer értéke a *this objektumé *this túlél minden hívást
[*this] *this másolata (C++17) a closure-é a típus másolható
[p = std::move(q)] a mozgatott érték (init-capture) a closure-é move-only erőforrás átadásakor

A táblázat jól mutatja, hogy a „referencia gyorsabb” intuíció csak akkor érvényes, ha a closure élettartama garantáltan az eredeti objektumon belül marad — tipikusan szinkron algoritmusokban vagy olyan callbacknél, ahol a hívó a hívás idejére garantálja az objektum élettartamát.

Nézzük meg a tipikus async élettartam-hibát. Az alábbi példa egy egyszerű, késleltetett hívásokat gyűjtő Timer-t használ, és a Session élettartama rövidebb, mint a queue-ban maradó closure-é.

// c30_async_lifetime.cpp
// Build: g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o c30_async_lifetime c30_async_lifetime.cpp
#include <functional>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class Timer {
public:
    void schedule(std::function<void()> f) { q_.push_back(std::move(f)); }
    void fire_all() {
        for (auto& f : q_) f();
        q_.clear();
    }
private:
    std::vector<std::function<void()>> q_;
};

struct Session {
    std::string token = "abc";

    // HIBÁS: [&] tagfüggvényben a this pointert rögzíti, nem a token másolatát.
    void activate_bad(Timer& t) {
        t.schedule([&] { std::cout << "bad:  " << token << '\n'; });
    }

    // HELYES: init-capture a tag értékét másolja a closure-be.
    void activate_good(Timer& t) {
        t.schedule([tok = token] { std::cout << "good: " << tok << '\n'; });
    }
};

int main() {
    Timer t;
    {
        Session s;
        s.activate_bad(t);
        s.activate_good(t);
    } // s itt megszűnik
    t.fire_all(); // a bad closure dangling this-t használ: UB
}

A activate_bad closure-je egy this pointer értékét tartalmazza, és a fire_all() pillanatában a Session már nem létezik — a viselkedés nem definiált. A activate_good init-capture-rel a token stringet a closure saját tagjaként tárolja, így a fire_all() biztonságos. A példa buildelése a fenti paranccsal történik, a laborprofilban rögzített GCC vagy Clang kiadással; a programot a tanuló saját laborjában futtatja, a könyv nem állítja, hogy a példa futási eredménye itt ellenőrzött.

30.1.3 Init-capture és move-only erőforrás

Az init-capture (C++14) lehetővé teszi, hogy a capture-lista ne csak másoljon vagy hivatkozzon, hanem új closure-tagot definiáljon egy kifejezésből. Alakja [név = kifejezés], illetve [&név = kifejezés]. Ez az egyetlen eszköz arra, hogy move-only erőforrást (pl. std::unique_ptr) lambda-ba zárjunk: érték szerinti capture másolna (nem megy), referencia szerint pedig nem adnánk át tulajdonjogot.

// c30_init_capture.cpp
// Build: g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o c30_init_capture c30_init_capture.cpp
#include <memory>
#include <utility>

auto make_owner() {
    auto p = std::make_unique<int>(42);
    return [q = std::move(p)]() { return ++*q; };
}

A visszaadott closure a q unique_ptr-t birtokolja, a p a mozgatás után üres. A mutable itt nem kell: az operator() alapértelmezésben const, de a ++*q nem a q tagot módosítja, hanem a mutatott int-et — a const std::unique_ptr<int> operator*-ja is nem const int&-et ad. A mutable akkor kell, ha magát a rögzített tagot módosítjuk, például q.reset()-tel:

// lambda_without_mutable.cpp — C++20: a mutatott objektum módosítása nem kíván mutable-t
#include <cassert>
#include <memory>
#include <utility>

auto make_owner() {
    auto p = std::make_unique<int>(42);
    return [q = std::move(p)]() { return ++*q; };   // a q tag nem változik, csak *q
}

int main() {
    const auto f = make_owner();   // const closure is hívható
    assert(f() == 43);
#ifdef NEEDS_MUTABLE
    auto g = [q = std::make_unique<int>(1)]() { q.reset(); };   // hiba: q const
#endif
    auto h = [q = std::make_unique<int>(1)]() mutable { q.reset(); return q == nullptr; };
    assert(h());
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic lambda_without_mutable.cpp figyelmeztetés nélkül fordul, a const closure hívása 43-at ad. -DNEEDS_MUTABLE mellett a mutable nélküli q.reset() hibát ad: passing 'const std::unique_ptr<int>' as 'this' argument discards qualifiers. Fontos következmény: az ilyen closure csak mozgatható, nem másolható. Ezért nem tehető be std::function-be, mert annak követelménye, hogy a becsomagolt callable CopyConstructible legyen. Ilyenkor template paraméterként adjuk át a closure-t, vagy std::move_only_function-t használunk (C++23 — ebben a részben nem tárgyaljuk részletesen, a laborprofilban ellenőrizendő).

30.1.4 this versus *this capture

A [this] és a [*this] közötti különbség aszimmetrikus, és pontosan ez okozza a legtöbb async élettartam-hibát tagfüggvényben.

A [this] pointert rögzít: a closure mérete tipikusan egy pointer, és minden hívás a this-en keresztül ér el a tagokhoz. A closure élettartama ettől független a *this objektum élettartamától, de a hívás pillanata mindig a *this élettartamán belül kell legyen. Ha a closure <this> után hívódik meg (például egy queue-ban, egy std::thread-en, egy késleltetett timerben), a hozzáférés nem definiált.

A [*this] (C++17) másolatot rögzít a *this objektumról: a closure a saját tagjaként birtokol egy Session másolatot, így a hívás akkor is biztonságos, ha az eredeti objektum megszűnt. Feltétele, hogy a *this típusa másolható legyen; nem másolható osztály (pl. std::mutex tag, unique_ptr tag) esetén [*this] nem használható. Ilyenkor a helyes megoldás a szükséges állapot kézi, szelektív másolása ([tok = token, fd = fd]) vagy shared_ptr-alapú megosztott birtoklás.

A C++20-ban deprecált [=] tagfüggvényben implicit módon this-t rögzít, nem *this-t — ez a leggyakoribb, olvasás során nehezen észrevehető hibaforrás. Az explicit [this] legalább vizuálisan jelzi a szerződést; a [*this] pedig kifejezetten a „túlélhetem a *this-t” szándékot.

30.1.5 Mutable lambda és const call operator

A closure típus operator()-je alapértelmezésben const. Ez azt jelenti, hogy az érték szerint rögzített tagok a hívásban nem módosíthatók, referenciaként rögzített entitások viszont igen (mert a hivatkozáson keresztül a külső objektum nem része a closure const állapotának). A mutable kulcsszó a capture-lista után eltávolítja a const-ot:

int outer = 0;
auto by_ref  = [&outer] { ++outer; };        // OK: külső objektumot módosít
auto by_val  = [outer]   { /* ++outer; */ };  // fordítási hiba: const operator()
auto counter = [n = 0]() mutable { return ++n; };

A counter closure minden példánya saját számlálót tart; a mutable csak a closure belső állapotára hat, a külső outer-t nem érinti. Ez fontos tervezési pont: egy mutable lambda megosztott példánya nem threadsafe, mert a closure belső tagján nincs szinkronizáció. Ha a closure actor-szerű számlálást végez, a hozzáférést külső mutexnek vagy atomiknak kell védenie (RAII lockok, condition variable és wait predicate (34.2), C++ atomics, atomic_ref és publication (34.3)).

30.1.6 Generic lambda, template paraméter és constraints

A generic lambda (auto paraméterrel, C++14) valójában egy template hívási operátorral rendelkező closure, ahol minden auto paraméter egy-egy template paraméter. C++20-tól a lambda saját template paraméterlistát is kaphat, és requires-szel vagy constrained template paraméterrel szűkíthető:

auto twice      = [](auto x) { return x + x; };
auto size_of    = []<typename T>(const std::vector<T>& v) { return v.size(); };
auto positive   = []<std::integral T>(T x) { return x > 0; };

A size_of esetben a lambdanak saját template paraméterre van szüksége, mert nem lehet auto-val kifejezni, hogy a paraméter std::vector<T>. A positive a C++20 std::integral concepttel szűkít; a feltétel a hívási operátoron érvényesül, így nem integrál típusra a hívás nem kompillál, és az overload-feloldásban a constraint részt vesz. A constraints részleteit a Concepts, requires és constrained overload (27.2) tárgyalja; itt a lambda-specifikus alak a lényeg: []<...>(...) requires ... illetve []<Concept T>(...).

30.1.7 Stateless lambda és függvénypointer-konverzió

Egy nem capturing (stateless) lambda rendelkezik egy implicit konverziós operátorral (conversion to function pointer), amely a closure operator()-jének megfelelő szignatúrájú függvényre mutat. Ez a C callback ABI-val kompatibilis: egy stateless lambda átadható olyan C API-nak, amely int (*)(int) típusú paramétert vár. Capturing lambda nem konvertálható függvénypointerré, mert a closure állapotának nincs helye a függvénypointerben — ilyenkor std::function, template paraméter vagy explicit void * context szükséges.

A C callback+context minta és a closure közötti megfelelés:

Szerep C forma C++ closure forma
viselkedés void (*fn)(void *) operator() a closure típuson
állapot külön struct ctx * a closure rögzített tagjai
állapot élettartama a hívó felelőssége a closure objektumé
stateless eset fn + NULL stateless lambda, fn pointerré konvertál
típusbiztonság nincs (void*) fordítási idejű

Ez a megfelelés az alapja annak, hogy a C-ben megszokott callback-regisztráció C++ oldalon típusosan, capture-rel fejezhető ki. A callback regisztrációs szerződés (ki birtokolja a context-et, meddig hívható) viszont változatlanul él: a closure ugyanúgy megköveteli, hogy a rögzített objektumok a hívás pillanatában érvényesek legyenek.

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

30.1.9 Összefoglaló

A lambda egy névtelen closure típusú objektum, amelynek élettartam- és másolási szabályai megegyeznek a közönséges osztályokéval. A capture-policy kiválasztását a closure és a rögzített entitás élettartam-viszonya vezérli: érték szerint biztonságos a hosszú életű closure-höz, referencia szerint csak akkor, ha a closure élettartama az objektuméba ágyazódik. A [this] pointert, a [*this] (C++17) másolatot rögzít; tagfüggvényben a [=] implicit this-t rögzít, ami C++20 óta deprecált. Init-capture-rel move-only erőforrás tehető a closure-be, de az ilyen closure nem másolható, így std::function-be nem illeszkedik. Az operator() alapból const, a mutable oldja fel; generic lambda auto paraméterrel vagy explicit template paraméterlistával (C++20) készíthető, requires-szel szűkíthető. Végül: stateless lambda függvénypointerré konvertál, capturing lambda nem — ez a C callback és a C++ closure közötti gyakorlati határvonal.

30.1.10 Feladatok

1. feladat (diagnózis). Az alábbi függvény egy closure-t ad vissza, amely msg-re hivatkozik. Mi a hiba, és milyen minimális javítás adja meg a helyes, hosszú életű closure-t?

#include <functional>
#include <string>
std::function<void()> make_printer() {
    std::string msg = "hello";
    return [&msg]() { /* használja msg-et */ };
}

2. feladat (init-capture és std::function). Adott egy std::unique_ptr<int>-ot birtokló closure. Magyarázd el, miért nem fordul le std::function<void()>-ba helyezve, és adj egy működő alternatívát.

3. feladat (this vs *this). Az alábbi Server egy queue-ba tesz egy closure-t, amely name-re hivatkozik. Írd át úgy, hogy a closure biztonságos legyen akkor is, ha a Server a hívás előtt megszűnik. Mi a kompromisszum?

struct Server {
    std::string name;
    void schedule(std::function<void()> f);
    void start() { schedule([this] { use(name); }); }
    void use(const std::string&) const;
};

4. feladat (típusosság). Írj egy lambda-t, amely csak integrál típusra hívható, és összeadja a paraméterét 1-gyel. Miért fordítási hiba a std::string argumentum?

30.1.11 Megoldások

1. megoldás. A visszaadott closure a msg-re hivatkozik, de msg a make_printer visszatérésével megszűnik; a closure hívása nem definiált viselkedés. A javítás érték szerinti capture, amely másolatot tesz a closure-be:

return [msg]() { /* használja msg-et */ };

Az init-capture alak [m = msg] szintén működik, és hasznos, ha a nevünket szeretnénk átnevezni. Ellenőrzés: a javított closure a make_printer visszatérése után is biztonságosan hívható, mert a msg másolata a closure tagja.

2. megoldás. A std::function<void()> megköveteli, hogy a becsomagolt callable CopyConstructible legyen, mert a std::function maga másolható. Az init-capture-rel rögzített std::unique_ptr miatt a closure csak mozgatható, ezért a std::function konstruktor nem illeszkedik. Alternatíva: a closure-t template paraméterként adjuk át (ami a hívást statikusan oldja fel), vagy — ha a laborprofil támogatja — C++23 std::move_only_function-t használunk. Ellenőrzés: a template-paraméteres változat lefordul, a std::function-be helyezett változat fordítási hibát ad, és a hibaüzenet a másolási követelményre hivatkozik.

3. megoldás. A [this] a Server pointerét rögzíti; ha a Server megszűnik a hívás előtt, a closure dangling pointerrel dolgozik. A [*this] (C++17) másolatot készít a Server-ről, így a name is a closure-be kerül, és a hívás biztonságos. Kompromisszumok: (a) a Server típusának másolhatónak kell lennie, (b) a closure a Server állapotának pillanatképét tartalmazza, nem az élő objektumot — ha a name a regisztráció után változik, a closure a régit látja. Ha ez nem elfogadható, a helyes megoldás megosztott birtoklás (std::shared_ptr<Server> és [self = shared_from_this()]) vagy a szükséges mezők explicit másolása ([n = name]). Ellenőrzés: a módosított closure a Server destruktora után is hívható anélkül, hogy UB-t okozna; ha a Server nem másolható, a [*this] fordítási hibát ad, ami jelzi a szerződés sérülését.

4. megoldás. A C++20 constrained generic lambda:

auto inc = []<std::integral T>(T x) { return x + 1; };

A std::string argumentum nem elégíti ki a std::integral conceptet, ezért a hívás overload-feloldása nem talál érvényes jelöltet — fordítási hiba. A constrainttel a hiba a hívás helyén, érthető üzenettel jelenik meg, nem a lambda törzsében. Ellenőrzés: inc(41) eredménye 42, inc(std::string{"x"}) fordítási hiba a concept megsértése miatt.