Das Abstract Factory Design Pattern
Studienprojekt von Philipp Hauer. 2009 - 2010. ©
Strategy, Observer, Decorator, Factory Method, Abstract Factory, Singleton, Command, Composite, Facade, State
Literaturverzeichnis, Philipps Blog
Inhalt
Einführung
Unser Kunde möchte, dass wir ein Framework zur Erstellung von verschiedenen stereotypen Spielwelten (Regenwald, Wüste, Polargebiet) kreieren. Wir sollen eine API bereitstellen mit dem sich Objekte für die Spielwelt (Pflanzen, Tiere, Untergrund) generieren lassen. Unser Framework soll Methoden zur Generierung dieser Elemente zu Verfügung stellen.
Der Kunde möchte, dass wir ihm zu Beginn einen Prototyp erstellen: Ein Spielweltgenerator für die Spielwelt Regenwald. Für den Regenwald wählen wir Bäume, Elefanten und Gras als Spielweltelemente. Wir erstellen eine Factory, die den Erstellungscode kapselt und zentralisiert: der Regenwaldgenerator.
class Regenwaldgenerator{
public Elefant createElefant(){
return new Elefant();
}
public Baum createBaum(){
return new Baum();
}
public Gras createGras(){
return new Gras();
}
}
class Elefant{}
class Baum{}
class Gras{}
Über den Regenwaldgenerator (der Factory) kann der Client (also der Anwender unseres Spielweltgenerators) beliebige Elemente erstellen:
public static void main(String[] args) {
Regenwaldgenerator regenwaldgenerator = new Regenwaldgenerator();
Baum baum1 = regenwaldgenerator.createBaum();
Baum baum2 = regenwaldgenerator.createBaum();
Elefant elefant = regenwaldgenerator.createElefant();
Gras gras = regenwaldgenerator.createGras();
//Welt zusammen setzen...
}
Der Kunde kann nun fleißig Regenwaldwelten erstellen, ist begeistert und möchte nun auch andere Welten (Wüste und Polargebiet) generieren. Übermütig und unüberlegt wird folgender Entwurf aus der Hüfte geschossen:
Für jeden Spielwelttyp wird ein eigener Generator geschrieben. Der Client wählt je nach gewünschter Spielwelt den passenden Generator und holt sich die konkreten Spielweltelementen von diesen.
public static void main(String[] args) {
//Flags die bestimmen, welchen Weltentyp der Client bauen soll
boolean regenwald = true;
boolean polargebiet = false;
boolean wüste = false;
//Elemente holen
if (regenwald){
Regenwaldgenerator regenwaldgenerator = new Regenwaldgenerator();
Baum baum1 = regenwaldgenerator.createBaum();
Baum baum2 = regenwaldgenerator.createBaum();
Elefant elefant = regenwaldgenerator.createElefant();
Gras gras = regenwaldgenerator.createGras();
//Welt zusammen setzten...
} else if (wüste){
Wüstengenerator wüstengenerator = new Wüstengenerator();
Kaktus kaktus1 = wüstengenerator.createKaktus();
Kaktus kaktus2 = wüstengenerator.createKaktus();
Kamel kamel = wüstengenerator.createKamel();
Sand sand = wüstengenerator.createSand();
//Welt zusammen setzten...
} else if (polargebiet){
Polargebietgenerator polargebietgenerator = new Polargebietgenerator();
// etc.
//Welt zusammen setzten...
}
}
Mit diesem Entwurf haben wir dem Unsinn Türen und Tore geöffnet:
-
Inkonsistenz. Der Client kann nun die Welten beliebig miteinander mischen. Dies ist nicht vorgesehen, da die Spielweltobjekte, nur mit Objekten der selben Welt interagieren und funktionieren können. Ein Kamel kann auf Schnee nicht gehen und unser Baum wird nicht lange auf trockenem Sand überleben. Unser "Framework" ermöglicht Inkonsistenz und damit Fehlfunktionen.
- Keine Allgemeingültigkeit. Der Client kann keinen allgemeingültigen Code schreiben. Hat er Code für eine Polargebietwelt geschrieben und will nun die gleiche Anzahl von Tieren und Pflanzen bei identischem Arrangement in eine Regenwaldwelt übertragen, so muss sein Code komplett umgeschrieben werden, damit sie mit den Klassen des Regenwaldes arbeiten. Ein Wartungsalptraum. Eine unbefriedigende Alternative wäre die zahlreichen Nutzung von if-else-Konstrukten, um zwischen dem gewünschten Generator und Welttyp auszuwählen. Der Code wird unnötig größer, unübersichtlicher, fehlerträchtiger und damit schlechter wartbar.
-
Keine Erweiterbarkeit. Begründet liegt obiger Punkt in der Tatsache, dass der Client mit konkreten Klassen arbeitet, sowohl bei den Generatoren als auch bei den Spielweltobjekten. Dies führt zu einer engen Kopplung. Soll nun ein Generator angepasst werden (zum Beispiel Polargebietgenerator: statt Eisbären sollen nun Schneeleoparden als Tiere erzeugt werden) bricht der Clientcode und Wartung wird erforderlich. Eine Factory, die konkrete Klassen zurückgibt, ist aus diesem Grund sinnlos. Sie verfehlt den Zweck von Factorys: Flexibler Austausch von Implementierungen.
Unser Ziel ist es, dass der Client generischen Code zur Spielwelt schreiben kann ("Ich nehme 20 Pflanzen und 12 Tiere auf dem passenden Untergrund, ordne sie an und lasse sie interagieren."). Es soll keine Rolle spielen, von welchem konkreten Typ die Spielwelt (Regenwald, Wüste, Polargebiet) ist, die er gerade baut. Der Client soll dies bei der Spielweltgestaltung nicht einmal mehr wissen und sich auf das Zusammensetzen der Spielwelt konzentrieren.
Der Schlüssel zur dieser angestrebten Allgemeingültigkeit und zur Lösung all unserer Probleme liegt in Abstraktion. Erinnern wir uns an folgendes OO-Entwurfsprinzip:
Stütze dich auf Abstraktion, nie auf konkrete Klassen.
Wir führen Abstraktionen zu unseren Spielweltelementen in Form der Interfaces Pflanze, Tier und Untergrund ein.
Auch führen wir eine Schnittstelle für unsere Generatoren ein. Dieses Interface (AbstractGenerator) definiert die drei createX()-Methoden, liefert aber nun nicht mehr konkrete Klassen zurück, sondern die eben definierten Spielweltobjektabstraktionen (Pflanze, Tier, Untergrund) zurück.
interface AbstractGenerator{
public Tier createTier();
public Pflanze createPflanze();
public Untergrund createUntergrund();
}
class Regenwaldgenerator implements AbstractGenerator{
public Tier createTier(){
return new Elefant();
}
public Pflanze createPflanze(){
return new Baum();
}
public Untergrund createUntergrund(){
return new Gras();
}
}
class Wüstengenerator implements AbstractGenerator{
public Tier createTier(){
return new Kamel();
}
public Pflanze createPflanze(){
return new Kaktus();
}
public Untergrund createUntergrund(){
return new Sand();
}
}
class Polargebietgenerator implements AbstractGenerator{
public Tier createTier(){
return new Eisbär();
}
public Pflanze createPflanze(){
return new Flechte();
}
public Untergrund createUntergrund(){
return new Schnee();
}
}
//Interface
interface Tier{}
interface Pflanze{}
interface Untergrund{}
//Regenwald
class Elefant implements Tier{}
class Baum implements Pflanze{}
class Gras implements Untergrund{}
//Wüste
class Kamel implements Tier{}
class Kaktus implements Pflanze{}
class Sand implements Untergrund{}
//Polargebiet
class Eisbär implements Tier{}
class Flechte implements Pflanze{}
class Schnee implements Untergrund{}
Welches konkrete Spielweltobjekt nun wirklich zurückgeliefert wird, entscheidet allein die Implementierung.
Was ändert sich für den Client? Er ist von der Implementierung entkoppelt und stützt sich allein auf die Abstraktion für Generator, Pflanzen, Tiere und dem Untergrund.
public static void main(String[] args) {
//An einer zentrale Stelle wird der Typ bestimmt -> schneller Austausch
AbstractGenerator generator = new Polargebietgenerator();
//Objekte werden erstellt
Pflanze pflanze = generator.createPflanze();
Tier tier = generator.createTier();
Untergrund untergrund = generator.createUntergrund();
//...
//Welt wird gebaut
}
Er instanziiert eine AbstractGenerator-Implementierung und entscheidet damit von welchem Typ seine Spielwelt sein soll. Nun kann er beliebig Tier, Pflanzen und den Untergrund erstellen und diese zusammensetzen.
Schauen wir uns die Vorzüge unseres neuen Entwurfs an.
- Flexibilität und Allgemeingültigkeit durch Entkopplung des Client von konkreten Implementierungen. Der Typ der Spielwelt wird an einer zentralen Stelle festgelegt: bei der einmaligen Instanziierung des gewünschten Generators (beispielsweise Regenwaldgenerator). Fortan wird abstrakter, allgemeingültiger Code geschrieben. Der Client weiß nicht, dass er in Wirklichkeit mit Bäumen, Elefanten und Gras arbeitet. Das ermöglicht das denkbar einfache Austauschen des Generators und damit der gesamten Spielweltobjektfamilie. So kann ein Wüstengenerator stattdessen instanziiert werden und schon baut der Client, ohne es zu wissen, seine Welten mit Kakteen, Kamelen und Sand.
- Erweiterbarkeit und Wartbarkeit. Aus diesem Grund können auch schnell neue Welten (beispielsweise Steppe) ins System integriert werden. Dazu muss lediglich der AbstractGenerator implementiert werden und die eigenen Spielweltobjekte, die wiederum die entsprechenden Interfaces realisieren, zurückgeben werden. Alles ohne bestehenden Code zu brechen.
- Wartbarkeit. Auch können leicht Änderungen an bestehenden Spielwelten durchgeführt werden. Soll es im Regenwald nun Tiger statt Elefanten geben, muss lediglich die createTier()-Methode des Regenwaldgenerators angepasst werden. Schon tummeln sich in allen bisher gebauten Welten (Clientcode) Tiger. Es entsteht kein Änderungsaufwand am Client, dank allgemeingültigen, auf Abstraktion gestützten Code.
- Konsistenz. Jetzt stellt unser kleines Spielweltframework sicher, dass nur Spielweltobjekte erstellt werden, die auch zueinander passen und miteinander funktionieren. Kapselt man nun noch die Instanziierung der Generatorklasse im Framework (beispielsweise in einer eigenen Factory) und überlässt sie nicht dem Client (zur Zeit könnte er noch mehrere Generatoren erstellen und die Objekte damit mischen), so wird das höchste Maß an Konsistenz sichergestellt.
In der Gesamtschau ergibt sich folgendes Klassendiagramm:
Es zeigt sich, dass durch den Einsatz des Abstract Factory Patterns, ein hohes Maß an Flexibilität und Allgemeingültigkeit gewonnen wird, während zeitgleich die Wartung erleichtert wird und Erweiterungen schnell und unkompliziert möglich sind.
Nach dieser Einführung wird im folgendem Abschnitt das Abstract Factory Design Pattern formalisiert, näher analysiert und diskutiert.
Das Spielweltgeneratorbeispiel mit Abstract Factory Pattern Termini
Klasse | Abstract Factory Teilnehmer |
---|---|
AbstractGenerator | AbstractFactory |
Konkrete Generatoren | Concrete Factorys |
Spielweltobjekte (Tiere, Pflanzen, Untergrund) | Products |
konkrete Spielweltobjekte (Elefant, Sand, Schnee, Kaktus etc.) | Concrete Products |
Analyse und Diskussion
Gang Of Four-Definition
Abstract Factory:
"Biete eine Schnittstelle zum Erzeugen von Familien verwandter oder voneinander abhängiger Objekte, ohne ihre konkreten Klassen zu benennen."
([GoF], Seite 107)
Beschreibung
Das Abstract Factory Design Pattern dient der Definition einer zusammenhängenden Familie aus Produkten (engl. products). Die Familien können elegant ausgetauscht werden.
Der Instanziierungscode wird in eine Factory ausgelagert. Allerdings wird die Factory hinter einer abstrakten Schnittstelle vor dem Client verborgen. Diese Factoryschnittstelle, die namensgebende Abstract Factory, definiert für jedes Produkt der Produktfamilie (Produktsatz) eine Operation, mit der der Client eine Instanz des jeweiligen Produkts erhalten kann. Der Client ist damit von einer bestimmten Factoryimplementierung entkoppelt. Es stützt sich allein auf Abstraktion - sowohl bei den Produkten als auch bei der Factory.
Da eine Factory immer die gesamte Schnittstelle erfüllen muss, wird sicher gestellt, dass der Client nur mit Produkten arbeitet, die zusammen gehören und zusammen passen.
Es wird eine Schnittstelle für die Factory und die Products definiert (hier mit den Interfaces AbstractFactory, IProduct1 etc.). Konkrete Factorys (ConcreteFactoryA, ConcreteFactoryB) realisieren die Schnittstelle der AbstractFactory und implementieren die Erstellungsmethoden. In dieser instanziiert die Factory ihre eigene Implementierung des jeweiligen Produkts und gibt sie zurück. Der Client kennt nur die Schnittstellen.
//Factory
interface AbstractFactory{
public Product1 createProduct1();
public Product2 createProduct2();
public Product3 createProduct3();
}
class ConcreteFactoryA implements AbstractFactory{
@Override
public Product1 createProduct1() {
return new ConcreteProduct1A();
}
@Override
public Product2 createProduct2() {
return new ConcreteProduct2A();
}
@Override
public Product3 createProduct3() {
return new ConcreteProduct3A();
}
}
class ConcreteFactoryB implements AbstractFactory{
@Override
public Product1 createProduct1() {
return new ConcreteProduct1B();
}
@Override
public Product2 createProduct2() {
return new ConcreteProduct2B();
}
@Override
public Product3 createProduct3() {
return new ConcreteProduct3B();
}
}
//Products
interface Product1{}
interface Product2{}
interface Product3{}
class ConcreteProduct1A implements Product1{}
class ConcreteProduct1B implements Product1{}
class ConcreteProduct2A implements Product2{}
class ConcreteProduct2B implements Product2{}
class ConcreteProduct3A implements Product3{}
class ConcreteProduct3B implements Product3{}
public static void main(String[] args) {
//zentraler Austauschpunkt -> Implementierungsaustausch
AbstractFactory factory = new ConcreteFactoryA();
IProduct1 product1 = factory.createProduct1(); //product1-implementierung von ConcreteFactoryA
IProduct2 product2 = factory.createProduct2(); //product2-implementierung von ConcreteFactoryA
IProduct3 product3 = factory.createProduct3(); //product3-implementierung von ConcreteFactoryA
}
Anwendungsfälle
-
Wenn verschiedene Objekte zu einem Kontext erstellt werden und daher immer zusammenhängend erstellt werden müssen. Also immer dann, wenn Konsistenz im Objektpool sichergestellt werden muss.
- In einem System müssen Dateien verschiedenen Formats eingelesen und unterschiedlich weiterverarbeitet werden. Dafür wird für jedes Format eine Reader-Klasse erstellt und für jede mögliche Weiterverarbeitung eine Transformer-Klasse. Dabei ist es unbedingt erforderlich, dass zu einem Reader auch der passende Transformer gewählt wird. Um dieser Anforderung zu genügen, kann das Abstract Factory verwendet werden. (nach [PK], Seite 21)
- Die Persistenzlogik einer Applikation kapselt den Zugriff auf die Datenbank. Dabei soll diese natürlich nicht nur auf einer MySQL-Datenbank ihre Daten ablegen können, sondern auch mit anderen Datenbanken (Oracle etc.) interagieren können. Dazu sind allerdings verschiedene DBConnection- und jeweils korrespondierende DBCommand-Objekte von Nöten. Nun könnte nach dem Abstract Factory Pattern eine OracleDBClientFactory zwei Methoden zum Erhalten von solchen Objektpaaren bereitstellen. Die Persistenzlogik arbeitet auf Interfaces und hat keine Kenntnis von den verwendeten datenbankspezifischen Objekten. (frei nach [Mosa])
- Wenn ein System mit unterschiedlichen Sets von Objekten konfiguriert werden muss oder dies ermöglich sein soll.
- Wenn ein System losgelöst davon sein soll, wie bestimmte Objekte erstellt werden.
- Eine Objektfamilie bereitgestellt werden soll, aber noch keine Aussagen zu den konkreten Implementierungen gemacht werden kann oder soll. Stattdessen werden Interfaces bereitgestellt.
Typische Anwendung von Abstract Factorys sind GUI-Bibliotheken, die unterschiedliche Look & Feels unterstützen. Dabei wird für jedes typische "Widget" (Textfeld, Button, Scrollpane, Panel, Labels) ein Interface definiert. Für jedes Look & Feel werden alle Widgets implementiert und eine konkrete Factory (beispielsweise WindowsFactory, MacOSXFactory oder GDKFactory) erzeugt, die Instanzen des entsprechenden Widgets zurückgibt. Die konkreten Factorys erfüllen dabei die Schnittstelle einer Abstract Factory (Methoden: createButton(), createTextField() etc). Der Client holt sich die Widgets (vom Typ des Interfaces) von der Abstract Factory, weiß dabei aber nicht, welches Widget er nun konkret erhält bzw. von welchem Look & Feel es ist. Dies ermöglicht das schreiben von GUI-Code unabhängig vom gewählten Look & Feel und macht das Look & Feel damit austauschbar (frei nach [GoF], Seite 107f; Bilderquelle: SWT).
Vorteile
- Durch das Abschirmen der konkreten Klassen wird der Clientcode allgemeingültig. Es ist kein Code für spezielle Fälle notwendig.
- Konsistenz. Es wird sichergestellt, dass nur jene Objekte zum Client gelangen, die auch zusammenpassen. Es ist weiterhin gewährleistet, dass immer nur ein konkretes Familienmitglied eines Typs zur gleichen Zeit im Einsatz ist.
- Flexibilität. Ganze Objektfamilien können ausgetaucht werden, ohne dass der Clientcode bricht, da sich der Client nur auf Abstraktionen (Abstract Factory, Productschnittstellen) stützt.
- Einfache Erweiterung mit neuen Produktfamilien. Neue Productsets können sehr einfach ins System integriert werden. Dazu ist lediglich das erneute Implementieren der Factoryschnittstelle nötig. Anschließend muss nur noch an einer zentralen Stelle im Client die neue Factory instanziiert werden.
- Wiederverwendbarkeit. Konkrete Products können Mitglieder verschiedener Produktfamilien sein.
- Verschlankung des Clientcodes.
Nachteile
- Unflexibilität hinsichtlich neuer Familienmitglieder. Soll der Produktfamilie ein neues Produkt hinzugefügt werden, so ist eine Änderung der Schnittstelle der Abstract Factory notwendig. Dies führt aber zum Brechen von Code aller konkreten Factorys. Der Änderungsaufwand ist groß. Daher sollte gleich zu Beginn sehr genau überlegt werden, welche Produkttypen erstellt werden sollen. Je weiter das System zum Zeitpunkt der nötigen Erweiterung fortgeschritten ist, umso mehr Code muss modifiziert werden.
Exkurs: Vergleich von Abstract Factory und Factory Method
Einsteiger in der Thematik von Design Pattern fällt oft die Unterscheidung zwischen den Entwurfsmustern Abstract Factory und Factory Method schwer.
Abstract Factory | Factory Method |
---|---|
Gemeinsamkeit: Beide Pattern entkoppelt den Client von konkreten Typen. Der Client kennt nur die Schnittstelle zur Factory und den Produkten. Welche Produkte konkret erstellt werden, entscheidet die Unterklasse/Implementierung der Schnittstelle. Der Client stützt sich allein auf Abstraktion. |
|
|
|