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
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);
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
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 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 .
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);
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.
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::function
. Jedná 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í.
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
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í!
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
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ý.
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.
V C++26 by měl být std::function_ref.
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p0792r14.html
Jinak mimo topic, tak byla schválena i nějaká reflexe do C++26!
https://isocpp.org/blog/2025/06/reflection-voted-into-cpp26-whole-new-language-herb-sutter
tech funkci a templatu je tolik, ze to uz neudrzim v hlave. do rustu se mi jeste furt moc nechce, c++ by bylo super kdyby move byl default a veskera omacka kolem move by neexistovala a kopie by se musely delat clone.
To je diskutabilní. Copy je bezpečné protože když někomu něco předáváš a neviš co s tím bude dělat tak mu radši dáš kopii. V js se předává kopie proměnné což je ukazatel na objekt a pak se člověk diví že mu voláný objekt změnil. (Tam je obrovský problém udělat kopii) Mně vyhovuje explicitně specifikovat move. Chaos ve std::function není ani tak chyba jazyka jako chyba špatného návrhu v stl. Těch tam máme víc. Začal bych třeba iostreamy
Move je v Rustu taky bezpečné, dělá to destructive move, tozn. původní objekt se zničí a interně je to implementované jako memcpy. Má to dost výhod, není potřeba psát kód pro move (což ušetří práci a nedá se v tom udělat chyba) a asi ani nejde vymyslet nic rychlejšího než memcpy, takže to má i vysoký výkon.
V C++ nebylo možné destructive move udělat ze dvou důvodů. První je, že C++ nemá borrow checker a tedy ani koncept objektu v moved stavu, který se už nesmí použít. Tohle by šlo simulovat něčím jako std::optional nebo nullptr, ale je těžké to nějak rozumně přidat vedle C++ copy sémantiky, aby se move jednoduše požívalo a dávalo to smysl. Druhý důvod, proč nešlo destructive move v C++ udělat, je ten, že v tomto konceptu nelze mít v objektu ukazatel/referenci na sama sebe (adresa se po move změní, dělá to memcpy) a přidání takového pravidla by zase rozbilo spoustu existujícího kódu.
To se mi na c++ líbí nejvíc že si mohu definovat co se při operaci přesunu skutečně děje. Navíc mohu zakázat kopírování a každýmu kdo bude chtít vytvořit kopii to vynadá hele nesmíš.
Já mluvil o bezpečnosti z hlediska organizace kódu a čitelnosti, ne z hlediska zabezpečení paměti. Chyby lze dělat i v jiné doméně než je pamět.
Obecně je v c++ vše hodnotou, bez ohledu na traits daného objektu. Pokud je to movable only object , musím explicitně povolit move
Koncept move, jak je implementovaný v C++, je už překonaný, není to dobré řešení. Když se v C++11 move přidávalo, tak to ale bohužel nešlo udělat lépe z důvodů, které jsem uvedl výše.
Destructive move je ve všem lepší, jak z hlediska výkonu, tak i organizace a přehlednosti kódu. Pro destructive move není třeba psát žádný kód, o všechno se postará automaticky překladač. Kód, který neexistuje, je z hlediska organizace a údržby nejlepší, není potřeba se jím vůbec zabývat.
Tohle je ale věc překladače a optimalizace.
Pokud překladač vidí kód destruktoru a vidí, že objekt má stav, který přeskakuje jeho skutečnou destrukci, může generovat kód tak, že vynechá destruktor úplně.
Pořád nějak rustaři neumí oddělit jazyk jako výrazový prostředek a finální kód.
Takže pokud někde použiju move nad std::unique_ptr, tak sic destruktor by se měl volat vždy, i když byl pointer přesunut, tak optimalizace překladače může zjistit, že destruktor se volá vždy s null pointerem a tedy destruktor v rámci optimalizace úplně vynechat (tedy nebude tam ani ten podmíněný skok). Blbý je, pokud k move došlo mimo aktuální scope, do kterého překladač nevidí.
To všechno se může dít bez nutnosti zavádět nový jazyk.
To vůbec není o Rustu nebo C++, ale o obecném designu move. Všimni si, že v mém předchozím příspěvku není o Rustu ani zmínka, s Rustem jsi začal ty.
Takže ještě jednou, destructive move, jako obecný design, má oproti move v C++ tyto zásadní výhody:
C++ překladač může někdy v okrajových případech nevolat destruktor, ale většinou se volá uživatelský kód move constructoru a pak ještě destructor, což je ztráta výkonu.
C++ komunita je si toho samozřejmě vědoma a existují návrhy, jak dostat destructive move do C++, tady je jeden z nich. Třeba se časem dočkáme destructive move i v C++.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 53 866×
Přečteno 25 677×
Přečteno 23 879×
Přečteno 23 032×
Přečteno 22 305×