Ez az alfejezet a callbackek tárolásának azt a kérdését járja körül,
amely a gyakorlatban a legtöbb hibát okozza: ki birtokolja a hívható
objektumot, meddig él, és mennyibe kerül a hívás? A Lambda, capture és closure lifetime (30.1) a lambda
kifejezés és a closure élettartamát rögzítette, itt azt vizsgáljuk,
hogyan lehet a különböző fajtájú hívható objektumokat egységes interfész
mögé tenni. A cél egy döntési térkép: mikor elég egy sablonparaméter,
mikor kell típusőrlés (type erasure), és mikor kell a nem tulajdonló
tárolást választani. A shared_ptr, control block és
weak_ptr (21.4) shared_ptr-mechanikájára és a Virtual, override, final és dispatch (24.2)
virtuális dispatch-magyarázatára építünk; az előbbit a megosztott
tulajdonlásnál, az utóbbit a típusőrlés implementációs mintájánál
használjuk fel.
std::invokeA C++-ban a „hívható” (callable) fogalma szélesebb, mint a
f(x) alak: lehet függvénypointer, függvényobjektum, lambda,
tagfüggvényre mutató pointer (int (T::*)()) vagy tagnévre
mutató pointer (int T::*). A sablonkód nem tudhatja,
melyikkel kapja a paramétert, ezért a szabvány egyetlen kifejezéssé
alakítja őket: ez az INVOKE kifejezés, amelynek
könyvtári formája az std::invoke.
Definíció. std::invoke(f, args...)
pontosan akkor fordul le, ha a szabvány INVOKE-szabályai szerint
f hívható args...-tal; az eredmény típusa
std::invoke_result_t<F, Args...>. A
tagfüggvény-pointer esetén az első argumentum az objektum (vagy
pointer/referencia-wrapper rá), a tagnév esetén az érték a tag
referenciája.
A gyakorlati haszon az, hogy a hívó kód egyetlen alakot ír, és a tagfüggvény/tagadat eltérés eltűnik:
// invoke_demo.cpp — C++20
#include <algorithm>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
struct Metric {
std::string name;
int value;
int weight() const { return value * 2; }
};
int main() {
std::vector<Metric> ms{{"cpu", 3}, {"mem", 1}, {"io", 2}};
auto w = &Metric::weight; // int (Metric::*)() const
auto v = &Metric::value; // int Metric::*
std::cout << std::invoke(w, ms[0]) << '\n'; // várható: 6
std::cout << std::invoke(v, ms[1]) << '\n'; // várható: 1
std::sort(ms.begin(), ms.end(),
[v](const Metric& a, const Metric& b) {
return std::invoke(v, a) < std::invoke(v, b);
});
for (const auto& m : ms) std::cout << m.name << ' ';
std::cout << '\n'; // várható: mem io cpu
}g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 invoke_demo.cpp -o invoke_demoAz itt közölt kimenet várható érték, nem mért eredmény: a
példát nem futtattam, a tényleges fordítást és a warningmentességet a
saját laborprofilodban kell ellenőrizni. Fontos, hogy a lambdán belül a
v pointert érték szerint kaptuk el — ez egy tagra
mutató pointer, amely nem függ az aktuális Metric
objektumtól, így a sort alatti mozgatás nem érvényteleníti.
Ha ehelyett egy konkrét objektumra mutató pointert vagy referenciát
kaptunk volna el, a sort utáni használat dangling
lenne.
std::function:
típusőrlés tulajdonlássalA típusőrlés (type erasure) lényege, hogy a konkrét
típus eltűnik a felület mögül: a hívó csak a szignatúrát látja, a tárolt
objektum dinamikus típusát egy belső indirekció kezeli. A
std::function<R(Args...)> ennek a szabványos,
tulajdonló változata.
Definíció és feltételek.
std::function<Sig> olyan hívható objektumot tárol,
amelynek hívása Sig-gyel kompatibilis, és amely
másolható (CopyConstructible). A másolás a tárolt objektumot is
másolja; a wrapper üres állapotba kerülhet, és üres wrapper hívása
std::bad_function_call kivételt dob. A tárolt callable
élettartama a wrapper élettartamához kötött, tehát a
std::function tulajdonos — ez a lényegi
különbség a következő szakasz referencia-wrapperéhez képest.
A másolhatóság nem jelenti azt, hogy a viselkedés is
független másolatokkal. Ha a closure shared_ptr-t kap el
érték szerint, a másolat ugyanazt a control blockot osztja meg (shared_ptr, control block és weak_ptr (21.4)), és a
két wrapper ugyanazt az állapotot módosítja:
// shared_state.cpp — C++20
#include <functional>
#include <iostream>
#include <memory>
int main() {
auto counter = std::make_shared<int>(0);
std::function<int()> f = [counter] { return ++*counter; };
std::function<int()> g = f; // wrapper másolat, közös állapot
std::cout << f() << ' ' << g() << '\n'; // várható: 1 2
}Költség. A std::function hívása egy
indirekció (tipikusan függvénypointeren vagy belső vtable-en keresztül),
a másolás pedig a tárolt objektum méretétől függő művelet. Sok
implementáció small-buffer optimalizálást (SBO)
alkalmaz: egy kis méretű callable a wrapperbe inline kerül, és nem
allokál. Ez azonban nem szabványos garancia — sem a
küszöbméret, sem az, hogy egyáltalán létezik. A
sizeof(std::function<int()>) implementációfüggő; erre
építeni hordozhatatlan. A helyes gyakorlat: a konstrukció allokálhat,
ezt a szerződés részeként kell kezelni, és hot pathon mérni kell, nem
feltételezni.
std::reference_wrapper: nem tulajdonló tárolásA std::reference_wrapper<T> egy másolható,
értékszemantikájú referencia: get()-tel (és
implicit konverzióval T&-ra) adja vissza az objektumot.
A std::ref / std::cref állítja elő. Ha
callable-t csomagolunk bele, a wrapper hívható marad, mert a hívás
továbbítódik a hivatkozott objektumra. Így a std::function
nem másolja a callable-t, hanem kölcsönzi.
A szerződés innentől szigorú: a hivatkozott objektumnak túl kell élnie a wrappert. Ezt a fordító nem ellenőrzi, és a hiba tipikusan csak jóval később, más kontextusban jelentkezik:
// ref_lifetime.cpp — C++20 (hibás és helyes változat)
#include <functional>
struct Counter {
int n = 0;
int operator()() { return ++n; }
};
std::function<int()> make_bad() {
Counter c;
return std::ref(c); // HIBA: c a függvény végén megszűnik
} // -> a visszaadott wrapper dangling
std::function<int()> make_good() {
Counter c;
return c; // másolat kerül a wrapperbe: biztonságos
}A make_bad() eredményének bármilyen hívása nem
definiált viselkedés; nincs garantált kimenet, és a hiba nem
feltétlenül azonnal jelentkezik. A make_good() ezzel
szemben önálló élettartamú állapotot hordoz. A döntés tehát nem stílus
kérdése: a std::ref használata akkor helyes, ha a
hivatkozott objektum (például egy hosszú életű konfigurációs objektum
vagy a main-ben élő állapot) egyértelműen túlélő, és ezt a
kód dokumentálja.
bind /
bind_front vagy olvasható lambda?A std::bind a C++11-es eszköz részleges alkalmazásra és
argumentum-átrendezésre, helyőrzőkkel
(std::placeholders::_1). A C++20
std::bind_front ennek a leggyakoribb esetét egyszerűsíti:
az első néhány argumentumot köti, a többit változatlanul továbbítja,
perfect forwardinggal.
// bind_vs_lambda.cpp — C++20
#include <functional>
int add(int a, int b) { return a + b; }
void demo() {
auto b1 = std::bind_front(add, 10); // (int) -> int
auto l1 = [](int b) { return add(10, b); };
// b1(5) == l1(5) == 15
}Az olvashatósági szabály egyszerű: bind helyett
lambdát írj, ha a kötés nem triviális. A bind két
valós buktatót hordoz. Egyrészt a std::ref nélküli kötés
másolatot tárol, ami nagy objektumnál drága, és meglepetést
okoz, ha az eredeti módosul. Másrészt a beágyazott bind és
a helyőrzők száma könnyen olvashatatlan kódot ad, ráadásul a
bind eredményének hívási szignatúrája nehezen követhető. A
lambda ugyanezt explicit capture-listával, explicit paramétertípusokkal
és a hívási helyen látható testtel adja — a closure típusa ugyanúgy
tárolható std::function-ban. A bind_front
C++20-as, tehát C++20 alapprofilban használható; ha korábbi szabványra
kell buildelni, lambdával helyettesítsd.
std::move_only_function (C++23)A std::function másolhatósági követelménye kizárja a
std::unique_ptr-t elkapó lambdákat. C++23-ban erre a
std::move_only_function<Sig> szolgál: csak mozgatható
callable-t fogad, a wrapper maga mozgatható, másolása tilos.
Elérhetőségét feature-test makróval kell ellenőrizni,
és a támogatás libstdc++/libc++ verziófüggő:
// move_only.cpp — C++23, feature-testtel
#include <functional>
#include <memory>
#if defined(__cpp_lib_move_only_function) && \
__cpp_lib_move_only_function >= 202110L
using Task = std::move_only_function<void()>;
#define HAVE_MOVE_ONLY 1
#else
using Task = std::function<void()>; // fallback: megosztott tulajdonlás
#define HAVE_MOVE_ONLY 0
#endif
Task make_task() {
#if HAVE_MOVE_ONLY
return [p = std::make_unique<int>(5)] { /* ... */ };
#else
auto sp = std::make_shared<int>(5);
return [sp] { /* ... */ }; // másolható helyettesítés
#endif
}g++ -std=c++23 -Wall -Wextra -Wpedantic -O2 move_only.cpp -o move_onlyA fallback nem ekvivalens: shared_ptr esetén a
tulajdonlás megosztott, a control block allokál, és a lambda másolható
lesz — ez könnyen elrejti a tervezési szándékot. Ha a cél az egyedi
tulajdonlás kifejezése, a C++20-as helyettesítés egy saját, kis
típusőrlő wrapper (lásd alább), nem a shared_ptr. Azt, hogy
a __cpp_lib_move_only_function a te toolchaineden
definiált-e, compile probe-bal ellenőrizd; ezt a könyv állításaként nem
tekintheted igazoltnak.
Levezetés vázlata. Tegyük fel, hogy egy heterogén callable-halmazt (különböző closure-típusokat) futásidőben, egyetlen változóban kell tárolni. Ha a tároló statikus típusa ismert, minden elem típusa eltér, így egyetlen tömbváltozó nem tudja őket együtt tartani. A közös típus bevezetéséhez a konkrét típust el kell dobni, és a hívást egy olyan indirekción keresztül kell elérni, amelyet a konstrukció idején, a típus még ismert pillanatában rögzítettünk. Ezért a típusőrlő wrappernek legalább három műveletre van szüksége: hívás, másolás (vagy mozgatás), megszüntetés — mindhárom a rögzített típus ismeretében generálható. Ez pontosan a virtuális dispatch szerkezete (Virtual, override, final és dispatch (24.2)), csak kifelé fordítva: nem az objektum örökli a felületet, hanem a wrapper tárol egy függvénypointer-táblát. Következmény: hívásonként egy indirekció, és a wrapper mérete fix, függetlenül a tárolt típustól. Az SBO ezt csak optimalizálja, a szerződést nem változtatja.
| Tárolási mód | Tulajdonos? | Másolható? | Hívási költség | Tipikus használat |
|---|---|---|---|---|
sablonparaméter (F f) |
nem (a hívó dönt) | a típustól függ | nulla indirekció, inline-olható | generikus algoritmus, hot path |
| függvénypointer | nem | igen | egy indirekció | C API, stabil határ |
std::reference_wrapper |
nem | igen | indirekció + élettartam-felelősség | hosszú életű objektum kívülről |
std::function |
igen | igen | indirekció, allokálhat | heterogén callback-tároló |
std::move_only_function (C++23) |
igen | nem | indirekció | move-only erőforrás átadása |
A leggyakoribb hiba a std::ref-fel tárolt rövid életű
objektum: a kód lefordul, a hívás később UB. A második, hogy a fejlesztő
a std::function SBO-jára épít teljesítményérvként — ez
implementációfüggő, ezért mérés nélkül hamis állítás. A harmadik, hogy
üres std::function-t hívnak megfeltétel nélkül: ez
std::bad_function_call, nem csendes no-op, tehát a hívás
előtt if (f) ellenőrzés vagy szerződéses invariáns kell.
Végül gyakori, hogy a std::bind-et std::ref
nélkül használják nagy objektumon, és a másolat költsége rejtve
marad.
A std::invoke egységesíti a tagfüggvény-, tagadat- és
függvényobjektum-hívást, így a sablonkód nem kénytelen
esetszétválasztásra. A std::function tulajdonló, másolható
típusőrlő wrapper: hívása indirekció, konstrukciója allokálhat, üres
hívása kivételt dob, és az SBO nem garantált. A
std::reference_wrapper nem tulajdonló: a hivatkozott
objektum élettartama a hívó felelőssége. A bind_front
(C++20) és a move_only_function (C++23) a két hiányzó
esetet fedi le — utóbbit feature-testtel és fallbackkal kell
használni.
Diagnosztikai. Egy hosszú életű registry
std::function<void()> elemeket tárol. A regisztráló
kód std::ref(state)-tel ad át egy lokális
Handler objektumot. Mi a hiba, hol jelentkezik, és hogyan
javítod anélkül, hogy a Handler másolása drága
lenne?
Kódolási. Írj egy make_task()
függvényt, amely std::unique_ptr<int>-et elkapó
lambdát ad vissza olyan típusban, amely C++23-on
std::move_only_function, C++20-on pedig saját, minimális
move-only wrapper. A wrapper tárolja a callable-t és híváskor
továbbítson.
Tervezési. Egy socket-eseményhurok minden
iterationben egy std::function<void(int)> handlert
hív 10 000-szer. Indokold, mérés nélkül milyen állításokat nem
tehetsz a költségéről, és adj két alternatívát a döntési táblázat
alapján.
1. Megoldás. A std::ref(state) egy
reference_wrapper-t tesz a std::function-ba,
tehát a wrapper nem tulajdonol. A lokális Handler a
regisztráló függvény végén megszűnik, a registry-ben maradt wrapper
dangling; a hiba a későbbi híváskor jelentkezik, UB formájában, nem a
regisztrációnál. Javítás: ha a Handler állapota
megosztható, csomagold std::shared_ptr-be és azt kapd el
érték szerint (közös tulajdonlás, dokumentált élettartam); ha egyedi
tulajdonlás a szemantika, használj move-only wrappert (C++23
std::move_only_function, egyébként a 2. feladat wrapperét)
és mozgasd be a Handler-t. Ellenőrzési feltétel: a wrapper
élettartama alatt a hivatkozott objektum nem szűnhet meg, és ezt a kód
vagy típus, vagy komment nem „ígéri”, hanem garantálja.
2. Megoldás. A minimális wrapper lényege a típusőrlés három művelete: hívás, mozgatás, megszüntetés.
// mini_task.cpp — C++20 fallback
#include <memory>
#include <utility>
class MoveTask {
public:
MoveTask() = default;
template <class F>
explicit MoveTask(F f)
: self_(std::make_unique<Model<F>>(std::move(f))) {}
MoveTask(MoveTask&&) noexcept = default;
MoveTask& operator=(MoveTask&&) noexcept = default;
MoveTask(const MoveTask&) = delete;
MoveTask& operator=(const MoveTask&) = delete;
void operator()() { if (self_) self_->call(); }
explicit operator bool() const noexcept { return static_cast<bool>(self_); }
private:
struct Concept {
virtual ~Concept() = default;
virtual void call() = 0;
};
template <class F>
struct Model final : Concept {
explicit Model(F f) : f_(std::move(f)) {}
void call() override { f_(); }
F f_;
};
std::unique_ptr<Concept> self_;
};Ez a szerkezet a vázlatban leírt három műveletet valósítja meg: a
Model<F> a konstrukciókor ismert F-et
rögzíti, a Concept pedig a hívást egységesíti. Az allokáció
itt minden konstrukciónál megtörténik — ez a fallback ára, és pontosan
ezért nem helyettesíti a std::move_only_function
implementációját, ha az elérhető. Ellenőrzési feltétel: a típus másolása
fordítási hibát ad, mozgatás után az eredeti false-ra
értékelődik, és a hívás egyszer lefut.
3. Megoldás. Mérés nélkül nem állíthatod, hogy a
hívás allokál, hogy a wrapper mérete 32 byte, hogy az SBO érvényesül,
sem azt, hogy a hívás ciklusonként mennyi idő. Ezek implementációfüggők,
illetve mérési kérdések. Amit állíthatsz: a std::function
hívása indirekciót tartalmaz, a wrapper másolása a tárolt objektumot
másolja, és az üres hívás kivételt dob, ezért a ciklusban a hívás előtti
ellenőrzés vagy egy invariáns szükséges. Két alternatíva: (a)
sablonparaméter — az eseményhurok legyen sablon a handler típusára, így
az indirekció eltűnik, de a hurok fejléce template lesz, és a handler
típusa fordítási időben rögzül; (b) nem tulajdonló
reference_wrapper vagy függvénypointer, ha a handler
élettartama egyértelműen hosszabb a huroknál. Ha a heterogenitás
futásidőben kötelező, marad a std::function, de a költséget
mérni kell, és az eredményt a laborprofilhoz (fordító, stdlib, target)
kell kötni.