![]() |
|
|||||
Die objektorientierte Programmierung versucht nun, diese drei Faktoren zu beachten. Stehen die Daten im Vordergrund, so müssen wir weniger in Funktionen denken, sondern in Objekten, die die Daten beschreiben. Damit Änderungen gut möglich sind, kapseln wir die Funktionen so weit von den Daten, dass sie allgemein angewendet werden können. Wir sehen schon an dieser kurzen Beschreibung, dass ein Objekt immer im Mittelpunkt steht. Alles dreht sich um das Objekt, und dies bezeichnen wir als Objekt-Orientiert. 3.1.2 Modularität und Wiederverwertbarkeit
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Attribute (Variablen, auch »Felder«3 genannt) |
| Operationen (Methoden4 , die Funktionen einer Klasse) |
| weitere Klassen (innere Klassen) |
Attribute und Operationen heißen auch Eigenschaften5 eines Objekts. Welche Eigenschaften eine Klasse tatsächlich besitzen soll, wird in der Analyse- und Design-Phase festgesetzt. Dies wird in diesem Buch kein Thema sein; für uns liegen die Klassenbeschreibungen schon vor.
Um sich einer Klasse zu nähern, können wir einen lustigen Ich-Ansatz verwenden, der auch in der Analyse- und Design-Phase eingesetzt wird. Bei diesem Ich-Ansatz versetzen wir uns in das Objekt und sagen »Ich bin...« für die Klasse und »Ich habe...« für die Attribute und »Ich kann...« für die Operationen. Meine Leser sollte dies einmal an den Klassen Mensch, Auto, Wurm und Kuchen testen.
Bevor wir uns mit eigenen Klassen beschäftigen, wollen wir zunächst einige Klassen aus der Standardbibliothek kennen lernen. Eine dieser Klassen ist Point. Sie beschreibt einen Punkt in einer zweidimensionalen Ebene durch die Koordinaten x und y und bietet einige Funktionen an, mit denen sich Punkt-Objekte anlegen und verändern lassen. Testen wir einen Punkt wieder mit dem Objektansatz:
| Klassenaname | Ich bin ein Punkt. |
| Attribute | Ich habe eine x- und y-Koordinate. |
| Operationen | Ich kann mich verschieben und meine Position festlegen |
Für die Darstellung einer Klasse lässt sich Programmcode verwenden, also eine Textform oder aber eine grafische Notation. Eine dieser grafischen Beschreibungsformen ist die UML. Grafische Abbildungen sind für Menschen deutlich besser zu verstehen und erhöhen die Übersicht.
|
Im ersten Abschnitt lassen sich die Attribute ablesen, im zweiten die Methoden. Das + davor zeigt an, dass die Eingeschafften öffentlich sind und jeder sie nutzen kann. Die Typenangabe ist umgekehrt wie in Java: Zuerst kommt der Name der Variable, dann der Typ beziehungsweise bei Methoden der Typ des Rückgabewerts.
UML (Unified Modeling Language) ist mehr als nur eine Notation zur Darstellung von Klassen. Mit ihrer Hilfe lassen sich die Analyse und das Design im Softwareentwicklungsprozess beschreiben. Mittlerweile hat sich UML jedoch zu einer allgemeinen Notation für andere Beschreibungen entwickelt, zum Beispiel für Datenbanken oder Workflow-Anwendungen.
Vor UML waren andere Darstellungsvarianten wie OMT oder Booch verbreitet. Diese waren eng mit einer Methode verbunden, die einen Entwicklungsprozess und ein Vorgehensmodell beschrieben. Methoden versuchen eine Vorgehensweise beim Entwurf von Systemen zu beschreiben, etwa »erst Vererbung einsetzen und dann die Attribute finden« oder »erst die Attribute finden und dann mit Vererbung verfeinern«. Bekannte OO-Methoden sind etwa Shlaer/Mellor, Coad/Yourdon, Booch, OMT und OOSE/Objectory. Aus dem Wunsch heraus, OO-Methoden zusammenzufassen, ging UML hervor. Anfangs stand die Abkürzung noch für Unified Method. Die Urversion 0.8 war die erste Veröffentlichung im Jahre 1995. Die Initiatoren waren Jim Rumbaugh und Grady Booch. Später trat Ivar Jacobson dazu und die drei »Amigos« erweiterten UML, die in der Version 1.0 bei der Object Management Group (OMG) als Standardisierungsvorschlag eingereicht wurde. Die Amigos nannten UML nun »Unified Modeling Language«, was deutlich macht, dass UML keine Methode ist, sondern lediglich eine Modellierungssprache. Folgende Tabelle gibt eine Kurzübersicht über die Veränderungen von UML:
| 1994 | Booch und Rumbaugh vereinigen ihre Ansätze OOAD und OMT. |
| 1995 | Unified Method in der Version 0.8. Jacobson bringt OOSE mit ein und UML wird vereinfacht und von einer einheitlichen Methode wird abgesehen. |
| 1996 | UML in der Version 0.9. Notation wird verfeinert unter anderem von vielen Unternehmen wie Oracle, Microsoft, Digital, HP. |
| 1997 | Versionen 1.0 und 1.1 erscheinen. UML wird bei der OMG eingereicht. Im September wird UML 1.1 zum Standard. |
| 1998 | Version 1.2 mit Detailverbesserungen und einigen Korrekturen. |
| 1999 | UML in der Version 1.3 |
Eine aktuelle Version des Standards lässt sich bei http://www.rational.com/uml beziehen.
In UML werden unterschiedliche Diagramme definiert, die die unterschiedlichen Sichten auf die Software beschreiben. Für die einzelnen Phasen im Softwareentwurf sind unterschiedliche Diagrammtypen wichtig. Wir wollen kurz drei Diagramme und ihr Einsatzgebiet besprechen.
Die Use-Cases-Diagramme entstehen meist während der Anforderungsphase und beschreiben die Geschäftsprozesse, in dem die Interaktion von Personen oder von bereits existierenden Programmen mit dem System dargestellt werden. Die handelnden Personen oder aktiven Systeme werden Aktoren genannt. Ein Use-Case beschreibt dann eine Interaktion mit dem System. Dazu werden die Aktoren als lustige Männchen gemalt (wobei die Geschlechter nicht zu erkennen sind) und die einzelnen Anwendungsfälle (Use-Cases) als Ellipsen.
Für die statische Sicht auf einen Programmentwurf sind Klassendiagramme einer der wichtigsten Diagrammtypen. Sie sind besonders interessant, da Hilfsprogramme aus diesen Diagrammen automatisch Teile des Quellcodes erzeugen können. Die Diagramme stellen zum einen die Elemente der Klasse dar, zum anderen die Beziehungen der Klassen untereinander. Die Diagramme werden öfters in diesem Buch eingesetzt. Klassen werden als Rechteck dargestellt und die Beziehungen zwischen den Klassen durch Linien angedeutet.
Der Begriff umfasst zwei Unterdiagramme zur Darstellung der zeitlichen Abläufe eines Systems, die Sequenzdiagramme und die Kollaborationsdiagramme. Damit wird im Gegensatz zum Klassendiagramm das dynamische Verhalten von Objekten dargestellt.
Von der Klasse Point werden zur Laufzeit Exemplare erzeugt, die Point-Objekte. Eine Klasse beschreibt also, wie ein Objekt aussehen soll. In einer Mengen- bzw. Element-Beziehung ausgedrückt entsprechen Objekte den Elementen und Klassen den Mengen, in denen die Objekte als Elemente enthalten sind.
Wir verbinden nun einen Variablennamen mit der Klasse und definieren bzw. deklarieren somit eine Variable, die eine Referenz auf ein Point-Objekt (ein Element der Klasse Point) erzeugt.
Beispiel Definieren einer Variablen p vom Typ Point.
Point p; |
Vergleichen wir dies mit der bereits bekannten Deklaration einer Variablen für einen ganzzahligen Wert
int i;,
so können wir uns dies gut merken, denn links steht in beiden Fällen der Typ und rechts der Name der Variablen.
Im oberen Beispiel deklarieren wir eine Variable p und teilen dem Compiler mit, dass diese Variable Referenzen auf Objekte vom Typ Point erzeugen soll. Falls es sich bei p um eine Objekt- oder Klassenvariable handelt, wird p anfangs mit der Null-Referenz initialisiert, die auf kein Objekt verweist; als lokale Variable hätte p keinen vordefinierten Wert.
Durch die Deklaration einer Variablen mit dem Namen einer Klasse als Typ wird noch kein Exemplar erzeugt. Dazu müssen wir mit dem new-Operator explizit ein Objekt erzeugen. Hinter dem new-Operator folgt immer der Name der Klasse von der ein Exemplar erzeugt werden soll und ein Paar Klammern. Wir werden später sehen, dass hier ein spezieller Methodenaufruf (Konstruktoraufruf) stattfindet, bei dem wir auch Werte übergeben können.
Beispiel Anlegen eines Objekts und speichern der Referenz in einer Variablen.
p = new Point(); |
Das tatsächliche Objekt wird erst dynamisch, also zur Laufzeit, mit new erzeugt. Damit stellt das System Speicher für ein Point-Objekt bereit und speichert eine Referenz auf diesen reservierten Speicherblock in der Variablen p ab.
Die Deklaration der Variablen p und die separate Erzeugung eines Exemplars der Klasse Point lassen sich, wie bei der Deklaration primitiver Datentypen, auch kombinieren.
Beispiel Deklaration mit Initialisierung
double pi = 3.1415926535; Point p = new Point(); |
Die in einer Klasse definierten Variablen werden »Objektvariablen« (auch »Exemplar-, Instanz- oder Ausprägungsvariablen«) genannt. Wird ein Objekt erschaffen, dann erhält es seinen eigenen Satz von Objektvariablen6 . Sie bilden einen Zustand.
Ist das Objekt angelegt, wird auf die Methoden oder Variablen mit einem Punkt zugegriffen. Der Punkt (auch »Selektor« genannt) steht zwischen der Referenz und der Objekt-Eigenschaft.
Beispiel Folgende Zeilen erzeugen ein Point-Objekt, speichern eine Referenz auf dieses Objekt mit der Variablen p und weisen den Objektvariablen x und y Werte zu.
Point p = new Point(); p.x = 12; p.y = 45; |
Der Typ links vom Punkt muss immer eine Referenz sein. Im Prinzip funktioniert auch Folgendes:
new Point().x = 1;
Dies ist allerdings unsinnig, da zwar das Objekt erzeugt und ein Attribut gesetzt wird, anschließend aber der Garbage-Collector das Objekt wieder wegräumt. Für einen Methodenaufruf kann dies schon sinnvoller sein.
Ein Methodenaufruf gestaltet sich genau so einfach wie eine Objekterzeugung. Hinter der Referenz und dem Punkt folgt der Methodenname. Das nachfolgende Beispiel ist lauffähig und bindet gleich noch die Point-Klasse aus dem Paket java.awt ein. Ein Paket ist eine Gruppe zusammengehöriger Klassen.
Listing 3.1 MyPoint.javaimport java.awt.Point; class MyPoint { public static void main( String args[] ) { Point p = new Point(); p.x = p.y = 12; p.setLocation( -3, 2 ); System.out.println( p.toString() ); // alternativ // System.out.println( p ); } }
Die letzte Anweisung ist gültig, da println() bei einem Objekt automatisch die toString()-Methode aufruft.
Werden Objekte mit dem new-Operator angelegt, so wird ein Konstruktor aufgerufen, eine Art Methode mit besonderer Signatur. Bei der Erschaffung eines Objekts sollen in der Regel die Objektvariablen initialisiert werden. Diese Initialisierung wird dazu in den Konstruktor gesetzt, um sicherzustellen, dass das neue Objekt einen sinnvollen Anfangszustand aufweist.
Ein Konstruktor ohne Argumente ist der Standard-Konstruktor (auch »Default-Konstruktor«, selten »No-Arg-Konstruktor« genannt).
Beispiel Folgende Zeilen erzeugen schlussendlich zwei Point-Objekte mit denselben Koordinaten. Die Variablen p und q referenzieren jedoch zwei völlig getrennte Objekte; lediglich die Belegung der x- und y-Koordinaten ist bei den beiden Objekten »zufällig« gleich.
Point p = new Point(); p.setLocation( 10, 10 ); Point q = new Point( 10, 10 ); Der erste Konstruktor ist der Standard-Konstruktor, der zweite ein parametrisierter Konstruktor. |
Ein Konstruktor-Aufruf wird bei der Erschaffung eines Objekts durch den new-Operator ausgelöst. So erzeugt
Point p = new Point();
ein Exemplar der Klasse Point. Die Laufzeitumgebung von Java reserviert so viel Speicher, dass ein Point-Objekt dort Platz hat. Anschließend ruft die Laufzeitumgebung den Konstruktor auf und gibt eine Referenz auf das Objekt zurück, die im obigen Beispiel der Variablen p zugewiesen wird. Kann das System nicht genügend Speicher bereitstellen, so wird der GC aufgerufen. Kann dieser keinen freien Platz besorgen, generiert die Laufzeitumgebung einen OutOfMemoryError.
In Java gibt das spezielle Literal null, das anzeigt, dass eine Referenz auf kein Objekt verweist. Der Wert ist nur für Referenzen vorgesehen und kann nicht in einen primitiven Typ wie die Ganzzahl 0 umgewandelt werden.7 Die Null-Referenz ist typenlos, das heißt, sie kann jedem Objekt zugewiesen werden. Daher ist Folgendes gültig:
Point p = null; String s = null;
Da sich hinter null kein Objekt verbirgt, ist es auch nicht möglich, eine Methode aufzurufen. Der Compiler kennt zwar den Typ jedes Objekts, weiß aber erst zur Laufzeit, was referenziert wird. Wird versucht über die Null-Referenz auf eine Eigenschaft eines Objekts zuzugreifen, dann wird eine NullPointerException ausgelöst.
Listing 3.2 NullPointer.javaimport java.awt.Point; public class NullPointer { public static void main( String args[] ) { Point p = null; String s = null; p.setLocation(1,2); // (1) s.length(); } }
Das Programm bricht schon bei (1) mit folgender Ausgabe ab:
java.lang.NullPointerException at NullPointer.main(NullPointer.java:10) Exception in thread "main"
Wir wollen an dieser Stelle noch einmal auf die logischen Kurzschlussoperatoren und normalen logischen Operatoren kommen. Letztere werten Operanden nur solange von links nach rechts aus, bis der Wert der Operation feststeht. Es scheint auf den ersten Blick nicht viel auszumachen, ob alle Teilausdrücke ausgewertet werden oder nicht. Es ist aber in einigen Ausdrücken wichtig, wie das folgende Beispiel zeigt.
if ( p != null && p.x >= 10 )
Die Bedingung testet, ob p überhaupt auf ein Objekt verweist und ob die x-Koordinate mindestens 10 ist. Diese Schreibweise ist häufig und der Und-Operator zur Verknüpfung muss ein Kurzschluss-Operator sein, denn in diesem Fall kommt es ausdrücklich darauf an, dass die Koordinate nur dann bestimmt wird, wenn die Variable p überhaupt auf ein Point-Objekt verweist. Andernfalls bekämen wir eine NullPointerException, wenn jeder Teilausdruck ausgewertet würde und p gleich null ist.
Eine Referenz erlaubt den Zugriff auf das referenzierte Objekt. Es kann durchaus mehrere Kopien dieser Referenz geben, die in Variablen mit unterschiedlichen Namen abgelegt sind. So wie ein Personen-Objekt von den Mitarbeitern als »Chefin« angesprochen wird, aber von ihrem Mann als »Schnuckiputzi«.
Wir wollen uns dies an einem Punkt-Objekt näher anschauen, das wir unter einem alternativen Variablennamen ansprechen können:
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 durch die Variable q. Danach verweisen p und q auf dasselbe Objekt.
|
Beispiel Das hat die Konsequenz, dass bei einer Änderung des Punkt-Objekts über die in der Variablen p gespeicherte Referenz die Änderung auch bei Zugriff über die Variable q beobachtet werden kann.
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 Was wir über die Variable p ändern, kann über die andere Variable q erfragt werden und umgekehrt. |
Primitive Variablen werden immer per Wert kopiert. Betrachten wir folgende Zeilen, dann ist leicht zu sehen, wie sich die Daten verändern. Zunächst deklarieren wir zwei Variablen:
int i = 2; int j;
Anschließend weisen wir j den Wert von i zu. An dieser Stelle wird der Wert aus i ausgelesen und in j kopiert. Dabei ist es der Zuweisung ziemlich egal, woher der Wert kommt (er könnte beispielsweise auch die Rückgabe einer Funktion sein). Die Ausgabe gibt demnach 2 aus. Ändert sich nun das i und geben wir j aus, so ist die Ausgabe natürlich immer noch 2, da eine Änderung von j keine Änderung von i mit sich zieht. Wir könnten also sagen: »Kopierer haben keine Geschichte«.
j = i; System.out.println( j ); i = 3; System.out.println( j );
Dass sich das gleiche Objekt unter zwei Namen (über zwei verschiedene Variablen) ansprechen lässt, werden wir auch bei Methoden beobachten können. Eine Funktion, die als Parameter eine Objektreferenz bekommt, bezieht sich genau auf das übergebene Objekt. Das heißt, die Funktion kann dieses Objekt mit den angebotenen Methoden ändern oder auf die Attribute zugreifen.
Listing 3.3 PointFunktion.javaimport java.awt.Point; public class PointFunktion { static void foo( Point p ) { p.x = 10; } public static void main( String args[] ) { Point q = new Point( 0,0 ); foo( q ); System.out.println( q.x ); // 10 } }
Dieses Beispiel ist insofern bedeutend, da es bewusst macht, dass es in Java keine Referenzsemantik für Objekte wie in C++ gibt. Die Referenzen werden wie primitive Werte kopiert. Daher hat auch die folgende Funktion keine Nebenwirkungen:
static void buuh( Point p ) { p = new Point(); }
Der lokale Parameter referenziert hier ein anderes Punkt-Objekt. Diese Änderung wird nicht nach außen sichtbar.
Eine Parameterübergabe per Referenz (Call-By-Reference), wie sie in C++ verwendet wird, gibt es in Java nicht. In C++ ließe sich das von Java benutzte Verfahren nur mittelbar nachbilden, indem überall explizit Zeiger auf Objekte (anstelle der Objekte selbst) verwendet würden. Wir werden gleich noch ein zweites Beispiel für die Auswirkungen der in Java allgegenwärtigen Objektreferenzen kennen lernen.
Die Zuweisung mit dem Gleichheitszeichen schafft eine zusätzliche Kopie einer Referenz auf ein bereits existierendes Objekt. Der Vergleichsoperator == ist für alle Datentypen so definiert, dass er die vollständige Übereinstimmung zweier Werte testet. Bei primitiven Datentypen ist das einfach einzusehen und bei Referenztypen ist dies im Prinzip genauso. Der Operator == testet bei Referenzen, ob diese übereinstimmen, also auf das gleiche Objekt verweisen. Demnach sagt der Test etwas über die Identität der referenzierten Objekte aus, aber nicht, ob zwei verschiedene Objekte möglicherweise den gleichen Inhalt haben.
Beispiel Drei unterschiedliche Punkte und die Bedeutung von ==
Point p = new Point(); p.x = 12; Point q = p; Point r = new Point(); r.x = 12; if ( p == q ) // ist wahr, da p und q dasselbe Objekt referenzieren if ( p == r ) // ist falsch, 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 ein Punkt sich 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.
Die allgemein gültige Lösung ist, die Klasse festlegen zu lassen, wann Objekte gleich sind. Da Klassen Eigenschaften definieren, können nur sie Attribute für einen Gleichheitstest heranziehen. Dazu kann jede Klasse eine Methode equals() implementieren, die Exemplare dieser Klasse mit beliebigen anderen Objekten vergleichen kann, und true liefern, wenn die gewünschten Zustände (Objektvariablen) übereinstimmen.
Beispiel Zwei inhaltlich gleiche Punkt-Objekte verglichen mit == und equals().
Point a = new Point( 10, 10 ); Point b = new Point( 10, 10 ); if ( a == b ) // false ... if ( a.equals(b) ) // true ... Nur equals() testet in diesem Fall die inhaltliche Gleichheit. |
Aus den unterschiedlichen Bedeutungen müssen wir demnach die Begriffe »Identität« und »Gleichheit« von Objekten sorgfältig unterscheiden. Daher noch einmal eine Zusammenfassung:
| Getestet mit | Implementierung | |
| Identität | == | |
| Gleichheit | equals() | Aus der Klasse Object geerbte Methode muss geeignet überschrieben werden. |
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. Eine Unterklasse, wie etwa Point, kann nun eine eigene Implementierung angeben. Sie muss es aber nicht.
Werfen wir einen Blick auf die equals()-Methode aus Point, um eine Vorstellung von der Arbeitsweise zu bekommen:
public boolean equals( Object obj ) { if ( obj instanceof Point ) { Point pt = (Point)obj; return (x == pt.x) && (y == pt.y); // (*) } return super.equals(obj); }
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 Objekts, das als Parameter an equals() übergeben wurde.
Wenn eine Klasse keine equals()-Methode angibt, dann erbt sie eine Implementierung aus der Klasse Object, die wie folgt aussieht:
public boolean equals( Object obj ) { return ( this == obj ); }
Wir erkennen, dass hier die Gleichheit auf die Gleichheit der Referenzen abgebildet wird. Ein inhaltlicher Vergleich findet nicht statt.
Ein Array (auch »Feld« oder »Reihung« genannt) ist ein spezieller Datentyp, der mehrere Werte, die über einen ganzzahligen Index angesprochen werden, zu einer Einheit zusammenfasst. Er ist vergleichbar mit einem Setzkasten, in dem die Plätze durchnummeriert sind. Jeder Platz (etwa für Schlümpfe) nimmt immer Werte des gleichen Typs auf (nur Schlümpfe und keine Pokemons). Normalerweise liegen die Plätze eines Arrays (seine Elemente) im Speicher hintereinander, doch dies ist ein Implementierungsdetail, welches in Java unwichtig ist.
Jedes Array beinhaltet Werte eines bestimmten Datentyps. Dies können sein:
| Elementare Datentypen wie int, byte, long, usw. |
| Referenzen auf Objekte |
| Referenzen auf andere Arrays, um mehrdimensionale Arrays zu realisieren |
Eine Array-Variablendeklaration ähnelt einer gewöhnlichen Deklaration, nur dass nach dem Datentyp oder den Variablen die Zeichen »[« und »]« gesetzt werden müssen. Uns ist es freigestellt, welche Schreibweise wir wählen. Hauptsache, es werden überhaupt Klammern gesetzt. Wie bei der gesamten Programmierung, so sollte auch hier konsistent vorgegangen werden – einmal so, einmal so, behindert die schnelle Wahrnehmung von Programmquelltext.
int schachfeld[]; int [] auchSchach; Point punkte[];
So ganz ohne Unterschied ist die Deklaration nicht. Das zeigt sich spätestens, wenn mehr als eine Variable deklariert wird. Die Klammern können einerseits Teil des Typs sein, andererseits Teil der Variablen. Sind sie Teil des Typs, so sind alle deklarierten Variablen ein Feld. Es entspricht demnach
int[] prims, matrix[], 3dmatrix[][];
int prims[], matrix[][], 3dmatrix[][][];
Hier ist doppelt Vorsicht geboten, denn der eine oder andere wollte vielleicht nur
int []prims, i;
schreiben, um auszudrücken, dass i eine normale Ganzzahlvariable ist. Stattdessen würde der Compiler jedoch annehmen, dass i ein Feld ist und eine Zuweisung der Art i=2 gnadenlos ablehnen. Es ist aber nicht direkt ersichtlich, wo der Fehler liegt. Ich empfehle daher, die Klammern hinter den Bezeichner zu setzen und bestenfalls in jede Zeile nur eine Deklaration setzen. (Aus diesem Grunde ist es auch ungünstig, unter C(++) das Sternchen für den Zeiger direkt an den Datentyp zu packen.8 ) Das beugt Fehlern vor. Nach reiner Java-Lehre jedenfalls, gehören die Klammern hinter den Typbezeichner, so hat es Gosling gewollt.
Die bisherigen Deklarationen von Array-Variablen erzeugen noch lange kein Array-Objekt, das die einzelnen Array-Elemente aufnehmen kann. Wenn allerdings die Einträge direkt mit Werten belegt werden sollen, so gibt es in Java eine Abkürzung, die ein Array-Objekt anlegt und zugleich mit Werten belegt.
Beispiel Wertebelegung eines Felds
int primiMäuschen[] = { 1, 2, 3, 5, 7, 7+4, }; String substantive[] = { "Haus", "Maus", translator.toGerman("dog"), new Point().toString() }; |
In diesem Fall wird ein Feld mit passender Größe angelegt und die Elemente in das Feld kopiert, die in der Aufzählung genannt sind. Innerhalb der Aufzählung kann als Letztes ein Komma stehen, wie die Aufzählung bei primiMäuschen demonstriert.
Es ist nicht möglich (wie in C(++)) das Feld mit einer bestimmten Größe zu initialisieren, ohne gleichzeitig die Werte aller Elemente aufzuzählen.
Beispiel Folgende Zeile ist in Java falsch und führt zu einem Compilerfehler:
int einhundertElemente[100]; // Compilerfehler |
Wir müssen das über eine explizite Objekt-Erzeugung lösen, wie wir etwas später mit einem new-Operator sehen werden,
Ein Array von Char-Zeichen ist nicht mit einem String vergleichbar. Die Klasse String bietet jedoch einen Konstruktor an, sodass aus einem Feld mit Zeichen ein String-Objekt erzeugt werden kann. Alle Zeichen des Felds werden kopiert, sodass anschließend Feld und String keine Verbindung mehr besitzen. Das bedeutet, falls sich das Feld ändert, ändert sich der String nicht automatisch mit. Das kann er auch nicht, da Strings unveränderlich sind.
Beispiel Mit der Methode toCharArray() können wir einen String in ein char-Feld konvertieren.
char umlaut[] = "aeiouäöü".toCharArray(); |
Die Anzahl der Elemente, folglich die Länge des Arrays, ist für jedes Array-Objekt in der frei zugänglichen Objektvariablen length gespeichert. length ist eine public final int Variable, deren Wert entweder positiv oder Null ist.
Beispiel Ein Feld und Ausgabe der Länge
int primiMäuschen[] = { 1, 2, 3, 5, 7, 7+4, }; System.out.println( primiMäuschen.length ); |
Das Attribut length eines Felds ist nicht nur öffentlich (public) und vom Typ int, sondern natürlich auch final. Schreibzugriffe sind nicht gestattet. (Was sollten sie bewirken? Eine dynamische Vergrößerung des Feldes?) Ein Schreibzugriff führt zu einem Übersetzungsfehler.
Die Anzahl der Elemente, die ein Array aufnehmen kann, wird auch als Größe bzw. Länge bezeichnet. In Java beginnt ein Array, ähnlich wie in C(++), bei 0 (und nicht bei einer frei wählbaren Untergrenze wie in PASCAL). Die Größe lässt sich später nicht mehr ändern. Da die Elemente eines Arrays ab 0 nummeriert werden, ist der letzte gültige Index um 1 kleiner als die Länge des Felds. Der Zugriff auf die Elemente eines Felds erfolgt mit Hilfe der eckigen Klammern [], die hinter die Referenz an das Array-Objekt gesetzt werden. Bei einem Array a der Länge n ist der gültige Bereich somit a[0] bis a[n-1].
Beispiel Greife auf das erste und letzte Zeichen aus dem Feld zu
char name[] = "ABC".toCharArray[]; char first = name[0]; char last = name[name.length-1]; |
Innerhalb der eckigen Klammern steht ein Ganzzahl-Ausdruck, der sich zur Laufzeit berechnen lassen muss. long-Werte sowie Gleitkommazahlen sind nicht möglich. Bei Long-Werten wäre der Wertebereich zu groß, denn ein int-Index erlaubt ja schon mehr als 2,4 Milliarden Elemente. Bei Gleitkommazahlen bliebe die Frage nach der Zugriffstechnik. Hier müssten wir den Wert auf ein Intervall runterrechnen.
Beispiel Liegt etwa eine Fließkommazahl f im Intervall von 0 bis 1 und haben die Werte eine Genauigkeit von einem Tausendstel, so ließe sich für ein Array a mit 1000 Elementen für eine Ausgabe schreiben:
f = 0.01; // im Intervall von 0 bis 1 out( a[(int)(f*1000 )] ) |
Der Index eines Felds muss von einem Typ sein, der ohne Verlust auf int konvertierbar ist. Dazu gehören byte, short, und char.
Beispiel Das Zeichen c soll in eine Java-Unicode-Zeichenfolge der Form \uxxxx umgewandelt werden.
public static String charToUnicodeEscape( char c ) { char chars[] = { '\\', 'u', hexchars[c >> 12 & 0xf], hexchars[c >> 8 & 0xf], hexchars[c >> 4 & 0xf], hexchars[c & 0xf] }; return new String( chars, 0, 6 ); } private static final char hexchars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' }; |
Genau genommen haben wir es auch hier mit Indexwerten vom Typ int zu tun, weil mit den char-Werten vorher noch gerechnet wird.
Obwohl es Hexzeichen-Felder schon in anderen Klassen gibt (etwa in Properties), sind diese oft privat. Wenn wir ein eigenes Array in der Klasse definieren, hat das zusätzlich den Vorteil, dass keine eventuell unerwünschten Abhängigkeiten zu anderen Klassen entstehen.
Ein Array muss mit dem new-Operator unter Angabe einer festen Größe erzeugt werden. Das Anlegen der Variablen alleine erzeugt noch kein Feld mit einer bestimmten Länge. In Java ist das Anlegen des Felds genauso dynamisch wie die Objekterzeugung. Dies drückt auch der new-Operator aus. Die Länge des Feldes wird in eckigen Klammern angegeben. Hier kann ein beliebiger Integer-Wert stehen, auch eine Variable.
Beispiel Die zweite Zeile erzeugt ein Array-Objekt für 100 Elemente.
int arrayOfInts[]; arrayOfInts = new int[100]; Die Felder mit den primitiven Werten sind mit 0, 0.0 oder false initialisiert. |
Die Deklaration ist auch zusammen mit der Zuweisung möglich.
Beispiel Deklaration eines 10-elementigen Felds und Initialisierung des Elemente
double x[] = new double[10]; // dann gilt für die Indexwerte 0 <= x <= 9 for ( int i = 0; i < 10; i++ ) x[i] = 2*i; |
Beispiel Günstig ist ein Index vom Typ char, der automatisch zum int konvertiert wird, zum Beispiel als Laufvariable, wenn Felder von Zeichenketten generiert werden.
char alphabet[] = new char['z'-'a'+1]; for( char c = 'a'; c <= 'z'; c++ ) alphabet[c-'a'] = c; |
Das ist auch eine elegante und schnelle Möglichkeit, Strings zu erzeugen, denn die Zeichenkette kann mit einem String(char [])-Konstruktor in ein String-Objekt umgewandelt werden:
String result = new String( alphabet );
Beim Zugriff auf ein Array-Element können Fehler auftreten. Zunächst einmal kann das Array-Objekt fehlen, sodass die Referenzierung fehlschlägt. Etwa in dem folgenden Fall, bei dem der Compiler den Fehler nicht bemerkt:9
int feld[]; feld[1] = 1;
Die Strafe ist eine NullPointerException.
Der zweite und dritte Fehler liegt im Index begründet. Dieser könnte negativ sein oder über der maximalen Länge liegen. Jeder Zugriff auf das Feld wird zur Laufzeit getestet. Auch bei Operationen, die für den Compiler entscheidbar wären, wird dieser Weg eingeschlagen, etwa bei den nachfolgenden Zeilen:
int feld[] = new int[100]; feld[100] = 100;
Hier könnte der Compiler theoretisch Alarm schlagen, was aber kaum ein Compiler bisher macht, denn der Zugriff auf Elemente mit einem ungültigen Index ist syntaktisch und statisch semantisch völlig in Ordnung.
Ist der Index negativ10 oder zu groß, dann hagelt es eine IndexOutOfBoundException. Wird diese nicht abgefangen, bricht das Laufzeitsystem das Programm mit einer Fehlermeldung ab.
Wir haben beim Inkrement schon ein Phänomen wie i=i++ betrachtet. Ebenso ist auch die Anweisung bei einem Feldzugriff zu behandeln.
a[i] = i++;
Bei der Position a[i] wird i gesichert und anschließend die Zuweisung gemacht. Wenn wir darum eine Schleife konstruieren, erweitern wir dies zu einer Initialisierung:
int is[] = new int[4]; int i = 0; while ( i < is.length ) is[i] = i++;
Die Ausgabe ergibt 0, 1, 2 und 3.
Der Datentyp der Array-Elemente muss nicht zwingend ein primitiver sein. Auch ein Array von Objektreferenzen kann deklariert werden. Dieses Array besteht dann nur aus Referenzen auf die eigentlichen Objekte, die in dem Array abgelegt werden sollen. Die Größe des Arrays im Speicher errechnet sich demnach aus der Länge des Felds multipliziert mit dem Speicherbedarf einer Referenz. Nur das Array-Objekt selbst wird angelegt, nicht aber die Objekte, die das Array aufnehmen soll. Dies lässt sich einfach damit begründen, dass der Compiler auch gar nicht wüsste, welchen Konstruktor er aufrufen sollte.
Beispiel Ein nicht-primitives Feld mit fünf Punkt-Objekten
Point punkte[] = new Point[5]; Hier wird Platz für 5 Referenzen auf Punkte gemacht, aber kein einziges Point-Objekt angelegt. Später würde das Feld etwa mit point[0] = new Point() gefüllt. Standardmäßig werden die Array-Elemente mit der Null-Referenz initialisiert. |
Beispiel Fünf Punkte werde mit willkürlichen Werten erzeugt. Die Zufallszahlen werden dabei mit Hilfe der mathematischen Funktion Math.random() erzeugt.
Point punkte[] = new Point[5]; for ( int i=0; i<punkte.length; i++ ) punkte[i] = new Point( (int)(Math.random()*100), (int)(Math.random()*100) ); for (int i = 0; i < punkte.length; i++) System.out.println( punkte[i] ); Die Ausgabe erzeugt zum Beispiel Folgendes: java.awt.Point[x=59,y=77] java.awt.Point[x=47,y=86] java.awt.Point[x=18,y=71] java.awt.Point[x=55,y=97] java.awt.Point[x=12,y=70] |
Wir haben gesehen, dass Arrays Objekte sind, die geordnete Elemente enthalten. Trotz der weitgehenden Übereinstimmung mit gewöhnlichen Objekten, sollten wir die Unterschiede nicht verschweigen:
| Mit dem Operator [] kann auf Array-Elemente über ihren ganzzahligen Index zugegriffen werden. Dieser Operator wird bei anderen Objekten nicht angeboten. |
| Eine spezielle Form des new-Operators erzeugt ein Exemplar der Array-Klasse. |
| Eine entsprechende Array-Klasse wird automatisch generiert, wenn ein Array-Typ deklariert wird. |
Wenn wir in Java ein Array-Objekt erzeugen und gleich mit Werten initialisieren wollen, dann schreiben wir etwa
int primi[] = { 2, 5, 7, 11, 13 };
Wollen wir uns erst nach der Variablendeklaration für die Feldinhalte interessieren und sie gegebenenfalls auch ändern, schlägt ein Versuch wie der Folgende fehl:
int primi[]; primi = { 2, 5, 7, 11, 13 };
Besonders ärgerlich wird dies bei der Parameterübergabe. So scheitert der folgende praktische Funktionsaufruf:
ausgleichsgerade( { 1.23, 4.94, 9.33, 3.91, 6.34 } );
Das Einzige, was spontan zur Lösung dieses Problems beiträgt, ist die Einführung einer neuen Variablen:
int primi[]; int tmpprimi[] = { 2, 5, 7, 11, 13 }; primi = tmpprimi;
Glücklicherweise gibt eine Variante des new-Operators, der durch ein Paar eckiger Klammern erweitert wird, in denen die Initialwerte des Arrays folgen. Die Größe des Arrays entspricht genau der Anzahl der Werte.
Für die oberen Beispiele ergibt sich folgenden Schreibweise:
int primi[]; primi = new int[]{ 2, 5, 7, 11, 13 }; ausgleichsgerade( new double[]{ 1.23, 4.94, 9.33, 3.91, 6.34 } );
Da, wie im zweiten Beispiel, ein initialisiertes Feld mit Werten gleich an die Funktion übergeben wird und keine zusätzliche Variable benutzt wird, wird diese Art der Arrays »anonyme Arrays« genannt. An sich gibt es auch sonst anonyme Arrays, wie new int[2000].length zeigt. Doch in diesem Fall wird das Feld nicht mit Werten initialisiert.
Mit dem Konzept der initialisierten Array-Objekte lässt sich eine variable Parameterliste nachstellen. Wir schreiben einfach eine Methode, die ein Feld verlangt und übergeben dann beim Aufruf ein entsprechend vorbelegtes Array-Objekt.
Listing 3.4 VariableParameter.javapublic class VariableParameter { static double sum( double values[] ) { double sum = 0; for ( int i = 0; i < values.length; i++ ) sum += values[i]; return sum; } public static void main( String args[] ) { System.out.println( sum(new double[]{ 1.5, 2.5 }) ); } }
Java realisiert mehrdimensionale Arrays durch Arrays von Arrays. Sie können etwa für die Darstellung von mathematischen Matrizen oder Rasterbildern Verwendung finden. Ein zweidimensionales Feld mit dem Platz für Reihen von 8 Elementen definiert sich einfach über folgende Zeile:
int A[][] = new int[4][8];
Zwei alternative Deklarationen sind
int[][] A = new int[4][8]; // Der Typ von A ist zweidimensionales Array // mit Elementtyp int int[] A [] = new int[4][8];
Einzelne Elemente werden mit A[i][j] angesprochen.11 Der Zugriff erfolgt mit so vielen Klammerpaaren, wie die Dimension des Arrays angibt. Obwohl mehrdimensionale Arrays im Prinzip Arrays mit Arrays als Elementen sind, lassen sie sich leicht deklarieren.
Beispiel Der Aufbau von zweidimensionalen Feldern ist vergleichbar mit einer Matrix beziehungsweise Tabelle. Dann lässt sich der Eintrag im Feld a[x][y] in folgender Tabelle ablesen.
a[0][0] a[0][1] a[0][2] a[0][3] a[0][4] a[0][5] ... a[1][0] a[1][1] a[1][2] a[1][3] a[1][4] a[1][5] a[2][0] a[2][1] a[2][2] a[2][3] a[2][4] a[2][5] ... |
Da in Java mehrdimensionale Arrays als Arrays von Arrays implementiert sind, müssen diese nicht zwingend rechteckig sein. Jede Zeile im Feld kann eine eigene Größe haben.
Beispiel Ein dreieckiges Array mit Zeilen der Länge 1, 2 und 3.
int m[][] = new int[3][]; for ( int i=0; i<3; i++ ) m[i] = new int[i+1]; |
int m[][] = new int [3][4]; int m[][] = new int [3][];
zeigt, dass im ersten Fall die passenden Unterfelder automatisch erzeugt werden. Dies ist im zweiten Fall nicht so. Hier müssen wir selber die Unterfelder initialisieren, bevor wir auf die Elemente zugreifen.