Avant C++11, stocker des objets appelables arbitraires nécessitait des pointeurs de fonction bruts ou des classes de base polymorphiques personnalisées. L'introduction de std::function a fourni un wrapper à effacement de type capable de stocker n'importe quel appelable, mais a imposé des exigences CopyConstructible et a utilisé l'Optimisation de Petit Tampon (SBO) pour éviter l'allocation sur le tas pour les petits functors. Comme C++14 et C++17 ont popularisé des types uniquement déplaçables comme std::unique_ptr, les développeurs ont rencontré la limitation que std::function ne pouvait pas stocker des lambdas capturant des ressources uniques. C++23 a introduit std::move_only_function, qui supprime l'exigence de copie et prend en charge les appelables uniquement déplaçables tout en maintenant les avantages de performance de SBO.
std::function utilise l'effacement de type pour masquer le type appelable réel derrière une interface uniforme. Lorsque l'appelable dépasse la taille du tampon interne (typiquement 16-32 octets), l'implémentation alloue de l'espace sur le tas. Cependant, la contrainte fondamentale est que std::function elle-même est copiables, ce qui nécessite que le mécanisme d'effacement de type implémente une opération "clone" via dispatch virtuel. Par conséquent, l'appelable stocké doit être CopyConstructible, excluant les lambdas uniquement déplaçables qui capturent std::unique_ptr ou des poignées de fichiers. Cela oblige les développeurs à utiliser std::shared_ptr (ajoutant une surcharge atomique) ou un héritage virtuel manuel (ajoutant de l'indirection).
std::move_only_function est un wrapper uniquement déplaçable qui élimine l'exigence CopyConstructible. Il atteint l'effacement de type grâce à un modèle de vtable uniquement déplaçable, lui permettant de stocker des appelables qui ne peuvent être que déplacés. Comme std::function, il utilise SBO, plaçant de petits functors directement dans le stockage interne sans allocation sur le tas. Cela permet des motifs tels que le retour d'une lambda capturant un std::unique_ptr depuis une fonction de fabrique, ou le stockage de rappels de propriété exclusive dans des conteneurs sans surcharge de dispatch virtuel.
#include <functional> #include <memory> #include <iostream> // Simulation simplifiée de 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 échouerait : capture de type non copiables MoveOnlyFunc<void()> task = [p = std::move(ptr)] { std::cout << "Valeur : " << *p << " "; }; task(); // Sortie : Valeur : 42 }
Contexte : Une plateforme de trading à haute fréquence (HFT) traite les événements du marché par un système de dispatching basé sur un pool de threads. Chaque tâche encapsule un socket réseau pour envoyer des réponses, modélisé comme un std::unique_ptr<Socket> pour garantir une propriété exclusive et un nettoyage automatique.
Problème : La file d'attente de dispatch héritée utilisait std::function<void()> pour l'effacement de type. Lors du refactoring pour moderniser la gestion des ressources en passant de pointeurs bruts à std::unique_ptr, la compilation a échoué avec des erreurs indiquant que la lambda était non copiables. Cela a bloqué la migration car std::function ne peut pas stocker des appelables uniquement déplaçables, forçant une réévaluation de l'architecture.
Solutions considérées :
1. Remplacer unique_ptr par shared_ptr : Convertir la propriété de socket en std::shared_ptr satisferait l'exigence de copiabilité de std::function.
Avantages : Changements de code minimes, compatibilité standard avec std::function.
Inconvénients : Le comptage de références atomiques introduit une latence à l'échelle de la microseconde inacceptable en HFT. Sémantiquement incorrect : les sockets ne doivent pas être partagés entre les tâches ; la propriété doit être transférée exclusivement.
2. Classe de base de tâche polymorphique : Mise en œuvre d'une interface abstraite Task avec une méthode virtuelle execute() et stockage de std::unique_ptr<Task> dans la queue.
Avantages : Sémantique de propriété claire, aucune exigence de copiabilité.
Inconvénients : Surcharge de dispatch virtuel (indirection de vtable) ajoute des nanosecondes à chaque appel. Nécessite une allocation sur le tas pour chaque objet tâche, fragmentant la mémoire dans le chemin critique.
3. Type d'effacement de type personnalisé uniquement déplaçable : Mise en œuvre d'un effacement de type basé sur des modèles en utilisant std::aligned_storage et des vtables manuels.
Avantages : Performance optimale, prise en charge des mouvements uniquement.
Inconvénients : Mise en œuvre fragile nécessitant une gestion d'alignement et de destructeurs minutieuse. Charge de maintenance d'un code de métaprogrammation basé sur des modèles.
4. Adoption de C++23 std::move_only_function : Mise à jour du compilateur pour prendre en charge C++23 et remplacement de std::function par std::move_only_function.
Avantages : Solution normalisée avec SBO (pas de tas pour de petites fermetures), zéro surcharge de dispatch virtuel, prise en charge native des mouvements uniquement. Correspond parfaitement à l'exigence de propriété exclusive.
Inconvénients : Nécessite la disponibilité de l'outil de chaîne C++23. Nécessite la mise à jour des API dépendantes pour accepter le nouveau type.
Solution choisie : La solution 4 a été sélectionnée après confirmation que les compilateurs de la société de trading prenaient en charge C++23. La migration impliquait le remplacement de std::function<void()> par std::move_only_function<void()> dans la queue de dispatch.
Résultat : Le système a géré avec succès les ressources de socket uniquement déplaçables. Les benchmarks ont montré une réduction de 15% de la latence de dispatch des tâches par rapport à l'approche shared_ptr, et aucune allocation sur le tas pour de petites fermetures grâce à SBO. La base de code a éliminé des hacks d'effacement de type personnalisés, améliorant la maintenabilité.
Pourquoi std::function exige-t-il que l'appelable soit CopyConstructible même si l'objet std::function lui-même n'est jamais copié ?
Les candidats supposent souvent que la copiabilité n'est vérifiée que lorsque la copie se produit. Cependant, std::function est CopyConstructible par conception. Le mécanisme d'effacement de type doit fournir une opération "clone" dans sa table virtuelle pour prendre en charge la copie de l'enveloppe. Si l'appelable stocké manque d'un constructeur de copie, cette opération ne peut pas être mise en œuvre, rendant le type incompatible au moment de l'instanciation. Il s'agit d'une contrainte de temps de compilation dérivée de la signature de type de l'enveloppe, et non d'un contrôle à l'exécution. La norme exige que l'appelable modèle CopyConstructible pour s'assurer que la couche d'effacement de type peut satisfaire aux propres sémantiques de copie de std::function.
Comment l'Optimisation de Petit Tampon (SBO) interagit-elle avec la sécurité des exceptions lors des mouvements de std::function ?
De nombreux candidats supposent que le déplacement de std::function est noexcept. Bien que déplacer l'enveloppe elle-même soit peu coûteux, si l'appelable stocké réside dans le tampon interne (SBO actif) et que son constructeur de déplacement n'est pas noexcept, le constructeur de déplacement std::function peut propager des exceptions. Cela viole les garanties noexcept requises par des conteneurs comme std::vector pour une forte sécurité des exceptions lors de la réallocation. La norme ne garantit pas que les mouvements noexcept de std::function à moins que le mouvement de l'appelable contenu soit noexcept et que l'implémentation optimise en conséquence. Cette subtilité est importante lors du stockage d'objets std::function dans des conteneurs qui dépendent des opérations de mouvement noexcept pour la performance.
Pourquoi std::function ne peut-il pas propager les qualifiants de référence (&& ou &) de l'appelable enveloppé à son opérateur() et comment std::move_only_function aborde-t-il cela ?
L'opérateur d'appel de std::function est toujours qualifié const et traite l'enveloppe comme une lvalue, quel que soit les qualifiants de référence de l'appelable. Cela empêche l'invocation d'un appelable qui consomme des ressources (opérateur() qualifié rvalue) par le biais de l'enveloppe. std::move_only_function résout ce problème en permettant à la signature de spécifier des qualifiants de référence (par exemple, std::move_only_function<void() &&>). Il stocke des métadonnées ou des entrées de vtable séparées pour invoquer l'appelable avec la bonne catégorie de valeur, permettant un parfait transfert des états de valeur de l'enveloppe vers l'appelable sous-jacent. Cela permet à l'appelable enveloppé de faire la distinction entre les invocations lvalue et rvalue, ce qui est crucial pour les sémantiques de mouvement dans les pipelines fonctionnels.