30.2 invoke, function wrapper és type erasure

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.

30.2.1 Egységes hívás: std::invoke

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

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

30.2.2 std::function: típusőrlés tulajdonlással

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

30.2.3 std::reference_wrapper: nem tulajdonló tárolás

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

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

30.2.5 Move-only callable-ek: 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_only

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

30.2.6 A típusőrlés szerkezete és a vázlat

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

30.2.7 Gyakori hibák

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.

30.2.8 Összefoglaló

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.

30.2.9 Feladatok

  1. 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?

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

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

30.2.10 Megoldások

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.