Klassifizierung von Design Patterns in der Softwareentwicklung
Muster können auf verschiedene Arten klassifiziert werden. Das Buch "Design Patterns" bietet eine gute Richtlinie.
- Rainer Grimm
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.
Beginnen möchte ich in chronologischer Reihenfolge mit der Klassifizierung im Buch "Design Patterns: Elements of Reusable Object-Oriented Software".
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, Strukturmuster und Verhaltensmuster
- 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.
Zerstörungsmuster
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?
- Der Mitautor des Design Patterns Buches Jon Vlissides hat in seinem Buch "Pattern Hatching: Design Patterns Applied" (1998) über die Zerstörung eines Singletons geschrieben.
- Man kann das überwältigende Buch "Modern C++ Design: Generic Programming and Design Principle Applied" (2001) von Andrei Alexandrescu lesen, um zu erfahren, wie man ein Singleton zerstört.
- Das ausgezeichnete Buch "Small Memory Software: Patterns for systems with limited memory" (2000) von James Noble und Charles Weir widmet der Allokation ein ganzes Kapitel.
Nun komme ich zu der nicht ganz so offensichtlichen Klassifizierung.
Der Geltungsbereich eines Musters lässt sich unterscheiden.
Klassenmuster und Objektmuster
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.
Komposition
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.
Ein Gedankenexperiment
Den Themen Komposition und Vererbung widme ich mich, indem ich die folgenden Fragen beantworte:
- Wie viele verschiedene Autos kann man aus vorhandenen Fahrzeugteilen bauen?
- Wie viele Klassen sind erforderlich, um die gleiche Komplexität mit Vererbung zu lösen?
- 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?
- Wie einfach ist es, den Preis für ein Autoteil zu ändern?
- 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:
- Man kann 3 * 3 * 3 = 27 verschiedene Autos aus den 14 Komponenten erstellen.
- 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 undTrabiWheelVWMotorBMWBody
drei Basisklassen gibt. In diesem Fall mĂĽsste man vonTrabiWheel
,VWMotor
undBMWBody
ableiten. AuĂźerdem mĂĽsste man die MemberfunktiongetPrice
umbenennen. - 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.
- 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.
- 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.
Wie geht's weiter?
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.
Meine C++ Schulungen in 2022:
Dieses Jahr werde ich die folgenden offenen C++-Schulungen anbieten.
- C++20: 23.08.2022 - 25.08.2022 (Termingarantie, Vorort bei Herrenberg)
- Clean Code: Best Practices fĂĽr modernes C++: 11.10.2022 - 13.10.2022 (Vorort bei Herrenberg)
- Design Pattern und Architekturpattern mit C++: 08.11.2022 - 10.11.2022 (Termingarantie, Online)
(rme)