Patterns in der Softwareentwicklung: Das Brückenmuster

Das Brückenmuster ist ein strukturelles Muster. Es entkoppelt die Schnittstelle von seiner Implementierung.

Lesezeit: 5 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 31 Beiträge

(Bild: Asvolas / Shutterstock.com)

Von
  • Rainer Grimm
Inhaltsverzeichnis

Patterns sind eine wichtige Abstraktion in der modernen Softwareentwicklung. Sie bieten eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Das klassische Buch "Design Patterns: Elements of Reusable Object-Oriented Software" (kurz Design Patterns) enthält 23 Muster, darunter das Brückenmuster, die zu den Strukturmustern gehört. Es entkoppelt die Schnittstelle von seiner Implementierung. In C++ wird oft eine vereinfachte Version verwendet: das Pimpl Idiom.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Bevor ich über das Pimpl Idiom schreibe, sind hier die Fakten zu dem Brückenmuster.

Zweck

  • Entkoppelt die Schnittstelle von der Implementierung

Auch bekannt als

  • Handle/Body
  • Pimpl (Pointer to Implementation) Idiom

Anwendbarkeit

  • Die Schnittstelle und die Implementierung können erweitert werden
  • Eine Änderung der Schnittstelle der Implementierung hat keine Auswirkungen auf den Client
  • Die Implementierung ist versteckt

Abstraktion

  • Definiert die Schnittstelle der Abstraktion
  • Besitzt ein Objekt vom Typ Implementor

RedefinedAbstraction

  • Implementiert oder verfeinert die Schnittstelle der Abstraction

Implementor

  • Definiert die Schnittstelle der Implementierung

ConcreteImplementor

  • Implementiert die Schnittstelle des Implementor

Das Brückenmuster hat zwei Hierarchien: Eine für die Abstraktion (Schnittstelle) und eine für die Implementierung. Der Client programmiert gegen die Abstraktion, und die Abstraktion nutzt die Implementierung. Folglich können verschiedene Implementierungen der Abstraktionsschnittstelle und verschiedene Implementierungen der Implementierungsschnittstelle transparent verwendet werden. Das Brückenmuster bietet große Flexibilität, da die Abstraktion und die Implementierung variiert und während der Laufzeit des Programms ausgetauscht werden können.

Das Brückenmuster ist ein gutes Beispiel für die Kombination von Vererbung und Komposition. Einerseits hat es zwei Typenhierarchien (Vererbung) und andererseits besitzt die Abstraktion eine Implementierung (Komposition).

Beispiel

Das Beispiel zeigt eine einfache Implementierung des Brückenmusters.

// bridge.cpp

#include <iostream>

class Implementor {                           // (1)
public:
    virtual void implementation() const = 0;

    virtual ~Implementor() = default;
};
 
class ImplementorA: public Implementor {
public:
    ImplementorA() = default;
 
    void implementation() const {
        std::cout << "ImplementatorA::implementation" << '\n';
    }
};
 
class ImplementorB: public Implementor {
public:
    ImplementorB() = default;

    void implementation() const {
        std::cout << "ImplementatorB::implementation" << '\n';
    }
};

class Abstraction {                           // (2)      
public:
    virtual void function() const = 0;
    virtual ~Abstraction() = default;
};

class RefinedAbstraction: public Abstraction {
public:
    RefinedAbstraction(Implementor& impl) : 
		implementor(impl) {
    }
 
    void function() const {
        std::cout << "RefinedAbstraction::function\n";
        implementor.implementation();
    }
private:
    Implementor& implementor;
};
 
int main() {

    std::cout << '\n';

    ImplementorA implementorA;
    ImplementorB implementorB;
 
    RefinedAbstraction refinedAbstraction1(implementorA);  // (3)
    RefinedAbstraction refinedAbstraction2(implementorB);  // (4)

    Abstraction *abstraction1 = &refinedAbstraction1;
    Abstraction *abstraction2 = &refinedAbstraction2;

    abstraction1->function();

    std::cout << '\n';

    abstraction2->function();

    std::cout << '\n';

}

Die Klasse Implementor (1) ist die Schnittstelle für die Implementierungshierarchie und die Klasse Abstraction (2) die Schnittstelle für die Abstraktion. Die Instanzen redefinedAbstraction1 und redefinedAbstraction2 erhalten ihre Implementierung in ihrem Konstruktor (3 und 4).

Der folgende Screenshot zeigt die Ausgabe des Programms.

  • Das Adapter Pattern, wenn es als Objektadapter implementiert wird, ähnelt dem Bridge Pattern, verfolgt aber eine andere Absicht. Der Zweck des Bridge Patterns ist es, die Schnittstelle von der Implementierung zu trennen, während der Zweck des Adapters darin besteht, eine bestehende Schnittstelle zu verändern.

In C++ wird oft eine vereinfachte Version des Bridge Patterns verwendet.

Der Kerngedanke des Pimpl-Idioms ist, dass die Implementierung der Klasse hinter einem Zeiger versteckt wird.

