AngelikaLanger.com - Effective Java - OOP 2002 Conference Proceedings (original) (raw)
Effective Java - OOP 2002 Conference Proceedings
Effective Java Programming
OOP 2002 Conference Proceedings, January 2002
Angelika Langer & Klaus Kreft
Vorbemerkung
Dieses Tutorial unter dem Titel "Effective Java" befasst sich mit der Programmiersprache Java, wobei wir den Titel "Effective Java" bewusst gewählt haben, um an die Tradition von Scott Meyers anzuknüpfen, der den Begriff "Effective..." durch seine "Effective C++"-Bücher populär gemacht hat. Betrachtungen unter dem Motto "Effective" wenden sich typischerweise der Tücke des Objekts zu und so widmen wir uns in dem "Effective Java"-Tutorial den mehr oder weniger offensichtlichen und bisweilen überraschenden Effekten und "Features" der Programmiersprache Java.
Im einzelnen werden im Tutorial die folgenden Themen betrachtet:
- Objekt-Infrastruktur. Korrekte Implementierung von "trivialer" Infrastruktur vie equals() und clone() ist überraschenderweise alles andere als trivial. In diesem Tutorial sehen wir, wie man es richtig macht.
- Inner Classes. Innere Klasses sind ein flexibles Sprachmittel, das allerdings erstaunlich kryptische Syntax hervorbringt (im Falle anonymer Klassen). Wie funktionieren innere Klassen und wofür verwendet man sie?
- Reachability. Auch in Java kann man Memory Leaks produzieren, in Form von unerwünschten Referenzen (unwanted references). Schwache Referenzen (weak references) wurde der Sprach hinzugefügt, um genau dieses Problem zu lösen. Wie funktioniert das?
Das Material des Tutorials ist dem "Effective Java"-Seminar von Angelika Langer Training/Consulting entnommen. Der begleitende Text zu diesem Tutorial ist ein Auszug aus der "Effective Java"-Kolumne von Klaus Kreft und Angelika Langer, die im JavaSpektrumerscheint, und motiviert, warum man sich überhaupt mit der Infrastruktur von Java-Objekten befassen muss.
Objekt-Infrastruktur in Java
In objekt-orientierten Programmiersprachen unterstützen alle Objekte ein Minimum an offensichtlich sinnvoller Basisfunktionalität. Dazu gehören scheinbare Trivialitäten wir das "Kopieren von Objekten" und "Vergleichen von Objekten". In Java sind das die Methoden clone(), equals() und einige andere, die zusammen so etwas wie die "Infrastruktur" eines Objekts ausmachen. Was genau meinen wir mit "Infrastruktur"?
Alle Klassen in Java sind implizit von der Klasse Object abgeleitet und erben daher alle Methoden aus Object. Zu diesen geerbten Methoden gehören die public Methoden equals() und hashCode(). equals() vergleicht zwei Objekte miteinander, während hashCode() einen integralen Wert (den sogenannten Hashcode) berechnet. Mit den Details dieser Methoden werden wir uns in diesem und den folgenden Artikeln noch eingehend beschäftigen. An dieser Stelle nur soviel: beide Methoden werden u.a. gebraucht, um Java-Objekte in hash-basierten Containern wie beispielsweise HashSet ablegen zu können.
Wegen der automatischen Ableitung von der Superklasse Object sind equals() und hashCode() Teil der Schnittstelle einer jeder Java-Klasse, d.h. man kann auf allen Objekten in Java equals() und hashCode()aufrufen. Es gibt auch immer eine Implementierung dieser Methoden, nämlich entweder die aus Object geerbte Default-Implementierung oder eine klassenspezifische Implementierung, wenn die betreffende Klasse die geerbte Methode überschrieben hat.
Methoden wie equals() und hashCode() stellen Basisfunktionalität zur Verfügung, die man von allen Objekten in Java erwartet. Die Menge der Basisfunktionalitäten bezeichnet man bisweilen als "Infrastruktur" eines Objekts. Zur Infrastruktur gehören nicht nur equals() und hashCode(), sondern auch Funktionalität für Initialisierung und Aufräumen von Objekten sowie für Kopieren und Vergleichen von Objekten. Initialisierung geschieht üblicherweise mittels Konstruktoren, Aufräumen mittels finalize()-Methode, Kopieren mittels clone()-Methode, Vergleichen mittels equals() und compareTo()-Methode. Die Liste erhebt keinen Anspruch auf Vollständigkeit. Zur Infrastruktur gehören in gewissem Sinne auch die Methoden für die Serialisierung von Objekten, nämlich readObject() und writeObject(), weil sie ebenfalls so etwas wie Konstruieren und Kopieren von Objekten definieren. Die von einer Klasse geforderte Infrastruktur kann also variieren abhängig vom Kontext, in dem die Klasse verwendet werden soll.
Wie wir bereits gesehen haben, werden equals() und hashCode() von der Superklasse Object geerbt. Beides sind public Methoden in Object, d.h. equals() und hashCode() gehören immer zur Schnittstelle einer Klasse. Das ist anders bei clone() und finalize(). Diese beiden Methoden sind ebenfalls in der Superklasse Object definiert, aber sie sind dort als protected deklariert. Damit werden sie zwar geerbt, sind aber nicht automatisch Bestandteil der Schnittstelle der Subklasse. Nur wenn die Subklasse Funktionalität für das Kopieren oder Aufräumen unterstützen will, dann wird sie diese geerbten Methoden aus Object überschreiben und als eigene public Methoden zur Verfügung stellen. (Im Falle von clone() kommt noch hinzu, dass die Subklasse zusätzlich das Cloneable-Interface implementieren muss, damit die clone()-Methode funktioniert.)
Andere Teile der Infrastruktur haben gar nichts mit der Superklasse Object zu tun, sondern man implementiert gewisse Interfaces, um die entsprechende Infrastruktur zur Verfügung zu stellen. In diese Kategorie fallen die Methoden compareTo() aus dem Comparable-Interface und readObject() und writeObject(), aus dem Serializable-Interface. Diese Teile der Infrastruktur wird eine Klasse nur dann zur Verfügung stellen, wenn das sinnvoll erscheint, was allerdings häufig der Fall ist: wenn Objekte in baum-basierten Containern wie TreeSet abgelegt werden sollen, dann macht es sehr viel Sinn, dass die Klasse eine compareTo()-Methode bekommt. Analog, wenn Objekte serialisiert werden sollen, dann müssen readObject() und writeObject() implementiert werden.
Damit haben wir nun eine Liste von Basisfunktionalität, die jede Java-Klasse zur Verfügung stellen kann. Beim Design einer neuen Klasse muss entschieden werden, welche Teile der Infrastruktur unterstützt werden sollen. Gewisse Methoden, nämlich equals() und hashCode(), können gar nicht vermieden werden. Wenn eine Klasse diese Methoden nicht überschreibt, dann steht automatisch die Default-Funktionalität aus der Superklasse Object zur Verfügung. Für diese Methoden ist die entscheidende Frage nicht "Unterstützen? Ja oder Nein?", sondern man muss entscheiden: "Ist das Default-Verhalten korrekt? Ja oder Nein?". Die Entscheidungen, die der Autor einer Klasse an dieser Stelle trifft, haben weitreichende Auswirkungen für die Benutzung und Benutzbarkeit der Klasse. Das gilt ganz besonders, wenn die Klasse eine potentielle Superklasse ist, und jede Klasse in Java, die nicht als final erklärt ist, ist eine potentielle Superklasse.
Im Tutorial sehen wir uns einige Teile dieser Infrastruktur näher an. Dabei wird sich herausstellen, dass korrekte Implementierungen der Infrastruktur keineswegs immer trivial sind. Was theoretisch so harmlos aussieht, kann in der Praxis tückisch sein. Landläufig herrscht die Meinung: "Es ist doch kein Problem, clone() oder equals() zu implementieren. Da muss man doch nur alle Felder kopieren bzw. miteinander vergleichen und das war's dann schon. Oder nicht?" Oder doch? Wir werden sehen!
Schauen wir uns diesmal den Objektvergleich mittels equals() an.
Objektvergleich in Java
In Java gibt es zwei Möglichkeiten, Variablen zu vergleichen: die eine ist der Vergleich über den == Operator, die andere Möglichkeit ist der Vergleich mit Hilfe der equals()-Methode.
Beispiel:
int x = 100;
int y = 100;
...
if (x==y) ...
Hier werden zwei int-Variablen miteinander verglichen. Für den Vergleich gibt es nur den == Operator, weil der Typ int keine equals()-Methode hat. Generell unterscheidet man in Java zwischen Variablen vom primitivem Typ und Referenz-Variablen.
Primitive Typen sind in der Sprache vordefinierte Typen wie int, long, boolean, etc.. Für Variablen vom primitivem Typ gibt es nur den Vergleich über den == Operator und der liefert true, wenn beide Variablen den gleichen Wert enthalten, wie das in obigem Beispiel der Fall ist.
Nicht-primitiven Typen sind Klassen und Interfaces. Alle Variablen diesen Typs sind in Java Referenzvariablen. Sie verweisen lediglich auf Objekte, enthalten diese Objekte aber nicht.
Beispiel:
String s1 = new String("Hello World !");
String s2 = new String("Hello World !");
...
if (s1 == s2) ... // yields false
...
if (s1.equals(s2)) ... // yields true
Hier werden zwei String-Variablen verglichen. String ist eine Klasse und deshalb sind die beiden Variablen s1 und s2 Referenzvariablen. Für Referenzvariablen gibt es neben dem Vergleich per == Operator den Vergleich mit Hilfe der equals()-Methode. Die beiden Vergleiche haben nicht nur unterschiedliche Syntax, sondern auch unterschiedliche Semantik.
Der Vergleich per == Operator ist die Prüfung auf Identität der beiden referenzierten Objekte. In unserem Beispiel haben wir zwei Referenzen s1 und s2 auf zwei String -Objekte, die an verschiedenen Stellen auf dem Heap angelegt wurden und den gleichen Inhalt haben. Die beiden referenzierten String-Objekte sind zwar gleich in dem Sinne, dass sie den gleichen Inhalt, nämlich "Hello World !", haben, aber sie sind nicht identisch, da sie an verschiedenen Stellen im Speicher angelegt sind.
Das Beispiel zeigt den Unterschied zwischen dem == Operator und der equals()-Methode: Der Vergleich mittels == Operator prüft auf Identität der referenzierten Objekte, während der Vergleich mittels equals()-Methode im Falle von String auf Gleichheit des Inhalts der referenzierten Objekte prüft. In unserem Beispiel liefert der erste Vergleich false (d.h. "nicht identisch") und der zweite Vergleich true (d.h. "inhaltlich gleich").
Damit haben wir nun ein erstes intuitives Verständnis von equals(): es prüft auf inhaltliche Gleichheit im Gegensatz zum == Operator, der auf Identität prüft (equality vs. identity).
Leider ist es nicht immer so, dass equals() und der == Operator diese unterschiedlichen Eigenschaften haben. Man findet schon in den Java-Bibliotheksklassen Beispiele für abweichendes Verhalten.
Beispiel:
String init = "Hello World !";
StringBuffer sb1 = new StringBuffer(init);
StringBuffer sb2 = new StringBuffer(init);
...
if (sb1 == sb2) ... // yields false
...
if (sb1.equals(sb2)) ... // yields false (!!!)
Offenbar sind StringBuffer-Objekte selbst bei gleichem Inhalt nicht gleich; jedenfalls ist dies das Ergebnis des Vergleichs mittels equals(). Wie kann das sein?
Nun, das liegt daran, dass jede Klasse die equals()-Methode von der Superklasse Object erbt. Eine Klasse wie StringBuffer, die die geerbte equals()-Methode nicht überschreibt, stellt damit automatisch die Default-Implementierung von equals()aus Object zur Verfügung. Die Default-Implementierung ist aber identisch mit dem Verhalten des == Operators: es wird auf Identität der referenzierten Objekte geprüft.
Dieses Defaultverhalten von equals()aus Object erklärt sich dadurch, dass in der Klasse Object über die Struktur und den Inhalt von Subklassen nichts bekannt ist. Eine universelle Implementierung von equals(), die für jede beliebige Subklasse "das Richtige" tut, nämlich den Inhalt vergleichen, wäre zwar machbar gewesen (mit Hilfe von dynamischer Typinformation), aber aufwendig. Die Designer der Klasse Object haben sich für eine einfachere Lösung entschieden und deshalb wird in Object.equals() nur auf Identität und nicht auf inhaltliche Gleichheit geprüft.
Dieses Default-Verhalten von Object.equals() und die Tatsache, dass die Klasse StringBuffer die geerbte equals()-Methode nicht überschreibt, erklären, warum in obigem Beispiel in beiden Vergleichen false als Ergebnis geliefert wird: die StringBuffer-Objekte haben zwar gleichen Inhalt, sind aber nicht identisch.
Ob das Ergebnis des Vergleichs von StringBuffer-Objekten mittels equals() das ist, was man erwartet, kann man sicher kontrovers diskutieren. Zumindest wirft es Fragen auf ... wann muss eine Klasse die Default-Implementierung von equals() überschreiben, und wann nicht? Und wenn ja, wie? Damit wollen wir uns im Folgenden beschäftigen.
Value vs. Entity-Types
Typen lassen sich in zwei Kategorien einteilen: man unterscheidet zwischen sogenannten Value- und Entity-Typen.
Value-Typen. Alle primitiven Typen in Java sind Value-Typen. Sie enthalten einen Wert und dieser Wert ist das Wesentliche. Klassen können ebenfalls Value-Typen sein. Bei solchen Klassen ist der Inhalt der Objekte ganz wesentlich. Der Inhalt repräsentiert den Wert des Objekts und bestimmt das Verhalten der Objekte fast vollständig. Beispiele solcher Value-Klassen sind die Standard-Klassen BigDecimal, String, Date, Point, etc.
Entity-Typen. Darunter versteht man Klassen, bei denen der Inhalt nicht das Wesentliche ist. Sie werden nicht als "Werte" betrachten und auch nicht als "Wert" herumgereicht. Das sind Typen, die hauptsächlich Dienste anbieten, oder Typen, die Referenzen auf andere unterliegende Objekte darstellen. Beispiele sind die Standardklassen Thread, Socket, oder FileOutputStream.
Betrachten wir zur Illustration ein Thread-Objekt und ein String-Objekt. Ein String-Objekt ist im wesentlichen durch seinen Inhalt, nämlich die enthaltene Zeichenkette, bestimmt. Davon kann man Kopien anlegen und man kann sie vergleichen. Das ist bei einem Thread-Objekt ganz anders. Natürlich hat auch ein Thread -Objekt Inhalt; ein Thread hat einen Namen und einen Zustand (runnable, blocked, dead, usw.) und eine Priorität und er verwendet ein Runnable-Objekt, dessen Code er ausführt. Aber all diese Eigenschaften ergeben in ihrer Gesamtheit keinen "Wert", den man vergleichen oder kopieren möchte. Wann sind zwei Threads gleich? Wenn sie denselben Namen haben? Oder denselben Code ausführen? Das macht logisch keinen Sinn. Was soll man sich unter der Kopie eines Threads vorstellen? Auch das macht nicht so recht Sinn. In solchen Fällen spricht man von Entity-Typen, wobei die Grenze zwischen Value- und Entity-Typen oftmals schwer zu ziehen ist.
Was bedeutet die Unterscheidung zwischen Value- und Entity-Typen für die Implementierung von equals()?
Entity-Typen überschreiben selten die equals()-Methode. Da sie keine Werte darstellen, ist der Vergleich des Inhalts praktisch bedeutungslos und aus diesem Grunde ist es völlig in Ordnung, wenn zwei Entity-Objekte genau dann "gleich" sind, wenn sie identisch sind.
Das ist bei Value-Typen ganz anders. Der Inhalt ist das Wesentliche des Objekte und deshalb sind zwei Value-Objekte genau dann gleich, wenn sie den gleichen Inhalt haben. In solchen Fällen muss equals() überschrieben werden, denn die Default-Implementierung ist unbrauchbar für solche Value-Typen.
Was schließen wir daraus? Eine der ersten Entscheidungen, die beim Design einer neuen Klasse gefällt werden muss, ist die Entscheidung, ob die Klasse Value- oder Entity-Objekte beschreiben soll. Im Falle von Entity-Verhalten kann man sich die Arbeit mit equals() sparen; im Falle von Value-Verhalten muss man es implementieren.
In der Praxis
Wie ist das nun in der Praxis?
"Habe ich was falsch gemacht, wenn ich eine Klasse ohne equals() geschrieben habe?"
Das kommt darauf an. Wenn es ein Entity-Typ ist, also eine reine Service-Klasse ist oder einen Verweis auf irgendwas darstellt, dann nicht. Wenn es aber ein Value-Typ ist, dann ist die geerbte equals()-Methode normalerweise inkorrekt.
"Aber ich weiß genau, dass equals() überhaupt nicht aufgerufen, nirgendwo in der gesamten Applikation. Wozu soll ich mir all die ganze Arbeit machen, wenn das sowieso keiner braucht?"
Das ist eine überzeugendes Argument! Aber ... wer kann schon mit Bestimmtheit sagen, dass eine Methode, die heute nicht gebraucht wird, morgen ebenfalls nicht gebraucht werden wird? Das Gefährliche an equals() ist, dass es immer definiert ist, weil es bereits in der Superklasse Object implementiert ist. Wenn morgen jemand MyClass.equals() ruft, dann lässt sich das klaglos übersetzen und es läuft ... aber leider falsch. Die dann einsetzende Fehlersuche erinnert fatal an die Suche nach Pointer-Problemen in C oder C++ - und das glaubte man doch in Java hinter sich gelassen zu haben. Sobald man sich halbwegs darüber klar geworden ist, dass man mit seiner Klasse einen Value-Typen implementiert, dann sollte man auf jeden Fall equals() korrekt implementieren. Alles andere ist fahrlässig.
Erschwerend kommt hinzu, dass equals() nicht immer sichtbar benutzt wird, sondern bereits implizit von gewissen JDK-Klassen verwendet wird. Der wichtigste Vertreter dieser equals()-benutzenden JDK-Klassen sind die hash-basierten Container wie Hashtable, HashMap und HashSet. Aber auch andere Klassen benutzen equals(). Häufig ist dies nicht einmal explizit in der JavaDoc ausgewiesen; eine korrekte equals()-Implementierung wird deshalb von jeder Klasse erwartet. Man kann also gar nicht mit Gewissheit sagen, dass equals() nicht gebraucht wird, weil es nicht benutzt wird.
Das heißt, der Autor einer Klasse muss in jedem Fall entscheiden, welche Semantik (Entity- oder Value-Typ) die Klasse haben soll. Daraus ergibt sich dann die Semantik für die equals()-Methode der neuen Klasse. Anders als bei anderen Teilen der Objekt-Infrastruktur kann man sich bei equals() um die Entscheidung nicht drücken. Wenn man sich nicht entscheidet, ist die Klasse mit ihrer geerbten Default-Implementierung von equals() u.U. inkorrekt.
Der sogenannte equals()-Contract
Wenn man nun equals() implementieren will, was muss man tun? Was wird von equals() erwartet? Intuitiv ist klar, dass es den Inhalt zweier Objekte vergleichen soll. Aber was bedeutet das genau?
Der Vergleich zweier Objekte sollte gewissen Regeln folgen, die man mehr oder weniger intuitiv von einem Vergleich erwartet. Diese zusätzlichen Eigenschaften einer Implementierung von equals() sind formal beschrieben im sogenannten "equals()-Contract". Den equals()-Contract findet man in der JDK JavaDoc unter Object.equals(). Hier ist die Originalbeschreibung aus der API Spezifikation der JavaTM 2 Platform, Standard Edition:
public boolean equals(Object obj)
Indicates whether some other object is "equal to" this one.
The equals method implements an equivalence relation:
- It is reflexive: for any reference value x, x.equals(x) should return true.
- It is symmetric: for any reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
- It is transitive: for any reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
- It is consistent: for any reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the object is modified.
- For any non-null reference value x, x.equals(null) should return false.
Das bedeutet das Folgende:
- Jedes Objekt liefert beim Vergleich mit sich selbst true.
- Es ist egal, ob man x mit y vergleicht, oder y mit x; das Ergebnis ist dasselbe.
- Wenn x gleich y ist und y gleich z, dann sind auch x und z gleich.
- Man kann zwei Objekte beliebig oft miteinander vergleichen; es kommt immer dasselbe heraus, solange sich die Objekte nicht verändern.
- Alle Objekte sind von null verschieden.
Eigentlich sind die Forderungen im equals()-Contract naheliegend und intuitiv verständlich. Das ist genau das, was jeder von einer Gleichheitsrelation erwartet. Man sollte also stets darauf achten, dass equals() konform zu diesen Regeln implementiert wird. Wenn eine Implementierung davon abweicht, dann sind Probleme unvermeidbar, weil sich alle Benutzer von equals() intuitiv auf die Eigenschaften verlassen, die der equals()-Contract formal beschreibt.
Anleitung zum Implementieren von equals()
Im Tutorial wird equals() Zeile für Zeile implementiert. An dieser Stelle können wir leider nur eine Übersicht über die Aufgaben und Verantwortlichkeiten in einer Klassenhierarchie geben, sowie Beispielimplementierungen im Source-Code:
Implementierung von equals() in einer direkten Subklasse von Object
class MyClass {
private String s;
private int i;
...
public boolean equals(Object other) {
if (this == other)
return true;
if (other == null)
return false;
if (other.getClass() != getClass())
return false;
if (!(s.equals(((MyClass)other).s)))
return false;
if (i != ((MyClass)other).i)
return false;
...
return true;
}
}
Implementierung von equals() in einer indirekten Subklasse von Object
class MySubclass extends MyClass {
private String t;
...
public boolean equals(Object other) {
if (this == other)
return true;
if (!super.equals(other))
return false;
if (!(t.equals(((MySubclass)other).t)))
return false;
...
return true;
}
}
Damit bleiben natürlich zahlreiche Fragen offen. Antworten finden Sie in den Büchern und Zeitschriften der nachfolgenden Referenzliste.
Weiterführende Literatur
Themen aus dem "Effective Java"-Tutorial werden in den folgenden Büchern behandelt:
/1/ “The Java Programming Language 3rd Edition”, Ken Arnold and James Gosling, Addison-Wesley, 2000
Das ist das Standardwerk zu Java und erklärt fast alle Sprachmittel. Zu verzwickten Problemen wie den oben angedeuteten wird man nicht viel finden. Exotische Sprachmittel wir etwa Phantom-Referenzen sind ebenfalls nicht besprochen. Aber insgesamt ist es trotzdem Pflichtlektüre für jeden Java-Programmierer.
/2/ “Effective Java”, Joshua Bloch, Addison-Wesley, 2001
Ein Buch im Stile von Scott Meyers's "Effective C++"-Büchern. Josh Bloch bespricht verschiedene Fallstricke der Sprache Java. Die vorgeschlagenen Lösungen sind aber bisweilen mit Vorsicht zu genießen (anders als bei Scott Meyers, der wirklich allgemein anerkannte Wahrheiten verkündet hat). Insgesamt aber durchaus lesens- und empfehlenswert.
/3/ “Practical Java”, Peter Haggar, Addison-Wesley, 2000
Vorläufer von "Effective Java". Ebenfalls im Stile von Scott Meyers's "Effective C++"-Büchern geschrieben. Peter Haggar kommt bisweilen zu ganz anderen Schlüssen und Empfehlungen als Joshua Bloch.
Lesenswerte Zeitschriften in diesem Zusammenhang sind:
/4/ "Java Report"
Ein US-Magazin, das leider im November 2001 das Erscheinen eingestellt hat. Wer Zugriff auf alte Ausgaben hat, kann sich die Kolumne von Mark Davies ansehen, der interessante Aspekte der Sprache besprochen hat.
/5/ "JavaSpektrum"
Ein deutsches Magazin. Themen aus dem Tutorial kann man in unserer Kolumne "Effective Java" nachlesen.
Informationen über das weiterführende Seminar, dem das Material des Tutorials entnommen ist, finden Sie unter:
Effective Java Advanced Java Programming Idioms 4-day seminar (open enrollment and on-site) |
---|