PIMPL - Pointer to IMPLementation : Compileabhängigkeiten reduzieren
In diesem Post geht es darum, Compileabhängigkeiten zu reduzieren und damit vor allem das Erstellen nach kleineren Codeänderungen deutlich zu beschleunigen. Beschrieben ist diese Technik in “Effective Modern C++” von Scott Meyers, “The C++ Standard Library” von Nicolai Josuttis oder auch in “C++ Coding Standards” von Herb Sutter und Andrei Alexandrescu und sicher noch weiteren.
Hier sind ein paar Literaturempfehlungen, wenn man etwas tiefer einsteigen möchte:
- herbsutter.com/gotw/_100/ - eigentlich die umfassendste Diskussion des Themas
- www.gotw.ca/gotw/024.htm - ein paar spezielle Anmerkungen dazu
- en.cppreference.com/w/cpp/language/pimpl.html - noch ein paar zusätzliche Infos, aber unter Verwendung von
std::experimental
, also (noch) nix für Produktivcode
Hier ist jetzt die “Kurzfassung” und meine Empfehlung, wie man das mit modernen Compilern umsetzt.
Alle unten gezeigten Quelltextbeispiele gibt es gesammelt in einem Quelltextarchiv: Pimpl-Examples.7z
Der Hintergrund
Man stelle sich eine Klassenhierarchie vor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// in header A.h
class A {
// ...
};
// in header B.h
#include "A.h"
class B {
public:
A a; // verwendet A
};
// in header C.h
#include "B.h"
class C {
public:
B b; // verwendet B und indirekt auch A
};
und nun gibt es etliche (große Klassen), die auch die Klasse C als Aggregate-Member verwenden (analog so, wie die Klasse C die Klasse B verwendet)
1
2
3
4
5
6
#include "C.h" // benötigt
class D {
private:
C c; // verwendet C, und damit indirekt auch B und A
};
und damit die Headerdatai C.h
einbinden.
Sobald ich den Header von Klasse A, B oder C anfasse, wird die ganze Welt neu gebaut und der Compiler rödelt ewig vor sich hin. Dies liegt einfach daran, dass der Compiler bei Verwendung eines Objekts schon genau wissen muss, wie das Objekt intern aussehen muss.
Würde man die Klasse C nur als Pointer einbinden, so würde man mit einer Vorwärtsdeklaration der Klasse auskommen und müsste die Details erst in der cpp-Datei kennen und auch nur dort lokal die jeweiligen Header einbinden. Das ist der Grundgedanke des Pointer-to-Implementation Musters. Dabei wird eine Klasse in zwei Teile zerlegt, in einen versteckten Teil (der nicht mehr Teil der öffentlichen Schnittstelle ist) und einen öffentliche Teil, der nur sehr klein ist und keine Abhängigkeiten zu anderen Klassen und Headerdateien hat.
Es gibt zwei unterschiedliche Anwendungsfälle für solche Klassen, wobei die Variante mit nicht kopierbaren Klassenobjekte übersichtlicher und leichter verständlich ist. Daher beginne ich mit der Erklärung mit solchen Objekten
Nicht kopierbare Objekte
Für nicht kopierbare Klassenobjekte, welche normalerweise ausschließlich auf dem Heap erstellt werden, ist alles noch recht einfach. Solche Klassen sind üblich bei der GUI Programmierung, oder wenn die Klasse sonst irgendeine Ressource kapselt. In diesem Fall muss die Klasse nicht wie ein normales Objekt kopierbar oder zuweisbar sein.
Implementierung mit rohem Zeiger
Die Implementierung des PIMPL-Musters ist dann ziemlich simpel:
Deklaration in Headers C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#ifndef C_H
#define C_H
// Vorwärtsdeklaration der eigentlichen Implementierung
class CPrivate;
// Klasse mit versteckter Implementierung.
class C {
public:
// Konstruktor, erstellt das Objekt mit den Implementierungsdetails.
C();
// Destruktor, Speicherfreigabe.
~C();
// Kopierkonstruktor verbieten.
C(const C& c) = delete;
// Zuweisungsoperator verbieten.
C& operator=(const C& c) = delete;
private:
// Zeiger auf Objekt mit Implementierungsdetails.
CPrivate * m_impl = nullptr;
};
#endif // C_H
Indem der Kopierkonstruktor und der Zuweisungsoperator mittels = delete
verboten werden, kann niemand die Klasse einfach so kopieren. Damit reicht es aus, im Konstruktor das Implementierungsobjekt zu erstellen und im Destruktor wieder freizugeben. Die Klasse CPrivate
mit dem versteckten Implementierungsteil ist nur in der cpp-Datei bekannt, sodass bei Änderungen dieser Implementierungsdetails auch nur diese cpp neu erstellt werden muss.
Die Klasse C hält nun nur noch einen Zeiger auf die eigentliche Implementierung. Man verschiebt also die Details der Implementierung in eine private Klasse und diese wird nur über einen Zeiger in der eigentlichen Basisklasse referenziert (Pointer-to-IMPLementation – daher die Abkürzung PIMPL). Dadurch reicht eine Vorwärtsdeklaration für den Compiler aus.
Implementierung in C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "C.h"
#include <string>
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
// alle Implementierungsdetails der Klasse C
std::string str;
// ...
};
// *** Implementierung der Klasse C ***
C::C()
: m_impl(new CPrivate) // Implementierungsobjekt erstellen
{
}
C::~C() {
delete m_impl; // Speicher freigeben
}
Implementierung mit std::unique_ptr
Man kann das auch mittels std::unique_ptr
implementieren.
Deklaration in Headers C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#ifndef C_H
#define C_H
#include <memory> // for std::unique_ptr
// Vorwärtsdeklaration der eigentlichen Implementierung
class CPrivate;
// Klasse mit versteckter Implementierung.
class C {
public:
// Konstruktor, erstellt das Objekt mit den Implementierungsdetails.
C();
// Destruktor, Speicherfreigabe.
~C();
// Kopierkonstruktor verbieten.
C(const C& c) = delete;
// Zuweisungsoperator verbieten.
C& operator=(const C& c) = delete;
private:
// Zeiger auf Objekt mit Implementierungsdetails.
std::unique_ptr<CPrivate> m_impl;
};
#endif // C_H
Implementierung in C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "C.h"
#include <string>
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
// alle Implementierungsdetails der Klasse C
std::string str;
// ...
};
// *** Implementierung der Klasse C ***
C::C()
: m_impl(new CPrivate) // Implementierungsobjekt erstellen
{
}
// Speicher wird vom Destruktur der std::unique_ptr-Klasse freigegeben.
// Desktruktor muss aber in der cpp-Datei stehen, da nur hier die konkreten Details
// der Klasse CPrivate bekannt sind. Uns reicht aber der automatisch
// generierte Konstruktor, also lassen wir den Compiler den hier erzeugen.
C::~C() = default;
Viel kürzer ist das nicht, und man muss nun selbst noch den Header memory
einbinden, was ja auch wieder Compileroverhead bedeutet.
Ich empfehle in diesem Fall auf
std::unique_ptr
zu verzichten und lieber die schlanke, erste Variante zu wählen (auch wenn C++11 Fans gerne jeden einzelnen rohen Zeiger durch einen shared-pointer ersetzen wollen).
Vereinfachung durch Makros
Wenn man nun viele Klassen auf diese Art aufteilt, so muss man häufig den gleichen Quelltext schreiben. Man kann das durch die Verwendung von Makros abkürzen:
Makrodefinitionsdatei PIMPL_macros.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef PIMPL_MACROS_H
#define PIMPL_MACROS_H
#define PIMPL_DECLARATION(X) \
public: \
X(); \
~X(); \
X(const X&) = delete; \
X& operator=(const X&) = delete; \
private: \
X##Private * m_impl = nullptr;
#define PIMPL_DEFINITION(X) \
X::X() : m_impl(new X##Private) {} \
X::~X() { delete m_impl; }
#endif // PIMPL_MACROS_H
Damit verkürzen sich Klassendeklarationen und Standardimplementierung erheblich:
Deklaration in Headers C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef C_H
#define C_H
#include "PIMPL_macros.h"
// Vorwärtsdeklaration der eigentlichen Implementierung
class CPrivate;
// Klasse mit versteckter Implementierung.
class C {
PIMPL_DECLARATION(C)
};
#endif // C_H
Implementierung in C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "C.h"
#include <string>
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
// alle Implementierungsdetails der Klasse C
std::string str;
// ...
};
// *** Implementierung der Klasse C ***
PIMPL_DEFINITION(C)
Kopierbare Klassenobjekte, welche wie normale C++ Objekte kopiert werden sollen
Bei diesen ist es etwas aufwändiger, wenngleich die Methodik sich der vorab gezeigten Variante ähnelt. Kopieren und Zuweisen sind explizit erwünscht, d.h. die resultierende Klasse soll sich wie die ganz am Anfang gezeigte Klasse C (oder A oder B) verhalten. Daher müssen nun in der öffentlichen Schnittstelle noch die übrigen 4 Funktionen deklariert werden.
Diese Variante soll nun nachfolgend im Detail erläutert werden.
Die klassische Lösung mit rohem Zeiger
Hier mal ein Beispiel, wie man die Klasse C aus dem anfänglichen Beispiel mit dem PIMPL-Entwurfsmuster unter Verwendung eines rohen Zeigers auf das Implementierungsobjekt umsetzen würde:
Deklaration im Header C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#ifndef C_H
#define C_H
// Vorwärtsdeklaration der eigentlichen Implementierung
class CPrivate;
// Klasse mit versteckter Implementierung.
class C {
public:
// Konstruktor, erstellt das Objekt mit den Implementierungsdetails.
C();
// Kopierkonstruktor.
C(const C& c);
// Seit C++11, Move-Konstruktor
C(C&& c) noexcept;
// Destruktor, Speicherfreigabe.
~C();
// Zuweisungsoperator
C& operator=(const C& c);
// Seit C++11, Move-Zuweisungsoperator
C& operator=(C&& c) noexcept;
private:
CPrivate * m_impl = nullptr;
};
#endif // C_H
Es sind die zwei Kopierkonstruktoren und zwei Zuweisungsoperatoren hinzugekommen. Beachte die noexcept
-Deklaration der Move-Funktionen, denn in diesen werden keine Objekte sondern nur Zeiger verändert.
Implementierung in C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "C.h"
#include "B.h" // wird nur noch in C.cpp eingebunden, nicht mehr in C.h !!!
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
B b;
// ... weitere Membervariablen
};
// *** Implementierung der Klasse C ***
C::C()
: m_impl(new CPrivate)
{
}
C::C(const C &c)
: m_impl(new CPrivate(*c.m_impl))
{
}
C::C(C &&c) noexcept
: m_impl(c.m_impl)
{
// Originalreferenz auf den verschobenen Speicherbereich auf null setzen
c.m_impl = nullptr;
}
C::~C() {
delete m_impl;
}
// Zuweisungsoperator
C &C::operator=(const C &c) {
if (this != &c) {
// Inhalt des Implementierungsobjekts zuweisen
*m_impl = *c.m_impl;
}
return *this;
}
C& C::operator=(C&& c) noexcept {
if (this != &c) {
// Bisherige Daten freigeben
delete m_impl;
// Implementierungsdaten verschieben
m_impl = c.m_impl;
// Originalreferenz auf den verschobenen Speicherbereich auf null setzen
c.m_impl = nullptr;
}
return *this;
}
Die Implementierung der vier bzw. ab C++11 sechs wesentlichen Funktionen bei auf dem Heap abgelegten Membervariablen ist hier natürlich Pflicht:
- Standard Konstructor
- Kopierkonstruktor
- Destruktor
- Zuweisungsoperator
- Seit C++11: Move-Konstruktor
- Seit C++11: Move-Zuweisungsoperator
Das ist eine ganze Menge Quelltext, mit diversen Fallstricken (siehe nur den häufig vergessenen Vergleich auf Selbstzuweisung if (this != &c)
im Zuweisungsoperator). Aber das ist halt notwendig, wenn man die Klasse C in abgeleiteten Klassen wie einen normalen Objektdatentyp verwenden möchte.
Für den Fall, dass bei Ausführung des Zuweisungsoperators der Klasse
CPrivate
eine Exception auftreten könnte, kann man auch das Exception-Safe “Copy-Swap”-Muster verwenden:
1 2 3 4 5 6 7 8 9 10 11 // Zuweisungsoperator (Copy-and-Swap Idiom für Exception Safety) C &C::operator=(const C &c) { if (this != &c) { C tmp(c); // Kopie des Objekts 'c' erstellen // Zeiger auf Implementierungsobjekte tauschen (geht sehr schnell) std::swap(tmp.m_impl, m_impl); // wenn 'tmp' out-of-scope geht, löscht 'tmp' // das alte Implementierungsobjekt. } return *this; }Diese Implementierungs ist Exception-sicher, hat aber den zusätzlichen Overhead eines Kopierkonstruktors und Destruktors.
Die Klasse C kann nun wie ein normales C++ Objekt verwendet werden, beispielsweise in einer Klasse D:
D.h
:
1
2
3
4
5
6
7
8
9
10
11
12
#ifndef D_H
#define D_H
#include "C.h" // leichtgewichtiges Include
class D {
public:
// Verwendung der Klasse C wie einen normalen Datentyp.
C c;
};
#endif // D_H
Zum Lohn für Mühe wird nun jedoch die Klassendeklaration von B nur noch in der C.cpp
eingebunden, und nicht mehr in der Headerdatei. Wenn man also nun an der Schnittstelle der Klasse B was ändert, so muss man nur noch C.cpp
neu bauen, und nicht mehr D.cpp
oder andere, von C abhängige Implementierungen.
Hier ist mal ein Beispielprogramm, in dem die Verwendung der Klasse sichtbar wird:
main.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include "D.h"
int main() {
D d; // (1) 1 Konstruktoraufruf von CPrivate
D d2 = d; // (2) 1 Kopierkonstruktoraufruf von CPrivate
d = d2; // (3) Zuweisung
d = std::move(d2); // (4) Move-Zuweisung = 1 Destruktoraufruf von CPrivate
D d3 = std::move(d); // (5) Move-Konstruktor
return 0;
} // (6) cleanup = 1 Destruktoraufruf von CPrivate
In Schritt (1) wird Objekt D erstellt, was die Konstruktoren C()
und CPrivate()
aufruft. In Schritt (2) werden die Kopierkonstruktoren aufgerufen.
Bei der Zuweisung in Schritt (3) wird kein neues Objekt erstellt (außer man verwendet das Copy-Swap-Muster). In Schritt (4) wird das in d
enthaltene Objekt ersetzt und daher ein Destruktor aufgerufen.
In Schritt (5) wird das Implementierungsobjekt von d
in das neu erstellte Objekt d3
verschoben (kein Konstruktor/Destruktor-Aufruf). Und schließlich wird am Ende in (6) das verbleibende Objekt entfernt.
Implementierung mit std::unique_ptr
Man kann das Schema auch mit einen std::unique_ptr
umsetzen, allerdings reduziert sich der Code nicht nennenswert. In der Headerdatei kommt ein #include <memory>
dazu und die Membervariable m_impl
wird ein std::unique_ptr
:
1
std::unique_ptr<CPrivate> m_impl;
Die Implementierung der Funktionen sieht minimal anders aus:
In C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include "C.h"
#include <utility> // Für std::move und std::swap
#include "B.h" // wird nur noch in C.cpp eingebunden, nicht mehr in C.h !!!
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
B b;
// ... weitere Membervariablen
};
// *** Implementierung der Klasse C ***
// Konstruktor
C::C()
: m_impl(new CPrivate())
{
}
// Speicher wird vom Destruktur der std::unique_ptr-Klasse freigegeben.
// Desktruktor muss aber in der cpp-Datei stehen, da nur hier die konkreten Details
// der Klasse CPrivate bekannt sind. Uns reicht aber der automatisch
// generierte Konstruktor, also lassen wir den Compiler den hier erzeugen.
C::~C() = default;
// Kopierkonstruktor (tiefe Kopie der Implementierung)
C::C(const C& c)
: m_impl(std::make_unique<CPrivate>(*c.m_impl))
// Ruft den Kopierkonstruktor von CPrivate auf
{
}
// Zuweisungsoperator (Copy-and-Swap Idiom für Exception Safety)
C &C::operator=(const C &c) {
if (this != &c) { // Selbstzuweisung vermeiden
// Inhalt des Implementierungsobjekts zuweisen
*m_impl = *c.m_impl;
// das Gleiche wie: *m_impl.get() = *c.m_impl.get();
}
return *this;
}
// Move-Konstruktor
C::C(C&& c) noexcept
: m_impl(std::move(c.m_impl))
{
// std::move übergibt den Besitz des unique_ptr von 'c' an 'this'
// 'c.m_impl' ist danach nullptr
}
// Move-Zuweisungsoperator
C& C::operator=(C&& c) noexcept {
if (this != &c) { // Selbstzuweisung vermeiden
m_impl = std::move(c.m_impl); // Übergibt den Besitz
}
return *this;
}
Auch hier besteht die Möglichkeit, im Zuweisungsoperator das Copy-Swap-Muster zu nutzen:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Zuweisungsoperator (Copy-and-Swap Idiom für Exception Safety)
C &C::operator=(const C &c) {
if (this != &c) { // Selbstzuweisung vermeiden
// Erstelle eine temporäre Kopie des 'c'-Objekts
// Dies ruft den Kopierkonstruktor auf und wirft bei Fehlern
// eine Ausnahme, bevor der Zustand des aktuellen Objekts geändert wird.
C tmp(c);
// Tausche die Implementierungen
m_impl.swap(tmp.m_impl);
// Wenn 'tmp' aus dem Scope geht, wird das "überschriebene"
// Impl-Objekt gelöscht
}
return *this;
}
Wenn man die Variante mit
unique_ptr
und dem rohen Zeiger vergleicht, sind die Unterschiede klein und der Code auch nicht viel kürzer. Ist das wirklich den Aufwand für die Verwendung derstd::unique_ptr
Klasse wert? Ich denke nicht und auch deshalb empfehle ich hier eher die Verwendung eines einfachen Zeigers (keep it simple).
Vereinfachung durch Makros
Wie schon bei der Variante mit den nicht-kopierbaren PIMPL-Klassen kann man den Boilerplate-Code in Makros verstecken:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef PIMPL_MACROS_H
#define PIMPL_MACROS_H
// ...
#define PIMPL_DECLARATION_COPYABLE(X) \
public: \
X(); \
X(const X&); \
X(X&&) noexcept; \
~X(); \
X& operator=(const X&); \
X& operator=(X&&) noexcept; \
private: \
X##Private * m_impl = nullptr;
#define PIMPL_DEFINITION_COPYABLE(X) \
X::X() : m_impl(new X##Private){} \
X::X(const X &x) : m_impl(new X##Private(*x.m_impl)) {} \
X::X(X && x) noexcept : m_impl(x.m_impl) { x.m_impl = nullptr; } \
X::~X() { delete m_impl; } \
X &X::operator=(const X &x) { \
if (this != &x) { *m_impl = *x.m_impl; } \
return *this; \
} \
X& X::operator=(X&& x) noexcept { \
if (this != &x) { delete m_impl; m_impl = x.m_impl; x.m_impl = nullptr; } \
return *this; \
}
Verwendung in Headerdatei und Implementierungsdatei:
In C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef C_H
#define C_H
#include "PIMPL_macros.h"
// Vorwärtsdeklaration der eigentlichen Implementierung
class CPrivate;
// Klasse mit versteckter Implementierung.
class C {
PIMPL_DECLARATION_COPYABLE(C)
};
#endif // C_H
und in C.cpp
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "C.h"
#include "B.h" // wird nur noch in C.cpp eingebunden, nicht mehr in C.h !!!
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
B b;
// ... weitere Membervariablen
};
// *** Implementierung der Klasse C ***
PIMPL_DEFINITION_COPYABLE(C)
Schön kompakt, oder?
Zugriff auf die Implementierungsdaten
In den Beispielen wurden bisher alle Implementierungsdetails, d.h. auch alle Membervariablen in die private Klasse CPrivate
verschoben. Nun stellt sich die Frage, wie Nutzer der Klasse C
an diese Variablen und Memberfunktionen herankommen? Hierbei gibt es zwei Spielarten:
- Alle Details der Implementierung sind nur in der zugehörigen cpp-Datei deklariert und definiert, aber die Schnittstelle der Klasse reicht alle Zugriffsfunktionen durch und bietet getter/setter-Funktionen für alle Membervariablen.
- Die Deklaration der Implementierungsklasse wird in einer eigenen Headerdatei veröffentlicht und für Nutzer der Klasse zugänglich gemacht.
Die erste Variante erfordert ziemlich viel Schreibarbeit - je mehr Membervariablen des privaten Implementierung nach außen zugänglich gemacht werden, umso mehr Zugriffsfunktionen muss man programmieren. Auch für Memberfunktionen der privaten Implementierungsklasse, welche man von außen aufrufen will, muss man Wrapper-Funktionen erstellen. Alles ziemlich mühsam.
Die zweite Variante veröffentlicht zwar wiederum die privaten Implementierungsdetails, aber nur für die Nutzer, welche diese auch wirklich brauchen. Im Beispiel oben verschiebt man die Klasse CPrivate
in eigene Header- und Implementierungsdateien:
CPrivate.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef CPRIVATE_H
#define CPRIVATE_H
#include "B.h" // wird nur noch in C.cpp eingebunden, nicht mehr in C.h !!!
// Enthält Implementierungsdetails der Klasse C
class CPrivate {
public:
B b;
// ... weitere Membervariablen
};
#endif // CPRIVATE_H
Und in C.cpp
wird diese einfach nur eingebunden.
Andere Nutzer dieser Klasse können nun einfach diese Headerdatei einbinden, und kennen damit die Internas. Zugriff auf die interne Implementierung schaffen Memberfunktionen in der Klasse C.
Headerdatei C.h
:
1
2
3
4
5
6
7
8
9
10
11
12
13
// ... wie bisher
class C {
// ... wie bisher
// Lese/Schreib-Zugriff auf Implementierungsobjekt
CPrivate * operator->() { return m_impl; }
// Nur-Lese-Zugriff auf Implementierungsobjekt
const CPrivate * operator->() const { return m_impl; }
private:
CPrivate * m_impl = nullptr;
}
Man kann dann auf die private Implementierung z.B. so zugreifen, als wäre die Klassen-Membervariable selbst ein Zeiger.
1
2
3
D d;
// Überladener Operator '->' gibt Zugriff auf Implementierungsklasse
d.c->b = B(str);
Wenn man das mit der ganz ursprünglichen Variante aus dem Einleitungskapitel vergleicht, ist das sehr ähnlich:
1
2
D d;
d.c.b = B(str); // b ist Objekt-Member von c, und c ist Objekt-Member von d
Diese Zugriffsfunktionen kann man auch noch in das
PIMPL_DECLARATION_COPYABLE(X)
Makro von oben packen.
Fazit/Zusammenfassung
- Bei Klassen, welche nicht kopiert werden müssen, kann der PIMPL-Code sehr viel kürzer ausfallen. Dann ist nur der Konstruktor und Destruktor zu implementieren.
- Der Destruktur muss in jedem Fall in die Implementierungsdatei, wo das Details der privaten Implementierungsklasse bekannt sind.
- Bei Klassen, welche wie normale Klassen einfach kopiert werden soll, müssen zusätzlich noch Kopierkonstruktor, Zuweisungsoperator, und ab C++11 auch noch Move-Konstruktor und Move-Zuweisungsoperator implementiert werden.
- Die Verwendung von
std::unique_ptr
bringt nicht wirklich Vorteile - der zu schreibende Code ist ähnlich lang, und man muss zusätzlich<memory>
einbinden. - Zugriff auf die Implementierungsklassendetails für ausgewählte Nutzer macht man am Besten durch Hinzufügen von Zugriffsfunktionen, wobei die Variante mit dem Überladenene
operator->()
am elegantesten scheint. - Um den PIMPL-Code nicht mehrfach schreiben zu müssen, sollte man diesen in Makros definieren, was dann den eigentlichen Quelltext kompakt hält.