TU Wien:Fortgeschrittene objektorientierte Programmierung VU (Puntigam)/Mitschrift SS17

Aus VoWi
Zur Navigation springen Zur Suche springen

Alle Angaben sind ohne Gewähr von Richtigkeit und Vollständigkeit. Bitte ergänzen wenn möglich.

Ersetzbarkeit und Zusicherungen[Bearbeiten | Quelltext bearbeiten]

Ersetzbarkeitsprinzip[Bearbeiten | Quelltext bearbeiten]

S ist ein Untertyp von T genau dann, wenn eine Instanz von S verwendbar ist wo eine instanz von T erwartet wird. (Achtung: es geht hier um das Objekt eines Typs, nicht um den Typ selber!)

Das Ersetzbarkeitsprinzip kann man formal überprüfen. Es muss folgendes gelten damit dieses Prinzip erfüllt ist:

  • Typen von Eingangsparametern müssen kontravariant sein. Die Typen variieren in die gegengesetzte Richtung der Typhierarchie. Im Untertyp steht also ein Obertyp. Das ist übrigens das einzige, was kontravariant sein darf in einer Sprache.
  • Variablen und Durchgangsparameter müssen invariant sein (die Typen bleiben also gleich)
  • Typen von Ausgangsparametern müssen kovariant sein. Sie variieren also in dieselbe Richtung.
  • Methoden im Untertyp müssen sich wie die Methoden im Obertyp verhalten. Leider stellt sich heraus, dass das oft problematisch wird, da man Methoden überschreiben kann. Man muss irgendwie zeigen, dass sich die Methoden dann immer noch gleich verhalten.

Ersetzbarkeit und Verhalten[Bearbeiten | Quelltext bearbeiten]

Für dieses Problem gibt es verschiedene Lösungsansätze. Eine Methode im Untertyp S zeigt das gleiche Verhalten wie die im Obertyp T wenn:

  • im besten Fall die Methode einfach nicht überschrieben wird, was oft aber nicht reicht
  • man sich auf die Intuition verlässt. Das spielt in der objektorientierten Modellierung eine wichtige Rolle. Wir haben ein Gefühl dafür, dass Dinge sich gleich verhalten. Beispielsweise verhält sich ein Auto gleich wie ein Fahrzeug, einfach weil wir es aus der realen Welt so kennen, ohne dass wir das irgendwie beweisen müssen. Diese Intuition kann aber auch oft täuschen. Oft ist sie richtig, aber manchmal eben nicht.
  • man sich absichern kann durch Zusicherungen (formale Basis dafür: Design by Contract). Sie beschreiben das Verhalten, zusätzlich zu dem was wir intuitiv haben. Diese Beschreibungen müssen im Ober- und Untertyp zusammenpassen:
    • Wenn der Client verantwortlich ist für die Einhaltung der Zusicherungen, dann dürfen diese im Untertyp nur schwächer werden oder gleich bleiben. Das sind hauptsächlich Vorbedingungen, es gibt aber auch Ausnahmen.
    • Wenn der Server verantwortlich ist, dann dürfen Zusicherungen nur stärker werden oder gleich bleiben im Untertyp. Das betrifft vor allem die Nachbedingungen, und auch die Invarianten, und vielleicht auch irgendwelche andere Dinge auch.
    • Wenn Client und Server verantwortlich sind, müssen die Zusicherungen gleich bleiben. Das sind Arten von Invarianten.
    • Die Methode im Untertyp darf nicht mehr Exceptions werfen als die im Obertyp. Hier geht es darum, dass Exceptions in den gleichen Situationen geworfen werfen, sowohl im Obertyp als auch im Untertyp, wie es durch die Zusicherungen beschrieben wird.
  • man Protokolle beschreibt. Ein Objekt kommuniziert also mit einem anderen Objekt über solche Protokolle, die formal beschrieben werden. Das Problem ist aber, dass es schwierig ist, diese Protokolle zueinander in Beziehung zu setzen, also auf Kompatibilität zu prüfen. Dieses Problem ist nicht entscheidbar. Es gibt aber Möglichkeiten durch Approximationen Entscheidbarkeit wieder zu erreichen, z.B. durch die Verwendung von Namen. Stand der Dinge sind aber trotzdem Zusicherungen heute.

