Skip to content

Latest commit

 

History

History
325 lines (221 loc) · 10.3 KB

File metadata and controls

325 lines (221 loc) · 10.3 KB

Opaque Pointer / Pimpl Pattern

Zurück


Wesentliche Merkmale

Kategorie: Structural Pattern

Ziel / Absicht:

Was versteht man unter einem Opaque Pointer? Opaque, zu Deutsch undurchsichtig, impliziert, das wir es mit etwas zu tun haben, durch das wir nicht durchschauen können. Ein Opaque Pointer (zu Deutsch gewissermaßen ein "undurchsichtiger Zeiger") ist ein Zeiger, der auf eine Datenstruktur (Objekt) verweist, deren Inhalt zum Zeitpunkt seiner Definition nicht verfügbar ist.

Hinweis:

Das Opaque Pointer Pattern ist auch unter den Begriffen d-pointer, compiler firewall oder auch Cheshire Cat Pattern oder Pimpl ("Pointer to implementation") bekannt.

Problem:

Opaque Pointer sind eine Möglichkeit, die Implementierungsdetails einer Schnittstelle vor Benutzern zu verbergen.

Auf diese Weise kann die Implementierung geändert werden, ohne dass die C++-Module, die sie verwenden, neu kompiliert werden müssen.

Dies kommt auch dem Programmierer zugute, da eine einfache Schnittstelle erstellt werden kann und die meisten Details in einer anderen Datei versteckt sind.

Dies ist wichtig, um beispielsweise die Binärcode-Kompatibilität (ABI, Application Binary Interface) mit verschiedenen Versionen einer Shared-Bibliothek zu gewährleisten.

Lösung:

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 PrivateImpl ergeben (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.

Pro / Kontra:

Pros:
  • 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.
Kontras:
  • 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.

Ein Beispiel:

Quellcode Car.h
Quellcode Car.cpp
Quellcode Engine.h
Quellcode CarClient.cpp

Grundlagen:

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!

Verbesserungen:

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 CarImpl in 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() {};

Hinweis:

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.
Weiterer Hinweis:

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!


Ein zweiter Beispiel:

Quellcode User.h
Quellcode User.cpp
Quellcode UserClient.cpp


Struktur (UML):

Abbildung 1: Struktur des Pimpl-Idioms.

Pimpl @ Microsoft

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)


Weiterarbeit:

Stellen Sie einen Vergleich Pimpl-Idiom versus Interface Inheritance an.


Literaturhinweise

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)


Zurück