Przed C++11 przechowywanie dowolnych obiektów wywołujących wymagało surowych wskaźników do funkcji lub niestandardowych klas bazowych polimorficznych. Wprowadzenie std::function zapewniło owinięcie z usuniętymi typami, które mogło przechowywać dowolny obiekt wywołujący, ale wymagało CopyConstructible i stosowało Optymalizację Małego Bufora (SBO), aby uniknąć alokacji na stercie dla małych funktorów. Ponieważ C++14 i C++17 upowszechniły typy, które można tylko przenieść, takie jak std::unique_ptr, programiści napotkali na ograniczenie, że std::function nie mogło przechowywać lambd, które przechwytują unikalne zasoby. C++23 wprowadziło std::move_only_function, które usuwa wymóg kopiowania i wspiera wywoływania, które można tylko przenieść, zachowując jednocześnie korzyści wydajnościowe SBO.
std::function wykorzystuje usuwanie typów, aby ukryć rzeczywisty typ wywołujący za jednolitą interfejsem. Gdy obiekt wywołujący przekracza rozmiar wewnętrznego bufora (zwykle 16–32 bajtów), implementacja alokuje przestrzeń na stercie. Jednak fundamentalnym ograniczeniem jest to, że std::function sam w sobie jest kopiowalny, co wymaga, aby mechanizm usuwania typów implementował operację "klonowania" przez wirtualne przekazywanie. W konsekwencji przechowywany obiekt wywołujący musi być CopyConstructible, co wyklucza lambdy, które można tylko przenieść i które przechwytują std::unique_ptr lub uchwyty do plików. To zmusza programistów do używania std::shared_ptr (dodając nadhead atomowy) lub ręcznego dziedziczenia wirtualnego (dodając pośrednictwo).
std::move_only_function jest opakowaniem, które można tylko przenieść, które eliminuje wymóg CopyConstructible. Osiąga usuwanie typów za pomocą wzorca vtable, który można tylko przenieść, co pozwala mu przechowywać wywołania, które można tylko przenieść. Podobnie jak std::function, stosuje SBO, umieszczając małe funktory bezpośrednio w wewnętrznej pamięci, bez alokacji na stercie. To umożliwia wzorce, takie jak zwracanie lambdy przechwytującej std::unique_ptr z funkcji fabrycznej lub przechowywanie callbacków z wyłącznym dostępem w kontenerach bez nadheadu wirtualnego przekazywania.
#include <functional> #include <memory> #include <iostream> // Uproszczona symulacja C++23 std::move_only_function template<typename Signature> class MoveOnlyFunc; template<typename Ret, typename... Args> class MoveOnlyFunc<Ret(Args...)> { struct Concept { virtual Ret call(Args... args) = 0; virtual ~Concept() = default; }; template<typename F> struct Model : Concept { F f; Model(F&& f) : f(std::move(f)) {} Ret call(Args... args) override { return f(args...); } }; std::unique_ptr<Concept> impl; public: template<typename F> MoveOnlyFunc(F&& f) : impl(std::make_unique<Model<F>>(std::forward<F>(f))) {} MoveOnlyFunc(MoveOnlyFunc&&) = default; MoveOnlyFunc& operator=(MoveOnlyFunc&&) = default; Ret operator()(Args... args) { return impl->call(args...); } }; int main() { auto ptr = std::make_unique<int>(42); // std::function nie zadziała: przechwycenie niekopiawalnego typu MoveOnlyFunc<void()> task = [p = std::move(ptr)] { std::cout << "Wartość: " << *p << " "; }; task(); // Wyjście: Wartość: 42 }
Kontekst: Platforma handlu o wysokiej częstotliwości (HFT) przetwarza zdarzenia rynkowe za pomocą systemu dyspozycji z wykorzystaniem puli wątków. Każde zadanie kapsułkuje gniazdo sieciowe do wysyłania odpowiedzi, modelowane jako std::unique_ptr<Socket>, aby zapewnić ekskluzywne własność i automatyczne czyszczenie.
Problem: Dziedziczna kolejka rozdzielcza używała std::function<void()> dla usuwania typów. Podczas refaktoryzacji w celu modernizacji zarządzania zasobami przez przejście z surowych wskaźników na std::unique_ptr, kompilacja nie powiodła się z błędami wskazującymi, że lambda była niekopiawalna. To uniemożliwiło migrację, ponieważ std::function nie może przechowywać wywołań, które można tylko przenieść, zmuszając do ponownej analizy architektury.
Rozważane rozwiązania:
1. Zastąpienie unique_ptr shared_ptr: Przełączenie własności gniazda do std::shared_ptr spełniłoby wymóg kopiowalności std::function.
Zalety: Minimalne zmiany w kodzie, standardowa kompatybilność std::function.
Wady: Atomowe liczenie referencji wprowadza opóźnienie rzędu mikrosekund, które jest nieakceptowalne w HFT. Semytycznie niepoprawne: gniazda nie powinny być współdzielone między zadaniami; własność musi być przekazywana ekskluzywnie.
2. Polimorficzna klasa bazowa zadań: Implementacja abstrakcyjnego interfejsu Task z wirtualnym execute() i przechowywanie std::unique_ptr<Task> w kolejce.
Zalety: Czysta semantyka własności, brak wymagań dotyczących kopiowalności.
Wady: Nadhead wirtualnego przekazywania (indukcja vtable) dodaje nanosekundy do każdego wywołania. Wymaga alokacji pamięci dla każdego obiektu zadania, fragmentując pamięć w najbardziej obciążonym miejscu.
3. Niestandardowy typ usuwania typów tylko w ruchu: Ręczne tworzenie usuwania typów na podstawie szablonu z std::aligned_storage i ręcznymi vtable.
Zalety: Optymalna wydajność, wsparcie tylko dla przenoszenia.
Wady: Wrażliwa implementacja wymagająca starannego zarządzania wyrównaniem i destruktorami. Obciążenie konserwacyjne dla kodu programowania szablonów.
4. Przyjęcie C++23 std::move_only_function: Uaktualnienie kompilatora do obsługi C++23 i zastąpienie std::function std::move_only_function.
Zalety: Ustandaryzowane rozwiązanie z SBO (brak sterty dla małych zamknięć), zero nadheadu wirtualnego przekazywania, natywne wsparcie dla przenoszenia. Idealnie pasuje do wymagań ekskluzywnej własności.
Wady: Wymaga dostępności narzędzi dla C++23. Wymaga aktualizacji zależnych interfejsów API, aby przyjmowały nowy typ.
Wybrane rozwiązanie: Wybrano rozwiązanie 4 po potwierdzeniu, że kompilatory firmy z handlu wspierają C++23. Migracja polegała na zastąpieniu std::function<void()> std::move_only_function<void()> w kolejce dyspozycyjnej.
Rezultat: System pomyślnie obsługiwał zasoby gniazd, które można tylko przenieść. Benchmarki wykazały 15% redukcję w opóźnieniu dyspozycji zadań w porównaniu do podejścia z shared_ptr, oraz zero alokacji na stercie dla małych zamknięć dzięki SBO. Kod bazy danych wyeliminował niestandardowe sztuczki usuwania typów, poprawiając konserwowalność.
Dlaczego std::function wymaga, aby obiekt wywołujący był CopyConstructible, nawet jeśli sam obiekt std::function nigdy nie jest kopiowany?
Kandydaci często zakładają, że sprawdzanie kopiowalności następuje tylko w momencie kopiowania. Jednak std::function jest CopyConstructible z założenia. Mechanizm usuwania typów musi zapewnić operację "klonowania" w swojej tabeli wirtualnej, aby wspierać kopiowanie opakowania. Jeśli przechowywany obiekt wywołujący nie ma konstruktora kopiującego, ta operacja nie może być zaimplementowana, co czyni typ niekompatybilnym w czasie instancjonowania. To jest wymóg czasu kompilacji, wynikający z podpisu typu opakowania, a nie sprawdzenia czasu wykonywania. Standard wymaga, aby obiekt wywołujący modelował CopyConstructible, aby warstwa usuwania typów mogła spełniać własną semantykę kopiowania std::function.
Jak Optymalizacja Małego Bufora (SBO) wpływa na bezpieczeństwo wyjątków podczas ruchów std::function?
Wielu kandydatów zakłada, że przenoszenie std::function jest noexcept. Chociaż przenoszenie samego opakowania jest tanie, jeśli przechowywany obiekt wywołujący znajduje się w wewnętrznym buforze (aktywny SBO) i jego konstruktor przenoszący nie jest noexcept, konstruktor przenoszący std::function może propagować wyjątki. To narusza wymagania noexcept, wymagane przez kontenery, takie jak std::vector, w celu zapewnienia silnego bezpieczeństwa wyjątków podczas alokacji. Standard nie gwarantuje noexcept dla ruchów w przypadku std::function, chyba że ruch obiektu wywołującego jest noexcept, a implementacja optymalizuje odpowiednio. Ta subtelność ma znaczenie przy przechowywaniu obiektów std::function w kontenerach, które polegają na operacjach przenoszenia noexcept w celu uzyskania wydajności.
Dlaczego std::function nie może propagować kwalifikatorów odniesienia (&& lub &) z opakowanego obiektu wywołującego do jego operatora(), i jak to obsługuje std::move_only_function?
Operator wywołujący std::function jest zawsze kwalifikowany jako const i traktuje opakowanie jako lvalue, niezależnie od kwalifikatorów odniesienia obiektu wywołującego. To uniemożliwia wywołanie obiektu wywołującego, który konsumuje zasoby (kwalifikowany jako rvalue operator()) przez opakowanie. std::move_only_function rozwiązuje ten problem, pozwalając na określenie kwalifikatorów odniesienia w podpisie (np. std::move_only_function<void() &&>). Przechowuje metadane lub oddzielne wpisy w tabeli vtable, aby wywołać obiekt wywołujący z właściwą kategorią wartości, umożliwiając doskonałe przekazywanie stanu wartości opakowania do podstawowego obiektu wywołującego. To pozwala opakowanemu obiektowi wywołującemu rozróżniać między wywołaniami lvalue a rvalue, co jest kluczowe dla semantyki przenoszenia w potokach funkcjonalnych.