Něco o callback funkcích v C++

26. 6. 2025 17:00 (aktualizováno) Ondřej Novák

Díky potřebě portovat starší hru napsanou v C jsem měl možnost připomenout si některé techniky, které jsem v C používal a na které jsem zapomněl. Například callback funkce

Callback v Céčku

Pro odborníky v Céčku určitě nic bombastického. Ukážu na příkladu

typedef int (*DirectoryEntryCallback)(const char *name,
                             char type, size_t size, void *context);

int listDirectory(const char *path, DirectoryEntryCallback cb, void *context)

Celá ukázka na godbolt

Tento návrhový vzor slouží k možnosti zpětného volání uživatelské funkce v okamžiku, kdy je to vhodné, například zde v tomto příkladu, kdykoliv je získán další záznam z adresáře. To co je hlavním rysem takového volání je, že vždy obsahuje ukazatel na callback funkci, kterou dodává volající a jakýsi context. To je zpravidla void pointer na něco, co dále specifikuje context zpětného volání a kam si typicky volající může uložit nějaká data – tedy data si nejspíš uloží na zásobník a do context si vloží pointer na tyto data.  Například funkce listDirectory by se ve Skeldalu použila takto

int collectFilesToList(const char *name, char type, size_t, void *context) {
   if (type=='F') {
      TSTRLIST *listptr = (TSTRLIST *)ctx;
      str_add(listptr, name); //pridej do stringlistu
   }
   return 0; //go on
}


TSTRLIST get_file(const char *path) {
      TSTRLIST list = create_list(16); //16 je pocatecni reserve
      listDirectory(path, &collectFilesToList, &list);
      return list;
}

Je tady vidět práce s kontextem. Jako parametr context jsem dal ukazatel na proměnou list. Funkce str_add totiž při rozšiřování seznamu může změnit hodnotu pointeru na kopii listu rozšířeného o nové položky, a tak je potřeba změnu reflektovat v původní proměnné. To co je ale důležité, bez kontextu by funkce collectFilesToList nevěděla s čím pracuje. Předání samotného listu by se muselo udělat přes statickou proměnnou (fuj), nebo přes thread_local proměnnou (trochu menší fuj).

Ve standardním C přitom máme některé funkce, které kontext nepodporují, například qsort. Pokud například potřebuji řadit indexy do pole podle hodnoty odpovídající tomu indexu , ukazatel na pole, jakoby context, musím dát do jedno z výše uvedených fuj-prostředků. Nepřenositelné varianty jsou qsort_r a qsort_s (microsoft se samozřejmě musí lišit!)

Když si dnes ještě někdo vzpomene na Win32 API, tak tam určitě najde hromadu enumeračních funkcí, které přesně tento návrhový vzor (tedy z kontextem) mají. Třeba

BOOL EnumWindows(WNDENUMPROC lpEnumFunc,LPARAM lParam);
//ok, LPARAM není void *, ale lepším než drátem do oka

BOOL EnumResourceNamesW(HMODULE hModule,LPCWSTR lpType,
  ENUMRESNAMEPROCW lpEnumFunc, LONG_PTR lParam);
//ok, LONG_PTR se dá přetypovat na pointer

Ale také nemají

int EnumPropsA(HWND hWnd,PROPENUMPROCA lpEnumFunc);
UINT_PTR SetTimer(HWND hWnd,UINT_PTR nIDEvent,
                  UINT uElapse,TIMERPROC lpTimerFunc);

Jak to děláme v C++

Kupodivu různě a záleží také na to jak dalece půjdeme do historie. Protože i v C++ se používal callback+context, ale máme tam i nové možnosti. Třeba virtuální funkce

Abstraktní callback interface

class IDirectoryItemCallback {
public:
    virtual ~IDirectoryItemCallback() {};
    virtual int call(const char *name, char type, size_t size) const = 0;
};

Deklarace enumerační funkce by pak vypadala takto.

