43.3 Callable és forwarding eszközök: move_only_function, forward_like, bind_back

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.

43.3.1 A move_only_function mint minősített callable-tároló

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 dobhat

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

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

43.3.2 forward_like: érték-kategória továbbítása a tartalmazó objektum szerint

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:

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);
    }
}

43.3.3 bind_back, invoke_r és a callback-tároló minta

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.

43.3.4 Költség és kódméret

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.

43.3.5 Tipikus hibák és javításuk

  1. Üres 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.
  2. 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.
  3. && 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.
  4. 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).
  5. std::move(self) deducing this-ben. Elmozgatja az lvalue hívó objektumát is. Javítás: kizárólag forward_like.
  6. 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.

43.3.6 Összefoglaló

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.

43.3.7 Feladatok

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::vector

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

43.3.8 Megoldások

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.