Zusicherungen in der Praxis[Bearbeiten | Quelltext bearbeiten]

Tatsächlich sind die obigen Einschränkungen sehr restriktiv, weswegen es auch in der Praxis fast nie wirkliche Vererbung gibt. Meistens haben wir Code nur ganz unten in unserer Typhierarchie, ab und zu eine Methode die geerbt werden kann, dann aber in der Regel nicht überschrieben wird.

Zusicherungen kann man in der Theorie immer formal ausdrücken (über Logik, Algebren, …). Man kann sogar Beziehungen zwischen Zusicherungen statisch prüfen:

  • Gleichheit: wir prüfen ob die Ausdrücke syntaktisch gleich sind
  • Stärker werden: und-Verknüpfen
  • Schwächer werden: oder-Verknüpfen

In der Praxis schaut es jedoch anders aus:

  • Wir erleben immer wieder, dass die Sprache dafür zu schwach ist. Das Problem ist also nicht, dass wir Zusicherungen nicht ausdrücken könnten. Stattdessen werden Kommentare verwendet. Leider sind diese oft mehrdeutig. Aber genau weil wir mehrdeutig sein können, bevorzugen wir umgangssprachliche Beschreibungen. Dadurch lassen sich beispielsweise komplexe Zusammenhänge intuitiver ausdrücken. Wenn man es formal machen würde (z.B mit Hilfe der Logik), wäre das für den Entwickler komplex zu verstehen oder sogar völlig unverständlich.
  • Es gibt dann eklatante Widersprüche in OOP-Konzepten selbst. Ein Beispiel dafür ist data hiding, wo man Zugriffe von außen schützen bzw verstecken will. Oft werden aber Details versteckt, die gebraucht werden, um zu überprüfen, ob Zusicherungen passen oder nicht. In der Regel sind davon die Vorbedingungen betroffen.
  • Zustand eines Objekts ändert sich oft. Diese Änderung ist nicht immer vorhersehbar. Man weiß nicht wie und wann sich der Zustand ändert. Wenn der Client eine Nachricht an ein Objekt schickt, sieht der Ablauf wie folgt aus:
    Zeitpunkt 1: er überprüft ob die Vorbedingung erfüllt ist
    Zeitpunkt 2: er ruft die Methode auf
    Jetzt kann es aber sein, dass genau nach Zeitpunkt 1 aber noch vor Zeitpunkt 2 der Zustand des Objekts (z.B. durch Concurrency, Aliasing) so geändert wurde, sodass die Vorbedingung nicht mehr passt. Trotzdem wird die Methode aufgerufen.

Gegen diese Probleme könnte man folgendes tun:

  • Aliasing verhindern: das ist aber sehr teuer für die Sprache, schränkt vieles für den Entwickler ein, ist also keine so gute Lösung. Manche Programmiersprachen unterstützen das.
  • Überall atomare Aktionen: Synchronisation heißt wir verzichten auf Nebenläufigkeit.
  • Unerwartete Dinge vermeiden. Einfach nichts unerwartetes machen. Man soll sich normal verhalten, so wie es die anderen erwarten würden. Beispielsweise sollte man keine Threads im Konstruktor aufspannen. Wir können also nicht erwarten, dass durch Zusicherungen alles abgedeckt werden kann, wenn wir schlechte Software schreiben. Das Ziel sollte sein, Software so zu schreiben, dass man überhaupt keine Zusicherungen braucht. Ein gutes Beispiel dafür sind die Java-APIs.

History constraints[Bearbeiten | Quelltext bearbeiten]

