Klassifizierung von Design Patterns in der Softwareentwicklung

Muster können auf verschiedene Arten klassifiziert werden. Das Buch "Design Patterns" bietet eine gute Richtlinie.

In Pocket speichern vorlesen Druckansicht 5 Kommentare lesen

(Bild: Black Jack/Shutterstock.com)

Lesezeit: 7 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Muster können auf verschiedene Arten klassifiziert werden. Die bekanntesten sind die Muster, die in den Büchern "Design Patterns: Elements of Reusable Object-Oriented Software" und "Pattern-Oriented Software Architecture, Volume 1" verwendet werden.

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++.

Beginnen möchte ich in chronologischer Reihenfolge mit der Klassifizierung im Buch "Design Patterns: Elements of Reusable Object-Oriented Software".

Die folgende Tabelle gibt einen ersten Überblick über die 23 Muster, die in dem Buch vorgestellt werden.

Beim Studieren der Tabelle, kann man zwei Klassifizierungen feststellen. Erstens: Erzeugungsmuster, Strukturmuster und Verhaltensmuster und zweitens: Klassenmuster und Objektmuster. Die erste Klassifizierung ist offensichtlich, aber nicht die zweite.

  • Erzeugungsmuster befassen sich mit der Erstellung von Objekten auf eine genau definierte Weise,
  • Strukturmuster bieten Mechanismen zur Organisation von Klassen und Objekten für größere Strukturen,
  • Verhaltensmuster befassen sich mit der Kommunikation zwischen Objekten.

Die Muster, die fett gedruckt sind, habe ich in meiner Vergangenheit häufig verwendet. Folglich werde ich in zukünftigen Artikeln explizit über sie schreiben.

Es gibt eine eine Asymmetrie in dieser. Das Buch "Design Patterns: Elements of Reusable Object-Oriented Software" stellt Erzeugungsmuster vor, aber keine Zerstörungsmuster.Was soll man tun?

Nun komme ich zu der nicht ganz so offensichtlichen Klassifizierung.

Der Geltungsbereich eines Musters lässt sich unterscheiden.

In meinen Design-Pattern-Kursen bezeichne ich Klassenmuster und Objektmuster als Metamuster. Ich habe zwei Metamuster im Kopf, wenn ich eine Designherausforderung lösen will: Vererbung versus Komposition. Alle 23 Design Patterns sind Variationen dieser beiden Schlüsselprinzipien. Etwas konkreter: Vererbung ist ein Klassenmuster und Komposition ist ein Objektmuster.

  • Klassenmuster wenden Klassen und ihre Unterklassen an. Sie nutzen die Trennung von Schnittstelle und Implementierung und den Laufzeit-Dispatch mit virtuellen Funktionsaufrufen. Ihre Funktionen sind fest kodiert und zur Kompilierzeit verfügbar. Sie bieten weniger Flexibilität und dynamisches Verhalten als die Objektmuster an.
  • Objektmuster nutzen die Beziehung von Objekten. Man baut eine Abstraktion auf, indem man sie aus grundlegenden Bausteinen zusammensetzt. Diese Komposition kann zur Laufzeit durchgeführt werden. Folglich sind Objektmuster flexibler und verschieben die Entscheidung auf die Laufzeit des Programms.

Ehrlich gesagt, wird Vererbung viel zu häufig verwendet. Meistens ist die Komposition die bessere Wahl.

2006 habe ich meine ersten Design-Pattern-Kurse für die deutsche Automobilindustrie gehalten. Um die Komposition zu motivieren, entwarf ich ein generisches Auto:

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct CarPart{
    virtual int getPrice() const = 0;
};

struct Wheel: CarPart{
    int getPrice() const override = 0;
};

struct Motor: CarPart{
    int getPrice() const override = 0;
};

struct Body: CarPart{
    int getPrice() const override = 0;
};

// Trabi

struct TrabiWheel: Wheel{
    int getPrice() const override{
        return 30;
    }
};

struct TrabiMotor: Motor{
    int getPrice() const override{
        return 350;
    }
};

struct TrabiBody: Body{
    int getPrice() const override{
        return 550;
    }
};

// VW

struct VWWheel: Wheel{
    int getPrice() const override{
        return 100;
    }
};

struct VWMotor: Motor{
    int getPrice() const override{
        return 500;
    }
};

struct VWBody: Body{
    int getPrice() const override{
        return 850;
    }
};

// BMW

struct BMWWheel: Wheel{
    int getPrice() const override{
        return 300;
    }
};

struct BMWMotor: Motor{
    int getPrice() const override{
        return 850;
    }
};

struct BMWBody: Body{
    int getPrice() const override{
        return 1250;
    }
};

// Generic car
    
struct Car{
    Car(std::unique_ptr<Wheel> wh, 
        std::unique_ptr<Motor> mo, 
        std::unique_ptr<Body> bo): 
         myWheel(std::move(wh)), 
                 myMotor(std::move(mo)), 
                 myBody(std::move(bo)){}
         
    int getPrice(){
        return 4 * myWheel->getPrice() + 
          myMotor->getPrice() + myBody->getPrice();
    }

private:
    std::unique_ptr<Wheel> myWheel;
    std::unique_ptr<Motor> myMotor;
    std::unique_ptr<Body> myBody;

};

