Stockage de la stack de petits objects, règle de crénelage ssortingct et comportement non défini

J’écris un wrapper de fonction effacé du type similaire à std::function . (Oui, j’ai déjà vu des implémentations similaires et même la proposition p0288r0 , mais mon cas d’utilisation est plutôt étroit et assez spécialisé.). Le code fortement simplifié ci-dessous illustre ma mise en œuvre actuelle:

 class Func{ alignas(sizeof(void*)) char c[64]; //align to word boundary struct base{ virtual void operator()() = 0; virtual ~base(){} }; template struct derived : public base{ derived(T&& t) : callable(std::move(t)) {} void operator()() override{ callable(); } T callable; }; public: Func() = delete; Func(const Func&) = delete; template //SFINAE constraints skipped for brevity Func(F&& f){ static_assert(sizeof(derived) <= sizeof(c), ""); new(c) derived(std::forward(f)); } void operator () (){ return reinterpret_cast(c)->operator()(); //Warning } ~Func(){ reinterpret_cast(c)->~base(); //Warning } }; 

Compilé , GCC 6.1 met en garde contre le crénelage ssortingct :

 warning: dereferencing type-punned pointer will break ssortingct-aliasing rules [-Wssortingct-aliasing] return reinterpret_cast(c)->operator()(); 

Je connais aussi la règle de crénelage ssortingct . Par contre, je ne connais pas actuellement de meilleur moyen d’utiliser l’optimisation de la stack d’objects de petite taille. Malgré les avertissements, tous mes tests passent sur GCC et Clang (et un niveau supplémentaire d’indirection empêche l’avertissement de GCC). Mes questions sont:

  • Vais-je finir par me brûler en ignorant l’avertissement relatif à ce cas?
  • Existe-t-il un meilleur moyen de créer des objects sur place?

Voir l’exemple complet: Live on Coliru

Tout d’abord, utilisez std::aligned_storage_t . C’est ce que cela signifie.

Deuxièmement, la taille et la disposition exactes virtual types virtual et de leurs descendants sont déterminées par le compilateur. L’affectation d’une classe dérivée dans un bloc de mémoire, puis la conversion de l’adresse de ce bloc en un type de base peuvent fonctionner, mais rien dans la norme ne garantit qu’il fonctionnera.

En particulier, si nous avons struct A {}; struct B:A{}; struct A {}; struct B:A{}; il n’y a aucune garantie, à moins que vous n’ayez une disposition standard, qu’un pointeur sur B puisse être reintepret tant que pointeur sur A (en particulier par le biais d’un void* ). Et les classes contenant virtual objects virtual ne sont pas des dispositions standard.

Donc, la réinterprétation est un comportement indéfini.

Nous pouvons contourner cela.

 struct func_vtable { void(*invoke)(void*) = nullptr; void(*destroy)(void*) = nullptr; }; template func_vtable make_func_vtable() { return { [](void* ptr){ (*static_cast(ptr))();}, // invoke [](void* ptr){ static_cast(ptr)->~T();} // destroy }; } template func_vtable const* get_func_vtable() { static const auto vtable = make_func_vtable(); return &vtable; } class Func{ func_vtable const* vtable = nullptr; std::aligned_storage_t< 64 - sizeof(func_vtable const*), sizeof(void*) > data; public: Func() = delete; Func(const Func&) = delete; template> Func(F&& f){ static_assert(sizeof(dF) <= sizeof(data), ""); new(static_cast(&data)) dF(std::forward(f)); vtable = get_func_vtable(); } void operator () (){ return vtable->invoke(&data); } ~Func(){ if(vtable) vtable->destroy(&data); } }; 

Cela ne repose plus sur des garanties de conversion de pointeur. Cela nécessite simplement que void_ptr == new( void_ptr ) T(blah) .

Si vous êtes vraiment inquiet au sujet des alias ssortingcts, stockez la valeur de retour de la new expression sous la forme d’un void* et transmettez-la à invoke et à destroy au lieu de &data . Cela va être au-delà de tout reproche: le pointeur renvoyé de new est le pointeur sur l’object nouvellement construit. L’access aux data dont le cycle de vie est terminé est probablement invalide, mais il l’était également avant.

Lorsque les objects commencent à exister et quand ils se terminent, la norme est relativement floue. La dernière tentative que j’ai vue de résoudre ce problème est P0137-R1 , où il introduit T* std::launder(T*) pour que les problèmes d’aliasing disparaissent de manière extrêmement claire.

Le stockage du pointeur renvoyé par new est le seul moyen, à ma connaissance, de ne pas rencontrer de problèmes de repliement d’object avant P0137.

La norme indiquait:

Si un object de type T est situé à une adresse A, un pointeur de type cv T * dont la valeur est l’adresse A est censé pointer sur cet object, quelle que soit la méthode utilisée pour obtenir la valeur.

la question est “la nouvelle expression garantit-elle réellement que l’object est créé à l’emplacement en question”. Je suis incapable de me convaincre que cela est dit sans équivoque. Cependant, dans mes propres implémentations d’effacement de type, je ne stocke pas ce pointeur.

Pratiquement, ce qui précède va faire la même chose que beaucoup d’implémentations C ++ avec des tables de fonctions virtuelles dans des cas simples comme celui-ci, sauf qu’il n’ya pas de RTTI créé.

La meilleure option consiste à utiliser la fonctionnalité fournie par Standard pour le stockage aligné pour la création d’object, appelée aligned_storage :

 std::aligned_storage_t<64, sizeof(void*)> c; // ... new(&c) F(std::forward(f)); reinterpret_cast(&c)->operator()(); reinterpret_cast(&c)->~T(); 

Exemple.

Si disponible, vous devriez utiliser std::launder pour envelopper votre reinterpret_cast s: Quel est le but de std :: launder? ; Si std::launder n’est pas disponible, vous pouvez supposer que votre compilateur est antérieur à P0137 et que le nombre de reinterpret_cast s suffit selon la règle “points to” ( [basic.compound] / 3). Vous pouvez tester std::launder #ifdef __cpp_lib_launder utilisant #ifdef __cpp_lib_launder ; exemple .

S’agissant d’une installation standard, vous avez la garantie que si vous l’utilisez conformément à la description de la bibliothèque (comme ci-dessus), il n’y a aucun risque de brûlure.

En prime, cela garantira également la suppression des avertissements du compilateur.

La question initiale ne couvrait pas le risque que vous transmettiez l’adresse de stockage à un type de base polymorphe de votre type dérivé. Cela n’est acceptable que si vous vous assurez que la base polymorphe a la même adresse ( [ptr.launder] / 1: “Un object X qui se trouve dans sa vie […] se trouve à l’adresse A “) en tant qu’object complet. au moment de la construction, car cela n’est pas garanti par la norme (puisqu’un type polymorphe n’est pas standard-layout). Vous pouvez vérifier cela avec une assert :

  auto* p = new(&c) derived(std::forward(f)); assert(static_cast(p) == std::launder(reinterpret_cast(&c))); 

Il serait plus judicieux d’utiliser l’inheritance non polymorphe avec une vtable manuelle, comme le propose Yakk, car l’inheritance sera alors standard-layout et le sous-object de la classe de base aura la même adresse que l’object complet.


Si nous examinons l’implémentation de aligned_storage , cela équivaut à votre alignas(sizeof(void*)) char c[64] , juste enveloppé dans une struct , et bien gcc peut être fermé en encapsulant votre char c[64] une struct ; Bien qu’à proprement parler après P0137, vous devriez utiliser un caractère unsigned char plutôt qu’un caractère simple. Toutefois, il s’agit d’un domaine de la norme en évolution rapide qui pourrait changer à l’avenir. Si vous utilisez le service fourni, vous avez une meilleure garantie qu’il continuera à fonctionner.

L’autre solution consiste essentiellement à reconstruire ce que la plupart des compilateurs font sous le capot. Lorsque vous stockez le pointeur renvoyé par l’emplacement new, il n’est pas nécessaire de créer manuellement vtables:

 class Func{ struct base{ virtual void operator()() = 0; virtual ~base(){} }; template struct derived : public base{ derived(T&& t) : callable(std::move(t)) {} void operator()() override{ callable(); } T callable; }; std::aligned_storage_t<64 - sizeof(base *), sizeof(void *)> data; base * ptr; public: Func() = delete; Func(const Func&) = delete; template //SFINAE constraints skipped for brevity Func(F&& f){ static_assert(sizeof(derived) <= sizeof(data), ""); ptr = new(static_cast(&data)) derived(std::forward(f)); } void operator () (){ return ptr->operator()(); } ~Func(){ ptr->~base(); } }; 

Passer de derived * à la base * est parfaitement valide (N4431 §4.10 / 3):

Une valeur de type “pointeur sur cv D”, où D est un type de classe, peut être convertie en une valeur de type “pointeur sur cv B”, où B est une classe de base (clause 10) de D. [..]

Et comme les fonctions membres respectives sont virtuelles, leur appel par le biais du pointeur de base appelle en réalité les fonctions respectives de la classe dérivée.