Das Entwurfsmuster PImpl (Pointer to Implementation), auch bekannt als Opaque Pointer, entstand als Mittel zur Trennung von Schnittstelle und Implementierung einer Klasse in C++. Dies ist besonders wichtig, um die Binärkompatibilität der Schnittstellen (ABI) sicherzustellen, die Kompilierungszeit zu verkürzen und Implementierungsdetails vor dem Benutzer der Klasse zu verbergen.
Historie des Problems.
In einer Vielzahl von C++-Projekten müssen die Implementierungen von Klassen geändert werden, ohne die öffentliche Schnittstelle zu verändern und ohne die Clients dieser Klassen neu zu kompilieren. Das Problem besteht darin, dass Änderungen in den Header-Dateien eine Neukompilierung aller abhängigen Module erfordern, was in großen Codebasen äußerst kostspielig sein kann. PImpl minimiert die Neukompilierung und bietet eine bessere Kapselung.
Problem.
Die standardmäßige Methode zur Definition einer Klasse mit privaten Mitgliedern in der Header-Datei erfordert Wissen über alle diese Mitglieder zur Kompilierungszeit. Bei Erweiterungen oder Änderungen muss man alle Dateien neu kompilieren, die diesen Header einbinden. Darüber hinaus gibt dies Implementierungs-/Strukturdetails des Clients preis, was möglicherweise die Sicherheit und architektonische Integrität negativ beeinflusst.
Lösung.
PImpl implementiert die Verbergung der Implementierung durch die Verwendung eines Zeigers auf eine vorwärts deklarierten Struktur-Implementierung (Impl struct/class), die in der cpp definiert ist. Dadurch kann die Implementierung geändert werden, ohne die Schnittstelle zu berühren.
Beispielcode:
// Widget.h class Widget { public: Widget(); ~Widget(); void doSomething(); private: struct Impl; Impl* pimpl; // opaker Zeiger }; // Widget.cpp #include "Widget.h" struct Widget::Impl { int secret; }; Widget::Widget() : pimpl(new Impl{42}) {} // Geheimnis innerlich Widget::~Widget() { delete pimpl; } void Widget::doSomething() { pimpl->secret += 1; }
Schlüsselfunktionen:
Kann man std::unique_ptr anstelle eines rohen Zeigers in PImpl verwenden?
Ja, der moderne und sichere Ansatz ist die Verwendung von std::unique_ptr (oder std::shared_ptr, wenn gemeinsame Nutzung erforderlich ist). Dies ermöglicht eine ordnungsgemäße Speicherverwaltung und das Vermeiden der Notwendigkeit, einen Destruktor/Kopieroperator für den rohen Zeiger zu schreiben:
private: std::unique_ptr<Impl> pimpl;
Kann man eine Klasse mit PImpl verschiebbar, aber nicht kopierbar machen?
Ja, wenn man einen Move-Konstruktor/operator zur Verfügung stellt, aber den kopierenden entfernt. Zum Beispiel:
Widget(Widget&&) noexcept = default; Widget& operator=(Widget&&) noexcept = default; Widget(const Widget&) = delete; Widget& operator=(const Widget&) = delete;
Entsteht ein Performance-Overhead bei Verwendung von PImpl?
Ja, durch das Dereferenzieren des Zeigers und zusätzliche dynamische Speicherzuweisung (Heap-Zuweisung). Für leistungskritische Strukturen kann dies ein wesentlicher Nachteil sein.
Ein großes Unternehmen führte PImpl für alle Klassen ein, auch für einfache Datenstrukturen, was zu erheblichen Verlangsamungen bei einfachen Operationen aufgrund ständiger Dereferenzierung von Zeigern führte.
Vorteile:
Nachteile:
In einem Projekt mit einer langlebigen Benutzeroberflächenbibliothek wurde PImpl nur für komplexe Widgets verwendet, deren interne Implementierung häufig geändert wurde, während die ABI für externe Clients stabil blieb.
Vorteile:
Nachteile: