C++: Vor- und Nachteile des d-Zeiger-Idioms, Teil 1

Über das auch als d-Zeiger, Compiler-Firewall und Cheshire Cat bekannte Pimpl-Idiom wurde schon viel geschrieben. heise Developer beleuchtet über die klassische Technik hinaus beachtenswerte Winkel des praktischen Konstrukts.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 10 Min.
Von
  • Marc Mutz
Inhaltsverzeichnis

Über das auch als d-Zeiger, Compiler-Firewall und Cheshire Cat bekannte Idiom mit dem lustigen Namen (pimple = engl. für "Pickel") wurde schon viel geschrieben [1, 2, 3]. heise Developer beleuchtet in zwei Artikeln über die klassische Technik hinaus beachtenswerte Winkel des praktischen Konstrukts.

Dieser Beitrag rekapituliert zunächst das klassische Pimpl-Idiom (pointer-to-implementation), stellt im Weiteren seine Vorteile heraus und entwickelt auf der Grundlage weitere Idiome. Der zweite Beitrag wird sich damit beschäftigen, die typischen Nachteile zu mindern, die bei einem Einsatz von Pimpl unweigerlich entstehen.

Jeder C++-Programmierer ist wahrscheinlich einmal über eine Klassendefinition wie die folgende gestolpert:

class Class {
// ...
private:
class Private; // forward declaration
Private * d; // hide impl details
};

Mit ihr hat der Programmierer die Datenfelder seiner Class in eine geschachtelte Klasse Class::Private verschoben. Instanzen von Class enthalten nur noch einen Zeiger d auf ihre Class::Private-Instanz.

Um zu verstehen, aus welchem Grund der Autor die Indirektion benutzt hat, muss man etwas weiter ausholen und sich ein wenig mit dem C++-Modulsystem beschäftigen. Denn anders als in vielen anderen Programmiersprachen besitzt C++ (als Sprache) als eine der zahlreichen C-Altlasten keine eingebaute Unterstützung für Module. (Ein solcher Vorschlag wurde für C++0x eingebracht, aber nicht in den Standard aufgenommen [4].) Stattdessen lagert man die Deklarationen (aber üblicherweise nicht die Definitionen) der Modulfunktionen in Header-Dateien aus und bindet sie per #include-Präprozessordirektive in andere Module ein. Das führt allerdings dazu, dass den Header-Dateien eine Doppelrolle zukommt: Auf der einen Seite dienen sie als Schnittstelle zum Modul und auf der anderen als Deklaration für mögliche interne Implementierungsdetails.

In Zeiten von C funktionierte das prima: Die Implementierungsdetails von Funktionen sind per Deklaration und Definition vollständig gekapselt, und structs konnte man entweder "forward-deklarieren" (dann sind sie privat) oder direkt in der Header-Datei definieren (dann sind sie öffentlich). Die Klasse Class von oben sähe in "objektorientiertem C" vielleicht folgendermaßen aus:

struct Class;                           // forward declaration
typedef Class * Class_t; // -> only private members
void Class_new( Class_t * cls ); // Class::Class()
void Class_release( Class_t cls ); // Class::~Class()
int Class_f( Class_t cls, double num ); // int Class::f( double )
//...

Das funktioniert in C++ leider nicht. Es müssen Methoden innerhalb der Klasse deklariert werden. Da Klassen ohne Methoden recht langweilig sind, erscheinen daher in C++ normalerweise die Klassendefinitionen in den Header-Dateien. Da sich Klassen nicht wie Namensräume mehrfach öffnen und wieder schließen lassen, muss also die Header-Datei sämtliche Deklarationen von (Datenfeldern wie) Methoden enthalten:

class Class {
public:
// ... public methods ... ok
private:
// ... private data & methods ... don't want these here
};

Das Problem ist offensichtlich: Die Modulschnittstelle (Header-Datei) enthält hier zwingend Details der Implementierung; das ist immer eine schlechte Idee. Daher bedient man sich eines eigentlich recht hässlichen Tricks und lagert kurzum alle Implementierungsdetails (sowohl Datenfelder als auch private Methoden) in eine gesonderte Klasse aus:

// --- class.h ---
class Class {
public:
Class();
~Class();

// ... public methods ...

void f( double n );

private:
class Private;
Private * d;
};
// -- class.cpp --
#include <class.h>

class Class::Private {
public:
// ... private data & methods ...
bool canAcceptN( double num ) const { return num != 0 ; }
double n;
};

Class::Class()
: d( new Private ) {}

Class::~Class() {
delete d;
}

void Class::f( double n ) {
if ( d->canAcceptN( n ) )
d->n = n;
}

Da Class::Private in der Header-Datei nur in der Deklaration einer Zeigervariablen, also lediglich "dem Namen nach" und nicht "der Größe nach" (siehe [1]), verwendet wird, reicht eine Forward-Deklaration wie im Falle von C aus. Sämtliche Methoden von Class greifen nun durch d hindurch auf die Datenfelder und privaten Methoden in Class::Private zu.

Damit erhält man die Vorteile eines vollständig kapselnden Modulsystems auch in C++. Wegen des Rückgriffs auf Indirektion bezahlt der Entwickler diesen Vorteil allerdings mit einer zusätzlichen dynamischen Speicheranforderung (new Class::Private), der Indirektion beim Zugriff auf Datenfelder und private Methoden und dem vollständigen Verzicht auf (jedenfalls öffentliche) inline-Methoden. Wie der zweite Teil zeigen wird, verändert sich die Semantik von const-Methoden ebenfalls.

Bevor der zweite Teil des Artikels darauf eingeht, wie man die oben genannten Nachteile behebt oder zumindest in ihrer Bedeutung abschwächt, beleuchtet der Rest dieses Beitrags zunächst die Vorteile des Idioms.

Die sind beträchtlich. Durch das Wegkapseln sämtlicher Implementierungsdetails entsteht eine sowohl schlanke als auch langfristig stabile Schnittstelle (Header-Datei). Erstere führt zu lesbareren Klassendefinitionen, Letztere zur Wahrung der Binärkompatibilität selbst bei umfangreichen Änderungen an der Implementierung.

So hat Nokias "Qt Development Frameworks"-Abteilung (vormals Trolltech) bei der Klassenbibliothek "Qt 4" bei mindestens zwei Gelegenheiten tiefgreifende Änderungen am Widget-Rendering durchgeführt, ohne dass die Qt 4 benutzenden Programme hierfür auch nur neu gebunden hätten werden müssen.

Insbesondere in großen Projekten ist die Tendenz des Pimpl-Idoms nicht zu unterschätzen, Übersetzungsläufe dramatisch zu beschleunigen: Sowohl durch Einsparen von #include-Direktiven in Header-Dateien als auch durch die deutlich verringerte Änderungsfrequenz der Header-Dateien von
Pimpl-Klassen generell. Herb Sutter berichtet in "Exceptional C++" von regelmäßiger Verdopplung der Geschwindigkeit, John Lakos gar von bis zu zwei Größenordnungen schnelleren Übersetzungsläufen.

Ein weiterer Vorzug des Designs besteht darin, dass Klassen mit d-Zeigern sich gut für transaktionsorientierten beziehungsweise ausnahmesicheren Code eignen. Der Entwickler kann zum Beispiel auf das Copy-Swap-Idiom [3, Item 56] zurückgreifen, um dem Zuweisungsoperator Transaktionssemantik ("alles oder nichts") zu verleihen:

class Class {
// ...
void swap( Class & other ) {
std::swap( d, other.d );
}
Class & operator=( const Class & other ) {
// this may fail, but doesn't change *this
Class copy( other );
// this cannot fail, commits to *this:
swap( copy );
return *this;
}

Auch die Implementierung der neuen C++-0x-Verschiebeoperationen [5] ist trivial (und insbesondere für alle Pimpl-Klassen gleich):

    // C++0x move semantics:
Class( Class && other ) : d( other.d ) { other.d = 0; }
Class & operator=( Class && other ) {
std::swap( d, other.d );
return *this;
}
// ...
}:

Sowohl Member-Swap als auch die Zuweisungsoperatoren lassen sich in diesem Modell inline definieren, ohne die Kapselung der Klasse zu kompromittieren; davon sollte der Entwickler regen Gebrauch machen.

Als letzter Vorzug sei die Option angesprochen, einen Teil der durch das Pimpl-Idiom verursachten zusätzlichen dynamischen Speicheranforderungen dadurch wieder einzusparen, dass die Private-Klasse ihre Datenfelder direkt einbettet, statt wie üblich nur einen Zeiger darauf zu deklarieren. Durch einmaliges "Pimplen" der ganzen Klasse entfällt sozusagen die Notwendigkeit, private Datenfelder komplexen Typs zur Entkopplung von Klassen voneinander nur durch Zeiger hindurch zu halten (was eine Art Pimpl pro Datenfeld darstellt).

Aus einer idiomatischen Qt-Dialogklasse

class QLineEdit;
class QLabel;
class MyDialog : public QDialog {
// ...
private:
// idiomatic Qt:
QLabel * m_loginLB;
QLineEdit * m_loginLE;
QLabel * m_passwdLB;
QLineEdit * m_passwdLE;
};

wird dann zum Beispiel

#include <QLabel>
#include <QLineEdit>

class MyDialog::Private {
// ...
// not idiomatic Qt, but less heap allocations:
QLabel loginLB;
QLineEdit loginLE;
QLabel passwdLB;
QLineEdit passwdLE;
};

Qt-Kenner mögen einwenden, der QDialog-Destruktor zerstöre die Kind-Widgets bereits; die direkte Aggregation löse demnach einen "double-delete" aus. Es ist in der Tat zutreffend, dass beim Einsatz der Technik die Gefahr besteht, Allokationssequenzfehler (double-delete, use-after-free etc.) zu begehen, insbesondere wenn sich Datenfelder und Klasse wechselseitig besitzen. Die gezeigte Transformation ist im vorliegenden Fall jedoch sicher, denn in Qt ist das Löschen von Kindern vor dem Löschen der Eltern immer erlaubt.

Die Methode ist besonders dann effektiv, wenn die auf die Weise aggregierten Datenfelder selbst Instanzen "gepimpelter" Klassen sind. Im gezeigten Beispiel ist das der Fall, und der Einsatz des Pimpl-Idioms spart vier dynamische Speicheranforderungen der Größe sizeof(void*) ein, erzeugt aber nur eine zusätzliche (größere). Das kann zu verbesserter Ausnutzung des dynamischen Speichers führen, denn besonders kleine Speicheranforderungen erzeugen regelmäßig besonders hohen Overhead.

Zudem "de-virtualisiert" der Kompiler in dem Szenario Aufrufe virtueller Funktionen viel eher; er entfernt also die durch die Virtualität verursachte doppelte Indirektion beim Funktionsaufruf. Bei der Aggregation durch Zeiger ist dafür interprozedurale Optimierung notwendig. Ob das jedoch vor dem Hintergrund der Indirektion durch den d-Zeiger in der Summe einen Laufzeitgewinn oder -verlust darstellt, ist bei Bedarf im Einzelfall durch Messungen an konkreten Klassen nachzuweisen.

Sollte Profiling zeigen, dass die dynamische Speicheranforderung zu einem Flaschenhals wird, kann das "Fast Pimpl"-Idiom [2, Item 30] Abhilfe schaffen: In dieser Variante lässt sich ein schneller Allokator, etwa ein boost::singleton_pool anstelle des globalen operator new() verwenden, um Private-Instanzen zu erzeugen.

Als bekanntes C++-Idiom erlaubt Pimpl es dem Klassenautor, die Schnittstelle von der Implementierung einer Klasse in einem Maße zu trennen, wie das in C++ eigentlich nicht vorgesehen ist. Als positiver Nebeneffekt beschleunigt der Einsatz von d-Zeigern Übersetzungsläufe, erleichtert die Implementierung von Transaktionssemantik und erlaubt durch erweiterte Mittel der Komposition mitunter zur Laufzeit effizientere Implementierungen.

Nicht alles ist jedoch gut beim Einsatz von d-Zeigern: Neben der zusätzlichen Private-Klasse und deren dynamischer Speicheranforderung bereiten die geänderte const-Methoden-Semantik und Allokationssequenzfehler Sorgen.

Mehr Infos

Ausblick

Der zweite und letzte Teil der Artikelserie wird nochmals einen genaueren Blick unter die Pimpl-Haube werfen, die "Roststellen" aufdecken und das Idiom mit einer ganzen Reihe von Accessoires "pimpen".

Für einiges davon zeigt der Autor im zweiten Teil Lösungen auf. Die Komplexität nimmt dabei jedoch weiter zu, sodass in jedem konkreten Fall erneut zu prüfen ist, ob die Vorteile des d-Zeigers seine Nachteile überwiegen, im Zweifel pro in Frage kommender Klasse erneut. Pauschale Urteile sind wie so häufig nicht möglich.

Marc Mutz
arbeitet als Software Engineer, Trainer und Consultant bei KDAB (Deutschland) GmbH & Co. KG.

  1. John Lakos; Large-Scale C++ Software Design; Addison-Wesley Longman, 1996
  2. Herb Sutter; Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions; Addison-Wesley Longman, 2000
  3. Herb Sutter, Andrei Alexandrescu: C++ Coding Standards: 101 Rules, Guidelines and Best Practices; Addison-Wesley Longman, 2004
  4. Daveed Vandervoorde: Modules in C++ (Revision 5), N2316=06-0176 (PDF)
  5. "Rvalue reference and move semantics" bei C++0x; auf engl. Wikipedia
  6. Boost: Singleton Pool

(ane)