A callback-alapú rendszerprogramozásban két visszatérő probléma van:
hogyan tároljunk egy callable-t úgy, hogy közben ne kényszerítsük
másolhatóságra a benne élő erőforrásokat, és hogyan adjuk tovább a
tartalmazó objektum tagját pontosan azzal az érték-kategóriával és
const-voltassággal, amellyel a tartalmazó objektumot elértük. A C++23
három, egymást kiegészítő eszközt ad erre: a
std::move_only_function-t, a
std::forward_like-ot, valamint a
std::bind_back-ot és a std::invoke_r-t. Ez a
rész az invoke, function wrapper és type erasure
(30.2) type erasure tárgyalására épül, és feltételezi az Explicit object parameter: deducing this (40.2)
explicit object parameter (deducing this) mechanizmusát, valamint a Template, virtual és std::function költsége (37.5)
callable-költség modellt.
Fontos keret: a fejezettervben szereplő javaslat-azonosítók (P0288, P2445, P2387, P2136) a téma irodalmi hivatkozási pontjai, nem pedig általam ellenőrzött, idézett szöveghelyek. A generáló folyamat nem böngészett, ezért a konkrét fordító- és könyvtártámogatást nem állítom ellenőrzöttnek; minden új képességhez feature-test makrót és compile probe-ot adok, a támogatás tényleges meglétét a tanuló laborja rögzíti a saját laborprofiljában.
A std::move_only_function<Sig> (a
<functional> fejlécben) egy típus-törlést végző, csak
mozgatható callable-tároló. A Sig alakja
R(Args...) kiegészítve a hívási operátorra vonatkozó
minősítőkkel: const, &,
&&, illetve noexcept. A minősítők a
paraméterlista után állnak, például:
std::move_only_function<void(int)> a; // semmi extra minősítő
std::move_only_function<void(int) const> b; // const objektumon is hívható
std::move_only_function<void(int) &&> c; // csak rvalue-ként hívható
std::move_only_function<void(int) const & noexcept> d; // const lvalue, nem dobhatA minősítők statikus szerződést írnak le, nem
futásidejű viselkedést. A const azt jelenti, hogy a tárolt
callable-t const objektumként hívja meg a wrapper, tehát csak olyan
callable-t fogad el, amely const-hívható; egy mutable
lambda ebbe nem fér bele. Az && azt jelenti, hogy a
hívás csak rvalue move_only_function-ön engedélyezett, és a
tárolt callable is rvalue-ként hívódik — ez teszi lehetővé az „egyszer
használatos”, a saját állapotát elmozgató handlereket. A
noexcept a hívási operátorra kerül: ha a tárolt callable
mégis dob, a folyamat std::terminate-tel leáll, nem pedig
kivételt propagál.
Két, a std::function-höz képesti lényegi eltérés:
| Szempont | std::function |
std::move_only_function |
|---|---|---|
| A wrapper másolhatósága | másolható | csak mozgatható |
| Tárolt callable követelménye | másolható | mozgatható |
| Üres wrapper hívása | std::bad_function_call kivétel |
nem definiált viselkedés |
| Szignatúra-minősítők | nincs | const, &, &&,
noexcept |
noexcept hívási szerződés |
nem fejezhető ki | kifejezhető |
Az üres hívás különbsége biztonsági következményű: mivel a
noexcept szignatúra miatt a wrapper nem dobhat, a szabvány
az üres állapotban történő hívást nem definiált viselkedésnek hagyja.
Ezért a tárolt handler hívása előtt az explicit
operator bool-t kell ellenőrizni. Ez a
std::span „érvényességi szerződés”-logikájával rokon: a
típus megengedi az üres állapotot, a hívás előfeltétele viszont a nem
üresség.
Nézzünk egy teljes, fordítható példát, amely move-only erőforrást
(egy std::unique_ptr-t) birtokló handlert tárol:
// handlers.cpp — C++23
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#if !defined(__cpp_lib_move_only_function) || __cpp_lib_move_only_function < 202110L
#error "move_only_function nem elerheto ezen a forditon"
#endif
struct session {
std::string name;
};
class session_registry {
public:
using handler = std::move_only_function<void(session&) const>;
void add(handler h) { handlers_.push_back(std::move(h)); }
void run(session& s) const {
for (const auto& h : handlers_) {
if (h) h(s); // üres handler hívása UB lenne
}
}
private:
std::vector<handler> handlers_;
};
int main() {
session_registry reg;
reg.add([](session& s) { std::printf("plain: %s\n", s.name.c_str()); });
reg.add([log = std::make_unique<std::vector<std::string>>()](session& s) {
log->push_back(s.name); // a unique_ptr const lambda-tagként is mutálható tartalmat ad
std::printf("logged: %s (%zu)\n", s.name.c_str(), log->size());
});
session s{"build-42"};
reg.run(s);
}Fordítás és futtatás a tanuló laborjában (a konkrét verziót a laborprofil rögzítse):
g++ -std=c++23 -O2 -Wall -Wextra -Wpedantic handlers.cpp -o handlers && ./handlers
# vagy: clang++ -std=c++23 -O2 -Wall -Wextra -Wpedantic handlers.cpp -o handlersA log capture move-only, ezért ez a lambda
std::function-be nem tehető,
move_only_function-be viszont igen. A const
szignatúra itt helyes, mert a unique_ptr const objektumon
keresztül is nem-const hozzáférést ad a mutatott objektumhoz.
A std::forward_like<T>(x) (a
<utility> fejlécben) a T
érték-kategóriáját és const-voltasságát „ülteti rá” az x
kifejezésre, miközben x saját típusát megtartja. Pontos
szabály, U = std::remove_reference_t<decltype(x)>
jelöléssel:
std::is_const_v<std::remove_reference_t<T>>
dönti el;T&& típusra alkalmazott
is_rvalue_reference_v dönti el;T-nek nem kell referencia típusúnak lennie:
forward_like<X>(x) esetén T&&
rvalue referencia, tehát az eredmény X&&.Ez az az eszköz, amely a klasszikus „perfect forwarding tagfüggvény”
mintát biztonságossá teszi. A C++20-as
std::forward<Self> erre nem alkalmas, mert a
forward csak referencia-típusra van értelmezve, és nem
képes a const-voltasságot a tartalmazó objektumtól függetlenül
átvinni.
A deducing this-szel (C++23 explicit object parameter) a kombináció tömör:
// forward_like.cpp — C++23
#include <cstddef>
#include <type_traits>
#include <utility>
#include <vector>
struct frame {
std::vector<std::byte> payload;
auto&& bytes(this auto&& self) noexcept {
return std::forward_like<decltype(self)>(self.payload);
}
};
int main() {
frame f;
const frame cf;
static_assert(std::is_same_v<decltype(f.bytes()), std::vector<std::byte>&>);
static_assert(std::is_same_v<decltype(cf.bytes()), const std::vector<std::byte>&>);
static_assert(std::is_same_v<decltype(std::move(f).bytes()), std::vector<std::byte>&&>);
}Két részlet magyarázatra szorul. Egyrészt a
decltype(self) azért helyes, mert a
this Self&& self deklarációban a Self
a deduced típus (lehet frame, frame& vagy
const frame&), a self viszont nevesített
paraméter, tehát a kifejezés mindig lvalue — a
decltype(self) a deklarált típust, azaz
Self&&-t adja vissza, ami pont a kívánt
érték-kategória-információ. Másrészt std::move(self)
használata hibás lenne: lvalue hívó esetén is elmozgatná az objektumot,
és a const-voltasságot sem vinné át.
Ha a forward_like nem elérhető a laborprofilban, a
következő fallback viselkedés-azonos (azt a laborban ellenőrizni kell,
hogy a T nem-referencia esetét is lefedi):
template<class T, class U>
constexpr auto&& my_forward_like(U&& x) noexcept {
using UT = std::remove_reference_t<U>;
if constexpr (std::is_const_v<std::remove_reference_t<T>>) {
if constexpr (std::is_rvalue_reference_v<T&&>) return static_cast<const UT&&>(x);
else return static_cast<const UT&>(x);
} else {
if constexpr (std::is_rvalue_reference_v<T&&>) return static_cast<UT&&>(x);
else return static_cast<UT&>(x);
}
}A std::bind_back(f, args...) (a
<functional> fejlécben) egy olyan callable-t ad
vissza, amely g(call_args...) hívásakor
invoke(f, call_args..., args...)-t hajt végre: a kötött
argumentumok hátulra kerülnek. Ez a ranges-adaptorok
természetes illesztési iránya, és ott hasznos, ahol az utolsó paraméter
a konfiguráció: std::bind_back(std::clamp, 0, 100) egy
clamp(x, 0, 100) hívást eredményez. A kötött argumentumok
érték szerint tárolódnak (decay-eléssel), tehát referenciát
std::ref/std::cref nélkül nem lehet kötni;
ideiglenes argumentum élettartama a visszaadott objektum belsejéig tart,
nem tovább.
A std::invoke_r<R>(f, args...) (szintén
<functional>) az std::invoke eredményét
R-re konvertálja, és ha R a void,
eldobja. Type erasure-implementációkban ez azért értékes, mert a hívási
thunk így egységesen írható meg minden R-re, és a
void visszatérésű szignatúra sem igényel külön ágat:
template<class R, class F, class... Args>
R call_thunk(void* p, Args... args) {
return std::invoke_r<R>(*static_cast<F*>(p), std::forward<Args>(args)...);
}A callback-tároló minta e három eszközből áll össze: a hosszú életű
erőforrás (socket, puffermenedzser) tulajdonosa az osztály, a handlert
move_only_function tárolja, a handlert előállító kód
bind_back-kal köti be a konfigurációt, a hívási határon
pedig invoke_r biztosítja a szignatúra-konform
visszatérést. Így a handler birtokolhat unique_ptr-t, a
wrapper nem kényszerül másolásra, és a hívás előtti üresség-ellenőrzés
egyetlen if (h) feltétel marad.
A move_only_function hívása indirekt: a
típus-törlés határán a fordító nem látja a konkrét callable-t, így a
hívás nem inline-olható, ellentétben egy közvetlen lambdával vagy egy
template paraméterrel. A kis méretű callable-eket a legtöbb
implementáció kis pufferben (SBO) tárolja allokáció nélkül, de a wrapper
objektum emiatt nagyobb, mint egy puszta függvénypointer; a konkrét
SBO-méret és sizeof implementációfüggő, ezt ne tekintsük
hordozhatónak. Kódméret szempontjából minden
move_only_function<Sig> példányosítás saját hívási
thunk-táblát generál, tehát a szignatúrák száma növeli
a binárist; egy adott szignatúrán belül a különböző callable-ek száma
jellemzően nem növeli arányosan tovább. A minősítők (const,
&, &&, noexcept)
statikus információk, futásidejű költségük nincs. Mérési terv a
laborhoz: azonos számú hívás közvetlen lambdával, függvénypointerrel,
std::function-nel és move_only_function-nel,
külön rögzítve a sizeof-ot és az allokációszámot. A
mérés eredményét ez a szöveg nem állítja, mert nem futtattam
le.
move_only_function hívása. A
std::function-nél megszokott bad_function_call
itt nincs: a viselkedés nem definiált. Javítás:
if (h) h(args);, és a wrapper konstruktorában tiltsuk meg
az üres handler regisztrálását.mutable lambda const
szignatúrába.
std::move_only_function<void() const> nem fogad el
mutable lambdát, mert az nem const-hívható. Javítás: vagy
vedd le a const minősítőt, vagy ne legyen
mutable a lambda.&& minősítő lvalue híváson.
std::move_only_function<void() &&> csak
rvalue objektumon hívható; az f() lvalue hívás
fordításidejű hiba. Javítás: std::move(f)(), ha tényleg
egyszer használatos.std::forward<Self> a
forward_like helyett. A forward csak
referencia-típusra van értelmezve, és nem viszi át a const-voltasságot a
tartalmazó objektumtól. Javítás:
std::forward_like<decltype(self)>(self.tag).std::move(self) deducing this-ben.
Elmozgatja az lvalue hívó objektumát is. Javítás: kizárólag
forward_like.bind_back referenciakötés nélkül. Az
argumentumok decay-elődnek és bemásolódnak; referenciához
std::ref kell, és az ideiglenes argumentum élettartama nem
hosszabbodik meg.A move_only_function minősített szignatúrája statikus
hívási szerződést ad (const, ref, noexcept), cserébe az üres hívás nem
definiált, és a wrapper csak mozgatható. A forward_like a
tartalmazó objektum érték-kategóriáját és const-voltasságát viszi át a
tagra, és a deducing this-szel együtt teszi helyesen kifejezhetővé a
perfect forwarding tagfüggvényt. A bind_back a hátulról
kötött konfigurációt, az invoke_r a szignatúra-konform
hívási thunkot adja; a három együtt a move-only erőforrást birtokló
callback-tároló természetes eszközkészlete. Minden új képességnél
feature-test makróval és compile probe-bal kell ellenőrizni a tényleges
támogatást.
1. feladat (magyarázó). Egy
event_source osztály
std::move_only_function<void(int) const> típusú
handlert tárol. A handlerek egyike std::vector-ba ír, és
memóriahiány esetén kivételt dobhat. Indokold, miért nem szerencsés a
szignatúrát noexcept-ra váltani, és mi történne, ha mégis
megtesszük.
2. feladat (kódolási). Írj
template<class T> struct box osztályt, amelynek
value() tagfüggvénye deducing this-szel és
forward_like-kal a box érték-kategóriája szerint adja
vissza a tárolt értéket. static_assert-tel igazold a négy
esetet: nem-const lvalue, const lvalue, rvalue, const rvalue.
3. feladat (diagnosztikai). Az alábbi kódrészlet három helyen hibás vagy veszélyes. Javítsd, és minden javításhoz add meg az ellenőrzési feltételt.
std::function<void(int)> h;
h = [p = std::make_unique<int>(0)](int x) { *p += x; };
h(1);
h = nullptr;
h(2);
auto g = std::bind_back(handler, buffer); // buffer helyi std::vector4. feladat (kódolási, összetett). Készíts
handler_slot osztályt, amely az alábbi típusú callable-t
tárolja:
std::move_only_function<void(std::span<const std::byte>) const>A tárolt callable set()-tel cserélhető, és
invoke()-kal csak nem üres állapotban hív. Egészítsd ki egy
bind_back-kal előállított handlerrel, amely egy
std::unique_ptr-ban tartott puffert naplóz.
1. megoldás. A noexcept szignatúra azt
ígéri, hogy a hívás nem dob kivételt; ha a tárolt handler mégis dob, a
folyamat std::terminate-tel leáll, és a hiba nem kezelhető
a hívó oldalán. Mivel a std::vector allokációja dobhat, a
noexcept itt hamis ígéret lenne. Helyes megoldás: hagyjuk
el a noexcept-ot, és a hívási ponton
try/catch-csel vagy
std::expected-tel kezeljük a hibát. Ellenőrzési feltétel: a
szignatúra ne tartalmazzon noexcept-ot, és a hívó kód
forduljon le kivételkezeléssel.
2. megoldás.
template<class T>
struct box {
T stored;
auto&& value(this auto&& self) noexcept {
return std::forward_like<decltype(self)>(self.stored);
}
};
static_assert(std::is_same_v<decltype(std::declval<box<int>&>().value()), int&>);
static_assert(std::is_same_v<decltype(std::declval<const box<int>&>().value()), const int&>);
static_assert(std::is_same_v<decltype(std::declval<box<int>>().value()), int&&>);
static_assert(std::is_same_v<decltype(std::declval<const box<int>>().value()), const int&&>);Indoklás: a decltype(self) a Self&&
deklarált típust adja, amely a hívó érték-kategóriáját és
const-voltasságát kódolja; a forward_like ezt ülteti a
stored tagra, miközben a tag típusát megtartja. Ellenőrzési
feltétel: mind a négy static_assert forduljon le, és a
függvény noexcept maradjon.
3. megoldás. (a) A std::function csak
másolható callable-t fogad el, a unique_ptr-t capture-elő
lambda viszont move-only — ezért a hozzárendelés nem fordul le. Javítás:
std::move_only_function<void(int)>. (b) A
h = nullptr; utáni h(2) üres hívást jelent:
std::function esetén bad_function_call,
move_only_function esetén nem definiált viselkedés.
Javítás: if (h) h(2);. (c) A
std::bind_back(handler, buffer) bemásolja a
buffer-t (decay), így a kötött példány a hívás idejére
érvényes marad, de nem a helyi buffer-t látja — ha a
szándék a helyi vektor elérése volt, std::ref(buffer) kell,
és gondoskodni kell arról, hogy a buffer tovább éljen, mint
a visszaadott callable. Ellenőrzési feltétel: a javított kód forduljon
le, üres handler esetén ne hívódjon meg a callable, és a
bind_back esetén a dokumentált élettartam-szerződés
szerepeljen kommentben.
4. megoldás.
class handler_slot {
public:
using handler = std::move_only_function<void(std::span<const std::byte>) const>;
void set(handler h) { h_ = std::move(h); }
bool ready() const noexcept { return static_cast<bool>(h_); }
void invoke(std::span<const std::byte> data) const {
if (h_) h_(data); // üres hívás UB elkerülése
}
private:
handler h_;
};
auto make_logger(std::unique_ptr<std::vector<std::byte>> sink) {
return std::bind_back(
[sink = std::move(sink)](std::span<const std::byte> data, std::size_t limit) {
const auto n = std::min(data.size(), limit);
sink->insert(sink->end(), data.begin(), data.begin() + n);
},
std::size_t{64});
}Indoklás: a bind_back a méretkorlátot köti hátulra, így
a hívási felület void(std::span<const std::byte>)
marad; a lambda move-only capture-t tartalmaz, ezért csak
move_only_function fogadja el. Ellenőrzési feltétel:
ready() hamis legyen alapból, set() után igaz;
a make_logger eredménye közvetlenül átadható
set()-nek; a limit értéke a hívásban ne
íródjon felül.