Werbung

  1. Diese Seite verwendet Cookies. Wenn du dich weiterhin auf dieser Seite aufhältst, akzeptierst du unseren Einsatz von Cookies. Weitere Informationen

[C++] Das PIMPL-Prinzip 2015-03-24

Interface und Implementation trennen mit PIMPL

  1. -AB-
    Programmiersprache(n):
    C++
    Und ein weiterer Quicktipp. Diesmal geht es um das PIMPL-Idiom, was eigentlich nichts anderes bedeutet, als seine Klassen (=Implementation) in einer anderen Klasse als Pointer zu halten (Pointer to IMPLementation).

    Jeglicher Zugriff erfolgt dann über den Pointer in der äußeren Klasse. Klingt unsinnig? Ist es zum Glück auch meistens, in manchen Fällen braucht man aber genau das, und zwar in Fällen wie diesen:

    • Man schreibt eine Bibliothek und:
      • will bestimmte Details seiner Klassen verbergen
      • will keine Header einer Drittbibliothek mit ausliefern
      • sich öfters ändernde Klassen sollen den Nutzer nicht zu Codeänderungen zwingen
    • Compilezeiten sollen reduziert werden
    Wie kann man das erreichen, indem man einfach nur stupide seine Klassen durch einen Pointer abkapselt? Nun, das eigentlich Problem entsteht dadurch, dass man in C++ die gesamte Implementation der Klasse (wenn auch nicht derer Methoden) bekannt machen muss, um sie zu ersetzen.

    Code!
    Nehmen wir mal an, wir hätten eine Bibliothek geschrieben, mit einer Klasse DataProvider, die z.B. für bestimmte Schlüssel Werte zurück gibt.
    So könnte unsere Klasse aussehen:
    Code (C++):
    Quelltext kopieren
    1. #include <map>
    2. #include <string>
    3.  
    4. class DataProvider
    5. {
    6. public:
    7.     const std::string& getData(int key) const;
    8. private:
    9.     std::map<int, std::string> mData;
    10. };
    Ein Detail, was wir bei unserer gloriosen Klasse gerne verbergen würden, wäre natürlich, dass wir bloß eine std::map wrappen. Aber jemand, der den DataProvider nutzen will, muss natürlich den Header inkludieren, und in dem kann man natürlich dieses leicht beschämende Detail nachlesen - unschön für uns.

    Vielleicht wollen wir aber auch eine boost::flat_map nutzen, aus welchem Grund auch immer. Und wer einmal mit boost gearbeitet hat, weiß, dass man da nicht nur einen einzelnen Header inkludiert, sondern viele viele viele - alles Header-Dateien, die wir zusammen mit unserem DataProvider ausliefern müssen und die beim Kunden mit kompiliert werden müssen.

    So richtig schwierig wird es, wenn der Kunde auch boost nutzt - aber in einer anderen Version als die, die wir benutzt haben. In solche Höllen wollen wir heute gar nicht erst vorstoßen, sondern uns eher anschauen, wie man derartige Probleme verhindern kann. Also halten wir fest: 3rd party Bibliotheken mit auszuliefern ist - wenn nicht erforderlich - ein heikles Thema.

    Weiter in unserer Problemliste: Sagen wir, wir arbeiten eng mit einem Kunden zusammen, und liefern ihm täglich eine aktuelle Version des DataProviders. Jedes Mal, wenn wir Methoden oder Member hinzufügen, ändert sich der Header, was für unseren Kunden wiederum bedeutet, dass alle .cpps, die diesen Header (auch nur indirekt) inkludieren, neu gebaut werden müssen. Auch, wenn sich das Interface (die Methode const std::string& getData(int key) const;) gar nicht ändert.
    Das gleiche Problem haben wir in unserem eigenen Projekt, wenn wir an vielen Stellen DataProvider nutzen und sich dessen Implementierung häufig ändert. (aber nicht das Interface, was wir hauptsächlich nutzen.)
    In Java würde man hier mit einem Interface IDataProvider arbeiten, dessen, öhm, Interface sich möglichst nicht ändert, und einer Implementierung, die sich ruhig ändern darf. Etwas ähnliches werden wir hier auch anwenden, da wir aber keine Vererbung brauchen (und wollen), benötigen wir auch kein virtual.

    Wir werden das Interface von der eigentlichen Implementierung trennen, und zwar durch einen Pointer. Das geht ganz einfach - die Klasse DataProvider, die wir ausliefern, schrumpft zusammen auf:

    Code (C++):
    Quelltext kopieren
    1. #include <string>
    2.  
    3. class DataProviderImpl; //forward declaration
    4.  
    5. class DataProvider
    6. {
    7. public:
    8.     DataProvider();
    9.     ~DataProvider();
    10.     const std::string& getData(int key) const;
    11. private:
    12.     DataProviderImpl* const mPIMPL;
    13. };
    Man beachte die forward declaration in Zeile 3, ohne die das Konzept nicht funktionieren würde. Da wir in der Klasse nur einen Pointer halten, nicht aber eine vollwertige Instanz, muss die Implementation der Klasse hier nicht bekannt sein. (Im .cpp ändert sich das dann.)
    Und die Implementation schieben wir in eine Klasse DataProviderImpl:

    Code (C++):
    Quelltext kopieren
    1. #include <map>
    2. #include <string>
    3.  
    4. class DataProviderImpl
    5. {
    6. public:
    7.     const std::string& getData(int key) const;
    8.     void doSomething();
    9.     void methodJustAddedToday();
    10. private:
    11.     std::map<int, std::string> mData;
    12.     static const int changeCounterSinceYesterday = 14;
    13. };
    Und schon können wir dem Kunden die Klasse (i.e., den Header) DataProvider ausliefern und:
    • Die Implementation ist versteckt. (DataProviderImpl wird nicht ausgeliefert, also sieht niemand, dass wir bloß eine Map nutzen.)
    • Der DataProvider-Header hat keine Abhängigkeit mehr zum <map>-Header (oder unserer 3rd-Party-Bibliothek - keine weiteren Header müssen ausgeliefert werden!)
    • Wenn die DataProviderImpl modifiziert wird, z.B. wenn eine Funktion methodJustAddedToday hinzugefügt wird, oder ein Counter wie changeCounterSinceYesterday geändert wird, bekommt unser Kunde das überhaupt nicht mit.
    • Nicht nur unser Kunde, auch dem Compiler sind diese Änderungen egal. Wenn unser Code nur DataProvider und nicht DataProviderImpl inkludiert, führen Änderungen in letzterem zu keinem Neukompilieren.

    Alles zu gut, um wahr zu sein? Natürlich kostet alles seinen Preis, den man sehr schnell sieht, wenn wir in die DataProvider.cpp schauen:

    Code (C++):
    Quelltext kopieren
    1. #include "DataProvider.hpp"
    2. #include "DataProviderImpl.hpp"
    3.  
    4. DataProvider::DataProvider() : mPIMPL(new DataProviderImpl())
    5. {}
    6.  
    7. DataProvider::~DataProvider()
    8. {
    9.     delete mPIMPL;
    10. }
    11.  
    12. const std::string& DataProvider::getData(int key)
    13. {
    14.     return mPIMPL->getData(key);
    15. }
    Hier finden DataProvider und DataProviderImpl zusammen. Da wir auf den DataProviderImpl-Pointer zugreifen wollen, müssen wir hier natürlich die Implementation der Klasse kennen, und inkludieren den DataProviderImpl Header - was aber nach außen hin egal ist, denn .cpps werden nicht ausgeliefert.

    Natürlich fällt direkt ins Auge, dass wir das gesamte Interface (was in diesem Fall nur die Funktion const std::string& getData(int key) const; ist) duplizieren müssen, auch wenn jede Funktion nur die entsprechende Methode in DataProviderImpl aufruft. Das ist unschön, aber eigentlich klar - eben die Trennung von Interface und Implementierung.
    Performancemäßig fallen dann zwei Dinge auf: Einmal die Allokation per new - zu der wir gleich noch kommen - und der verzögerte Zugriff auf Memberfunktionen über eine Pointerindirektion. Für alle üblichen Anwendungsbeispiele - hier z.B. der Lookup in einer std::map - fällt letztere überhaupt nicht ins Gewicht.

    Das new und delete hingegen... Kann kritisch werden. Wenn wir eine Klasse PIMPLn wollen, die häufig zwischen Bibliothek und Kundencode ausgetauscht wird - sprich, sie wird oft konstruiert und destruiert - fällt die andauernde Allokation auf dem Heap natürlich negativ auf. Für solche Klassen eignet sich das PIMPL-Prinzip nur schlecht. (Allerdings lassen sich PIMPLs hervorragend moven.)

    Außerdem predige ich immer, dass man - wenn man C++ richtig nutzt - eigentlich nie ein new/delete oder einen Destruktor schreiben muss. Wenn wir den mPIMPL-Pointer als zu verwaltende Ressource ansehen - also exakt das, was er ist - schlägt uns das RAII-Prinzip vor, einen schlauen Pointer zu verwenden, einen, der eben auf das Verwalten von Speicher spezialisiert ist. Ein std::unique_ptr scheint perfekt zu passen - schließlich soll nur ein Zeiger auf unsere Implementation zeigen - und schon müssten wir unseren Destruktor ja los werden können. Oder?

    Code (C++):
    Quelltext kopieren
    1. #include <string>
    2. #include <memory>
    3.  
    4. class DataProviderImpl; //forward declaration
    5.  
    6. class DataProvider
    7. {
    8. public:
    9.     DataProvider();
    10.     const std::string& getData(int key) const;
    11. private:
    12.     const std::unique_ptr<DataProviderImpl> mPIMPL;
    13. };
    im Header,

    Code (C++):
    Quelltext kopieren
    1. #include "DataProvider.hpp"
    2. #include "DataProviderImpl.hpp"
    3.  
    4. DataProvider::DataProvider() : mPIMPL(new DataProviderImpl())
    5. {}
    6.  
    7. const std::string& DataProvider::getData(int key) const
    8. {
    9.     return mPIMPL->getData(key);
    10. }
    im .cpp - sollte alles gehen?

    Leider nicht.
    Der Compiler meldet, er könnte einen unvollständigen Typen nicht zerstören. Warum das? Eigentlich klar - wenn kein Destruktor angegeben ist, wird vom Compiler einer generiert. Aber von wo wird dieser Destruktor aufgerufen? Der uniqe_ptr ist ein template - also im Header. Hier ist unser Typ allerdings noch unbekannt. Dummerweise müssen wir einen leeren Destruktor angeben, so dass die Destruktor-Implementierung im .cpp landet - dass sie leer ist, schadet nicht.
    Trotzdem ist ein leerer Destruktor, nun, etwas ungewöhnlich - wer einen ausreichend aktuellen Compiler hat, kann im .cpp auch mit DataProvider::~DataProvider() = default; den Default-Destruktor im .cpp erzeugen lassen - wodurch etwas klarer wird, dass man den Destruktor aus einem technischen Grund braucht, aber keine spezielle Implementierung benötigt.

    Also da haben wir es. Einen Fall, in dem man tatsächlich einen Destruktor braucht - wenn auch nur eine explizite Anweisung, einen per default zu konstruieren.

    Weiterführend sollte man jetzt darüber nachdenken, für die Klasse die entsprechenden Copy/Move Konstruktoren zu schreiben, aber das geht mal wieder über den scope hinaus. ;)

    Allen Code gibts natürlich im mitgelieferten .zip.
    Die Info, dass man den Destruktor =defaulten kann, habe ich (wie so vieles) aus Scott Meyers "Effective Modern C++" - und, da es alles im Netz schon mehrfach gibt - hat Herb Sutter das Thema schon - und noch ausführlicher - behandelt.

Letzte Rezensionen

  1. German
    German
    5/5,
    Version: 2015-03-24
    Hat mir bei der Vermeidung von Namenskonflikten schon gute Dienste geleistet.