int listDirectory(const char *path, const IDirectoryItemCallback &callback);

Použití by bylo náročnější

std::vector<std::string> collectFiles(const char *path) {
     // pozor, třída ve funkci, to fungovalo už v C++03

     struct CB: IDirectoryItemCallback {
        std::vector<std::string>  &list;
        CB(
std::vector<std::string>  &list):list(list) {}
        
virtual int call(const char *name, char type, size_t size) const {
             if (type == 'F') list.push_back(name);
             return 0:
        } }; std::vector<std::string> result;
listDirectory(path,CB(result)); return result; }

Je to obrovské psaní a použití třídy uvnitř funkce je neobvyklé (ale povolené). Trochu matoucí může být vynucení const u callback třídy, to jednak umožňuje konstruovat instanci přímo v parametru (před vynálezem &&). a jednak se předpokládá, že funkce se chovají deterministicky, takže pokud chci funkci se stavem, musím stav povinně označit jako mutable.

Když to srovnám s callback+context, tak je to trochu delší, nicméně maličko čistější, nemáme tam například přetypování, což je asi největší zdroj chyb.

Abstraktní callback třídy trpěly „nestandardizací“. Už jen otázka: jak se má jmenovat akční funkce? Call? Run? Execute? Invoke?

Ve světě šablon se používají třídy s definovaným operátorem (), říká se jím funktory

Funktory

Funktory najdeme ve světě šablon, proto naše enumerační funkce musí být šablona

template<typename Callback, typename =
              typename  std::enable_if<std::is_invocable_r<
                int, Callback, const char *, char, size_t>::value>::type >
int listDirectory(const char *path, const Callback &cb);

V C++20 místo enable_if použijeme koncept a require. Jinak slouží k vynucení daného prototypu funkce. Pokud chci takový enumerátor využít, musím si callback napsat jako třídu

struct FilesToVector {
   std::vector<std::string>  &list;
   FilesToVector(std::vector<std::string>  &list):list(list) {}
int operator()(const char *name, char type, size_t size) const { if (type == 'F') list.push_back(name); return 0: } };

Použití je už snadné

std::vector<std::string> result;
listDirectory("/", FilesToVector(result));

Úvodní příklad napsaný jako šablona a funktor .

Virtuální funktory

Problém funktorů je, že enumerační funkce musí být deklarovaná jako šablona, protože každý funktor může být jiného typu a překladač generuje tuto funkci na míru typu funktoru. Takže je zřejmé, že se nedá použít všude tam kde nejde obecně deklarovat šablonu, například pokud by sama enumerační funkce měla být virtuální (virtuální šablony nelze deklarovat).

Samozřejmě, že někoho napadlo napsat před operátor závorek klíčové slovo virtual. Mám to demonstrovat? 

class IDirectoryItemCallback {
public:
    virtual ~IDirectoryItemCallback() {};
    virtual int operator()(const char *name, char type, size_t size) const = 0;
};

Použití virtuálního funktoru zbavuje nutnosti používat šablony a je to asi jediná cesta, jak čistě v C++ vytvářet virtuální enumerační funkce 

int listDirectory(const char *path, const IDirectoryItemCallback &cb);

Lambda funkce

Od C++11 byly zavedeny lambda funkce a je to óóóóóóbrovská úspora psaní funktorů. Protože lambda funkce je jen „zkratka“ pro generování funktorů.

std::vector<std::string> result;
listDirectory("/", [&](const char *name, char type, size_t){
   if (type='F') result.push_back(name);
   return 0
});

Tato specifická syntaxe se dá ekvivalentně přepsat na deklaraci funktoru. Překladač nejen že vytvoří zbrusu novou třídu, která bude velice podobná třídě FilesToVector (viz výše), ale zároveň ji instanciuje. Navíc použití lambda funkce je zpětně kompatibilní s funktory, takže veškeré enumerační funkce, které přijímaly funktory, mohou nadále přijímat i lambda funkce, bez nutnosti cokoliv měnit.