History Constraints sind eine weitere Form von Zusicherungen die über die klassischen Zusicherungen hinausgehen und ziemlich viel abdecken. Sie werden dann benutzt, wenn die klassischen Zusicherungen zu schwach sind. History Constraints beschreiben die historische Entwicklung, also die Änderungen der Zustände. Wenn es um die geschichtliche Entwicklung geht, ist es nicht mehr so eindeutig, was schwächer oder stärker werden heißt. Beispiele:

  • Der Wert eines Counters, der sich über die Zeit nur erhöhen kann. Für diese Bedingung ist der Server zuständig. History Constraints die vom Server kontrolliert werden ähneln Invarianten. Sie können im Untertyp restriktiver werden. Man kann im Untertyp also mehr Zustandsänderungen ausschließen.
  • Die Methoden lock() und unlock(): unlock() darf man nur dann aufrufen, wenn man vorher schon lock() aufgerufen hat. In diesem Fall wäre der Client für diese Bedingung verantwortlich. Ein Untertyp kann mehr Aufrufreihenfolgen erlauben als der Obertyp.

Empfehlungen und Fazit[Bearbeiten | Quelltext bearbeiten]

  • Niemals versuchen alles mögliche abzusichern. Das ist zu teuer
  • Einschätzbarkeit: die anderen sollen verstehen was man mit dem Code machen kann, Tricks vermeiden
  • Sich an Design Rules halten um einschätzbar zu bleiben
  • Versuchen alles was nötig ist um den Code zu verstehen als Zusicherung hinzuschreiben
  • Unnötige Abhängigkeiten vermeiden


Bedeutung von Namen[Bearbeiten | Quelltext bearbeiten]

  • Namen sind Abstraktionen, die das (nach außen sichtbare) Verhalten eines Objekts, einer Methode, usw beschreiben. Sie ähneln also Zusicherungen.
  • Namen unterstützen Intuition. Wir möchten auf dieser Ebene programmieren. Gute Programme sind dadurch ausgezeichnet, dass durch die Namen alleine alles verständlich ist und keine Zusicherungen oder Kommentare nötig sind. Deswegen sind Namen sehr wichtig.
  • Wenn jemand nicht auf gute Namen achtet, dann wird man dem Code eher nicht trauen. Es ist also oft eine Vertrauenssache.
  • Namen haben nicht nur für den Programmierer eine Bedeutung, sondern auch für den Compiler.

Nominale und strukturelle Typen[Bearbeiten | Quelltext bearbeiten]

Wir unterscheiden zwischen strukturellen und nominalen Typen.

  • Ein nominaler Typ ist einfach ein Typ der einen Namen hat. Wir sprechen den Typ also über den Namen an. Zwei nominale Typen sind gleich, wenn sie den gleichen Namen haben.
  • Zwei Typen sind auch gleich, wenn sie dieselbe Struktur haben. In diesem Fall spricht man von strukturellen Typen.

In C sind structs nominale Typen, alle anderen Typen sind strukturell. D.h. wenn wir 2 verschiedene structs definieren die gleich ausschauen (natürlich haben sie verschiedene Namen), werden sie trotzdem als unterschiedlich angesehen.

  • Bei einer strukturellen Untertypbeziehung (engl.: structural subtyping) braucht man nicht explizit angeben, dass ein struktureller Typ Untertyp eines anderen ist, weil eben die Beziehung aus der Struktur alleine hervorgeht. Aber es gibt hier ein Problem: Das Verhalten einer Methode die eine andere überschreibt wird nicht berücksichtigt. Das heißt es könnte auch nur zufällige strukturellen Untertypbeziehungen geben.
  • Bei nominalen Typen muss sich der Programmierer darum kümmern, dass die Methoden zusammenpassen.

Die ganze Theorie in OOP beruht auf strukturellen Typen, während in der Praxis nominale Typen verwendet werden. Das ist kein Problem, da man aus einem strukturellen Typ einen nominalen Typ machen kann (durch Hinzufügen von Methoden). Allerdings muss man hier aufpassen, da so auch eine nicht wirkliche Untertypbeziehung gefaked werden könnte. Vergleich zwischen nominalen und strukturellen Typen:

  • Wie gesagt, Subtyping ist bei strukturellen Typen implizit.
  • Die Verwendung von nominalen Typen ist schwieriger als die von strukturellen Typen.
  • Auch das Zusammenfügen von Komponenten ist mit strukturellen Typen einfacher.
  • Namenskonflikte sind bei nominalen Typen wahrscheinlicher.
  • Zufällige Untertypbeziehungen sind unwahrscheinlicher bei nominalen Typen. Das ist wohl der Hauptgrund, warum diese sich durchgesetzt haben.
  • Außerdem sind nominale Typen lesbarer und ermöglichen auch eine Abstraktion des Verhaltens. (2. guter Grund). Die Tatsache, dass etwas einen Namen hat, bedeutet, dass diese Namen mit irgendwelchen Zusicherungen verknüpft sind. Es nützt uns nichts Zusicherungen für strukturelle Typen zu schreiben, da sie sofort umgangen werden können. Zusicherungen und dynamische Typen sind deswegen eine komplizierte Geschichte.
  • Nominale Typen sichern auch Eigenschaften zu.
  • Bei gebundener Generizität würde man für die Schranken gerne strukturelle Typen haben. Bei nominalen Typen ist das unnötig kompliziert. Zum Beispiel muss man immer Comparable implementieren, obwohl man eigentlich nur die methode compareTo braucht.
  • Mit strukturierten Typen kann man ganz leicht neue Obertypen einführen.


Gebundene Generizität[Bearbeiten | Quelltext bearbeiten]

  • F-Gebundene Generizität(Java): S ist ein Untertyp von T(S). Über diese Formel können wir binäre Methoden einführen. Das Problem ist hier aber, dass das nur einmal funktioniert.
  • High Order Subtyping(C++, Haskell): für alle U gilt S(U) ist ein Untertyp von T(U). Unterstützt auch binäre Methoden, aber es erfüllt nicht das Ersetzbarkeitsprinzip. Praktisch wichtig weil man Matching verwenden kann statt Subtyping.


Implementierung von Dynamic Binding[Bearbeiten | Quelltext bearbeiten]

Implementierung von Single Inheritance[Bearbeiten | Quelltext bearbeiten]

Das Objekt vom Typ A liegt im Speicher. Es besteht aus mehreren Variablen. Wir haben einen Zeiger (eine Referenz) auf dieses Objekt. Außerdem haben wir im Objekt einen Zeiger auf den Typ des Objekts. Dieser Typ ist wieder so eine Art Objekt, das aus einzelnen Variablen besteht, die Information über den Typ angeben. Die wichtigste Information die hier drinnen steht ist der Virtual Function Table. Virtuelle Funktionen sind Funktionen über die dynamisch gebunden werden kann. Der VFT ist ein Array mit Zeigern auf die Methoden-implementierungen des Objekts.

Wenn jetzt eine Methode ausgeführt werden soll (über dynamisches Binden), kommt man über die Objektreferenz zu dem VFT. Intern hat jede Methode eine Nummer (Methoden werden fortlaufend nummeriert). Über diese Nummer kommt man dann zur gewünschten Methode.

Diese Operation ist normalerweise sehr gut durchoptimiert worden in den Prozessoren, sie kostet also relativ wenig. Ein bisschen teurer ist das Laden des Typs bzw des VFT. Dies ist aber auch kein so großes Problem, da der VFT nur einmal geladen werden muss und dann im Register steht.

