Titel | Inhalt | Suchen | Index | API | Go To Java 2, Zweite Auflage, Handbuch der Java-Programmierung |
<< | < | > | >> | Kapitel 10 - OOP IV: Verschiedenes |
Design-Patterns (oder Entwurfsmuster) sind eine der wichtigsten und interessantesten Entwicklungen der objektorientierten Programmierung der letzten Jahre. Basierend auf den Ideen des Architekten Christopher Alexander wurden sie durch das Buch "Design-Patterns - Elements of Reusable Object-Oriented Software" von Erich Gamma, Richard Helm, Ralph Johnson und John Vlissides 1995 einer breiten Öffentlichkeit bekannt.
Als Design-Patterns bezeichnet man (wohlüberlegte) Designvorschläge für den Entwurf objektorientierter Softwaresysteme. Ein Design-Pattern deckt dabei ein ganz bestimmtes Entwurfsproblem ab und beschreibt in rezeptartiger Weise das Zusammenwirken von Klassen, Objekten und Methoden. Meist sind daran mehrere Algorithmen und/oder Datenstrukturen beteiligt. Design-Patterns stellen wie Datenstrukturen oder Algorithmen vordefinierte Lösungen für konkrete Programmierprobleme dar, allerdings auf einer höheren Abstraktionsebene.
Einer der wichtigsten Verdienste standardisierter Design-Patterns ist es, Softwaredesigns Namen zu geben. Zwar ist es in der Praxis nicht immer möglich oder sinnvoll, ein bestimmtes Design-Pattern in allen Details zu übernehmen. Die konsistente Verwendung ihrer Namen und ihres prinzipiellen Aufbaus erweitern jedoch das Handwerkszeug und die Kommunikationsfähigkeit des OOP-Programmierers beträchtlich. Begriffe wie Factory, Iterator oder Singleton werden in OO-Projekten routinemäßig verwendet und sollten für jeden betroffenen Entwickler dieselbe Bedeutung haben.
Wir wollen nachfolgend einige der wichtigsten Design-Patterns vorstellen und ihre Implementierung in Java skizzieren. Die Ausführungen sollten allerdings nur als erster Einstieg in das Thema angesehen werden. Viele Patterns können hier aus Platzgründen gar nicht erwähnt werden, obwohl sie in der Praxis einen hohen Stellenwert haben (z.B. Adapter, Bridge, Mediator, Command etc.). Zudem ist die Bedeutung eines Patterns für den OOP-Anfänger oft gar nicht verständlich, sondern erschließt sich erst nach Monaten oder Jahren zusätzlicher Programmiererfahrung.
Die folgenden Abschnitte ersetzen also nicht die Lektüre weiterführender Literatur zu diesem Thema. Das oben erwähnte Werk von Gamma et al. ist nach wie vor einer der Klassiker schlechthin (die Autoren und ihr Buch werden meist als "GoF" bezeichnet, ein Akronym für "Gang of Four"). Daneben existieren auch spezifische Kataloge, in denen die Design-Patterns zu bestimmten Anwendungsgebieten oder auf der Basis einer ganz bestimmten Sprache, wie etwa C++ oder Java, beschrieben werden.
Ein Singleton ist eine Klasse, von der nur ein einziges Objekt erzeugt werden darf. Es stellt eine globale Zugriffsmöglichkeit auf dieses Objekt zur Verfügung und instanziert es beim ersten Zugriff automatisch. Es gibt viele Beispiele für Singletons. So ist etwa der Spooler in einem Drucksystem ein Singleton oder der Fenstermanager unter Windows, der Firmenstamm in einem Abrechnungssystem oder die Übersetzungstabelle in einem Parser.
Wichtige Designmerkmale einer Singleton-Klasse sind:
Eine beispielhafte Implementierung könnte so aussehen:
001 public class Singleton 002 { 003 private static Singleton instance = null; 004 005 public static Singleton getInstance() 006 { 007 if (instance == null) { 008 instance = new Singleton(); 009 } 010 return instance; 011 } 012 013 private Singleton() 014 { 015 } 016 } |
Singleton.java |
Singletons sind oft nützlich, um den Zugriff auf statische Variablen zu kapseln und ihre Instanzierung zu kontrollieren. Da in der vorgestellten Implementierung das Singleton immer an einer statischen Variable hängt, ist zu beachten, daß es während der Laufzeit des Programms nie an den Garbage Collector zurückgegeben und der zugeordnete Speicher freigegeben wird. Dies gilt natürlich auch für weitere Objekte, auf die von diesem Objekt verwiesen wird.
Manchmal begegnet man Klassen, die zwar nicht auf eine einzige, aber doch auf sehr wenige Instanzen beschränkt sind. Auch bei solchen "relativen Singletons", "Fewtons" oder "Oligotons" (Achtung, Wortschöpfungen des Autors) kann es sinnvoll sein, ihre Instanzierung wie zuvor beschrieben zu kontrollieren. Mitunter darf beispielsweise für eine Menge unterschiedlicher Kategorien jeweils nur eine Instanz pro Kategorie erzeugt werden (etwa ein Objekt der Klasse Uebersetzer je unterstützter Sprache). Dann müßten lediglich die getInstance-Methode parametrisiert und die erzeugten Instanzen anstelle einer einfachen Variable in einer statischen Hashtable gehalten werden (siehe Abschnitt 14.4). |
|
Als immutable (unveränderlich) bezeichnet man Objekte, die nach ihrer Instanzierung nicht mehr verändert werden können. Ihre Membervariablen werden im Konstruktor oder in Initialisierern gesetzt und danach ausschließlich im lesenden Zugriff verwendet. Unveränderliche Objekte gibt es an verschiedenen Stellen in der Java-Klassenbibliothek. Bekannte Beispiele sind die Klassen String (siehe Kapitel 11) oder die in Abschnitt 10.2 erläuterten Wrapper-Klassen. Unveränderliche Objekte können gefahrlos mehrfach referenziert werden und erfordern im Multithreading keinen Synchronisationsaufwand.
Wichtige Designmerkmale einer Immutable-Klasse sind:
Eine beispielhafte Implementierung könnte so aussehen:
001 public class Immutable 002 { 003 private int value1; 004 private String[] value2; 005 006 public Immutable(int value1, String[] value2) 007 { 008 this.value1 = value1; 009 this.value2 = (String[])value2.clone(); 010 } 011 012 public int getValue1() 013 { 014 return value1; 015 } 016 017 public String getValue2(int index) 018 { 019 return value2[index]; 020 } 021 } |
Immutable.java |
Durch Ableitung könnte ein unveränderliches Objekt wieder veränderlich werden. Zwar ist es der abgeleiteten Klasse nicht möglich, die privaten Membervariablen der Basisklasse zu verändern. Sie könnte aber ohne weiteres eigene Membervariablen einführen, die die Immutable-Kriterien verletzen. Nötigenfalls ist die Klasse als final zu deklarieren, um weitere Ableitungen zu verhindern. |
|
Ein Interface trennt die Beschreibung von Eigenschaften einer Klasse von ihrer Implementierung. Dabei ist es sowohl erlaubt, daß ein Interface von mehr als einer Klasse implementiert wird, als auch, daß eine Klasse mehrere Interfaces implementiert. In Java ist ein Interface ein fundamentales Sprachelement. Es wurde in Kapitel 9 ausführlich beschrieben und soll hier nur der Vollständigkeit halber aufgezählt werden. Für Details verweisen wir auf die dort gemachten Ausführungen.
Eine Factory ist ein Hilfsmittel zum Erzeugen von Objekten. Sie wird verwendet, wenn das Instanzieren eines Objekts mit dem new-Operator alleine nicht möglich oder sinnvoll ist - etwa weil das Objekt schwierig zu konstruieren ist oder aufwendig konfiguriert werden muß, bevor es verwendet werden kann. Manchmal müssen Objekte auch aus einer Datei, über eine Netzwerkverbindung oder aus einer Datenbank geladen werden, oder sie werden auf der Basis von Konfigurationsinformationen aus systemnahen Modulen generiert. Eine Factory wird auch dann eingesetzt, wenn die Menge der Klassen, aus denen Objekte erzeugbar sind, dynamisch ist und zur Laufzeit des Programms erweitert werden kann.
In diesen Fällen ist es sinnvoll, das Erzeugen neuer Objekte von einer Factory erledigen zu lassen. Wir wollen nachfolgend die drei wichtigsten Varianten einer Factory vorstellen.
Gibt es in einer Klasse, von der Instanzen erzeugt werden sollen, eine oder mehrere statische Methoden, die Objekte desselben Typs erzeugen und an den Aufrufer zurückgeben, so bezeichnet man diese als Factory-Methoden. Sie rufen implizit den new-Operator auf, um Objekte zu instanzieren, und führen alle Konfigurationen durch, die erforderlich sind, ein Objekt in der gewünschten Weise zu konstruieren.
Das Klassendiagramm für eine Factory-Methode sieht so aus:
Abbildung 10.1: Klassendiagramm einer Factory-Methode
Wir wollen beispielhaft die Implementierung einer Icon-Klasse skizzieren, die eine Factory-Methode loadFromFile enthält. Sie erwartet als Argument einen Dateinamen, dessen Erweiterung sie dazu verwendet, die Art des Ladevorgangs zu bestimmen. loadFromFile instanziert ein Icon-Objekt und füllt es auf der Basis des angegebenen Formats mit den Informationen aus der Datei:
001 public class Icon 002 { 003 private Icon() 004 { 005 //Verhindert das manuelle Instanzieren 006 } 007 008 public static Icon loadFromFile(String name) 009 { 010 Icon ret = null; 011 if (name.endsWith(".gif")) { 012 //Code zum Erzeugen eines Icons aus einer gif-Datei... 013 } else if (name.endsWith(".jpg")) { 014 //Code zum Erzeugen eines Icons aus einer jpg-Datei... 015 } else if (name.endsWith(".png")) { 016 //Code zum Erzeugen eines Icons aus einer png-Datei... 017 } 018 return ret; 019 } 020 } |
Icon.java |
Eine Klasse mit einer Factory-Methode hat große Ähnlichkeit mit der Implementierung des Singletons, die in Listing 10.6 vorgestellt wurde. Anders als beim Singleton kann allerdings nicht nur eine einzige Instanz erzeugt werden, sondern beliebig viele von ihnen. Auch merkt sich die Factory-Methode nicht die erzeugten Objekte. Die Singleton-Implementierung kann damit gewissermaßen als Spezialfall einer Klasse mit einer Factory-Methode angesehen werden. |
|
Eine Erweiterung des Konzepts der Factory-Methode ist die Factory-Klasse. Hier ist nicht eine einzelne Methode innerhalb der eigenen Klasse für das Instanzieren neuer Objekte zuständig, sondern es gibt eine eigenständige Klasse für diesen Vorgang. Das kann beispielsweise sinnvoll sein, wenn der Herstellungsvorgang zu aufwendig ist, um innerhalb der zu instanzierenden Klasse vorgenommen zu werden. Eine Factory-Klasse könnte auch sinnvoll sein, wenn es später erforderlich werden könnte, die Factory selbst austauschbar zu machen. Ein dritter Grund kann sein, daß es gar keine Klasse gibt, in der eine Factory-Methode untergebracht werden könnte. Das ist insbesondere dann der Fall, wenn unterschiedliche Objekte hergestellt werden sollen, die lediglich ein gemeinsames Interface implementieren.
Das Klassendiagramm für eine Factory-Klasse sieht so aus:
Abbildung 10.2: Klassendiagramm einer Factory-Klasse
Als Beispiel wollen wir noch einmal das Interface DoubleMethod aus Listing 9.13 aufgreifen. Wir wollen dazu eine Factory-Klasse DoubleMethodFactory entwickeln, die verschiedene Methoden zur Konstruktion von Objekten zur Verfügung stellt, die das Interface DoubleMethod implementieren:
001 public class DoubleMethodFactory 002 { 003 public DoubleMethodFactory() 004 { 005 //Hier wird die Factory selbst erzeugt und konfiguriert 006 } 007 008 public DoubleMethod createFromClassFile(String name) 009 { 010 //Lädt die Klassendatei mit dem angegebenen Namen, 011 //prüft, ob sie DoubleMethod implementiert, und 012 //instanziert sie gegebenenfalls... 013 return null; 014 } 015 016 public DoubleMethod createFromStatic(String clazz, 017 String method) 018 { 019 //Erzeugt ein Wrapper-Objekt, das das Interface 020 //DoubleMethod implementiert und beim Aufruf von 021 //compute die angegebene Methode der vorgegebenen 022 //Klasse aufruft... 023 return null; 024 } 025 026 public DoubleMethod createFromPolynom(String expr) 027 { 028 //Erzeugt aus dem angegebenen Polynom-Ausdruck ein 029 //DoubleMethod-Objekt, in dem ein äquivalentes 030 //Polynom implementiert wird... 031 return null; 032 } 033 } |
DoubleMethodFactory.java |
Die Anwendung einer Factory-Klasse ist hier sinnvoll, weil der Code zum Erzeugen der Objekte sehr aufwendig ist und weil Objekte geliefert werden sollen, die zwar ein gemeinsames Interface implementieren, aber aus sehr unterschiedlichen Vererbungshierarchien stammen können.
Aus Gründen der Übersichtlichkeit wurde das Erzeugen des Rückgabewerts im Beispielprogramm lediglich angedeutet. Anstelle von return null; würde in der vollständigen Implementierung natürlich der Code zum Erzeugen der jeweiligen DoubleMethod-Objekte stehen. |
|
Eine Abstracte Factory ist eine recht aufwendige Erweiterung der Factory-Klasse, bei der zwei zusätzliche Gedanken im Vordergrund stehen:
Eine abstrakte Factory wird auch als Toolkit bezeichnet. Ein Beispiel dafür findet sich in grafischen Ausgabesystemen bei der Erzeugung von Dialogelementen (Widgets) für unterschiedliche Fenstermanager. Eine konkrete Factory muß in der Lage sein, unterschiedliche Dialogelemente so zu erzeugen, daß sie in Aussehen und Bedienung konsistent sind. Auch die Schnittstelle für Programme sollte über Fenstergrenzen hinweg konstant sein. Konkrete Factories könnte es etwa für Windows, X-Window oder die Macintosh-Oberfläche geben.
Eine abstrakte Factory kann durch folgende Bestandteile beschrieben werden:
Das Klassendiagramm für eine abstrakte Factory sieht so aus:
Abbildung 10.3: Klassendiagramm einer abstrakten Factory
Das folgende Listing skizziert ihre Implementierung:
001 /* Listing1010.java */ 002 003 //------------------------------------------------------------------ 004 //Abstrakte Produkte 005 //------------------------------------------------------------------ 006 abstract class Product1 007 { 008 } 009 010 abstract class Product2 011 { 012 } 013 014 //------------------------------------------------------------------ 015 //Abstrakte Factory 016 //------------------------------------------------------------------ 017 abstract class ProductFactory 018 { 019 public abstract Product1 createProduct1(); 020 021 public abstract Product2 createProduct2(); 022 023 public static ProductFactory getFactory(String variant) 024 { 025 ProductFactory ret = null; 026 if (variant.equals("A")) { 027 ret = new ConcreteFactoryVariantA(); 028 } else if (variant.equals("B")) { 029 ret = new ConcreteFactoryVariantB(); 030 } 031 return ret; 032 } 033 034 public static ProductFactory getDefaultFactory() 035 { 036 return getFactory("A"); 037 } 038 } 039 040 //------------------------------------------------------------------ 041 //Konkrete Produkte für Implementierungsvariante A 042 //------------------------------------------------------------------ 043 class Product1VariantA 044 extends Product1 045 { 046 } 047 048 class Product2VariantA 049 extends Product2 050 { 051 } 052 053 //------------------------------------------------------------------ 054 //Konkrete Factory für Implementierungsvariante A 055 //------------------------------------------------------------------ 056 class ConcreteFactoryVariantA 057 extends ProductFactory 058 { 059 public Product1 createProduct1() 060 { 061 return new Product1VariantA(); 062 } 063 064 public Product2 createProduct2() 065 { 066 return new Product2VariantA(); 067 } 068 } 069 070 //------------------------------------------------------------------ 071 //Konkrete Produkte für Implementierungsvariante B 072 //------------------------------------------------------------------ 073 class Product1VariantB 074 extends Product1 075 { 076 } 077 078 class Product2VariantB 079 extends Product2 080 { 081 } 082 083 //------------------------------------------------------------------ 084 //Konkrete Factory für Implementierungsvariante B 085 //------------------------------------------------------------------ 086 class ConcreteFactoryVariantB 087 extends ProductFactory 088 { 089 public Product1 createProduct1() 090 { 091 return new Product1VariantB(); 092 } 093 094 public Product2 createProduct2() 095 { 096 return new Product2VariantB(); 097 } 098 } 099 100 //------------------------------------------------------------------ 101 //Beispielanwendung 102 //------------------------------------------------------------------ 103 public class Listing1010 104 { 105 public static void main(String[] args) 106 { 107 ProductFactory fact = ProductFactory.getDefaultFactory(); 108 Product1 prod1 = fact.createProduct1(); 109 Product2 prod2 = fact.createProduct2(); 110 } 111 } |
Listing1010.java |
Bemerkenswert an diesem Pattern ist, wie geschickt es die komplexen Details seiner Implementierung versteckt. Der Aufrufer kennt lediglich die Produkte, die abstrakte Factory und besitzt eine Möglichkeit, eine konkrete Factory zu beschaffen. Er braucht weder zu wissen, welche konkreten Factories oder Produkte es gibt, noch müssen ihn Details ihrer Implementierung interessieren. Diese Sichtweise verändert sich auch nicht, wenn eine neue Implementierungsvariante hinzugefügt wird. Das würde sich lediglich in einem neuen Wert im variant-Parameter der Methode getFactory der ProductFactory äußern.
Ein wenig mehr Aufwand muß allerdings getrieben werden, wenn ein neues Produkt hinzukommt. Dann müssen nicht nur neue abstrakte und konkrete Produktklassen definiert werden, sondern auch die Factories müssen um eine Methode erweitert werden.
Mit Absicht wurde bei der Benennung der abstrakten Klassen nicht die Vor- oder Nachsilbe "Abstract" verwendet. Da die Clients nur mit den Schnittstellen der abstrakten Klassen arbeiten und die Namen der konkreten Klassen normalerweise nie zu sehen bekommen, ist es vollkommen unnötig, sie bei jeder Deklaration daran zu erinnern, daß sie eigentlich nur mit Abstraktionen arbeiten. |
|
Auch in Java gibt es Klassen, die nach dem Prinzip der abstrakten Factory implementiert sind. Ein Beispiel ist die Klasse Toolkit des Pakets java.awt. Sie dient dazu, Fenster, Dialogelemente und andere plattformabhängige Objekte für die grafische Oberfläche eines bestimmten Betriebssystems zu erzeugen. In Abschnitt 24.2.2 finden sich ein paar Beispiele für die Anwendung dieser Klasse. |
|
Ein Iterator ist ein Objekt, das es ermöglicht, die Elemente eines Collection-Objekts nacheinander zu durchlaufen. Als Collection-Objekt bezeichnet man ein Objekt, das eine Sammlung (meist gleichartiger) Elemente eines anderen Typs enthält. In Java gibt es eine Vielzahl von vordefinierten Collections, sie werden in Kapitel 14 und Kapitel 15 ausführlich erläutert.
Obwohl die Objekte in den Collections unterschiedlich strukturiert und auf sehr unterschiedliche Art und Weise gespeichert sein können, ist es bei den meisten von ihnen früher oder später erforderlich, auf alle darin enthaltenen Elemente zuzugreifen. Dazu stellt die Collection einen oder mehrere Iteratoren zur Verfügung, die das Durchlaufen der Elemente ermöglichen, ohne daß die innere Struktur der Collection dem Aufrufer bekannt sein muß.
Ein Iterator enthält folgende Bestandteile:
Das Klassendiagramm für einen Iterator sieht so aus:
Abbildung 10.4: Klassendiagramm eines Iterators
Das folgende Listing zeigt die Implementierung eines Iterators, mit dem die Elemente der Klasse StringArray (die ein einfaches Array von Strings kapselt) durchlaufen werden können:
001 /* Listing1011.java */ 002 003 interface StringIterator 004 { 005 public boolean hasNext(); 006 public String next(); 007 } 008 009 class StringArray 010 { 011 String[] data; 012 013 public StringArray(String[] data) 014 { 015 this.data = data; 016 } 017 018 public StringIterator getElements() 019 { 020 return new StringIterator() 021 { 022 int index = 0; 023 public boolean hasNext() 024 { 025 return index < data.length; 026 } 027 public String next() 028 { 029 return data[index++]; 030 } 031 }; 032 } 033 } 034 035 public class Listing1011 036 { 037 static final String[] SAYHI = {"Hi", "Iterator", "Buddy"}; 038 039 public static void main(String[] args) 040 { 041 //Collection erzeugen 042 StringArray strar = new StringArray(SAYHI); 043 //Iterator beschaffen und Elemente durchlaufen 044 StringIterator it = strar.getElements(); 045 while (it.hasNext()) { 046 System.out.println(it.next()); 047 } 048 } 049 } |
Listing1011.java |
Der Iterator wurde in StringIterator als Interface realisiert, um in unterschiedlicher Weise implementiert werden zu können. Die Methode getElements erzeugt beispielsweise eine anonyme Klasse, die das Iterator-Interface implementiert und an den Aufrufer zurückgibt. Dazu wird in diesem Fall lediglich eine Hilfsvariable benötigt, die als Zeiger auf das nächste zu liefernde Element zeigt. Im Hauptprogramm wird nach dem Erzeugen der Collection der Iterator beschafft und mit seiner Hilfe die Elemente durch fortgesetzten Aufruf von hasNext und next sukzessive durchlaufen.
Die Implementierung eines Iterators erfolgt häufig mit Hilfe lokaler oder anonymer Klassen. Das hat den Vorteil, daß alle benötigten Hilfsvariablen je Aufruf angelegt werden. Würde die Klasse StringArray dagegen selbst das StringIterator-Interface implementieren (und die Hilfsvariable index als Membervariable halten), so könnte sie jeweils nur einen einzigen aktiven Iterator zur Verfügung stellen. |
|
Iteratoren können auch gut mit Hilfe von for-Schleifen
verwendet werden. Das folgende Programmfragment ist äquivalent
zum vorigen Beispiel:
|
|
In objektorientierten Programmiersprachen gibt es zwei grundverschiedene Möglichkeiten, Programmcode wiederzuverwenden. Die erste von ihnen ist die Vererbung, bei der eine abgeleitete Klasse alle Eigenschaften ihrer Basisklasse erbt und deren nicht-privaten Methoden aufrufen kann. Die zweite Möglichkeit wird als Delegation bezeichnet. Hierbei verwendet eine Klasse die Dienste von Objekten, aus denen sie nicht abgeleitet ist. Diese Objekte werden oft als Membervariablen gehalten.
Das wäre an sich noch nichts Besonderes, denn Programme verwenden fast immer Code, der in anderen Programmteilen liegt, und delegieren damit einen Teil ihrer Aufgaben. Ein Designpattern wird daraus, wenn Aufgaben weitergegeben werden müssen, die eigentlich in der eigenen Klasse erledigt werden sollten. Wenn also der Leser des Programmes später erwarten würde, den Code in der eigenen Klasse vorzufinden. In diesem Fall ist es sinnvoll, die Übertragung der Aufgaben explizit zu machen und das Delegate-Designpattern anzuwenden.
Anwendungen für das Delegate-Pattern finden sich meist, wenn identische Funktionalitäten in Klassen untergebracht werden sollen, die nicht in einer gemeinsamen Vererbungslinie stehen. Ein Beispiel bilden die Klassen JFrame und JInternalFrame aus dem Swing-Toolkit (sie werden in Kapitel 36 ausführlich besprochen). Beide Klassen stellen Hauptfenster für die Grafikausgabe dar. Eines von ihnen ist ein eigenständiges Top-Level-Window, das andere wird meist zusammen mit anderen Fenstern in ein Desktop eingebettet. Soll eine Anwendung wahlweise in einem JFrame oder einem JInternalFrame laufen, müssen alle Funktionalitäten in beiden Klassen zur Verfügung gestellt werden. Unglücklicherweise sind beide nicht Bestandteil einer gemeinsamen Vererbungslinie. Hier empfiehlt es sich, die Gemeinsamkeiten in einer neuen Klasse zusammenzufassen und beiden Fensterklassen durch Delegation zur Verfügung zu stellen. |
|
Das Delegate-Pattern besitzt folgende Bestandteile:
Das Klassendiagramm für ein Delegate sieht so aus:
Abbildung 10.5: Klassendiagramm eines Delegates
Eine Implementierungsskizze könnte so aussehen:
001 /* Listing1012.java */ 002 003 class Delegate 004 { 005 private Delegator delegator; 006 007 public Delegate(Delegator delegator) 008 { 009 this.delegator = delegator; 010 } 011 012 public void service1() 013 { 014 } 015 016 public void service2() 017 { 018 } 019 } 020 021 interface Delegator 022 { 023 public void commonDelegatorServiceA(); 024 public void commonDelegatorServiceB(); 025 } 026 027 class Client1 028 implements Delegator 029 { 030 private Delegate delegate; 031 032 public Client1() 033 { 034 delegate = new Delegate(this); 035 } 036 037 public void service1() 038 { 039 //implementiert einen Service und benutzt 040 //dazu eigene Methoden und die des 041 //Delegate-Objekts 042 } 043 044 public void commonDelegatorServiceA() 045 { 046 } 047 048 public void commonDelegatorServiceB() 049 { 050 } 051 } 052 053 class Client2 054 implements Delegator 055 { 056 private Delegate delegate; 057 058 public Client2() 059 { 060 delegate = new Delegate(this); 061 } 062 063 public void commonDelegatorServiceA() 064 { 065 } 066 067 public void commonDelegatorServiceB() 068 { 069 } 070 } 071 072 public class Listing1012 073 { 074 public static void main(String[] args) 075 { 076 Client1 client = new Client1(); 077 client.service1(); 078 } 079 } |
Listing1012.java |
Die Klasse Delegate implementiert die Methoden service1 und service2. Zusätzlich hält sie einen Verweis auf ein Delegator-Objekt, über das sie die Callback-Methoden commonDelegatorServiceA und commonDelegatorServiceB der delegierenden Klasse erreichen kann. Die beiden Klassen Client1 und Client2 verwenden das Delegate, um Services zur Verfügung zu stellen (am Beispiel der Methode service1 angedeutet).
In der Programmierpraxis werden häufig Datenstrukturen benötigt, bei denen die einzelnen Objekte zu Baumstrukturen zusammengesetzt werden können.
Es gibt viele Beispiele für derartige Strukturen:
Für diese häufig anzutreffende Abstraktion gibt es ein Design-Pattern, das als Composite bezeichnet wird. Es ermöglicht derartige Kompositionen und erlaubt eine einheitliche Handhabung von individuellen und zusammengesetzten Objekten. Ein Composite enthält folgende Bestandteile:
Somit sind beide Bedingungen erfüllt. Der Container ermöglicht die Komposition der Objekte zu Baumstrukturen, und die Basisklasse stellt die einheitliche Schnittstelle für elementare Objekte und Container zur Verfügung. Das Klassendiagramm für ein Composite sieht so aus:
Abbildung 10.6: Klassendiagramm eines Composite
Das folgende Listing skizziert dieses Design-Pattern am Beispiel einer einfachen Menüstruktur:
001 /* Listing1013.java */ 002 003 class MenuEntry1 004 { 005 protected String name; 006 007 public MenuEntry1(String name) 008 { 009 this.name = name; 010 } 011 012 public String toString() 013 { 014 return name; 015 } 016 } 017 018 class IconizedMenuEntry1 019 extends MenuEntry1 020 { 021 private String iconName; 022 023 public IconizedMenuEntry1(String name, String iconName) 024 { 025 super(name); 026 this.iconName = iconName; 027 } 028 } 029 030 class CheckableMenuEntry1 031 extends MenuEntry1 032 { 033 private boolean checked; 034 035 public CheckableMenuEntry1(String name, boolean checked) 036 { 037 super(name); 038 this.checked = checked; 039 } 040 } 041 042 class Menu1 043 extends MenuEntry1 044 { 045 MenuEntry1[] entries; 046 int entryCnt; 047 048 public Menu1(String name, int maxElements) 049 { 050 super(name); 051 this.entries = new MenuEntry1[maxElements]; 052 entryCnt = 0; 053 } 054 055 public void add(MenuEntry1 entry) 056 { 057 entries[entryCnt++] = entry; 058 } 059 060 public String toString() 061 { 062 String ret = "("; 063 for (int i = 0; i < entryCnt; ++i) { 064 ret += (i != 0 ? "," : "") + entries[i].toString(); 065 } 066 return ret + ")"; 067 } 068 } 069 070 public class Listing1013 071 { 072 public static void main(String[] args) 073 { 074 Menu1 filemenu = new Menu1("Datei", 5); 075 filemenu.add(new MenuEntry1("Neu")); 076 filemenu.add(new MenuEntry1("Laden")); 077 filemenu.add(new MenuEntry1("Speichern")); 078 079 Menu1 confmenu = new Menu1("Konfiguration", 3); 080 confmenu.add(new MenuEntry1("Farben")); 081 confmenu.add(new MenuEntry1("Fenster")); 082 confmenu.add(new MenuEntry1("Pfade")); 083 filemenu.add(confmenu); 084 085 filemenu.add(new MenuEntry1("Beenden")); 086 087 System.out.println(filemenu.toString()); 088 } 089 } |
Listing1013.java |
Die Komponentenklasse hat den Namen MenuEntry1. Sie repräsentiert Menüeinträge und ist Vaterklasse der spezialisierteren Menüeinträge IconizedMenuEntry1 und CheckableMenuEntry1. Zudem ist sie Vaterklasse des Containers Menu1, der Menüeinträge aufnehmen kann.
Bestandteil der gemeinsamen Schnittstelle ist die Methode toString. In der Basisklasse und den elementaren Menüeinträgen liefert sie lediglich den Namen des Objekts. In der Containerklasse wird sie überlagert und liefert eine geklammerte Liste aller darin enthaltenen Menüeinträge. Dabei arbeitet sie unabhängig davon, ob es sich bei dem jeweiligen Eintrag um einen elementaren oder einen zusammengesetzten Eintrag handelt, denn es wird lediglich die immer verfügbare Methode toString aufgerufen.
Das Testprogramm erzeugt ein "Datei"-Menü mit einigen Elementareinträgen
und einem Untermenü "Konfiguration" und gibt es auf Standardausgabe
aus:
(Neu,Laden,Speichern,(Farben,Fenster,Pfade),Beenden)
Das vorige Pattern hat gezeigt, wie man komplexe Datenstrukturen mit einer inhärenten Teile-Ganzes-Beziehung aufbaut. Solche Strukturen müssen oft auf unterschiedliche Arten durchlaufen und verarbeitet werden. Ein Menü muß beispielsweise auf dem Bildschirm angezeigt werden, aber es kann auch die Gliederung für einen Teil eines Benutzerhandbuchs zur Verfügung stellen. Verzeichnisse in einem Dateisystem müssen nach einem bestimmten Namen durchsucht werden, die kumulierte Größe ihrer Verzeichnisse und Unterverzeichnisse soll ermittelt werden, oder es sollen alle Dateien eines bestimmten Typs gelöscht werden können.
All diese Operationen erfordern einen flexiblen Mechanismus zum Durchlaufen und Verarbeiten der Datenstruktur. Natürlich könnte man die einzelnen Bestandteile jeder Operation in den Komponenten- und Containerklassen unterbringen, aber dadurch würden diese schnell unübersichtlich, und für jede neu hinzugefügte Operation müßten alle Klassen geändert werden.
Das Visitor-Pattern zeigt einen eleganteren Weg, Datenstrukturen mit Verarbeitungsalgorithmen zu versehen. Es besteht aus folgenden Teilen:
Das Klassendiagramm für einen Visitor sieht so aus:
Abbildung 10.7: Klassendiagramm eines Visitors
Das folgende Listing erweitert das Composite des vorigen Abschnitts um einen Visitor-Mechanismus:
001 /* Listing1014.java */ 002 003 interface MenuVisitor 004 { 005 abstract void visitMenuEntry(MenuEntry2 entry); 006 abstract void visitMenuStarted(Menu2 menu); 007 abstract void visitMenuEnded(Menu2 menu); 008 } 009 010 class MenuEntry2 011 { 012 protected String name; 013 014 public MenuEntry2(String name) 015 { 016 this.name = name; 017 } 018 019 public String toString() 020 { 021 return name; 022 } 023 024 public void accept(MenuVisitor visitor) 025 { 026 visitor.visitMenuEntry(this); 027 } 028 } 029 030 class Menu2 031 extends MenuEntry2 032 { 033 MenuEntry2[] entries; 034 int entryCnt; 035 036 public Menu2(String name, int maxElements) 037 { 038 super(name); 039 this.entries = new MenuEntry2[maxElements]; 040 entryCnt = 0; 041 } 042 043 public void add(MenuEntry2 entry) 044 { 045 entries[entryCnt++] = entry; 046 } 047 048 public String toString() 049 { 050 String ret = "("; 051 for (int i = 0; i < entryCnt; ++i) { 052 ret += (i != 0 ? "," : "") + entries[i].toString(); 053 } 054 return ret + ")"; 055 } 056 057 public void accept(MenuVisitor visitor) 058 { 059 visitor.visitMenuStarted(this); 060 for (int i = 0; i < entryCnt; ++i) { 061 entries[i].accept(visitor); 062 } 063 visitor.visitMenuEnded(this); 064 } 065 } 066 067 class MenuPrintVisitor 068 implements MenuVisitor 069 { 070 String indent = ""; 071 072 public void visitMenuEntry(MenuEntry2 entry) 073 { 074 System.out.println(indent + entry.name); 075 } 076 077 public void visitMenuStarted(Menu2 menu) 078 { 079 System.out.println(indent + menu.name); 080 indent += " "; 081 } 082 083 public void visitMenuEnded(Menu2 menu) 084 { 085 indent = indent.substring(1); 086 } 087 } 088 089 public class Listing1014 090 { 091 public static void main(String[] args) 092 { 093 Menu2 filemenu = new Menu2("Datei", 5); 094 filemenu.add(new MenuEntry2("Neu")); 095 filemenu.add(new MenuEntry2("Laden")); 096 filemenu.add(new MenuEntry2("Speichern")); 097 Menu2 confmenu = new Menu2("Konfiguration", 3); 098 confmenu.add(new MenuEntry2("Farben")); 099 confmenu.add(new MenuEntry2("Fenster")); 100 confmenu.add(new MenuEntry2("Pfade")); 101 filemenu.add(confmenu); 102 filemenu.add(new MenuEntry2("Beenden")); 103 104 filemenu.accept(new MenuPrintVisitor()); 105 } 106 } |
Listing1014.java |
Das Interface MenuVisitor stellt den abstrakten Visitor für Menüeinträge dar. Die Methode visitMenuEntry wird bei jedem Durchlauf eines MenuEntry2-Objekts aufgerufen; die Methoden visitMenuStarted und visitMenuEnded zu Beginn und Ende des Besuchs eines Menu2-Objekts. In der Basisklasse MenuItem ruft accept die Methode visitMenuEntry auf. Für die beiden abgeleiteten Elementklassen IconizedMenuEntry und CheckableMenuEntry gibt es keine Spezialisierungen; auch für diese Objekte wird visitMenuEntry aufgerufen. Lediglich der Container Menu2 verfeinert den Aufruf und unterteilt ihn in drei Schritte. Zunächst wird visitMenuStarted aufgerufen, um anzuzeigen, daß ein Menüdurchlauf beginnt. Dann werden die accept-Methoden aller Elemente aufgerufen, und schließlich wird durch Aufruf von visitMenuEnded das Ende des Menüdurchlaufs angezeigt.
Der konkrete Visitor MenuPrintVisitor
hat die Aufgabe, ein Menü mit allen Elementen zeilenweise und
entsprechend der Schachtelung seiner Untermenüs eingerückt
auszugeben. Die letzte Zeile des Beispielprogramms zeigt, wie er verwendet
wird. Die Ausgabe des Programms ist:
Datei
Neu
Laden
Speichern
Konfiguration
Farben
Fenster
Pfade
Beenden
In Abschnitt 21.4.1 zeigen wir eine weitere Anwendung des Visitor-Patterns. Dort wird eine generische Lösung für den rekursiven Durchlauf von geschachtelten Verzeichnisstrukturen vorgestellt. |
|
Bei der objektorientierten Programmierung werden Programme in viele kleine Bestandteile zerlegt, die für sich genommen autonom arbeiten. Mit zunehmender Anzahl von Bausteinen steigt allerdings der Kommunikationsbedarf zwischen diesen Objekten, und der Aufwand, sie konsistent zu halten, wächst an.
Ein Observer ist ein Design-Pattern, das eine Beziehung zwischen einem Subject und seinen Beobachtern aufbaut. Als Subject wird dabei ein Objekt bezeichnet, dessen Zustandsänderung für andere Objekte interessant ist. Als Beobachter werden die Objekte bezeichnet, die von Zustandsänderungen des Subjekts abhängig sind; deren Zustand also dem Zustand des Subjekts konsistent folgen muß.
Das Observer-Pattern wird sehr häufig bei der Programmierung grafischer Oberflächen angewendet. Ist beispielsweise die Grafikausgabe mehrerer Fenster von einer bestimmten Datenstruktur abhängig, so müssen die Fenster ihre Ausgabe verändern, wenn die Datenstruktur sich ändert. Auch Dialogelemente wie Buttons, Auswahlfelder oder Listen müssen das Programm benachrichtigen, wenn der Anwender eine Veränderung an ihnen vorgenommen hat. In diesen Fällen kann das Observer-Pattern angewendet werden. Es besteht aus folgenden Teilen:
Das Klassendiagramm für einen Observer sieht so aus:
Abbildung 10.8: Klassendiagramm eines Observers
Das folgende Listing zeigt eine beispielhafte Implementierung:
001 /* Listing1015.java */ 002 003 interface Observer 004 { 005 public void update(Subject subject); 006 } 007 008 class Subject 009 { 010 Observer[] observers = new Observer[5]; 011 int observerCnt = 0; 012 013 public void attach(Observer observer) 014 { 015 observers[observerCnt++] = observer; 016 } 017 018 public void detach(Observer observer) 019 { 020 for (int i = 0; i < observerCnt; ++i) { 021 if (observers[i] == observer) { 022 --observerCnt; 023 for (;i < observerCnt; ++i) { 024 observers[i] = observers[i + 1]; 025 } 026 break; 027 } 028 } 029 } 030 031 public void fireUpdate() 032 { 033 for (int i = 0; i < observerCnt; ++i) { 034 observers[i].update(this); 035 } 036 } 037 } 038 039 class Counter 040 { 041 int cnt = 0; 042 Subject subject = new Subject(); 043 044 public void attach(Observer observer) 045 { 046 subject.attach(observer); 047 } 048 049 public void detach(Observer observer) 050 { 051 subject.detach(observer); 052 } 053 054 public void inc() 055 { 056 if (++cnt % 3 == 0) { 057 subject.fireUpdate(); 058 } 059 } 060 } 061 062 public class Listing1015 063 { 064 public static void main(String[] args) 065 { 066 Counter counter = new Counter(); 067 counter.attach( 068 new Observer() 069 { 070 public void update(Subject subject) 071 { 072 System.out.print("divisible by 3: "); 073 } 074 } 075 ); 076 while (counter.cnt < 10) { 077 counter.inc(); 078 System.out.println(counter.cnt); 079 } 080 } 081 } |
Listing1015.java |
Als konkretes Subjekt wird hier die Klasse Counter
verwendet. Sie erhöht bei jedem Aufruf von inc
den eingebauten Zähler um eins und informiert alle registrierten
Beobachter, falls der neue Zählerstand durch drei teilbar ist.
Im Hauptprogramm instanzieren wir ein Counter-Objekt
und registrieren eine lokale anonyme Klasse als Listener, die bei
jeder Benachrichtigung eine Meldung ausgibt. Während des anschließenden
Zählerlaufs von 1 bis 10 wird sie dreimal aufgerufen:
1
2
divisible by 3: 3
4
5
divisible by 3: 6
7
8
divisible by 3: 9
10
Das Observer-Pattern ist in Java sehr verbreitet, denn die Kommunikation zwischen graphischen Dialogelementen und ihrer Anwendung basiert vollständig auf dieser Idee. Allerdings wurde es etwas erweitert, die Beobachter werden als Listener bezeichnet, und es gibt von ihnen eine Vielzahl unterschiedlicher Typen mit unterschiedlichen Aufgaben. Da es zudem üblich ist, daß ein Listener sich bei mehr als einem Subjekt registriert, wird ein Aufruf von update statt des einfachen Arguments jeweils ein Listener-spezifisches Ereignisobjekt übergeben. Darin werden neben dem Subjekt weitere spezifische Informationen untergebracht. Zudem haben die Methoden gegenüber der ursprünglichen Definition eine andere Namensstruktur, und es kann sein, daß ein Listener nicht nur eine, sonderen mehrere unterschiedliche Update-Methoden zur Verfügung stellen muß, um auf unterschiedliche Ereignistypen zu reagieren. Das Listener-Konzept von Java wird auch als Delegation Based Event Handling bezeichnet und in Kapitel 28 ausführlich erläutert. |
|
Titel | Inhalt | Suchen | Index | API | Go To Java 2, Zweite Auflage, Addison Wesley, Version 2.0 |
<< | < | > | >> | © 2000 Guido Krüger, http://www.gkrueger.com |