Opět ukázka na godbolt

Jak už jsem napsal, tyto typy enumeračních funkcí lze použít jen tam, kde šablona nevadí, což vylučuje virtuální funkci a veškeré funkce, jejichž implementaci z nějakého důvodu potřebuji skrýt v cpp souboru.

Lambda funkce mají tu nevýhodu, že vygenerované třídy nic nedědí. Nemohou tak dědit ani virtuální rozhraní. Pokud se někde očekává abstraktní funktor (s virtuálním operator()), překladač si bude stěžovat, že jím vygenerovaná třída z lambda funkce se nedá napasovat na tento interface. Jakože by překladač pochopil, že virtuální operator má stejný prototyp jako prototyp lambdy a automaticky by tak zařídil dědění z tohoto rozhraní – ne, tak chytré překladače nemáme.

std::function

Standarizační komise C++ je známa tím, že lidé v ní se rádi drbou za uchem přes hlavu. Problém s lambdami a šablonami se tedy snaží standard vyřešit pomocí wrapper třídy std::functionJedná se sice o šablonu, ale parametry šablony pouze určují požadovaný prototyp našeho callbacku

using DirectoryEntryCallback = std::function<int(const char *, char , size_t)>

int listDirectory(const char *path, DirectoryEntryCallback cb);

Při volání listDirectory dochází k zabalení dodané lambdy nebo funktoru do instance třídy std::function v dané konfiguraci. Tato instance se pak sama chová jako funktor, ale jeho typ známe dopředu, takže volaná funkce už nemusí být šablona. Tím se obchází problém s nutností použít šablonu na enumeraci a viz výše diskutovaný problém s virtuální enumerační funkcí.

Kritika std::function

Bohužel, std::function patří mezi šablony, které se hodí tak do akademického prostředí, kdy chci někomu demonstrovat abstraktní prvky jazyka. Pro praktické používání má zásadní nevýhody

  1. Alokuje paměť na heapu
  2. Vyžaduje copy-constructible lambdu

To první je hledisko výkonové. Cokoliv co alokuje paměť na heapu automaticky dostává visačku „pomalé“. Navíc diskutabilní to může být například u mikročipů, kde moc paměti nemáme. Přitom je to zbytečná komplikace vzniklá zřetězenou sekvencí abstrakcí. Staré C to umí bez alokací!

Co se týče druhého bodu, tak to se váže spíš ke schopnosti funkce uchovávat objekty s vlastnictvím. Zkuste si do  std::function vložit lambdu, která drží v closure std::unique_ptr. Nepochodíte. Když si projdete diskuzní fora, například StackOverflow, tak zjistíte, že na to narazil nejeden programátor a zpravidla jedinou radou v tomto ohledu je – použijte std::shared_ptr  – a to může vést další zbytečné alokace paměti. Standard naštěstí v tomto vyšel vstříc až v roce 2023, kdy se do normy dostala třída std::move_only_function

Pokud jde o callbacky (mluvíme stále o synchronních callbacích, kdy nedochází k přesunu closure), je použití std::function takový kanón na vrabce ale bohužel jsem ho viděl používat ve spoustě cizích projektů, skoro bych řekl nadužívat – protože alternativní cestou jsou šablony a těm přece nikdo nerozumí!

Zpátky na začátek

Ano, je třeba se vrátit k C zda by se nedal oprášit koncept callback+context. Samozřejmě že i dnes mohu toto použít v C++, ale používání lambd budu mít značne omezené. Leda že bych si na to napsal nějaký „udělátko“

Protože co je context – to je přece naše closure

A co je tedy callback jako takový – něco co mi zavolá operator() nad closure

Pokud vezmu úvodní příklad a chtěl bych zachovat původní enumerační funkci a pouze ji zavolat s lambdou – tak to mohu, ale lambda nesmí mít closure – a nebo si musím udělat nějaký context-wrapper. Třeba takto