Jetzt wollen wir einen Untertyp B definieren. Wie entsteht jetzt ein neuer VFT?

  • Die Nummerierung der Methoden bleibt gleich, wird also direkt wie sie ist, aus dem Obertyp übernommen. Neue Methoden im Untertyp werden einfach mit der nächsten Nummer drangehängt. Im Falle einer überschriebenen Methode wird der entsprechende Zeiger in dem VFT des Untertyps auch überschrieben.
  • In den Methoden wird auf Variablen des Objekts zugegriffen. Es muss sichergestellt werden, dass die relativen Adressen der Variablen dieses Objekts vom Untertyp gleich bleiben. So können Variablen die vom Obertyp geerbt wurden benutzt werden, aber auch neue Variablen die im Untertyp eingeführt worden sind. Die relativen Adressen sind ja gleich geblieben.
  • Wenn jetzt eine Methode für ein Objekt aufgerufen wird, dessen Typ wir nicht kennen, reicht uns die Information die wir im deklarierten Typ haben, weil eben nur die Methoden aufgerufen werden können. Die Nummern bleiben ja gleich.

Diese Implementierung ist sehr einfach und effizient. Deswegen gibt es einen gewissen Druck, Sprachen zu definieren, die nur Einfachvererbung unterstützen.

Implementierung von Interfaces[Bearbeiten | Quelltext bearbeiten]

Angenommen, die Klasse D implementiert die Interfaces A, B und C. Hier haben wir das Problem, dass wir von mehreren Interfaces erben können. Jedes Interface wird getrennt übersetzt und der Compiler erzeugt unterschiedliche Nummerierungen für dieselben Methoden. Deswegen fügen wir pro Interface und Klasse eine Tabelle hinzu.

Wir können noch ein bisschen optimieren: wir übernehmen das erste Interface (wie bei Einfachvererbung). Für das erste Interface brauchen wir also keine zusätzliche Tabelle. Wir stoßen hier außerdem auf das Problem, dass das Objekt immer größer wird, weil wir ja mehrere Zeiger brauchen. Für jedes neue Interface kommt im Objekt ein neuer Zeiger dazu. Das Erzeugen eines Objekts könnte deswegen teuer werden. Stattdessen kann man die Zeiger gleich im VFT speichern. Diese Zeiger werden nach oben hinzugefügt, nach unten werden nach wie vor neue Methoden für die Klasse hinzugefügt. Das heißt, für Zeiger auf VFT der Interfaces haben wir negative Indizes.

Wenn wir jetzt eine Methode aufrufen, welchen VFT nehmen wir? Das hängt vom deklarierten Typ des Objekts ab. Da es mehr Sprünge gibt ist deswegen die Verwendung von Interfaces eine Spur teurer als Vererbung.

Implementierung von Mehrfachvererbung[Bearbeiten | Quelltext bearbeiten]

Das Problem bei Mehrfachvererbung ist (im Gegensatz zu Interfaces), dass wir auch auf Variablen zugreifen können und somit die Adressen angepasst werden müssen. Mehrfachvererbung ist also ein sehr teurer und komplizierter Mechanismus, weshalb auch viele Sprachen darauf verzichten. Vererbung wird in der Praxis ja sowieso so gut wie gar nicht verwendet. Interfaces (Subtyping) werden aber sehr wohl verwendet.

C++ ist eine Sprache die Mehrfachvererbung unterstützt. Ein bekanntes Problem das dabei entsteht ist das sogenannte Diamond Inheritance Problem: B und C erben von A, D erbt von B und C. Jetzt erbt D jede Variable die in A vorkommt, 2 mal. Das Objektlayout von der Klasse D schaut wie folgt aus:

  • A: Variablen von A
  • B’: Variablen die in B dazugekommen sind
  • A: Nochmal die Variablen von A
  • C’: Variablen die in C dazugekommen sind
  • D’: Variablen die in D dazugekommen sind

Wenn wir jetzt auf eine Variable, die in A definiert ist, zugreifen wollen, dann müssen wir entsprechend qualifizieren, also angeben, welche Variable von A gebraucht wird: z.B. B::name_der_variable. Das gleiche gilt auch für einen Methodenaufruf.

