3.7 Mit Referenzen arbeiten, Vielfalt, Identität, Gleichwertigkeit
In Java gibt es mit null eine sehr spezielle Referenz, die Auslöser vieler Probleme ist. Doch ohne sie geht es nicht, und warum das so ist, wird der folgende Abschnitt zeigen. Anschließend wollen wir sehen, wie Objektvergleiche funktionieren und was der Unterschied zwischen Identität und Gleichwertigkeit ist.
3.7.1 null-Referenz und die Frage der Philosophie
In Java gibt es drei spezielle Referenzen: null, this und super. (Wir verschieben die Beschreibung von this und super auf Kapitel 6, »Eigene Klassen schreiben«.) Das spezielle Literal null lässt sich zur Initialisierung von Referenzvariablen verwenden. Die null-Referenz ist typenlos, kann also jeder Referenzvariablen zugewiesen und jeder Methode übergeben werden, die ein Objekt erwartet.[ 116 ](null verhält sich also so, als ob es ein Untertyp jedes anderen Typs wäre. )
[zB] Beispiel
Deklaration und Initialisierung zweier Objektvariablen mit null:
Point p = null;
String s = null;
System.out.println( p ); // null
Die Konsolenausgabe über die letzte Zeile liefert kurz »null«. Wir haben hier die String-Repräsentation vom null-Typ vor uns.
Da null typenlos ist und es nur ein null gibt, kann null zu jedem Typ typangepasst werden, und so ergibt zum Beispiel ((String) null == null && (Point) null == null das Ergebnis true. Das Literal null ist ausschließlich für Referenzen vorgesehen und kann in keinen primitiven Typ wie die Ganzzahl 0 umgewandelt werden.[ 117 ](Hier unterscheiden sich C(++) und Java. )
Mit null lässt sich eine ganze Menge machen. Der Haupteinsatzzweck sieht vor, damit uninitialisierte Referenzvariablen zu kennzeichnen, also auszudrücken, dass eine Referenzvariable auf kein Objekt verweist. In Listen oder Bäumen kennzeichnet null zum Beispiel das Fehlen eines gültigen Nachfolgers oder bei einem grafischen Dialog, dass der Benutzer den Dialog abgebrochen hat; null ist dann ein gültiger Indikator und kein Fehlerfall.
[»] Hinweis
Bei einer mit null initialisierten lokalen Variablen funktioniert die Abkürzung mit var nicht; es gibt einen Compilerfehler:
var text = null; // Cannot infer type for local variable initialized to 'null'
Auf null geht nix, nur die NullPointerException
Da sich hinter null kein Objekt verbirgt, ist es auch nicht möglich, eine Methode aufzurufen oder von null ein Attribut zu erfragen. Der Compiler kennt zwar den Typ jedes Ausdrucks, aber erst die Laufzeitumgebung (JVM) weiß, was referenziert wird. Bei dem Versuch, über die null-Referenz auf eine Eigenschaft eines Objekts zuzugreifen, löst eine JVM eine NullPointerException[ 118 ](Der Name zeigt das Überbleibsel von Zeigern. Zwar haben wir es in Java nicht mit Zeigern zu tun, sondern mit Referenzen, doch heißt es NullPointerException und nicht NullReferenceException. Das erinnert daran, dass eine Referenz ein Objekt identifiziert und eine Referenz auf ein Objekt ein Pointer ist. Das .NET Framework ist hier konsequenter und nennt die Ausnahme NullReferenceException. ) aus:
package com.tutego.insel.oop; // 1
public class NullPointer { // 2
public static void main( String[] args ) { // 3
java.awt.Point p = null; // 4
String s = null; // 5
p.setLocation( 1, 2 ); // 6
s.length(); // 7
} // 8
} // 9
Wir beobachten eine NullPointerException zur Laufzeit, denn das Programm bricht bei p.setLocation(…) mit folgender Ausgabe ab:
Exception in thread "main" java.lang.NullPointerException
at com.tutego.insel.oop.NullPointer.main(NullPointer.java:6)
Die Laufzeitumgebung teilt uns in der Fehlermeldung mit, dass sich der Fehler, die NullPointerException, in Zeile 6 befindet. Um den Fehler zu korrigieren, müssen wir entweder die Variablen initialisieren, das heißt, ein Objekt zuweisen wie in
p = new java.awt.Point();
s = "";
oder vor dem Zugriff auf die Eigenschaften einen Test durchführen, ob Objektvariablen auf etwas zeigen oder null sind, und in Abhängigkeit vom Ausgang des Tests den Zugriff auf die Eigenschaft zulassen oder nicht.
[»] »null« in anderen Programmiersprachen *
Ist Java eine rein objektorientierte Programmiersprache? Nein, da Java einen Unterschied zwischen primitiven Typen und Referenztypen macht. Nehmen wir für einen Moment an, dass es primitive Typen nicht gäbe. Wäre Java dann eine rein objektorientierte Programmiersprache, bei der jede Referenz ein pures Objekt referenziert? Die Antwort ist immer noch nein, da es mit null etwas gibt, womit Referenzvariablen initialisiert werden können, was aber kein Objekt repräsentiert und keine Methoden besitzt. Und das kann bei der Dereferenzierung eine NullPointerException geben.
Andere Programmiersprachen haben andere Lösungsansätze, und null-Referenzierungen sind nicht möglich. In der Sprache Ruby zum Beispiel ist immer alles ein Objekt. Wo Java mit null ein »nicht belegt« ausdrückt, macht Ruby das mit nil. Der feine Unterschied ist, dass nil ein Exemplar der Klasse NilClass ist, genau genommen ein Singleton, das es im System nur einmal gibt. nil hat auch ein paar öffentliche Methoden wie to_s (wie Javas toString()), das dann einen leeren String liefert. Mit nil gibt es keine NullPointerException mehr, aber natürlich immer noch einen Fehler, wenn auf diesem Objekt vom Typ NilClass eine Methode aufgerufen wird, die es nicht gibt. In Objective-C, der (bisherigen) Standardsprache für iOS-Programme, gibt es das Null-Objekt nil. Üblicherweise passiert nichts, wenn eine Nachricht an das nil-Objekt gesendet wird; die Nachricht wird einfach ignoriert.[ 119 ](Es gibt auch Compiler wie den GCC, der mit der Option -fno-nil-receivers dieses Verhalten abschaltet, um schnelleren Maschinencode zu erzeugen. Denn letztendlich muss in Maschinencode immer ein Test stehen, der auf 0 prüft. )
3.7.2 Alles auf null? Referenzen testen
Mit dem Vergleichsoperator == oder dem Test auf Ungleichheit mit != lässt sich leicht herausfinden, ob eine Referenzvariable wirklich ein Objekt referenziert oder nicht:
if ( object == null )
// Variable referenziert nichts, ist aber gültig mit null initialisiert
else
// Variable referenziert ein Objekt
null-Test und Kurzschluss-Operatoren
Wir wollen an dieser Stelle noch einmal auf die üblichen logischen Kurzschluss-Operatoren und den logischen, nicht kurzschließenden Operator zu sprechen kommen. Erstere werten Operanden nur so lange von links nach rechts aus, bis das Ergebnis der Operation feststeht. Auf den ersten Blick scheint es nicht viel auszumachen, ob alle Teilausdrücke ausgewertet werden oder nicht. In einigen Ausdrücken ist dies aber wichtig, wie das folgende Beispiel für die Variable s vom Typ String zeigt:
public static void main( String[] args ) {
String s = javax.swing.JOptionPane.showInputDialog( "Eingabe" );
if ( s != null && ! s.isEmpty() )
System.out.println( "Eingabe: " + s );
else
System.out.println( "Abbruch oder keine Eingabe" );
}
Die Rückgabe von showInputDialog(…) ist null, wenn der Benutzer den Dialog abbricht. Das soll unser Programm berücksichtigen. Daher testet die if-Bedingung, ob s überhaupt auf ein Objekt verweist, und prüft gleichzeitig, ob die Länge größer 0 ist. Dann folgt eine Ausgabe.
Diese Schreibweise tritt häufig auf, und der Und-Operator zur Verknüpfung muss ein Kurzschluss-Operator sein, da es in diesem Fall ausdrücklich darauf ankommt, dass die Länge nur dann bestimmt wird, wenn die Variable s überhaupt auf ein String-Objekt verweist und nicht null ist. Andernfalls bekämen wir bei s.isEmpty() eine NullPointerException, wenn jeder Teilausdruck ausgewertet würde und s gleich null wäre.
[»] Das Glück der anderen: null coalescing operator *
Da null viel zu oft vorkommt, null-Referenzierungen aber vermieden werden müssen, gibt es viel Code der Art o != null ? o : non_null_o. Diverse Programmiersprachen wie JavaScript, Kotlin, Objective-C, PHP oder Swift bieten für dieses Konstrukt eine Abkürzung über den sogenannten null coalescing operator (coalescing heißt auf Deutsch »verschmelzend«). Er wird mal als ?? oder als ?: geschrieben, für unser Beispiel so: o ?? non_null_o. Besonders hübsch ist das bei sequenziellen Tests der Art o ?? p ?? q ?? r, wo es dann sinngemäß heißt: »Liefere die erste Referenz ungleich null.« Java bietet keinen solchen Operator.
3.7.3 Zuweisungen bei Referenzen
Eine Referenz erlaubt den Zugriff auf das referenzierte Objekt, und eine Referenzvariable speichert eine Referenz. Es kann durchaus mehrere Referenzvariablen geben, die die gleiche Referenz speichern. Das wäre so, als ob ein Objekt unter verschiedenen Namen angesprochen wird – so wie eine Person von den Mitarbeitern als »Chefin« angesprochen wird, aber von ihrem Mann als »Schnuckiputzi«. Dies nennt sich auch Alias.
[zB] Beispiel
Ein Punkt-Objekt wollen wir unter einem alternativen Variablennamen ansprechen:
Point p = new Point();
Point q = p;
Ein Punkt-Objekt wird erzeugt und mit der Variablen p referenziert. Die zweite Zeile speichert nun dieselbe Referenz in der Variablen q. Danach verweisen p und q auf dasselbe Objekt. Zum besseren Verständnis: Wichtig ist, wie oft es new gibt, denn das sagt aus, wie viele Objekte die JVM bildet. Und bei den zwei Zeilen gibt es nur ein new, also auch nur einen Punkt.
Verweisen zwei Objektvariablen auf dasselbe Objekt, hat das natürlich zur Konsequenz, dass über zwei Wege Objektzustände ausgelesen und modifiziert werden können. Heißt die gleiche Person in der Firma »Chefin« und zu Hause »Schnuckiputzi«, wird der Mann sich freuen, wenn die Frau in der Firma keinen Stress hat.
Wir können das Beispiel auch gut bei Punkt-Objekten nachverfolgen. Zeigen p und q auf dasselbe Punkt-Objekt, können Änderungen über p auch über die Variable q beobachtet werden:
public static void main( String[] args ) {
Point p = new Point();
Point q = p;
p.x = 10;
System.out.println( q.x ); // 10
q.y = 5;
System.out.println( p.y ); // 5
}
3.7.4 Methoden mit Referenztypen als Parametern
Dass sich dasselbe Objekt unter zwei Namen (über zwei verschiedene Variablen) ansprechen lässt, können wir gut bei Methoden beobachten. Eine Methode, die über den Parameter eine Objektreferenz erhält, kann auf das übergebene Objekt zugreifen. Das bedeutet, die Methode kann dieses Objekt mit den angebotenen Methoden ändern oder auf die Attribute zugreifen.
Im folgenden Beispiel deklarieren wir zwei Methoden. Die erste Methode, initializeToken(Point), soll einen Punkt mit Zufallskoordinaten initialisieren. Übergeben werden ihr dann zwei Point-Objekte: einmal für einen Spieler und einmal für eine Schlange. Die zweite Methode, printScreen(Point, Point), gibt das Spielfeld auf dem Bildschirm aus und gibt dann, wenn die Koordinate einen Spieler trifft, ein & aus und bei der Schlange ein S. Falls Spieler und Schlange zufälligerweise zusammentreffen, »gewinnt« die Schlange.
package com.tutego.insel.oop;
import java.awt.Point;
public class DrawPlayerAndSnake {
static void initializeToken( Point p ) {
int randomX = (int)(Math.random() * 40); // 0 <= x < 40
int randomY = (int)(Math.random() * 10); // 0 <= y < 10
p.setLocation( randomX, randomY );
}
static void printScreen( Point playerPosition,
Point snakePosition ) {
for ( int y = 0; y < 10; y++ ) {
for ( int x = 0; x < 40; x++ ) {
if ( snakePosition.distanceSq( x, y ) == 0 )
System.out.print( 'S' );
else if ( playerPosition.distanceSq( x, y ) == 0 )
System.out.print( '&' );
else System.out.print( '.' );
}
System.out.println();
}
}
public static void main( String[] args ) {
Point playerPosition = new Point();
Point snakePosition = new Point();
System.out.println( playerPosition );
System.out.println( snakePosition );
initializeToken( playerPosition );
initializeToken( snakePosition );
System.out.println( playerPosition );
System.out.println( snakePosition );
printScreen( playerPosition, snakePosition );
}
}
Die Ausgabe kann so aussehen:
java.awt.Point[x=0,y=0]
java.awt.Point[x=0,y=0]
java.awt.Point[x=38,y=1]
java.awt.Point[x=19,y=8]
........................................
......................................&.
........................................
........................................
........................................
........................................
........................................
........................................
...................S....................
........................................
In dem Moment, in dem main(…) die statische Methode initializeToken(Point) aufruft, gibt es sozusagen zwei Namen für das Point-Objekt: playerPosition und p. Allerdings ist das nur innerhalb der virtuellen Maschine so, denn initializeToken(Point) kennt das Objekt nur unter p, aber kennt die Variable playerPosition nicht. Bei main(…) ist es umgekehrt: Nur der Variablenname playerPosition ist in main(…) bekannt, er hat aber vom Namen p keine Ahnung. Die Point-Methode distanceSq(int, int) liefert den quadrierten Abstand vom aktuellen Punkt zu den übergebenen Koordinaten.
[»] Hinweis
Der Name einer Parametervariablen darf durchaus mit dem Namen der Argumentvariablen übereinstimmen, was die Semantik nicht verändert. Die Namensräume sind völlig getrennt, und Missverständnisse gibt es nicht, da beide – die aufrufende Methode und die aufgerufene Methode – komplett getrennte lokale Variablen haben.
Wertübergabe und Referenzübergabe per Call by Value
Primitive Variablen werden immer per Wert kopiert (Call by Value). Das Gleiche gilt für Referenzen, die ja als eine Art Zeiger zu verstehen sind, und das sind im Prinzip nur Ganzzahlen. Daher hat auch die folgende statische Methode keine Nebenwirkungen:
package com.tutego.insel.oop;
import java.awt.Point;
public class JavaIsAlwaysCallByValue {
static void clear( Point p ) {
System.out.println( p ); // java.awt.Point[x=10,y=20]
p = new Point();
System.out.println( p ); // java.awt.Point[x=0,y=0]
}
public static void main( String[] args ) {
Point p = new Point( 10, 20 );
clear( p );
System.out.println( p ); // java.awt.Point[x=10,y=20]
}
}
Nach der Zuweisung p = new Point() in der clear(Point)-Methode referenziert die Parametervariable p ein anderes Punkt-Objekt, und der an die Methode übergebene Verweis geht damit verloren. Diese Änderung wird nach außen hin natürlich nicht sichtbar, denn die Parametervariable p von clear(…) ist ja nur ein temporärer alternativer Name für das p aus main; eine Neuzuweisung an das clear-p ändert nicht den Verweis vom main-p. Das bedeutet, dass der Aufrufer von clear(…) – und das ist main(…) – kein neues Objekt unter sich hat. Wer den Punkt mit null initialisieren möchte, muss auf die Zustände des übergebenen Objekts direkt zugreifen, etwa so:
static void clear( Point p ) {
p.x = p.y = 0;
}
[»] Call by Reference gibt es in Java nicht – ein Blick auf C und C++ *
In C++ gibt es eine weitere Argumentübergabe, die sich Call by Reference nennt. Würde eine Methode wie clear(…) mit Referenzsemantik deklariert, würde die Variable p ein Synonym darstellen, also einen anderen Namen für eine Variable – in unserem Fall q. Damit würde die Zuweisung im Rumpf den Zeiger auf ein neues Objekt legen. Die swap(…)-Funktion ist ein gutes Beispiel für die Nützlichkeit von Call by Reference:
void swap( int& a, int& b ) { int tmp = a; a = b; b = tmp; }
Zeiger und Referenzen sind in C++ etwas anderes, was Spracheinsteiger leicht irritiert. Denn in C++ und auch in C hätte eine vergleichbare swap(…)-Funktion auch mit Zeigern implementiert werden können:
void swap( int *a, int *b ) { int tmp = *a; *a = *b; *b = tmp; }
Die Implementierung gibt in C(++) einen Verweis auf das Argument.
Final deklarierte Referenzparameter und das fehlende const
Wir haben gesehen, dass finale Variablen dem Programmierer vorgeben, dass er Variablen nicht wieder beschreiben darf. Final können lokale Variablen, Parametervariablen, Objektvariablen oder Klassenvariablen sein. In jedem Fall sind neue Zuweisungen tabu. Dabei ist es egal, ob die Parametervariable vom primitiven Typ oder vom Referenztyp ist. Bei einer Methodendeklaration der folgenden Art wäre also eine Zuweisung an p und auch an value verboten:
public void clear( final Point p, final int value )
Ist die Parametervariable nicht final und ein Referenztyp, so würden wir mit einer Zuweisung den Verweis auf das ursprüngliche Objekt verlieren, und das wäre wenig sinnvoll, wie wir im vorangehenden Beispiel gesehen haben. final deklarierte Parametervariablen machen im Programmcode deutlich, dass eine Änderung der Referenzvariablen unsinnig ist, und der Compiler verbietet eine Zuweisung. Im Fall unserer clear(…)-Methode wäre die Initialisierung direkt als Compilerfehler aufgefallen:
static void clear( final Point p ) {
p = new Point(); // The final local variable p cannot be assigned.
}
Halten wir fest: Ist ein Parameter mit final deklariert, sind keine Zuweisungen möglich. final verbietet aber keine Änderungen an Objekten – und so könnte final im Sinne der Übersetzung als »endgültig« verstanden werden. Mit der Referenz des Objekts können wir sehr wohl den Zustand verändern, so wie wir es auch im letzten Beispielprogramm taten.
final erfüllt demnach nicht die Aufgabe, schreibende Objektzugriffe zu verhindern. Eine Methode mit übergebenen Referenzen kann also Objekte verändern, wenn es etwa setXXX(…)-Methoden oder Variablen gibt, auf die zugegriffen werden kann. Die Dokumentation muss also immer ausdrücklich beschreiben, wann die Methode den Zustand eines Objekts modifiziert.
In C++ gibt es für Parameter den Zusatz const, an dem der Compiler erkennen kann, dass Objektzustände nicht verändert werden sollen. Ein Programm nennt sich const-korrekt, wenn es niemals ein konstantes Objekt verändert. Dieses const ist in C++ eine Erweiterung des Objekttyps, die es in Java nicht gibt. Zwar haben die Java-Entwickler das Schlüsselwort const reserviert, doch genutzt wird es bisher nicht.
3.7.5 Identität von Objekten
Die Vergleichsoperatoren == und != sind für alle Datentypen so definiert, dass sie die vollständige Übereinstimmung zweier Werte testen. Bei primitiven Datentypen ist das einfach einzusehen und bei Referenztypen im Prinzip genauso (zur Erinnerung: Referenzen lassen sich als Pointer verstehen, was Ganzzahlen sind). Der Operator == testet bei Referenzen, ob sie übereinstimmen, also auf dasselbe Objekt verweisen. Der Operator != testet das Gegenteil, also ob sie nicht übereinstimmen, die Referenzen somit ungleich sind. Demnach sagt der Test etwas über die Identität der referenzierten Objekte aus, aber nichts darüber, ob zwei verschiedene Objekte möglicherweise den gleichen Inhalt haben. Der Inhalt der Objekte spielt bei == und != keine Rolle.
[zB] Beispiel
Zwei Objekte mit drei unterschiedlichen Punktvariablen p, q, r und die Bedeutung von ==:
Point p = new Point( 10, 10 );
Point q = p;
Point r = new Point( 10, 10 );
System.out.println( p == q ); // true, da p und q dasselbe Objekt referenzieren
System.out.println( p == r ); // false, da p und r zwei verschiedene Punkt-
// Objekte referenzieren, die zufällig dieselben
// Koordinaten haben
Da p und q auf dasselbe Objekt verweisen, ergibt der Vergleich true. p und r referenzieren unterschiedliche Objekte, die aber zufälligerweise den gleichen Inhalt haben. Doch woher soll der Compiler wissen, wann zwei Punkt-Objekte inhaltlich gleich sind? Weil sich ein Punkt durch die Attribute x und y auszeichnet? Die Laufzeitumgebung könnte voreilig die Belegung jeder Objektvariablen vergleichen, doch das entspricht nicht immer einem korrekten Vergleich, so wie wir ihn uns wünschen. Ein Punkt-Objekt könnte etwa zusätzlich die Anzahl der Zugriffe zählen, die jedoch für einen Vergleich, der auf der Lage zweier Punkte basiert, nicht berücksichtigt werden darf.
3.7.6 Gleichwertigkeit und die Methode equals(…)
Die allgemeingültige Lösung besteht darin, die Klasse festlegen zu lassen, wann Objekte gleich(wertig) sind. Dazu kann jede Klasse eine Methode equals(…) implementieren, und mit ihrer Hilfe kann sich jedes Exemplar dieser Klasse mit beliebigen anderen Objekten vergleichen. Die Klassen entscheiden immer nach Anwendungsfall, welche Attribute sie für einen Gleichheitstest heranziehen, und equals(…) liefert true, wenn die gewünschten Zustände (Objektvariablen) übereinstimmen.
[zB] Beispiel
Zwei nichtidentische, inhaltlich gleiche Punkt-Objekte, werden mit == und equals(…) verglichen:
Point p = new Point( 10, 10 );
Point q = new Point( 10, 10 );
System.out.println( p == q ); // false
System.out.println( p.equals(q) ); // true, da symmetrisch auch q.equals(p)
Nur equals(…) testet in diesem Fall die inhaltliche Gleichwertigkeit.
Bei den unterschiedlichen Bedeutungen müssen wir demnach die Begriffe Identität und Gleichwertigkeit (auch Gleichheit) von Objekten sorgfältig unterscheiden. Daher zeigt Tabelle 3.4 noch einmal eine Zusammenfassung:
Getestet mit | Implementierung | |
---|---|---|
Identität der Referenzen | Nichts zu tun | |
Gleichwertigkeit der Zustände | Abhängig von der Klasse |
equals(…)-Implementierung von Point *
Die Klasse Point deklariert equals(…), wie die API-Dokumentation zeigt. Werfen wir einen Blick auf die Implementierung, um eine Vorstellung von der Arbeitsweise zu bekommen:
public class Point ... {
public int x;
public int y;
...
public boolean equals( Object obj ) {
...
Point pt = (Point) obj;
return (x == pt.x) && (y == pt.y); // (*)
...
}
}
Obwohl bei diesem Beispiel für uns einiges neu ist, erkennen wir den Vergleich in der Zeile (*). Hier vergleicht das Point-Objekt seine eigenen Attribute mit den Attributen des Punktobjekts, das als Argument an equals(…) übergeben wurde.
Es gibt immer ein equals(…) – die Oberklasse Object und ihr equals(…) *
Glücklicherweise müssen wir als Programmierer nicht lange darüber nachdenken, ob eine Klasse eine equals(…)-Methode anbieten soll oder nicht. Jede Klasse besitzt sie, da die universelle Oberklasse Object sie vererbt. Wir greifen hier auf Kapitel 7, »Objektorientierte Beziehungsfragen«, vor; der Abschnitt kann aber übersprungen werden. Wenn eine Klasse also keine eigene equals(…)-Methode angibt, dann erbt sie eine Implementierung aus der Klasse Object. Diese Klasse sieht wie folgt aus:
public class Object {
public boolean equals( Object obj ) {
return ( this == obj );
}
...
}
Wir erkennen, dass hier die Gleichwertigkeit auf die Identität der Referenzen abgebildet wird. Ein inhaltlicher Vergleich findet nicht statt. Das ist das Einzige, was die vorgegebene Implementierung machen kann, denn sind die Referenzen identisch, sind die Objekte logischerweise auch gleich. Nur über Zustände »weiß« die Basisklasse Object nichts.
[»] Sprachvergleich
Es gibt Programmiersprachen, die für den Identitätsvergleich und Gleichwertigkeitstest eigene Operatoren anbieten. Was bei Java == und equals(…) sind, sind bei Python is und ==, bei Swift === und ==.