int main() {
    FILE *out = stdout;

    auto print_entry=[out](const char *name, char type, size_t size) {
        fprintf(out, "[%c] %s (%zu bytes)\n", type, name, size);
        return 0; // pokračuj
    };

    listDirectory("/lib", [](const char *name, char type,
                            size_t size, void *context){
        return (*reinterpret_cast<decltype(print_entry) *>(context))(
                            name,type,size);
    }, &print_entry);

    return 0;
}

Deklarace out slouží jako example pro uložení „něčeho“ do closure. Proměnná print_entry je naše lambda, kterou chceme v C kódu volat jako callback. Samotné volání listDirectory installuje jako callback wrapper, který přetypuje kontext na pointer na lambdu a tu pak zavolá. Pointer na lambdu tak cestuje jako context

Třída function_view

Na téhle myšlence vznikla třída function_view pro C++.

//@file function_view.hpp

#include <type_traits>
#include <utility>

template<typename Fn> class function_view;

namespace _details {

template<bool nx, typename RetVal, typename ... Args>
class FunctionViewImpl {
public:

    using CallFnPtr = RetVal (*)(const void *context, Args && ...)
                                                             noexcept (nx);


    RetVal operator()(Args ... args) const noexcept(nx){
        return _callptr(_context, std::forward<Args>(args) ...);
    }

    template<typename Fn>
    requires(std::is_invocable_r_v<RetVal, Fn, Args...>)
    FunctionViewImpl(Fn &&fn) {
        _context = &fn;
        _callptr = [](const void *ctx, Args && ...  args) {
            Fn *fptr = const_cast<Fn *>(
                       static_cast<std::add_const_t<Fn> *>(ctx));
            return (*fptr)(std::forward<Args>(args)...);
        };
    }

protected:

    CallFnPtr _callptr;
    const void *_context;
};

}

template< class R, class... Args >
class function_view<R(Args...)>:
   public _details::FunctionViewImpl<false, R, Args...> {
    using _details::FunctionViewImpl<false, R, Args...>::FunctionViewImpl;
};

template< class R, class... Args >
class function_view<R(Args...) noexcept>:
   public _details::FunctionViewImpl<true, R, Args...> {
    using _details::FunctionViewImpl<true, R, Args...>::FunctionViewImpl;
};

Šablona function_view představuje „pohled“ na lambda funkci, nebo funktor, podobně jako string_view je pohled na řetězec. Společným atributem všech view je, že se kopírováním pohledu nezasahuje do původního objektu na který je hleděno.

Benefitem function_view je, že vytváří abstraktní rozhraní nad type erasured objektem představující closure. Typ se sice smaže, ale zůstane zachován ve volací funkci, která obalí původní funktor (lambdu)

Šablona function_view se používá stejně jako std::function

using DirectoryEntryCallback = function_view<int(const char *, char , size_t)>

int listDirectory(const char *path, DirectoryEntryCallback cb);

Použití zůstává stejné

std::vector<std::string> result;
listDirectory("/", [&](const char *name, char type, size_t){
   if (type='F') result.push_back(name);
   return 0
});

Následující ukázka je reimplementací úvodní ukázky do  function_view

Nevýhodou  function_view je, že se takto předaný funktor nedá „odnést“. Pokud to potřebuji, pak musím použít std::function nebo std::move_only_function.  To na druhou stranu přináší výhodu v tom, že pokud programátor někde vidí, že se očekává function_view, má zaručeno, že je bezpečné v closure použít reference, protože volaný bude muset funkci zavolat synchronně, během svého běhu. Lze takto předat i funktor, který je úplně nekopírovatelný.

Závěr

Původním záměrem článku bylo krátce představit mé „udělátko“ ve formě function_view ale pak z toho vznikl přehled možností o vytváření a využívání callback funkcí obecně. Snad to pomohlo rozšířit vaše obzory.

Sdílet