„Das Pimpl Pattern kapselt die Implementierungsdetails einer Klasse hinter einem Zeiger auf ein verborgenes Implementierungsobjekt und trennt damit ihre öffentliche Schnittstelle von ihrer konkreten Implementierung.”
Das Pimpl-Idiom (kurz für „Pointer to Implementation„, auch als „Compiler Firewall” oder „Cheshire Cat Idiom” bekannt) ist ein C++-spezifisches Entwurfsmuster zur Trennung von Schnittstelle und Implementierung.
Anstatt private Member-Variablen und private Methoden direkt in der Klassendeklaration im Header offenzulegen, werden sie in eine separate, vorwärtsdeklarierte Klasse ausgelagert, die nur in der zugehörigen .cpp-Datei vollständig definiert ist.
Die öffentliche Klasse hält lediglich einen Zeiger (meist std::unique_ptr) auf diese Implementierungsklasse
und leitet alle Aufrufe an sie weiter.
Dadurch bleiben Implementierungsdetails wie private Datentypen, verwendete Bibliotheken oder interne Hilfsklassen
vollständig vor dem Header und damit vor den Nutzern der Klasse verborgen.
Ein wesentlicher praktischer Vorteil ist die Reduktion von Kompilierabhängigkeiten: Ändert sich nur die Implementierung, muss nicht mehr jede Datei neu kompiliert werden, die den Header inkludiert, sondern nur die .cpp-Datei der betroffenen Klasse selbst. Das verbessert insbesondere bei großen Projekten die Build-Zeiten spürbar.
Zusätzlich sorgt das Pattern für eine stabile Binärschnittstelle (ABI-Stabilität), da sich die Größe und das Layout des öffentlichen Objekts durch interne Änderungen nicht verändern.
Der Preis dafür ist ein gewisser Laufzeit-Overhead durch die zusätzliche Indirektion
sowie ein Mehraufwand bei Speicherverwaltung und Implementierung (z. B. explizite Destruktoren
wegen std::unique_ptr auf unvollständigen Typ).
In der Praxis wird Pimpl daher vor allem bei stabilen öffentlichen APIs, Bibliotheken und Klassen mit häufig wechselnder interner Implementierung eingesetzt.
In seiner Grundform sieht das Muster wie folgt aus:
- Ausgangspunkt ist eine Klasse (Hauptklasse), die eine zweite Klasse (Hilfsklasse) benutzt und damit in Abhängigkeit zu dieser Klasse steht. Alle Änderungen an der unterlagerten Klasse würden eine Neuübersetzung beider Klassen nach sich ziehen.
- In der Hauptklasse verschieben wir alle privaten Member (vor allem die der Hilfsklasse)
in einen neu deklarierten Typ - z.B. Klasse
PrivateImpl. - Zu diesem neu deklarierten Typ gibt es in der Header-Datei der Hauptklasse nur eine Forward-Deklaration.
- Die
PrivateImpl-Klasse wird in gewohnter Manier in einer cpp- und h-Datei deklariert und implementiert. - Der Client-Code der Hauptklasse muss nun nicht neu kompiliert werden, wenn sich Änderungen an der Implementierung der privaten Hilfsklasse
PrivateImplergeben (da sich die Schnittstelle nicht geändert hat). Im Headerfile der Hauptklasse ist die benutzte Hilfsklasse nur über eine Vorwärtsdeklaration bekannt. - Im Umkehrschluss bedeutet dies natürlich, dass Änderungen an der Schnittstelle tabu sind.
- Stellt eine so genannte "Compilation Firewall" dar: Wenn sich die private Implementierung ändert, muss der Clientcode nicht neu kompiliert werden. Alles in allem führt dies zu besseren Übersetzungszeiten.
- Bietet so genannte "Application Binary Compatibility": für Bibliotheksentwickler von Interesse. Solange die Binärschnittstelle gleich bleibt, kann man die Anwendung mit einer anderen Version der Bibliothek verknüpfen.
- Performance - Eine Indirektionsebene wird hinzugefügt.
- Komplexer Code - Es erfordert etwas Disziplin, um solche Klassen zu pflegen.
- Debugging - Da die Klasse aufgeteilt ist, erkennt man Details bei der Fehlersuche nicht sofort.
Quellcode Car.h
Quellcode Car.cpp
Quellcode Engine.h
Quellcode CarClient.cpp
Wir betrachten ein Auto (Klasse Car), das logischerweise einen Motor besitzt (Klasse Engine).
Die Klasse Car könnte folgendes Header-File besitzen:
// File "Car.h"
#include "engine.h"
class Car
{
public:
void coolDown();
private:
Engine m_engine;
};Die Implementierung der Klasse Engine soll hier nicht weiter betrachtet werden.
Die Implementierungs-Datei der Klasse Car sieht so aus:
// File "Car.cpp"
#include "car.h"
void Car::coolDown()
{
/* ... */
}Betrachten Sie das Problem, das - in Abhängigkeit von der Anzahl der Benutzer der Klasse Car -
weniger oder mehr in Erscheinung treten kann: Da die Datei car.h die Datei engine.h inkludiert,
besitzen alle Benutzer der Datei Car.h eine indirekte Abhängigkeit zur Datei engine.h.
Sprich alle Benutzer von car.h inkludieren engine.h.
Dies bedeutet, dass bei Änderungen an der Klasse Engine alle Benutzer von car.h neu übersetzt werden müssen,
eben auch für den Fall, dass an der Klasse Car überhaupt nichts geändert wurde. Und zum Zweiten vor dem Hintergrund,
dass diese Clients sich möglicherweise der Abhängigkeit zur Klasse Engine gar nicht bewusst sind.
Das Pimpl-Idiom (Pointer to Implementation) löst diese Abhängigkeit durch das Hinzufügen
einer Indirektionsstufe in Gestalt einer zusätzlichen Klasse - nennen wir sie CarImpl - auf.
Aufgabe der Klasse CarImpl ist es, die Präsenz der Klasse Engine zu verdecken.
Ein Header-File für die Klasse Car könnte nun so aussehen:
// File "Car.h"
class Car
{
public:
Car();
~Car();
void coolDown();
private:
class CarImpl;
CarImpl* m_impl;
};Beachten Sie an diesem Header-File, das die Include-Anweisung #include "engine.h" nicht mehr
vorhanden ist.
Eine mögliche Implementierung der Klassen CarImpl und Car in der Datei Car.cpp könnte nun so aussehen:
// file "Car.cpp"
#include "Engine.h"
#include "Car.h"
class Car::CarImpl
{
public:
void coolDown()
{
/* ... */
}
private:
Engine m_engine;
};
Car::Car() : m_impl(new CarImpl) {}
Car::~Car()
{
delete m_impl;
}
void Car::coolDown()
{
m_impl->coolDown();
}Die Klasse Car delegiert ihre Aufrufe an die Klasse Engine nun an die Klasse CarImpl,
natürlich in der Erwartung, dass diese den Aufruf an eine Instanz von Engine weiterreicht.
Allerdings ist für die Klasse Car eine neue Verantwortlichkeit entstanden:
Die Verwaltung der Lebenszeit des CarImpl-Zeigers.
Beachte: Haben Sie eine Erklärung dafür, warum Benutzer der Klasse Car, also des Header-Files car.h, übersetzungsfähig sind,
obwohl von der referenzierten Klasse CarImpl nur eine Vorwärts-Deklaration vorliegt, also die tatsächliche Klassendefinition fehlt?
Die Klasse Car besitzt von der Klasse CarImpl nur einen Zeiger (sagen wir: dieser belegt 4 Bytes, sprich seine Größe ist dem Compiler bekannt) und kein Objekt.
Bei diesem Sachverhalt kann der Compiler Code generieren, er muss das tatsächliche Aussehen der Klasse CarImpl nicht kennen!
Der Einsatz eines raw-Pointers in der Klasse Car entspricht nicht dem Aussehen eines Modern C++ Designs.
Aus diesem Grund tauschen wir diesen Zeiger mit einem Smart-Pointer, beispielsweise einem std::unique_ptr-Objekt, aus.
Die Header-Datei der Klasse Car sieht nun so aus:
// file "Car.h"
#include <memory>
class Car
{
public:
Car();
void coolDown();
private:
class CarImpl;
std::unique_ptr<CarImpl> m_impl;
};Die Implementierungsdatei ändert sich nur marginal:
// file "Car.cpp"
#include "Engine.h"
#include "Car.h"
class CarImpl
{
public:
void coolDown()
{
/* ... */
}
private:
Engine m_engine;
};
Car::Car() : m_impl(new CarImpl) {}Es sieht so weit alles gut aus - überraschenderweise erhalten wir in diesem Zustand des Programms einen Übersetzungsfehler:
use of undefined type 'Car::EngineImpl'can't delete an incomplete type
In C ++ gibt es eine Regel, die besagt, dass das Löschen eines Zeigers zu undefiniertem Verhalten führt, wenn:
- Dieser Zeiger hat den Typ
void*oder - Der Typ, auf den verwiesen wird, ist unvollständig, d.h. er wird nur vorwärts deklariert,
wie
CarImplin unserer Header-Datei.
Der Übersetzungsfehler liegt als darin begründet, dass der korrespondierende Typ nur vorwärts deklariert ist.
Es sind zwei Änderungen am aktuellen Programm vorzunehmen:
-
Hinzufügen einer Destruktor-Deklaration in der Klasse
Car:
~Car(); -
Hinzufügen einer Destruktor-Definition in der Klasse
CarImpl:
Car::~Car() {};
Prinzipiell gibt es 2 Möglichkeiten, um diese Fehlermeldung zu beseitigen:
- Bereitstellung eines benutzerdefinierten Destruktors für die Ausgangsklasse,
der explizit implementiert ist (dazu zählt auch eine Implementierung mit
default). Diese Definition muss im Quellcode nach der vollständigen Definition der Pimpl-Klasse stehen. - Bereitstellung eines benutzerdefinierten Deleters für das
std::unique_ptr<>-Objekt.
Die Fehlermeldung use of undefined type tritt nur dann auf, wenn die betrachtete Klasse (mit Pimpl-Zeiger) und der Anwender der Klasse in verschiedenen Dateien residieren!
Damit haben einen ersten minimalistischen Durchlauf durch das Pimpl-Idiom beschritten!
Quellcode User.h
Quellcode User.cpp
Quellcode UserClient.cpp
Abbildung 1: Struktur des Pimpl-Idioms.
Auf der offiziellen WebSite von Microsoft gibt es zu Pimpl auch eine kleine Verfahrensanweisung, wenn gleich sehr knapp gehalten:
Pimpl For Compile-Time Encapsulation (Modern C++) (Abruf: 16.07.2020)
Stellen Sie einen Vergleich Pimpl-Idiom versus Interface Inheritance an.
Weitere intessante Artikel und Aufsätze zum Pimpl-Idiom finden Sie unter
The Pimpl Pattern - what you should know (Abruf: 16.07.2020)
PImpl Idiom in C++ with Examples (Abruf: 16.07.2020)
In-depth: PIMPL vs pure virtual interfaces (Abruf: 16.07.2020)
Pimpl idiom versus Pure virtual class interface (Abruf: 16.07.2020)
Pimp My Pimpl - Reloaded (Abruf: 16.07.2020)
How to implement the pimpl idiom by using unique_ptr (Abruf: 16.07.2020)