Wie schaut das jetzt aus wenn wir Vererbung haben? Angenommen wir haben ein Objekt vom deklarierten Typ D und eine Methode, die in A definiert ist:

  • Wird sie in der Klasse D überschrieben, ist das kein Problem, weil sich ja die Referenzen auf das Objektlayout in D beziehen.
  • Was ist aber wenn diese Methode nicht in D überschrieben worden ist, sondern direkt aus A kommt? Die Referenzen müssen in dem Fall angepasst werden, da sich die Objektlayouts von A und D voneinander unterscheiden. Der Trick dabei ist, dass man einfach den Zeiger auf das Objekt verbiegt, also einen impliziten Cast macht. Die Offsets der Variablen sind ja immer relativ zum Objektzeiger bzw zur Startadresse des Objekts.

Um zu zeigen wie man den Zeiger verbiegt, nehmen wir ein kleines Beispiel: Angenommen, wir haben 2 Variablen. Die Variable x ist vom Typ D und y ist vom Typ Y. Beide Variablen zeigen auf das gleiche Objekt im Speicher. Wir wollen jetzt auf Objektidentität prüfen. Bevor wir jedoch den Vergleich machen können, müssen wir zuerst die Zeiger x und y in die Ursprungsform bringen, also normalisieren. Hier kommt das sogenannte Delta ins spiel. Dieses beschreibt den eigentlichen Abstand zum Anfang des Objekts. Um den Vergleich zu machen addieren/subtrahieren wir dieses Delta zum/vom Zeiger. Man kann dieses Delta direkt in den VFT schreiben als kleine Optimierung. Die Tabellen schauen wie bei Interfaces aus, nur kann man sie in einer zusammenfassen.

Eine Methode wird in C++ wie folgt aufgerufen:

  • Der Load Befehl lädt einen Wert in einen Register. Zurück bekommen wir dann die Anfangsadresse des VFT
  • Dann laden wir das Delta
  • Wir laden auch die Adresse der Methode
  • Zum Zeiger bzw zur Anfangsadresse des Objekts wird dann das Delta addiert
  • Jetzt kann die Methode aufgerufen werden

Das ist übrigens nicht die Variante die im heutigen C++ implementiert ist, sondern nur das Konzept was dahinter steht. Es können hier viele Optimierungen durchgeführt werden:

  • Die erste Optimierung geht davon aus, dass wir in den meisten Fällen Delta = 0 annehmen, da Vererbung nicht so oft benutzt wird. Diese Optimierung ist als Thunks oder Trampoline bekannt. Die Befehle die mit dem Delta zu tun haben, können einfach weggelassen werden. Dazu muss jede Methode überschrieben werden (wenn das der Fall ist, dann ist Delta garantiert immer 0). Vor jedem Aufruf wird das Delta addiert und man springt zur eigentlichen Methode.
  • Wenn man eine Methode statisch kennt, dann wird direkt gesprungen und kein VFT gebraucht. In dem Fall kann man Inlining machen. Das betrifft vor allem kleine Methoden wo einfach nur ein Wert zurückgegeben wird und Konstruktoren. Funktioniert also nur mit statischem Binden. Thunks sind übrigens ein Beispiel für partielles Inlining, es wird also nur ein Teil geinlined.
  • Wenn der Compiler die Typhierarchie kennt, dann steigt die Wahrscheinlichkeit, dass er auch die Methoden statisch kennt. Nur widerspricht das dann einem Konzept, nämlich dass man Klassen getrennt übersetzt haben will. Es geht immer darum, einen Kompromiss zu finden.
  • Dynamic Caching findet man in dynamischen Sprachen, wo die ganze Struktur die wir uns bisher angesehen haben gar nicht vorkommt. In SmallTalk wird die Methode gesucht, wirklich nach dem Namen. Wenn sie nicht lokal im Objekt vorkommt, schauen wir in der klasse von der wir erben usw bis wir die Methode gefunden haben. Wie kann man das ganze aber ein wenig effizienter gestalten? Wir springen auf die Adresse der zuletzt aufgerufenen Methode. In den meisten Fällen passt das tatsächlich. Wenn das nicht klappt, dann beginnt die Suche von vorne.


