PImpl
"Pointer to implementation" oder "pImpl" ist eine C++ Programmiertechnik, die Implementierungsdetails einer Klasse aus ihrer Objektrepräsentation entfernt, indem sie in einer separaten Klasse platziert werden, auf die über einen undurchsichtigen Zeiger zugegriffen wird.
// -------------------- // interface (widget.h) struct widget { // public members private: struct impl; // forward declaration of the implementation class // One implementation example: see below for other design options and trade-offs std::experimental::propagate_const< // const-forwarding pointer wrapper std::unique_ptr< // unique-ownership opaque pointer impl>> pImpl; // to the forward-declared implementation class }; // --------------------------- // implementation (widget.cpp) struct widget::impl { // implementation details };
Diese Technik wird verwendet, um C++-Bibliothekschnittstellen mit stabiler ABI (Application Binary Interface) zu erstellen und Kompilierungsabhängigkeiten zu reduzieren.
Inhalt |
[bearbeiten] Erklärung
Da private Datenmember einer Klasse an ihrer Objektrepräsentation beteiligt sind, was Größe und Layout beeinflusst, und da private Memberfunktionen einer Klasse an der Überladungsauflösung beteiligt sind (die vor der Überprüfung des Memberzugriffs stattfindet), erfordert jede Änderung dieser Implementierungsdetails eine Neukompilierung aller Benutzer der Klasse.
pImpl entfernt diese Kompilierungsabhängigkeit; Änderungen an der Implementierung führen nicht zu einer Neukompilierung. Folglich können neuere Versionen einer Bibliothek, die pImpl in ihrer ABI verwendet, die Implementierung ändern, während sie ABI-kompatibel mit älteren Versionen bleiben.
[bearbeiten] Kompromisse
Die Alternativen zum pImpl-Idiom sind:
- Inline-Implementierung: Private Member und öffentliche Member sind Member derselben Klasse.
- Rein abstrakte Klasse (OOP-Factory): Benutzer erhalten einen einzigartigen Zeiger auf eine leichte oder abstrakte Basisklasse, die Implementierungsdetails befinden sich in der abgeleiteten Klasse, die ihre virtuellen Memberfunktionen überschreibt.
[bearbeiten] Kompilierungs-Firewall
In einfachen Fällen entfernen sowohl pImpl als auch Factory Method Kompilierungsabhängigkeiten zwischen der Implementierung und den Benutzern der Klassenschnittstelle. Die Factory-Methode erstellt eine versteckte Abhängigkeit von der vtable, und so brechen das Neuordnen, Hinzufügen oder Entfernen virtueller Memberfunktionen die ABI. Der pImpl-Ansatz hat keine versteckten Abhängigkeiten. Wenn die Implementierungsklasse jedoch eine Spezialisierung einer Klassenvorlage ist, geht der Vorteil der Kompilierungs-Firewall verloren: Die Benutzer der Schnittstelle müssen die gesamte Vorlagendefinition beobachten, um die korrekte Spezialisierung zu instanziieren. Ein gängiger Designansatz in diesem Fall ist die Refaktorierung der Implementierung auf eine Weise, die eine Parametrisierung vermeidet. Dies ist ein weiterer Anwendungsfall für die C++ Core Guidelines.
- T.61 Vermeiden Sie übermäßige Parametrisierung von Membern und
- T.84 Verwenden Sie eine nicht-template-Kernimplementierung, um eine ABI-stabile Schnittstelle bereitzustellen..
Zum Beispiel verwendet die folgende Klassenvorlage den Typ `T` nicht in ihrem privaten Member oder im Körper von `push_back`.
template<class T> class ptr_vector { std::vector<void*> vp; public: void push_back(T* p) { vp.push_back(p); } };
Daher können private Member unverändert in die Implementierung übertragen werden, und `push_back` kann an eine Implementierung weitergeleitet werden, die `T` auch in der Schnittstelle nicht verwendet.
// --------------------- // header (ptr_vector.hpp) #include <memory> class ptr_vector_base { struct impl; // does not depend on T std::unique_ptr<impl> pImpl; protected: void push_back_fwd(void*); void print() const; // ... see implementation section for special member functions public: ptr_vector_base(); ~ptr_vector_base(); }; template<class T> class ptr_vector : private ptr_vector_base { public: void push_back(T* p) { push_back_fwd(p); } void print() const { ptr_vector_base::print(); } }; // ----------------------- // source (ptr_vector.cpp) // #include "ptr_vector.hpp" #include <iostream> #include <vector> struct ptr_vector_base::impl { std::vector<void*> vp; void push_back(void* p) { vp.push_back(p); } void print() const { for (void const * const p: vp) std::cout << p << '\n'; } }; void ptr_vector_base::push_back_fwd(void* p) { pImpl->push_back(p); } ptr_vector_base::ptr_vector_base() : pImpl{std::make_unique<impl>()} {} ptr_vector_base::~ptr_vector_base() {} void ptr_vector_base::print() const { pImpl->print(); } // --------------- // user (main.cpp) // #include "ptr_vector.hpp" int main() { int x{}, y{}, z{}; ptr_vector<int> v; v.push_back(&x); v.push_back(&y); v.push_back(&z); v.print(); }
Mögliche Ausgabe
0x7ffd6200a42c 0x7ffd6200a430 0x7ffd6200a434
[bearbeiten] Laufzeit-Overhead
- Zugriffsaufwand: Bei pImpl indirekt jeder Aufruf einer privaten Memberfunktion über einen Zeiger. Jeder Zugriff auf ein öffentliches Mitglied, das von einem privaten Mitglied gemacht wird, indirekt über einen weiteren Zeiger. Beide Indirektionen überschreiten Übersetzungsbereichsgrenzen und können daher nur durch Link-Time-Optimierung herausoptimiert werden. Beachten Sie, dass die OO-Factory eine Indirektion über Übersetzungsbereichsgrenzen erfordert, um sowohl auf öffentliche Daten als auch auf Implementierungsdetails zuzugreifen, und aufgrund der virtuellen Weiterleitung sogar weniger Möglichkeiten für den Link-Time-Optimizer bietet.
- Speicheraufwand: pImpl fügt dem öffentlichen Teil einen Zeiger hinzu, und wenn ein privates Mitglied auf ein öffentliches Mitglied zugreifen muss, wird entweder ein weiterer Zeiger zum Implementierungsteil hinzugefügt oder als Parameter für jeden Aufruf des privaten Mitglieds übergeben, der ihn benötigt. Wenn zustandsbehaftete benutzerdefinierte Allokatoren unterstützt werden, muss auch die Allokatorinstanz gespeichert werden.
- Overhead für die Lebensdauerverwaltung: pImpl (sowie OO Factory) platziert das Implementierungsobjekt auf dem Heap, was einen erheblichen Laufzeit-Overhead bei Konstruktion und Zerstörung mit sich bringt. Dies kann teilweise durch benutzerdefinierte Allokatoren ausgeglichen werden, da die Allokationsgröße für pImpl (nicht aber für OO Factory) zur Kompilierzeit bekannt ist.
Andererseits sind pImpl-Klassen move-freundlich; die Refaktorierung einer großen Klasse als verschiebbare pImpl kann die Leistung von Algorithmen, die Container mit solchen Objekten manipulieren, verbessern, obwohl verschiebbare pImpl eine zusätzliche Quelle für Laufzeit-Overhead aufweisen: Jede öffentliche Memberfunktion, die auf einem verschobenen Objekt zulässig ist und auf eine private Implementierung zugreifen muss, löst eine Nullzeigerprüfung aus.
| Dieser Abschnitt ist unvollständig Grund: Microbenchmark? |
[bearbeiten] Wartungsaufwand
Die Verwendung von pImpl erfordert eine dedizierte Übersetzungseinheit (eine nur-Header-Bibliothek kann pImpl nicht verwenden), führt eine zusätzliche Klasse, einen Satz von Weiterleitungsfunktionen ein und, wenn Allokatoren verwendet werden, legt sie das Implementierungsdetail der Allokatorverwendung in der öffentlichen Schnittstelle offen.
Da virtuelle Member Teil der Schnittstellenkomponente von pImpl sind, impliziert das Mocking einer pImpl das alleinige Mocking der Schnittstellenkomponente. Eine testbare pImpl ist typischerweise so konzipiert, dass sie eine vollständige Testabdeckung über die verfügbare Schnittstelle ermöglicht.
[bearbeiten] Implementierung
Da das Objekt des Schnittstellentyps die Lebensdauer des Objekts des Implementierungstyps kontrolliert, ist der Zeiger auf die Implementierung normalerweise std::unique_ptr.
Da std::unique_ptr erfordert, dass derzeigte Typ in jedem Kontext, in dem der Deleter instanziiert wird, ein vollständiger Typ ist, müssen die speziellen Memberfunktionen benutzerdefiniert und außerhalb der Zeile in der Implementierungsdatei deklariert und definiert werden, wo die Implementierungsklasse vollständig ist.
Da ein const-Memberfunktionsaufruf eine Funktion über einen nicht-const-Memberzeiger aufruft, wird die nicht-const-Überladung der Implementierungsfunktion aufgerufen, der Zeiger muss in std::experimental::propagate_const oder einem Äquivalent gekapselt werden.
Alle privaten Datenmember und alle privaten, nicht-virtuellen Memberfunktionen werden in der Implementierungsklasse platziert. Alle öffentlichen, geschützten und virtuellen Member bleiben in der Schnittstellenklasse (siehe GOTW #100 für die Diskussion der Alternativen).
Wenn einer der privaten Member auf ein öffentliches oder geschütztes Mitglied zugreifen muss, kann eine Referenz oder ein Zeiger auf die Schnittstelle als Parameter an die private Funktion übergeben werden. Alternativ kann die Rückreferenz als Teil der Implementierungsklasse beibehalten werden.
Wenn Nicht-Standard-Allokatoren für die Allokation des Implementierungsobjekts unterstützt werden sollen, können alle üblichen Allokator-Bewusstseins-Muster verwendet werden, einschließlich der Standardvorlage für den Allokatorparameter auf std::allocator und eines Konstruktorarguments vom Typ std::pmr::memory_resource*.
[bearbeiten] Anmerkungen
| Dieser Abschnitt ist unvollständig Grund: Verbindung zum wertsemantischen Polymorphismus herstellen. |
[bearbeiten] Beispiel
Demonstriert eine pImpl mit Const-Propagation, mit Rückreferenz als Parameter übergeben, ohne Allokator-Bewusstsein und verschiebbar ohne Laufzeitprüfungen.
// ---------------------- // interface (widget.hpp) #include <experimental/propagate_const> #include <iostream> #include <memory> class widget { class impl; std::experimental::propagate_const<std::unique_ptr<impl>> pImpl; public: void draw() const; // public API that will be forwarded to the implementation void draw(); bool shown() const { return true; } // public API that implementation has to call widget(); // even the default ctor needs to be defined in the implementation file // Note: calling draw() on default constructed object is UB explicit widget(int); ~widget(); // defined in the implementation file, where impl is a complete type widget(widget&&); // defined in the implementation file // Note: calling draw() on moved-from object is UB widget(const widget&) = delete; widget& operator=(widget&&); // defined in the implementation file widget& operator=(const widget&) = delete; }; // --------------------------- // implementation (widget.cpp) // #include "widget.hpp" class widget::impl { int n; // private data public: void draw(const widget& w) const { if (w.shown()) // this call to public member function requires the back-reference std::cout << "drawing a const widget " << n << '\n'; } void draw(const widget& w) { if (w.shown()) std::cout << "drawing a non-const widget " << n << '\n'; } impl(int n) : n(n) {} }; void widget::draw() const { pImpl->draw(*this); } void widget::draw() { pImpl->draw(*this); } widget::widget() = default; widget::widget(int n) : pImpl{std::make_unique<impl>(n)} {} widget::widget(widget&&) = default; widget::~widget() = default; widget& widget::operator=(widget&&) = default; // --------------- // user (main.cpp) // #include "widget.hpp" int main() { widget w(7); const widget w2(8); w.draw(); w2.draw(); }
Ausgabe
drawing a non-const widget 7 drawing a const widget 8
| Dieser Abschnitt ist unvollständig Grund: Eine weitere Alternative beschreiben – "Fast Pimpl". Der Hauptunterschied besteht darin, dass der Speicher für die Implementierung in einem Datenmember reserviert wird, der ein undurchsichtiges C-Array ist (innerhalb der PImpl-Klassendefinition), während im cpp-File dieser Speicher (mittels `reinterpret_cast` oder Placement-`new`) der Implementierungsstruktur zugeordnet wird. Dieser Ansatz hat seine eigenen Vor- und Nachteile, insbesondere ein offensichtlicher Vorteil ist keine zusätzliche Allokation, unter der Bedingung, dass zur Entwurfszeit der PImpl-Klasse genügend Speicher reserviert wurde. (Während zu den Nachteilen die reduzierte Move-Freundlichkeit gehört.) |
[bearbeiten] Externe Links
| 1. | GotW #28: Das Fast Pimpl Idiom. |
| 2. | GotW #100: Compilation Firewalls. |
| 3. | Das Pimpl-Muster – Was Sie wissen sollten. |