Hier ist ein Rezept für die Implementierung des Pimpl-Idioms:

  • Verschiebe private Daten und Mitgliedsfunktionen der Klasse (public class) in eine eigene Klasse (pimpl class).
  • Deklariere die pimpl class im Header der öffentlichen Klasse.
  • Deklariere den Zeiger vom Typ pimpl class in der public class.
  • Definiere die pimpl class in der Quelldatei der public class.
  • Instanziiere die pimpl class im Konstruktor der public class.
  • Die Mitgliedsfunktionen der public class verwenden die Funktionen der pimpl class.

Bartlomiej Filipek liefert in seinem Blogbeitrag "The Pimpl Pattern - what you should know" ein schönes Beispiel für das Pimpl Idiom:

// class.h
class MyClassImpl;
class MyClass
{
public:
    explicit MyClass();
    ~MyClass(); 

    // movable:
    MyClass(MyClass && rhs) noexcept;                       // (2)
    MyClass& operator=(MyClass && rhs) noexcept;            // (3)

    // and copyable
    MyClass(const MyClass& rhs);                            // (4)
    MyClass& operator=(const MyClass& rhs);                 // (5)

    void DoSth();
    void DoConst() const;

private:
    const MyClassImpl* Pimpl() const 
      { return m_pImpl.get(); }                              // (6)
    MyClassImpl* Pimpl() { return m_pImpl.get(); }           // (7)

    std::unique_ptr<MyClassImpl> m_pImpl;                    // (1)
};

// class.cpp
class MyClassImpl
{
public:
    ~MyClassImpl() = default;

    void DoSth() { }
    void DoConst() const { }
};

MyClass::MyClass() : m_pImpl(new MyClassImpl()) 
{

}

MyClass::~MyClass() = default;
MyClass::MyClass(MyClass &&) noexcept = default;
MyClass& MyClass::operator=(MyClass &&) noexcept = default;

MyClass::MyClass(const MyClass& rhs)
    : m_pImpl(new MyClassImpl(*rhs.m_pImpl))
{}

MyClass& MyClass::operator=(const MyClass& rhs) {
    if (this != &rhs) 
        m_pImpl.reset(new MyClassImpl(*rhs.m_pImpl));

    return *this;
}

void MyClass::DoSth()
{
    Pimpl()->DoSth();
}

void MyClass::DoConst() const
{
    Pimpl()->DoConst();
}

Dies sind die wichtigsten Ideen seiner Implementierung. Ich habe ein paar Zeilenmarkierungen hinzugefügt:

  • Der pimpl ist ein std::unique_ptr<MyClassImpl> (1)
  • Die Klasse unterstützt die Kopier- und Move-Semantic (2 - 5)
  • Die privaten Pimp()-Mitgliedsfunktionen von MyClass geben einen const und einen non-const MyClassImpl-Zeiger zurück (6 und 7)

Was sind die Vorteile des Pimpl Idioms? Es wäre einfacher, die Implementierung MyClassImpl in die Abstraktion MyClass einzubinden.

Beginnen möchte ich mit den Vorteilen

Vorteil

  • Binäre Kompatibilität: Wenn man die Implementierung ändert, wird die Schnittstelle für den Client, der die Abstraktion verwendet, nicht verändert.
  • Kompilierzeit: Änderungen der Implementierung erfordern nicht, dass der Client, der die Abstraktion nutzt, neu kompiliert werden muss. Aus diesem Grund wird das Pimpl Idiom oft als Compilation Firewall bezeichnet. Diesen Vorteilen besitzen auch Module in C++20.
  • Erweiterbarkeit: Es ist ziemlich einfach, die Implementierung während der Laufzeit auszutauschen. Im Allgemeinen besteht kein Bedarf an Virtualität.

Nachteile

  • Performanz: Die Zeigerumlenkung verursacht zusätzliche Laufzeitkosten.
  • Die Großen Sechs: Man muss die Großen Sechs berücksichtigen (siehe "C++ Core Guidelines: Die Nuller-, Fünfer- oder Sechserregel"). Da die Abstraktion einen std::unique_ptr hat, unterstützt sie keine Kopiersemantik. Das bedeutet für den konkreten Fall:
    • Man musst die Kopier-Semantik implementieren, wenn man sie braucht.
    • Beim Implementieren der Kopier-Semantik, bekommt man nicht automatisch die Move-Semantik.
  • Speicherzuweisung: Das Pimpl Idiom erfordert eine Speicherzuweisung. Diese ist in eingebetteten Systemen möglicherweise nicht möglich und kann eine Speicherfragmentierung verursachen.

Das Decorator Pattern ist ein häufig verwendetes Strukturmuster aus dem Buch "Design Patterns: Elements of Reusable Object-Oriented Software". Seine Aufgabe ist es, ein Objekt dynamisch mit Verantwortlichkeiten zu erweitern. Ich werde den Decorator in meinem nächsten Artikel genauer vorstellen. ()