Observer Pattern[Bearbeiten | Quelltext bearbeiten]

Es gibt den Subject (dieser besitzt einen Zustand), der von mehreren Observern beobachtet wird. Wenn sich der Subject ändert, dann muss der Observer irgendwelche Aktionen setzen. Der Subject informiert also die Observer darüber, dass sich was geändert hat. Im graphischen Bereich gut einsetzbar. Ist aber viel allgemeiner, und zwar fast immer dort wo es 1 zu n Beziehungen zwischen Objekten gibt. Hierbei handelt es sich nicht um eine statische Beziehung, die Anzahl der Objekte kann sich also dynamisch ändern. Diese n Objekte sind also abhängig von einem Objekt und werden über Zustandsänderungen dieses einen Objekts informiert. Einige Anwendungsfälle:

  • Eine Abstraktion mit mehreren oder zwei Aspekten, wenn ein Aspekt vom anderen abhängt. Wir wollen diese Aspekte voneinander trennen, also nicht in derselben Klasse haben (Separation of Concerns: Dinge die unterschiedliche Dinge betreffen sind unterschiedliche Aspekte). Dadurch wird ein Objekt kleiner.
  • Eine Zustandsänderung soll in weiteren Objekten Zustandsänderungen bewirken.
  • Es gibt eine schwache Kopplung zwischen den Objekten.

Struktur[Bearbeiten | Quelltext bearbeiten]

Der Subject schickt Nachrichten an den Observer, also kennt zumindest eine Menge solcher Observer. Hierbei ist die abstrakte Ebente komplett getrennt von der konkreten Ebene. Wichtig ist hier, dass die Beziehung zwischen Subject und Observer auf der abstrakten Ebene bleibt. Der Subject braucht also nie wissen, welche konkreten Observer wir haben. Dieses Pattern ist also sehr gut Einsetzbar, wenn wir eine Layered-Architektur haben.

Der Observer hat eine update() Methode, die vom Subject aufgerufen werden kann. Man schickt dem Observer diese Nachricht wenn sich der Zustand des Subjects geändert hat. Dafür wird die Methode notify() im Subject selbst aufgerufen, die dann die Updates triggert. Mit attach() und detach() kann man sich an das Subject dranhängen und wieder rausstreichen.

Auswirkungen[Bearbeiten | Quelltext bearbeiten]

  • Wir haben also eine abstrakte Kopplung zwischen Subject und Observer. Sie können also zu unterschiedlichen Layers hingehören.
  • Das Verschicken der Nachrichten erfolgt automatisch (Es reicht wenn wir einfach notify() aufrufen).
  • Allerdings können bei Updates Probleme passieren, da unerwartete Updates getriggert werden können. Die Ursachen dafür zu finden ist schwer (auch weil die Kopplung abstrakt ist). Auch die Kosten von Updates sind kaum vorhersehbar.