int main(){
    
    std::cout << '\n';
    
    Car trabi(std::make_unique<TrabiWheel>(), 
              std::make_unique<TrabiMotor>(),
              std::make_unique<TrabiBody>());
    std::cout << "Offer Trabi: " << trabi.getPrice() << '\n';
    
    Car vw(std::make_unique<VWWheel>(), 
           std::make_unique<VWMotor>(),
           std::make_unique<VWBody>());
    std::cout << "Offer VW: " << vw.getPrice() << '\n';
    
    Car bmw(std::make_unique<BMWWheel>(),
            std::make_unique<BMWMotor>(), 
            std::make_unique<BMWBody>());
    std::cout << "Offer BMW: " << bmw.getPrice() << '\n';
    
    Car fancy(std::make_unique<TrabiWheel>(), 
              std::make_unique<VWMotor>(), 
              std::make_unique<BMWBody>());
    std::cout << "Offer Fancy: " << fancy.getPrice() << '\n';
    
    std::cout << '\n';
    
}

Ich weiß aus der internationalen Diskussion in meinen Design Patterns Kursen, dass viele einen BMW und einen VW kennen, aber vielleicht keine Ahnung von einem Trabi haben. Das gilt auch für viele junge Menschen in Deutschland. Trabi ist die Abkürzung für Trabant und steht für Kleinwagen, die in der ehemaligen DDR hergestellt wurden.

Wer das Programm ausführst, erhält das erwartete Ergebnis:

Es ist ziemlich einfach, das Programm zu erklären. Das generische Auto ist eine Zusammensetzung aus vier Rädern, einem Motor und einer Karosserie. Jede Komponente ist von der abstrakten Basisklasse CarPart abgeleitet und muss daher die Memberfunktion getPrice implementieren. Die abstrakten Basisklassen Wheel, Motor und Body sind nicht notwendig, verbessern aber die Struktur der Vererbungshierarchie. Wenn ein Kunde ein spezielles Auto haben möchte, delegiert die generische Klasse Car den Aufruf von getPrice an ihre Autoteile. Natürlich habe ich in dieser Klasse beide Metamuster, Vererbung und Komposition, zusammen angewendet, um die Struktur typsicherer zu machen und die Autoteile leicht anpassbar zu halten.

Den Themen Komposition und Vererbung widme ich mich, indem ich die folgenden Fragen beantworte:

  1. Wie viele verschiedene Autos kann man aus vorhandenen Fahrzeugteilen bauen?
  2. Wie viele Klassen sind erforderlich, um die gleiche Komplexität mit Vererbung zu lösen?
  3. Wie einfach/komplex ist es, Vererbung/Komposition einzusetzen, um ein neues Auto wie Audi zu unterstützen - vorausgesetzt, dass alle Teile zur Verfügung stehen?
  4. Wie einfach ist es, den Preis für ein Autoteil zu ändern?
  5. Nehmen wir an, ein Kunde möchte ein neues, schickes Auto, das aus vorhandenen Autoteilen zusammengebaut wird. Wann muss man entscheiden, ob man das neue Auto auf Basis von Vererbung oder Komposition zusammenbaut? Welche Strategie wird zur Kompilierzeit und welche zur Laufzeit angewendet?

Hier ist meine Argumentation:

  1. Man kann 3 * 3 * 3 = 27 verschiedene Autos aus den 14 Komponenten erstellen.
  2. Man braucht 27 + 1 = 28 verschiedene Klassen, um 27 verschiedene Autos zu bauen. Jede Klasse muss ihre Autoteile in ihrem Klassennamen kodieren, z.B. TrabiWheelVWMotorBMWBody, TrabiWheelVWMotorVWBody, TrabiWheelVWMotorTrabiBody, ... . Das wird schnell unwartbar. Die gleiche Komplexität entsteht, wenn man mehrere Vererbungen anwendet und TrabiWheelVWMotorBMWBody drei Basisklassen gibt. In diesem Fall müsste man von TrabiWheel, VWMotor und BMWBody ableiten. Außerdem müsste man die Memberfunktion getPrice umbenennen.
  3. Bei der Kompositionsstrategie gilt es lediglich, die drei Autoteile für Auto implementieren. Damit lassen sich 4 * 4 * 4 = 64 verschiedene Autos aus 17 Komponenten erstellen. Im Gegensatz dazu muss man bei der Vererbung den Vererbungsbaum in allen notwendigen Zweigen erweitern.
  4. Es ist ziemlich einfach, den Preis eines Autoteils durch Komposition zu ändern. Bei der Vererbung muss man den gesamten Vererbungsbaum durchlaufen und den Preis an jeder Stelle ändern.
  5. Das ist mein Hauptargument: Dank der Komposition kann man die Autoteile während der Laufzeit zusammenstellen. Im Gegensatz dazu konfiguriert die Vererbungsstrategie das Auto zur Kompilierzeit. Für Autoverkäufer bedeutet das, dass sie die Autoteile lagern müssen, um sie zusammenzubauen, wenn der Kunde kommt. Bei der Vererbung müssen sie alle Konfigurationen ihres Autos vorproduzieren.

Das war natürlich nur mein Gedankenexperiment. Aber es sollte einen Punkt deutlich machen. Um die kombinatorische Komplexität zu beherrschen, sollten einfache, verknüpfbare Komponenten verwendet werden. Ich nenne es das Lego-Prinzip.

Auch das Buch Pattern-Oriented Software Architecture, Volume 1" liefert eine sehr interessante Klassifizierung von Patterns. Ich werde sie in meinem nächsten Artikel genauer vorstellen.

Dieses Jahr werde ich die folgenden offenen C++-Schulungen anbieten.

(rme)