Pimpl sans tas. Mauvaise ou superstition?

J’aspire à séparer l’interface de la mise en œuvre. Il s’agit principalement de protéger le code utilisant une bibliothèque des modifications apscopes à son implémentation, bien que des temps de compilation réduits soient certainement les bienvenus.

La solution standard à cela est le pointeur sur l’idiome d’implémentation, le plus susceptible d’être implémenté en utilisant un unique_ptr et en définissant soigneusement le destructeur de classe hors ligne, avec l’implémentation.

Inévitablement, cela soulève des préoccupations concernant l’allocation de tas. Je connais bien “fais-le marcher, fais vite”, “profil puis optimise” et une telle sagesse. Il existe également des articles en ligne, par exemple gotw , qui déclarent la solution de contournement évidente comme étant fragile et non portable. J’ai une bibliothèque qui ne contient actuellement aucune allocation de tas – et j’aimerais que cela rest ainsi – alors ayons quand même du code.

#ifndef PIMPL_HPP #define PIMPL_HPP #include  namespace detail { // Keeping these up to date is unfortunate // More hassle when supporting various platforms // with different ideas about these values. const std::size_t capacity = 24; const std::size_t alignment = 8; } class example final { public: // Constructors example(); example(int); // Some methods void first_method(int); int second_method(); // Set of standard operations ~example(); example(const example &); example &operator=(const example &); example(example &&); example &operator=(example &&); // No public state available (it's all in the implementation) private: // No private functions (they're also in the implementation) unsigned char state alignas(detail::alignment)[detail::capacity]; }; #endif 

Cela ne me semble pas trop mauvais. L’alignement et la taille peuvent être statiquement affirmés dans la mise en œuvre. Je peux choisir entre surestimer les deux (inefficace) ou tout recomstackr s’ils changent (fastidieux) – mais aucune des deux options n’est terrible.

Je ne suis pas certain que ce type de piratage fonctionnera en présence d’inheritance, mais comme je n’aime pas beaucoup l’inheritance dans les interfaces, cela ne me dérange pas trop.

Si nous supposons hardiment que j’ai correctement écrit l’implémentation (je l’appendai à cet article, mais c’est une preuve de concept non testée pour le moment, donc ce n’est pas une donnée), et la taille et l’alignement sont tous deux supérieurs ou égaux à celle de l’implémentation, le code présente-t-il alors un comportement défini ou non défini?

 #include "pimpl.hpp" #include  #include  // Usually a class that has behaviour we care about // In this example, it's arbitrary class example_impl { public: example_impl(int x = 0) { insert(x); } void insert(int x) { local_state.push_back(3 * x); } int resortingeve() { return local_state.back(); } private: // Potentially exotic local state // For example, maybe we don't want std::vector in the header std::vector local_state; }; static_assert(sizeof(example_impl) == detail::capacity, "example capacity has diverged"); static_assert(alignof(example_impl) == detail::alignment, "example alignment has diverged"); // Forwarding methods - free to vary the names relative to the api void example::first_method(int x) { example_impl& impl = *(reinterpret_cast(&(this->state))); impl.insert(x); } int example::second_method() { example_impl& impl = *(reinterpret_cast(&(this->state))); return impl.resortingeve(); } // A whole lot of boilerplate forwarding the standard operations // This is (believe it or not...) written for clarity, so none call each other example::example() { new (&state) example_impl{}; } example::example(int x) { new (&state) example_impl{x}; } example::~example() { (reinterpret_cast(&state))->~example_impl(); } example::example(const example& other) { const example_impl& impl = *(reinterpret_cast(&(other.state))); new (&state) example_impl(impl); } example& example::operator=(const example& other) { const example_impl& impl = *(reinterpret_cast(&(other.state))); if (&other != this) { (reinterpret_cast(&(this->state)))->~example_impl(); new (&state) example_impl(impl); } return *this; } example::example(example&& other) { example_impl& impl = *(reinterpret_cast(&(other.state))); new (&state) example_impl(std::move(impl)); } example& example::operator=(example&& other) { example_impl& impl = *(reinterpret_cast(&(other.state))); assert(this != &other); // could be persuaded to use an if() here (reinterpret_cast(&(this->state)))->~example_impl(); new (&state) example_impl(std::move(impl)); return *this; } #if 0 // Clearer assignment functions due to MikeMB example &example::operator=(const example &other) { *(reinterpret_cast(&(this->state))) = *(reinterpret_cast(&(other.state))); return *this; } example &example::operator=(example &&other) { *(reinterpret_cast(&(this->state))) = std::move(*(reinterpret_cast(&(other.state)))); return *this; } #endif int main() { example an_example; example another_example{3}; example copied(an_example); example moved(std::move(another_example)); return 0; } 

Je sais que c’est assez horrible. Cela ne me dérange pas d’utiliser des générateurs de code, alors ce n’est pas quelque chose que je devrai taper à plusieurs resockets.

Pour énoncer explicitement le noeud de cette question trop longue, les conditions suivantes sont-elles suffisantes pour éviter UB | IDB?

  • La taille de l’état correspond à la taille de l’instance impl
  • L’alignement de l’état correspond à l’alignement de l’instance d’impl
  • Les cinq opérations standard mises en œuvre par rapport à celles de l’impl
  • Emplacement nouveau utilisé correctement
  • Appels de destructeur explicite utilisés correctement

S’ils le sont, j’écrirai suffisamment de tests pour que Valgrind puisse éliminer les nombreux bugs de la démo. Merci à tous ceux qui vont aussi loin!

edit: Il est possible de pousser beaucoup de passe-partout dans une classe de base. Il y a un repository sur mon github nommé “pimpl” qui explore cela. Je ne pense pas qu’il existe un bon moyen d’instancier de manière implicite des constructeurs transférés arbitrairement, de sorte qu’il y a toujours plus de dactylographie impliqué que je ne le souhaiterais.

Oui, c’est un code parfaitement sûr et portable.

Toutefois, il n’est pas nécessaire d’utiliser la destruction par placement, nouvelle ou explicite, dans vos opérateurs d’affectation . En plus d’être sûr et plus efficace contre les exceptions, je dirais qu’il est également plus simple d’utiliser l’opérateur d’affectation de example_impl :

 //wrapping the casts const example_impl& castToImpl(const unsigned char* mem) { return *reinterpret_cast(mem); } example_impl& castToImpl( unsigned char* mem) { return *reinterpret_cast< example_impl*>(mem); } example& example::operator=(const example& other) { castToImpl(this->state) = castToImpl(other.state); return *this; } example& example::operator=(example&& other) { castToImpl(this->state) = std::move(castToImpl(other.state)); return *this; } 

Personnellement, j’utiliserais également std::aligned_storage au lieu d’un tableau de caractères aligné manuellement, mais je suppose que c’est une question de goût.