Implementierung[Bearbeiten | Quelltext bearbeiten]

  • Subject ist sehr häufig ein Argument von update(). So kann man unterscheiden von wo diese Nachricht kommt. Ein Observer könnte ja gleichzeitig mehrere Subjects beobachten.
  • Angenommen der Subject ist ein Text der von den Observern dargestellt werden soll (man kann den Text ändern durch Einfügen und Löschen von Zeichen). Jetzt möchte man einen Satz einfügen. Wenn man für jedes neue Zeichen im Subject notify() aufruft, entsteht dadurch eine große Anzahl von (unnötigen) Methodenaufrufen. Wenn der Client nofify() aufruft, reicht es wenn er das nur einmal am Ende des Satzes macht. Allerdings gibt es hier das Problem, dass er Client einfach vergessen könnte das zu tun.
  • Der Zustand muss konsistent sein bevor notify() aufgerufen wird (erst Zustand ändern, dann notify() aufrufen).
  • Observer detachen bevor man den zugehörigen Subject löscht.
  • In der Push-Methode wird der gesamte Zustand des Subjects (viele Daten) beim Update mitgeschickt. In der Pull-Methode wird nur bekanntgegeben, dass sich der Zustand geändert hat(wenige Daten). Der Observer müsste im diesem Fall dann selber nachfragen, falls er mehr Informationen über den Zustand haben möchte.
  • Es kann auch unterschiedliche Arten geben wie man sich registriert. Ein Observer möchte sich nur für bestimmte Aspekte informieren.


State Pattern[Bearbeiten | Quelltext bearbeiten]

Dieses Pattern ermöglicht es einem Objekt sein Verhalten dynamisch zu ändern, abhängig von seinem Zustand. Da sich dieses Verhalten oft ändert, entsteht dabei der Eindruck, als ob das Objekt seine Klasse ändern würde zur Laufzeit. Es gibt mehrere Gründe warum man das State Pattern verwenden könnte:

  • Wie schon erwähnt, hängt das Verhalten vom Zustand des Objekts ab und ändert sich abhängig von diesem Zustand zur Laufzeit.
  • Es gibt oft situationen, wo in einer Switch-Anweisung diverse Operationen ausgeführt werden. Jeder Fall wird durch einen Wert repräsentiert (dieser ist häufig auch eine Konstante). Gleichzeitig kann man es auch so sehen, dass dieser Wert auch den Zustand des Objekts repräsentiert. Das bedeutet also, dieses Objekt besitzt gleichzeitig mehrere Verhalten. Um das zu vermeiden, gibt es für jeden Zweig eine eigene Klasse die das Verhalten kapselt.

Struktur[Bearbeiten | Quelltext bearbeiten]

Der Context hat eine referenz auf den State. Er besitzt die methode request(), welche die Methode handle() im State aufruft, abhängig von dem konkreten State. Dieser Methode wird auch der aktuelle Context selbst übergeben, damit der konkrete State die möglichkeit hat dann den State im Context zu ändern, also um die Transition durchzuführen. Der State kann aber auch vom Context geändert werden (siehe weiter unten).

Auswirkungen[Bearbeiten | Quelltext bearbeiten]

  • Zustand-spezifisches Verhalten liegt jetzt in verschiedenen Objekten. Dadurch ist es auch ganz leicht neue States und Transitionen einzuführen.
  • Transitionen werden dadurch mehr explizit. Diese Transitionen sind atomar. Es können also keine Inkonsistenzen auftreten.
  • Gemeinsame Zustandsobjekte sind möglich.

Implementierung[Bearbeiten | Quelltext bearbeiten]

  • Ein wesentlicher Punkt ist wo die Transition ausgeführt wird. Wenn das im Context passiert, dann gibt es keine Abhängigkeiten zwischen den States. Macht man das aber im State, ist es zwar einfacher neue States hinzuzufügen, aber jetzt haben wir starke Abhängigkeiten zwischen den States (weil ein State den nächsten State kennen muss).
  • Oft reicht eine Instanz pro Klasse, weshalb man States als Singletons braucht.
  • Ein sogenannter State Transition Table wird benutzt wenn der Fokus auf Zustandsübergängen liegt (und nicht auf Verhalten). Diese Sprungtabelle kann leicht generiert und verändert werden.
  • Manchmal wird dynamische Vererbung benötigt. Man ändert also die Klasse eines Objekts wirklich zur Laufzeit. Das ist in Sprachen wie Self möglich.


Smalltalk und Eiffel[Bearbeiten | Quelltext bearbeiten]

Die Folien decken das meiste ab.