|
|||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||
|
Immutability - Part 2
|
||||||||||||||||||
Weil die Unveränderlichkeit (engl. immutability) von Typen ein
wichtiges Thema in Java ist, widmen wir ihm diesen und den nächsten
Artikel. Wir diskutieren die Implementierung von lesenden Methoden und
die Eigenschaften von unveränderlichen Typen. In diesem Zusammenhang
besprechen wir Read-Only-Adaptoren und sogenannte "duale Klassen". Im nächsten
Artikel sehen wir uns die Immutability-Adaptoren in Collection-Framework
des JDK an und diskutieren den Sinn und Zweck des Schlüsselworts final.
Wofür werden unveränderlichen Typen gebraucht?Immutability spielt immer dann eine Rolle, wenn Objekte gemeinsam verwendet werden (engl. object sharing). Diese Situation entsteht in Java, wenn Referenzen einander zugewiesen oder von und an Methoden übergeben werden. Es verweisen dann mehrere Referenzen auf ein Objekt und die Besitzer dieser Referenzen können abwechselnd lesend und/oder schreibend auf das gemeinsam verwendete Objekt zugreifen.Manchmal ist es unerwünscht, dass das gemeinsam verwendete Objekt von allen Beteiligten nach Belieben verändert werden kann. Dann kann man auf das Sharing verzichtet und Kopien anlegen, so dass jede Referenz auf ihre eigene Kopie des Objekts verweist.(Das Kopieren von Objekten haben wir ausführlich in / KRE1 / besprochen.) Es geht aber unter Umständen auch ohne das Kopieren. Den Aufwand für das Kopieren kann man vermeiden, wenn das gemeinsam verwendete Objekt gar nicht verändert werden kann. Und hier kommen unveränderliche Typen ins Spiel; sie helfen, den Performance-Overhead für das Kopieren von Objekten zu eliminieren. Es gibt eine weitere Situation, in der unveränderliche Typen nützlich sind. In Programmen mit mehreren parallelen Threads können Objekte gemeinsam von mehreren Threads verwendet werden. Dann gibt es ebenfalls mehrere Referenzen auf ein gemeinsam verwendetes Objekt. Die Referenzen werden in verschiedenen parallel ablaufenden Threads gehalten und in solchen Fällen ist das Object Sharing ausdrücklich erwünscht: es soll eine Kommunikation zwischen den Threads über das gemeinsam verwendete Objekt stattfinden. Wenn auf das gemeinsam verwendete Objekt schreibend zugegriffen werden kann, dann müssen die Zugriffe auf das gemeinsam verwendete Objekt synchronisiert werden, damit sie nacheinander und nicht ineinander verschränkt ablaufen. Sonst könnte es beispielsweise passieren, dass ein lesender Thread ein halb geschriebenes Objekt zu sehen bekommt, weil der schreibende Thread noch gar nicht fertig war, als er unterbrochen wurde. Hier helfen unveränderlichen Typen, den Synchronisationsaufwand zu vermeiden. Wenn man weiß, dass das Objekt unveränderlich ist, dann kann man auf die Synchronisation gänzlich verzichten.
Im folgenden werden wir den Fall von Multithread-Anwendungen nicht weiter
vertiefen, sondern wir werden den Nutzen von unveränderlichen Typen
am Beispiel des Object Sharing in Single-Thread-Programmen betrachten.
Alle Techniken und Vor- und Nachteile, die wir diskutieren werden, gelten
aber für die Nutzung von veränderlichen und unveränderlichen
Typen in Multithread-Umgebungen ganz genauso.
Lesende ZugriffsmethodenNehmen wir einmal an, wir wollen eine Zugriffsmethode implementieren, die nur lesenden Zugriff auf private Daten eines Objekts gibt. Betrachten wir dazu das folgende Beispiel:
public final class Widget
... other methods and fields ... public Stamp getLastModification(); } Es handelt sich um eine Klasse, die in einem Feld vom Typ Stamp Informationen über die letzte erfolgte Modifikation des Objekts festhält. Der Typ Stamp ist hier nicht näher ausgeführt, aber man stelle sich vor, er enthält Informationen wie den Zeitpunkt der Veränderung, den Urheber der Veränderung, etc. Das Feld wird an geeigneter Stelle initialisiert und in jeder verändernden Methode mit neuen Werten belegt. Wie das genau gemacht wird, ist an dieser Stelle ohne Belang. Uns interessiert vielmehr die Zugriffsmethode getLastModification(). Sie gibt Zugriff auf das private Feld. Dabei soll sie sicher keinen Schreibzugriff gestatten, sondern nur Lesezugriff. Sonst könnte "von Außen" der Zeitstempel manipuliert werden; er soll aber ausschließlich "von Innen", d.. von den Methoden der Klasse Widget, verändert werden. Wie kann man diese nur lesende Zugriffsmethode implementieren? Also, so ist es sicher falsch:
public final class Widget
... other methods and fields ...
public Stamp getLastModification()
Hier kann jeder nach dem Aufruf von getLastModification() auf das private Feld der Klasse Widget zugreifen und den Eintrag ändern. Zum Beispiel so:
Widget w = new Widget();
Mit der Veränderung des Felds wäre dann die logische Konsistenz des Widget Objekts zerstört. Das sollte eigentlich nicht passieren. Das Problem rührt daher, dass Stamp ein veränderlicher Typ ist. Hat man erst einmal Referenz auf ein Objekt des Typs Stamp, dann hat man nicht nur Lese- sondern auch Schreibzugriff auf das Objekt. Man kann das Problem lösen, indem man das Feld kopiert, damit dem Aufrufer von getLastModification() eine eigene unabhängige Kopie zur Verfügung steht, die er nach Belieben ändern kann, ohne dass es Auswirkungen auf die Klasse Widget hat.
public final class Widget
... other methods and fields ...
public Stamp getLastModification()
Das Kopieren von Objekten kann u.U. relativ teuer sein, abhängig
von der inneren Struktur und Größe des zu kopierenden Objekts.
Generell wird man versuchen, den Overhead des Kopierens zu vermeiden, wann
immer es geht, schon allein, weil es das neu erzeugte Objekt den Garbage
Collector mit zusätzlicher Arbeit belastet. Und in diesem Fall
geht es. Man kann ohne Kopie auskommen, nämlich mit Hilfe von
unveränderlichen Typen.
Unveränderliche TypenReferenzvariablen von einem unveränderlichen Typ zeigen entweder auf Objekte, die sich tatsächlich nicht verändern, oder sie lassen veränderliche Objekte zumindest so aussehen, als seien sie unveränderlich. Man unterscheidet zwischen Read-Only-Sichten und "echten" unveränderlichen Typen. Im folgenden sehen wir uns Adaptoren an, die diese beiden Arten von unveränderlichen Typen erzeugen.Read-Only-AdaptorenSehen wir uns einen Read-Only-Adaptor am Beispiel unserer Stamp Klasse an, die vermutlich die folgenden Methoden haben wird:
public final class Stamp
... other methods and fields ...
public Date getDate()
public String getAuthor()
Wenn wir ein Interface definieren, das nur die lesenden Methoden der Klasse Stamp enthält, dann haben wir eine Read-Only-Sicht auf Objekte des Typs Stamp:
public interface ImmutableStamp
Die Klasse Stamp kann nun dieses Interface implementieren:
public final class Stamp implements ImmutableStamp
... other methods and fields ...
public Date getDate();
Mit dem Read-Only-Adaptor wollten wir erreichen, dass wir in der lesenden Zugriffsmethode getLastModification() möglichst ohne Kopieren auskommen. Nun schaffen wir es nicht, das Kopieren von Objekten gänzlich zu vermeiden, weil ja bereits in den Methoden der Stamp-Klasse Kopien erzeugt werden, aber wir können es doch deutlich reduzieren. Man könnte nämlich nun die Methode getLastModification() so ändern, dass sie anstelle einer Referenz auf ein Stamp-Objekt eine Referenz vom Typ ImmutableStamp zurück gibt. Das sähe dann so aus:
public final class Widget
... other methods and fields ...
public ImmutableStamp getLastModification()
Durch die Rückgabe einer Referenz vom Typ ImmutableStamp auf das existierende Stamp-Feld des Widget-Objekts haben wir das Kopieren des Stamp-Objekts vermieden.Gleichzeitig haben wir aber mit Hilfe des Read-Only-Interfaces aber auch erreicht, dass der Aufrufer nur noch lesend auf das Stamp-Feld zugreifen kann. Das sieht man im nachfolgenden Beispiel:
Widget w = new Widget();
ImutableStamp log = w.getLastModification();
Bereits zur Compilezeit bekommt man hier eine Fehlermeldung, weil über das Interface ImmutableStamp nur noch die lesenden Methoden getDate() und getAuthor() sichtbar sind. Read-Only-Adaptoren haben einen gravierenden Haken. Das Stamp-Objekt sieht, durch die Brille des ImmutableStamp-Interfaces gesehen, nur so aus, als sei es unveränderlich. In Wahrheit kann das Stamp-Objekt natürlich immer noch geändert werden. Wir haben lediglich eine Art Absichtserklärung erreicht: die getLastModification()-Methode gibt zu erkennen, dass sie keinen schreibenden Zugriff auf das Stamp-Objekt geben möchte. Und in der Tat kann man auch versehentlich über das ImmutableStamp-Interface keine Veränderungen vornehmen. Aber das Interface gibt keine Garantie, dass das Stamp-Objekt tatsächlich unverändert bleibt. Es könnte ja an anderer Stelle über eine Stamp-Referenz verändert werden. Und natürlich kann man die ImmutableStamp-Referenz mit einem expliziten Cast in eine Stamp-Referenz verwandeln, und dann kann man sogar selber verändernd auf das referenzierte Stamp-Objekt zugreifen. Ein Read-Only-Adapter gibt also keine Garantie, dass das referenierte Objekt unverändert bleibt. Ob eine Read-Only-Sicht auf ein veränderliches Objekt nun gut oder schlecht ist, hängt ganz von den Umständen und der Erwartungshaltung ab. Es kann durchaus erwünscht sein, dass man selbst keine Veränderungen am Shared Object vornehmen will (und dies durch die Read-Only-Sicht zum Ausdruck bringt), man aber die Veränderungen am Shared Object, die von anderen herbeigeführt werden, sehen möchte. Dann ist eine Read-Only-Sicht auf ein veränderliches Objekt völlig in Ordnung. Es kann aber auch sein, dass man sich auf die Unveränderlichkeit des Objekts verlassen will. Das ist zum Beispiel bei Shared Objects in Multithread-Programmen der Fall. Die Synchronisation der Zugriffe auf das von mehreren Threads gemeinsam verwendete Objekt kann nur dann entfallen, wenn das Objekt sich tatsächlich nicht ändern kann. Bei Shared Objects in Multithread-Programmen wäre es ein fataler Fehler, wegen der Read-Only-Sicht auf die Synchronisation zu verzichten, weil das referenzierte Shared Objekt durch den Read-Only-Adapter keineswegs for Veränderungen geschützt ist. Im Beipiel unserer getLastModification()-Methode kann man darüber streiten, ob die Read-Only-Sicht auf das veränderliche Stamp-Objekt gut oder schlecht ist. In jedem Falle sollte aber sorgfältig dokumentiert sein, was genau die Methode zurück gibt.
Ganz allgemein muss man sich darüber klar sein, was ein Read-Only-Interface
tatsächlich bedeutet: es ist reine Read-Only-Sicht auf etwas möglicherweise
Veränderliches. Die Gefahr liegt darin, dass u.U. nicht jedem auf
Anhieb klar ist, dass etwas, das unveränderlich aussieht, dennoch
verändert werden kann. Ein Read-Only-Interface könnte zu Missverständnissen
führen. Deshalb ist es nicht ganz unproblematisch. Das gleiche gilt
übrigens auch für Superklassen, die Immutability versprechen,
dann aber auf Subklassen verweisen können, die gar nicht unveränderlich
sind.
Duale KlassenIn folgenden wollen wir eine Lösung vorstellen, die beide Aspekte von Immutability abdeckt: die Read-Only-Sicht auf etwas möglicherweise Veränderliches und die Referenz auf ein echt inveränderliches Objekt. Das kann man mit sogenannten dualen Klassen erreichen. Bei diesem Ansatz ist die Grundidee, dass man zwei verschiedene Klassen, eine für veränderliche und eine für unveränderliche Objekte, hat. Im Beispiel unserer Stamp-Abstraktion würde es neben der Stamp -Klasse noch eine zweite Klasse ImmutableStamp geben, die wie folgt aussähe:
public final class ImmutableStamp
public ImmutableStamp(Stamp s) { stamp = (Stamp)s.clone(); }
public Date getDate()
Objekte vom Typ ImmutableStamp sind im Prinzip Kopien der korrespondierenden Stamp-Objekte. Sie haben, genau wie unser Read-Only-Interface zuvor, nur die lesenden Methoden getDate() und getAuthor(). Die Zugriffsmethode getLastModification() unserer Widget-Klasse würde dann wie folgt aussehen:
public final class Widget
... other methods and fields ...
public ImmutableStamp getLastModification()
Mit dieser Lösung hat man ebenfalls erreicht, dass die Methode getLastModification() nur noch lesenden Zugriff auf den Zeitstempel gibt. Dieses Mal verweist die zurückgelieferte Referenz auf ein Objekt, das wirklich unveränderlich ist, weil es ein ImmutableStamp-Objekt ist, das überhaupt keine verändernden Methoden hat. Anders als in der zuvor besprochenen Adapter-Lösung mit dem ImmutableStamp-Interface, wo wir eine Referenz auf das Original-Stamp-Objekt zurückgeliefert hatten, welches nach wie .vor veränderlich ist. Nun kann man sich fragen, was man mit dieser Lösung an Kopieraufwand gespart hat. Zunächst einmal nichts. In unserer Original-Implementierung der Widget-Klasse hatte die Methode getLastModification() einen Klon erzeut. Jetzt passiert genau dasselbe, allerdings implizit im Konstruktor des unveränderlichen Typs. Von nun an spart man aber Kopieraufwände ein, weil man mit dem ImmutableStamp-Objekt ein unverändliches Objekt hat, das man nie mehr kopieren muss und das man immer per Referenz weiterreichen kann. Wenn die ImmutableStamp-Klasse nicht existiert, dann gibt es keine Möglichkeit sicher zu stellen, dass in einem bestimmten Kontext Schreibzugriffe ausgeschlossen sind. Im Zweifelsfall muss man dann Kopien von Stamp-Objekten erzeugen, um sich gegen Veränderungen an gemeinsam verwendeten Stamp-Objekten zu schützen, so wie wir das in unserer allerersten Lösung gemacht hatten.
Klassen, die wie unser Stamp/ImmutableStamp-Paar in zwei Ausprägungen
daher kommen, bezeichnet man als duale Klassen. Das wohl bekannteste
Beipiel für ein solches Paar ist die String-Abstraktion im JDK, die
in Form der beiden Klassen String und StringBuffer implementiert
ist.
Duale Klassen im DetailMit der dualen Klasse haben wir eine Möglichkeit gefunden, sowohl veränderliche als auch unveränderliche Ausprägungen einer Abstraktion zu verwenden. Fehlt uns also noch die Read-Only-Sicht für all die Situationen, in denen wir durch die Read-Only-Brille auf ein veränderliches Objekt blicken wollen.Die Read-Only-Sicht auf eine duale Klasse drückt man aus durch eine gemeinsame Superklasse oder ein gemeinsames Super-Interface, das es erlaubt, die beiden Typen von Objekten austauschbar zu verwenden. Das ist nützlich, wenn man Schnittstellen hat, denen es egal ist, ob die Objekte veränderlich oder unveränderlich sind. Diese Schnittstellen würden dann so deklariert, dass sie mit Superklassen- oder Super-Interface-Referenzen arbeiten, hinter denen sich beide Ausprägungen der Abstraktion verbergen können. Diese gemeinsame Superklasse oder das gemeinsame Super-Interface ist dann die Read-Only-Sicht auf beiden Arten von Objekten. Im Beispiel unserer Stamp-Abstraktion sähe das so aus, wenn man ein gemeinsames Interface definiert:
public interface StampBase
public final class Stamp implements StampBase
... other fields and methods ...
public Stamp(Date d, String a)
public String getAuthor()
public final class ImmutableStamp implements StampBase
public ImmutableStamp(Stamp s) { stamp = (Stamp)s.clone(); }
public Date getDate()
Man kann auch noch einen Schritt weiter gehen und Gemeinsamkeiten der beiden Stamp-Klassen in eine gemeinsame Superklasse herausziehen. Das sieht dann wie folgt aus:
public class StampBase
... other common fields ...
public StampBase(Date d, String a)
public Date getDate()
... other common read-only methods ...
public final class Stamp extends StampBase
public void setDate(Date d)
public void setAuthor(String a)
... other mutating methods ...
public final class ImmutableStamp extends StampBase
In beiden Fällen sollte man aber nicht etwa auf die Idee kommen, Stamp von ImmutableStamp abzuleiten. Diese Idee ist ziemlich naheliegend; schließlich würde man dann die lesenden Methoden erben und müßte sie nicht re-implementieren. Wenn man diese Ableitung macht, dann ist die Semantik der ImmutableStamp-Klasse radikal anders: sie gibt keine Immutability-Garantie mehr. Die ImmutableStamp-Klasse degeneriert dann zu einer Read-Only-Sicht. Das liegt daran, dass eine Referenzvariable vom Typ ImmutableStamp wegen der Vererbungsbeziehung auf ein veränderliches Stamp-Objekt verweisen kann. Das ist nicht die Idee einer dualen Klasse. Bei der dualen Klasse hat man getrennte Typen für die veränderliche und die unveränderliche Ausprägung der Abstraktion. Die beiden Typen sind nicht zuweisungsverträglich, d.h. nicht voneinander abgeleitet. Variablen des einen Typs können nicht in Variablen des anderen Type gecastet werden. Allerdings sind "Konvertierungen" möglich, indem aus dem Objekt des einen Typs ein Objekt des anderen Type konstruktiert wird. Das sind aber keine Typkonvertierungen, sondern Objektkonvertierungen, die Kopieraufwände beinhalten. Typkonvertierungen sind möglich zwischen dem dritten Supertyp (falls vorhanden) und den beiden Sybtypen. Das heißt, man kann eine Read-Only-Sicht (durch den gemeinsame Supertyp) auf beide Arten von Objekten haben, und man kann zwischen der Read-Only-Sicht und der uneingeschränkten "echten" Sicht hin und her konvertieren. Bei dieser Art der Konvertierung sind werden keine Kopien gemacht, sondern nur Sichten verändert. Zwischen den beiden Subtypen, dem veränderlichen und unveränderlichen Typ, kann jedoch nicht konvertiert werden. Mit dualen Klassen ist man nun sehr flexibel:
Marker-Interface für ImmutabilityLeider bietet die Sprache Java praktisch keine Unterstützung für die Immutability an. Es gibt ein winziges bischen an Unterstützung in Form des Schlüsselwortes final; darauf kommen wir in der nächsten Ausgabe der Kolumne zurück. Ansonsten ist man auf Konventionen und Programmierdisziplin angewiesen. Die Tatsache, dass eine Klasse unveränderlich ist, kann man nur im Namen der Klasse und/oder in der Dokumentation zur Klasse ausdrücken.Wer es etwas deutlicher sagen will, kann sich ein leeres Marker-Interface Immutable definieren und alle unveränderlichen Klassen davon ableiten. Das hat den Vorteil, dass man zur Laufzeit ein Objekt mit Hilfe des instanceof-Operators fragen kann, ob es unveränderlich ist.
Aber auch das bietet keine absolute Sicherheit, insbesondere dann nicht,
wenn Vererbung im Spiel ist. Wir haben in unserem Beispiel einer dualen
Abstraktion bewußt die veränderliche und die unveränderliche
Klasse als final Klassen deklariert. Bei einer non-final Klasse,
die unveränderlich ist, ist es reine Disziplin und guter Wille, dass
diese Semantik in den Subklassen auch beibehalten wird.
Zusammenfassung und AusblickIn diesem Artikel haben wir uns angesehen, wie man unveränderliche Typen implementiert. Unveränderliche Typen sind nützlich, weil sie die Notwendigkeit, Kopien von Objekten zu erzeugen, reduzieren und weil sie den Synchronisationsaufwand in Multithread-Umgebungen vermindern.Unveränderliche Typen haben nur lesende Methoden und sind idealerweise final Klassen oder haben nur Subklassen, die ebenfalls unveränderliche Typen sind. Unveränderliche Typen sollte man nicht mit Read-Only-Sichten auf veränderliche Typen verwechseln. Als duale Klasse bezeichnet man Abstraktionen, die als Paar von einer veränderlichen und einer unveränderlichen Klasse implementiert sind. Duale Klassen haben häufig einen gemeinsamen Supertyp, der die Read-Only-Sicht auf beide Klassen repräsentiert. In der nächsten Ausgabe dieser Kolumne sehen wir uns das Schlüsselwort final an und was es mit Immutability zu tun hat. Darüber hinaus untersuchen wir die Immuability-Adaptoren des JDK-Collection-Frameworks. Literaturverweise
|
|||||||||||||||||||
© Copyright 1995-2008 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/08.Immutability-Part1/08.Immutability-Part1.html> last update: 26 Nov 2008 |