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.
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ő:
[] — semmit nem rögzít;[=] vagy [&] — alapértelmezett capture
mód, a ténylegesen használt nevekre alkalmazva;[x, &y, ...] — explicit lista, kevert móddal.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.
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.
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ő).
this versus
*this captureA [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.
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)).
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>(...).
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.
[&] capture-rel
lokálisra. A closure túlél, a hivatkozott objektum nem.
Javítás: érték szerinti capture vagy init-capture.[=] vagy [&]
async callbackhez. Mindkettő a this pointert
rögzíti. Javítás: [*this] (ha másolható) vagy explicit,
csak a szükséges állapot másolása.std::function move-only closure-lel.
Fordítási hiba (copy követelmény). Javítás: template paraméter vagy
C++23 move-only wrapper.mutable elhagyása érték szerint módosító
closure esetén. Fordítási hiba. Ugyanakkor a
mutable önmagában nem tesz threadsafe-é.[]-re
vonatkozik.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.
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?
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.