Einführung in die Programmiersprache Java Prof. Dr. Thomas Hoch Fachbereich Ingenieurwissenschaften Hochschule RheinMain Version 0.85, 07.04.2015 c Thomas Hoch Inhaltsverzeichnis 1 2 3 Einleitung 5 1.1 Weshalb Java lernen? . . . . . . . . . . . . . . . . . . . . . . . . . . 5 1.2 Die Geschichte von Java . . . . . . . . . . . . . . . . . . . . . . . . 6 1.3 Aus der Einleitung von 2002 . . . . . . . . . . . . . . . . . . . . . . 6 Eigenschaften von Java 8 2.1 Eine moderne Programmiersprache . . . . . . . . . . . . . . . . . 8 2.2 Plattformunabhängigkeit . . . . . . . . . . . . . . . . . . . . . . . . 9 2.3 Die Verwandschaft mit C und C++ . . . . . . . . . . . . . . . . . . 10 2.4 Weitere nützliche Eigenschaften . . . . . . . . . . . . . . . . . . . . 12 2.5 Verfügbarkeit, Tools und Dokumentation . . . . . . . . . . . . . . 13 2.6 Wo ist der Haken? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 Die Sprache Java 16 3.1 Programmaufbau . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 3.2 Einfache Datentypen, Variablen und Konstanten . . . . . . . . . . 19 3.3 Operatoren, Ausdrücke und einfache Anweisungen . . . . . . . . 21 3.3.1 Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 3.3.2 Anweisungen . . . . . . . . . . . . . . . . . . . . . . . . . . 23 Kontrollstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.4.1 Auswahl (Selektion) . . . . . . . . . . . . . . . . . . . . . . 24 3.4.2 Schleife (Iteration) . . . . . . . . . . . . . . . . . . . . . . . 27 Standardein- und Ausgabe . . . . . . . . . . . . . . . . . . . . . . . 29 3.4 3.5 4 Objektorientierung bei Java 31 4.1 Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 4.2 Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32 2 4.3 Zugriff auf Instanzvariablen und -methoden . . . . . . . . . . . . 33 4.4 Statische Variablen und Methoden . . . . . . . . . . . . . . . . . . 34 4.5 Ableitung von Klassen, Vererbung . . . . . . . . . . . . . . . . . . 37 4.6 Objekte, Referenzen und Werte . . . . . . . . . . . . . . . . . . . . 40 4.6.1 Wertdatentypen und Referenzdatentypen . . . . . . . . . . 40 4.6.2 Zuweisung, Identität und Gleichheit . . . . . . . . . . . . . 41 4.6.3 Erzeugen und Freigeben von Objekten . . . . . . . . . . . 43 4.6.4 Übergabe von Parametern an Funktionen . . . . . . . . . . 43 Weitere Möglichkeiten . . . . . . . . . . . . . . . . . . . . . . . . . 44 4.7.1 Abstrakte Klassen . . . . . . . . . . . . . . . . . . . . . . . 44 4.7.2 Interfaces und Mehrfachvererbung . . . . . . . . . . . . . . 45 4.7.3 Felder (Arrays) . . . . . . . . . . . . . . . . . . . . . . . . . 47 4.7 5 6 7 Grafische Benutzeroberflächen 50 5.1 Überblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 5.2 Komponenten und Container . . . . . . . . . . . . . . . . . . . . . 52 5.3 Layout-Manager . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 5.4 Ereignisse (Events) . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 5.5 2D-Grafik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68 Pakete 77 6.1 Wofür Pakete? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77 6.2 Verwendung von existierenden Paketen . . . . . . . . . . . . . . . 78 6.3 Definition von eigenen Paketen . . . . . . . . . . . . . . . . . . . . 78 Ausnahmebehandlung (Exception handling) 80 7.1 Worum geht es? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 7.1.1 Der Ausnahmemechanismus . . . . . . . . . . . . . . . . . 80 7.2 Auslösen einer Ausnahme . . . . . . . . . . . . . . . . . . . . . . . 81 7.3 Abfangen von Ausnahmen . . . . . . . . . . . . . . . . . . . . . . 82 7.3.1 Abfangen eines bestimmten Ausnahmetyps . . . . . . . . 82 7.3.2 Abfangen mehrerer Ausnahmetypen . . . . . . . . . . . . 83 7.3.3 Aufräumarbeiten bei Ausnahmen . . . . . . . . . . . . . . 83 8 9 Multithreading 85 8.1 Was ist Multithreading? . . . . . . . . . . . . . . . . . . . . . . . . 85 8.1.1 Wofür ist es gut? . . . . . . . . . . . . . . . . . . . . . . . . 85 8.2 Starten eines Threads . . . . . . . . . . . . . . . . . . . . . . . . . . 86 8.3 Beenden eines Threads . . . . . . . . . . . . . . . . . . . . . . . . . 87 8.4 Wettlaufsituationen . . . . . . . . . . . . . . . . . . . . . . . . . . . 89 Collections 92 9.1 Was sind Collections? . . . . . . . . . . . . . . . . . . . . . . . . . . 92 9.2 Generische Klassen ... . . . . . . . . . . . . . . . . . . . . . . . . . . 93 9.3 Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95 9.4 Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97 1 Einleitung 1.1 Weshalb Java lernen? Kennt man schon eine Programmiersprache, so stellen sich beim Erlernen einer neuen Programmiersprache immer die Fragen: Was bringt die neue Sprache im Vergleich zur alten? Lohnt sich der Aufwand die neue Sprache zu erlernen? Eine dritte Frage ist: Wie verbreitet ist eine Programmiersprache und wird sie weiter gepflegt? Denn es nützt wenig, auf sich allein gestellt eine schöne Programmiersprache zu verwenden, wenn es keine Community gibt, an die man sich mit Fragen wenden kann, oder wenn keine Aussicht besteht, dass aktuelle Entwicklungen in die Programmiersprache einfließen werden. Ich werde versuchen, diese Fragen aus meiner Sicht zu beantworten. Dabei gehe ich davon aus, dass C oder C++ schon bekannt ist. Zunächst ersten Frage: Was bringt die neue Sprache im Vergleich zur alten? Java hat eine Reihe nützlicher Eigenschaften im Vergleich zu C++, auf die im nächsten Kapitel eingegangen wird. Gegenüber konkurrierenden Sprachen wie C# zeichnet Java aus, dass übersetzte Java-Programme auf allen gängigen Plattformen (Windows, Linux, Mac OS) direkt lauffähig sind. Nach einer kurzen Anfangsphase, in der Java noch nicht als Sprache für Desktop-Anwendungen geeignet war, hat sich Java zu einer ernsthaften Sprache entwickelt. Zur Zweiten Frage: Lohnt sich der Aufwand die Java zu erlernen? Ich denke, auf jeden Fall, wenn man Anwendungen mit grafischer Benutzeroberfläche, oder Netzwerk-/Multimedia-Anwendungen programmieren möchte. Dies ist in C++ nur mit der Verwendung externer Bibliotheken (wie Qt) möglich. Der Einstieg in Java wird durch die mit C++ fast identische Syntax erleichtert. Allerdings ist die Klassenbibliothek von Java sehr umfangreich, so dass man sich je nach Anwendung in die entsprechenden Pakete einarbeiten muss. Für die reine Systemprogrammierung, bei der die Performanz im Vordergrund steht, ist C++ wahrscheinlich geeigneter. Zur dritten Frage: Wie verbreitet ist Java und wird es weiter gepflegt? Es gibt verschiedene Statistiken zur Verbreitung von Programmiersprachen. Eine ist der TIOBE-Index. Nach diesem Index lag Java seit 2005 immer auf Platz 1 oder 2 der meistbenutzten Sprachen (die ersten beiden Plätze teilten sich C und Java). Wie aus dem nächsten Abschnitt ersichtlich, gab es bei Java im Schnitt alle zwei Jahre eine neue Version. Diese Versionen sind „major releases“ zu denen es in kurzen Abständen Updates mit Bugfixes gibt. Zwar ist Java proprietär und wurde von der Firma SUN entwickelt, die mittlerweile von der Firma Oracle 1 Einleitung 1.2 Die Geschichte von Java übernommen wurde. Es gibt aber den „Java Community Process“ (JCP), an dem neben verschiedenen Firmen auch Einzelpersonen teilnehmen. SUN war dem OpenSource-Gedanken gegenüber sehr aufgeschlossen. Dies hat sich leider durch die Übernahme durch Oracle geändert, was dazu führte, dass 2010 z. B. die Appache Software Foundation aus dem JCP ausgetreten ist. Es bleibt also abzuwarten, wie viel Einfuss die Anwender auf die Weiterentwicklung von Java nehmen können. 1.2 Die Geschichte von Java Ich will hier nicht auf die Vorgeschichte eingehen, die auf das Jahr 1991 zurückgeht, sondern stattdessen 1994 einsetzen, als das World Wide Web noch in den Anfängen seiner steilen Entwicklung war. Die Idee der Sprachentwickler Jonathan Payne und James Gosling bei der Firma SUN war es damals, eine Sprache für das Web zu schaffen. Sie demonstrierten ihre neue, Java genannte, Sprache, indem sie damit einen Web-Browser entwickelten (HotJava), der auch die Möglichkeit bot, in Java geschriebene Applets auszuführen. Die erste offizielle Version Java 1.0 wurde 1996 freigegeben. Sie war allerdings noch nicht als Allround-Programmiersprache für Anwendungen geeignet, sondern wurde hauptsächlich zur Programmierung von Browser-Applets eingesetzt. Innerhalb der nächsten Jahre wurde die Leistungsfähigkeit von Java mit jeder Version verbessert. Die Beliebtheit von Browser-Applets trat in den Hintergrund, dafür wurde Java mit jeder neuen Version als Sprache für DesktopAnwendungen immer interessanter. Ein Meilenstein war die Java-Version 5 (2004), die zahlreiche neue Sprach-Features einführte. Mittlerweile ist Version 8 (2014) erreicht. 1.3 Aus der Einleitung von 2002 Evolution von Programmiersprachen Betrachtet man die „Evolution“ von verbreiteten Programmiersprachen (z. B. FORTRAN, LISP, COBOL, BASIC, Pascal, C, C++, Java, C#), so scheint das Auftreten einer neuen Sprache von (mindestens) drei Bedingungen abzuhängen: 1. Die Entwickler haben eine konsistente Programmiersprache geschaffen. 2. Die neue Sprache unterscheidet sich von existierenden Sprachen in positivem Sinne, z.B. durch neue Einsatzgebiete, einfachere Syntax oder andere Programmiertechnik (Objektorientierung, etc.). 6 1 Einleitung 1.3 Aus der Einleitung von 2002 3. Die neue Sprache wird von den Anwendern (d.h. den Programmierern) akzeptiert und setzt sich durch. Dies hat einige Ähnlichkeit mit der Entstehung neuer Arten in der Biologie, wobei Punkt 1 die Rolle der spontanten Mutation übernimmt. Punkt 2 entspricht dem Vorhandensein einer ökologischen Nische und Punkt 3 schließlich der Selektion, d.h. der Überlebensfähigkeit der neuen Art. Dass sich eine Programmiersprache mit genau diesen und jenen Eigenschaften etabliert, ist sicherlich kein zwangsläufiger Prozess, sondern durch viele Zufälle bedingt. Allerdings muss schon ein gewisses Bedürfnis nach einer neuen Sprache vorhanden sein, sonst wird die Mehrzahl der Programmierer keine Veranlassung sehen, auf die neue Sprache umzusteigen. Java tritt auf Mitte der 1990er Jahre, als das WWW einen Boom des Internets auslöste, entstand das Bedürfnis nach einer Programmiersprache für das Internet, d.h. einer modernen, grafik-, multimedia- und netzwerkfähigen Sprache, die vom Betriebssystem unabhängig ist. Zwar ist es auch mit Programmiersprachen wie C++ möglich, grafische Benutzeroberflächen zu programmieren oder Kommunikation über ein Netzwerk zu betreiben. Dies gelingt aber nur mit Hilfe externer Bibliotheken, die meist in den Entwicklungsumgebungen der verschiedenen Hersteller enthalten sind, aber nicht zu Standard-C++ gehören. Diese Bibliotheken sind Hersteller- und Betriebssystemspezifisch. Man kann also nicht einfach die Entwicklungsumgebung, geschweige denn das Betriebssystem wechseln. War dies in einer Welt von Einzel-Arbeitsplatz-Anwendungen noch tolerierbar, so erwies es sich in der Welt global vernetzter Computer verschiedenster Betriebssysteme zunehmend als Handicap. Dies erkannten auch die Entwickler von Java bei Sun und sprangen 1996 mit der ersten Java-Entwicklungsumgebung (Java 1.0) in diese Lücke. Bei Java sind die Möglichkeiten zur Grafik- und Netzwerkprogrammierung in den StandardPaketen enthalten, andere, z.B. Multimedia-Pakete können über das Internet heruntergeladen werden. Das schönen dabei ist, dass Java-Programme auf allen Plattformen lauffähig ist, auf denen die Java-Laufzeitumgebung (Java Runtime Environment, JRE) installiert ist, Die Portabilität von Java-Programmen bezieht sich dabei nicht nur auf den Java-Quellcode, sondern auch auf übersetzte JavaProgramme, die im sogenannten Byte-Code vorliegen. Diese Eigenschaften, mit denen Java die Grenzen zwischen Rechnern und Betriebssystemen sprengt, haben sicherlich zu der begeisterten Aufnahme von Java bei vielen Software-Entwicklern beigetragen. 7 Eigenschaften von Java 2 Die Programmiersprache Java zeichnet sich durch eine Reihe nützlicher Eigenschaften aus, die zu ihrer schnellen Verbreitung beigetragen haben: • Java ist eine moderne Programmiersprache (Grafik-, Netzwerk-, Multimediafähigkeit, Sicherheit). • Java ist plattformunabhängig (Java-Programme laufen auf vielen Prozessortypen und Betriebssystemen). • Java ist objektorientiert und hat eine C-ähnliche Syntax (was den Umstieg von C/C++ erleichtert). • Java hat noch verschiedene andere nützliche Features wie die Unterstützung von Multithreading, Garbage Collection, Exception Handling und geringe Fehleranfälligkeit, • Last but not least: Die Java-Entwicklungsumgebung ist kostenlos verfügbar. Auf diese Punkte wird in den folgenden Abschnitten genauer eingegangen: 2.1 Eine moderne Programmiersprache Grafische Benutzeroberflächen Nach anfänglichen Schwierigkeiten kann man die eingebauten Möglichkeiten zur Programmierung Grafischer Benutzeroberflächen (Grafical User Interface, GUI) als ausgereift bezeichnen. Wie im letzten Kapitel erwähnt, bieten C und C++ solche Möglichkeiten nur über Hersteller- und Betriebssystem-spezifische Bibliotheken. Wenn man sich auf einen Compilerhersteller festgelegt hat, kann man seine Programme nur mit großem Aufwand auf einen andern Compiler oder gar ein anderes Betriebssystem portieren. In Java dagegen ist ein Programm normalerweise ohne Änderung auf jedem der unterstützten Betriebssysteme (Windows 9x/ME/NT, Sun OS, Linux, Macintosh) lauffähig. Dies ist insofern bemerkenswert, als GUI und Betriebssystem eng miteinander verwoben sind, und es eine große Anforderung an eine Programmiersprache ist, die gleichen GUI-Komponenten auf verschiedenen Betriebssystemen bereitzustellen. Java geht sogar noch einen Schritt weiter: Auch ein übersetztes Java-Programm kann auf verschiedenen Plattformen laufen (siehe Abschnitt 2.2). Wir GUI 2 Eigenschaften von Java 2.2 Plattformunabhängigkeit 9 werden uns in Kapitel 5 ausfühlich mit der Programmierung grafische Benutzeroberflächen befassen. Netzwerke Java unterstützt das TCP/IP-Protokolls, das am weitesten verbreitete Netzwerk-Protokoll, auf dem auch das Internet basiert. Diese Protokoll kann man komfortabel über Streams (analog zur Ein- und Ausgabe) angesprechn, ohne sich um Details zu kümmern. Darüber hinaus hat sich Java als wichtige Sprache für das WWW etabliert: Alle gängigen Web-Browser unterstützen (über ein Java-Plugin) den Ablauf von in den HTML-Seiten eingebetteten Java-Programmen. Diese sogenannten JavaApplets werden über das Netz von einem Server geladen und laufen auf dem Client-Rechner ab. Sie unterscheiden sich insofern von normalen Java-Anwendungsprogrammen, als sie starke Sicherheitsrestriktionen (kein Zugriff auf lokale Speichermedien, etc.) erfüllen. Im Zeitalter der Vernetzung ist es eines der Designziele von Java, Software gegen Angriffe von außen sicher zu machen. So muss z.B. bei verteilter Software sichergestellt werden, dass nicht Teile der Software unbefugt ausgetauscht werden können. Außerdem sollte die Software sicher gegen Viren und trojanische Pferde etc. sein. Multimedia Mit Java können ohne großen Aufwand Bild-Dateien angezeigt oder Audiooder MIDI-Dateien abgespielt oder aufgenommen werden. Mit entsprechenden Erweiterungen, die man sich im Internet herunterladen kann, können auch Filmsequenzen wiedergegeben werden. 2.2 Plattformunabhängigkeit Die Plattformunabhängigkeit von Java bedeutet, dass übersetzte Java-Programme auf Rechnern mit unterschiedlichen Prozessoren und Betriebssystemen (inklusive grafischer Oberflächen) lauffähig sind. Dies übersteigt die Portabilität von C/C++-Programmen, deren Quellcode für jeden Prozessortyp neu übersetzt werden muss. Außerdem beschränkt sich deren Portabilität auf nichtgrafische Anwendungen (siehe Abschnittmodern). Wer sich etwas mit dem Aufbau von Rechnern auskennt, der weiß, dass ausführbare Programme normalerweise in Maschinensprache vorliegen. Diese Sprache versteht der Prozessor direkt, aber unterschiedliche Prozessortypen sprechen unterschiedliche Maschinensprachen. Es ist also nicht möglich, ein ausführbares Programm für Prozessortyp A auf einem Prozessor vom Typ B ablaufen zu lassen. Mit diesem Wissen im Hinterkopf stellt sich die Frage: Wie ist es möglich, dass dieselben übersetzeten Java-Programme (z.B. auch Java-Applets) auf Rechnern TCP/IPProtokoll Applets NetzwerkSicherheit 2 Eigenschaften von Java 2.3 Die Verwandschaft mit C und C++ 10 mit verschiedenen Prozessoren laufen können? Dies ist nur über einen Trick möglich: 1. Lauffähige Java-Programme liegen nicht in Maschinensprache vor, sondern in einem anderen Code, dem sogenannten Java-Bytecode (kurz Bytecode). 2. Der Byte-Code wird nicht direkt vom Prozessor ausgeführt, sondern läuft auf einer sogenannten virtuellen Maschine (Java Virtual Machine, JVM) ab. Die virtuelle Maschine interpretiert den Byte-Code, d.h. sie übersetzt ihm simultan also zur Laufzeit in Maschinenbefehle für den jeweiligen Prozessor und in Betriebssystemanweisungen. Sie wird auch Java-Interpreter genannt, obwohl Java-Bytecode-Interpreter eigentlich korrekter wäre. Die virtuelle Maschine ist Teil der Java-Laufzeitumgebung (Java Runtime Environment, JRE). Vom Java-Quellcode zum ausführbaren Maschinenbefehl werden also zwei Schritte durchlaufen: 1. Der Java-Quellcode wird vom Java-Compiler in Java-Bytecode übersetzt. Diesen Schritt erledigt der Softwareentwickler einmal für alle Zielsysteme. 2. Der Java-Bytecode wird von der auf dem Zielsystem installierten JavaLaufzeitumgebung (mit virtueller Maschine) zur Laufzeit des Programms interpretiert. Dies wird in Abb. 2.1 gezeigt, wobei zum Vergleich auch der entsprechende Ablauf für ein C++-Programm angegeben ist. 2.3 Die Verwandschaft mit C und C++ Große Gemeinsamkeiten zwischen Java und C/C++ bestehen in der Syntax, wodurch dem C-Programmierer der Umstieg auf Java seht erleichtert wird: • Java hat fast die gleichen Operatoren (+, -, *, /, %, <, >, ==, !=, !, &&, ||, &, |, <<, >>, etc.), auch die Symbole {, }, ;, ., [, ], ’ und " werden genauso verwendet wie in C/C++. • Es gibt ähnliche elementare Datentypen wie in C++. • Variablen- und Funktionsdefinitionen sind wie in C aufgebaut. • Als Kontrollstrukturen gibt es in Java wie in C die if- und die switchAnweisung für Verzweigungen sowie die while-, do..while- und forAnweisungen für Schleifen. Die Schleifen unterscheiden sich in Java lediglich durch komfortablere Möglichkeiten bei break und continue. Bytecode virtuelle Maschine JVM JRE 2 Eigenschaften von Java Java-Quellcode Java 2.3 Die Verwandschaft mit C und C++ C++-Quellcode Compiler ? Java-Bytecode C++ Compiler ? Maschinensprache ? Virtuelle Maschine Maschinen- Maschinenbefehle befehle ? ? Betriebssystem Betriebssystem ? Prozessor ? Prozessor Abbildung 2.1: Vergleich: Programmentwicklung und -ablauf bei Java und C++ 11 2 Eigenschaften von Java 2.4 Weitere nützliche Eigenschaften 12 • Die Ausnahmebehandlung (exception handling) mittels der Kontrollstruktur try..catch ist sehr ähnlich wie in C++. Unterschiede zwischen beiden Sprachen bestehen hauptsächlich • in der Behandlung von Feldern (Arrays), die in Java generell dynamisch angelegt werden, • in den Bibliotheken (die bei Java Pakete heißen), z.B. bei der Ein- und Ausgabe (auch mit Dateien), Strings, Collections, etc., • in den zusätzlichen Paketen, die bei Java gegenüber C++ vorhanden sind, z.B. für grafische Oberflächen, Netzwerke, Multimedia, Verschlüsselung, etc. • in der Objektorientierung (s. unten) und in weiteren Eigenschaften, die in Abschnitt 2.4 Wir werden auf die Gemeinsamkeiten und Unterschiede der Syntax in Kapitel 3 ausführlicher eingehen. Java ist wie C++ eine objektorientierte Programmiersprache. Wenn es auch in dieser Hinsicht oberflächlich viel Ähnlichkeit mit C++ gibt, so bestehen doch einige tiefergehende Unterschiede. Da die Objektorientierung in Java noch wichtiger ist als in C++ (wo man auch nicht-objektorientiert programmieren kann), werden wir uns damit in einem eigenen Kapitel (Kap. 4) damit befassen. 2.4 Weitere nützliche Eigenschaften Parallele Abläufe Als Multithreading bezeichnet man die Möglichkeit, in einem Programm mehrere Programmteile (= Threads = Fäden) zeitlich parallel ablaufen lassen zu können. Dies kann entweder eine echte Parallelität sein, wenn der Rechner mehrere Prozessoren besitzt (für jeden Thread einen), oder eine Quasi-Parallelität, wenn die Threads alle auf einem Prozessor laufen, aber so schnell umgeschaltet werden, dass der Eindruck von Gleichzeitigkeit besteht. Multithreading Java hat im Gegensatz zu C/C++ einen eingebauten Mechanismus zur Erzeugung von Threads, die in Java Objekte der Klasse Thread sind. Ein ThreadObjekt enthält eine Funktion run (), die gestartet werden kann und dann (quasi)parallel zum startenden Programmteil abläuft. Auf diese Weise lassen sich (im Prinzip) beliebig viele Threads erzeugen. Es gibt vielfältige Möglichkeiten, wie Threads miteinander kommunizieren und sich synchronisieren können. Automatische Speicherfreigabe Als Garbage Collection (= Müllabfuhr) bezeichnet man bei Programmierspra- Garbage Collection 2 Eigenschaften von Java 2.5 Verfügbarkeit, Tools und Dokumentation chen das Aufräumen von nicht mehr verwendetem Speicher auf dem Heap (Speicherbereich wo man dynamisch Objekt anlegen kann). Bei C++ erfolgt die Garbage Collection nicht automatisch: Man muss mit new erzeugte Objekte, die nicht mehr benutzt werden, mit delete freigeben. Anders bei Java: Die Laufzeit-Umgebung stellt automatisch fest, welche Objekte nicht mehr verwendet werden (d.h. auf welche keine Referenzen mehr existieren) und gibt den entsprechenden Speicherplatz frei. Dies ist eine sehr nützliche Eigenschaft, denn bei komplexen Programmen muss man ohne automatische Garbage Collection sehr viel (Programmier-)Zeit aufwenden, um Objekte korrekt freizugeben. Inkorrekte Freigabe kann entweder dazu führen, dass nach und nach immer mehr Speicher geschluckt wird, bis am Ende kein neues Objekt mehr erzeugt werden kann (wenn vergessen wurde Objekte wieder freizugeben) oder es kann zu Programmabsturz führen (wenn Objekte zu früh oder mehrfach freigegeben werden). Geringe Fehleranfälligkeit Die automatische Garbage Collection ist ein Beispiel für Eigenschaften, die Software weniger Fehleranfällig machen, indem sie potentielle Fehlerquellen ausschließen oder minimieren. Weitere sind: • Ausnahmebehandlung (Exception Handling) bei Fehlern. Dieses auch in C++ enthaltene Feature ermöglicht eine einfacherer Bearbeitung von Fehlerfällen. Es wird auch in Verbindung mit den beiden nächsten Punkten verwendet. • Strenge statische (beim Übersetzen) und dynamische (während der Laufzeit) Typüberprüfung, die Fehlerquellen durch Typinkompatibilität minimiert. • Bereichsüberprüfung bei Feldern. Liegt ein Feldindex außerhalb des erlaubten Bereichs, wird eine Ausnahme ausgelöst. • Keine Pointer. Pointer in C/C++ stellen bei unsachgemäßer Behandlung eine große Fehlerquelle dar. Java kommt deshalb ohne Pointer aus. Allerdings werden Objekte in Referenz-Variablen abgespeichert, die zumindest einige Eigenschaften von Pointern besitzen. Die Vermeidung solcher Fehlerquellen ist auch eine wichtige Voraussetzung (aber nicht die einzige) um Software gegen Angriffe von außen sicher zu machen. 2.5 Verfügbarkeit, Tools und Dokumentation Entwicklungstools Das Java Software Developement Kit (Java SDK, kurz JDK) der Firma SUN Microsystems kann im Internet kostenlos heruntergeladen werden. Es beinhaltet 13 2 Eigenschaften von Java 2.5 Verfügbarkeit, Tools und Dokumentation sämtliche zur Entwicklung von Java-Programmen notwendigen Tools (z.B. Java-Compiler, JRE), allerdings keine grafische Entwicklungsumgebung. Das Java SDK ist für die Plattformen Windows PC, Linux PC, Apple Macintosh und Sun Solaris erhältlich. Zur Entwicklung von Java-Programmen ist es sicher nützlich auf eine Integrierte Enwicklungsumgebung (Integrated Developement Environment, IDE) zurückzugreifen. Hier bieten sich folgende frei verfügbare IDEs an: Eclipse von eclipse.org ist eine mächtige IDE, die für viele Programmiersprachen verfügbar ist. NetBeans von SUN ist eine IDE speziell für die Java-Entwicklung, die vom Funktionsumfang mit Eclipse vergleichbar ist, aber zusätzlich die Möglichkeit bietet, Benutzeroberflächen durch Mausklicks zusammenzustellen. Eine einfache Java-IDE, die aber zum Übersetzen und Ausführen kleiner Programme sehr praktisch ist, ist der JCreator LE von Xinox. Dokumentation und Literatur Zusätzlich zum Java SDK gibt es von Sun (ebenfalls kostenlos) umfangreiche Dokumentation auf Englisch zum SDK. Diese liegt in Form von HTML-Dateien vor und kann mit jedem HTML-Browser betrachtet werden. Hierbei sind hauptsächlich zwei Dokumentationspakete zu nennen: Das Java Tutorial, ein vollständiges Lehrbuch über Java, mit vielen Beispielprogrammen. Die Beschreibung der Java API (Application Programming Interface), d.h. sämtlicher Pakete, Klassen und Methoden, die in Java enthalten sind. Dies ist allerdings zum Lernen, sondern als Referenz zum nachschlagen gedacht und für Anfänger recht schwer zu durchschauen. An dieser Stelle sind auch einige Bücher zu nennen, die es in elektronischer Form kostenlos gibt: „Java ist auch eine Insel“ von Christian Ullenboom und das „Handbuch der Java-Programmierung“von Guido Krüger sind sehr gute und umfangreiche Lehrbücher über Java, die man natürlich auch in Papierform käuflich erwerben kann (jeweils über 1000 Seiten). Ein weiteres, sehr zu empfehlendes Referenzwerk, das allerdings nur in gedruckter Form zu erwerben ist, sind die beiden Bände „Core Java 2“ (Grundlage und Expertenwissen) von Horstmann und Cornell (zusammen über 2000 Seiten). Die Java Language Specification (englisch) ist die offizielle Spezifikation des Sprach-Kerns (also nicht des API) von Java. Hier sind alle Details exakt definiert, aber in einer Form die zum Erlernen der Sprache ungeeignet ist. Diese Spezifikation richtet sich eher an Java-Compiler-Entwickler. Ebenso ist die Java Virtual Machine Specification eine Referenz für Entwickler, welche die Details kennen wollen oder müssen. Daneben gibt es eine Unzahl von Büchern über Java, die meist ausführlicher sind als die vorliegende Einführung. Einige dieser Werke umfassen fast alle Aspekte dieser mächtigen Programmiersprache und sind über 1000 Seiten stark. Wir begnügen uns hier mit einem Einblick in Java. Wer Geschmack gefunden hat, der sollte aber auf eines der umfangreicheren Werke zurückgreifen. 14 2 Eigenschaften von Java 2.6 2.6 Wo ist der Haken? Wo ist der Haken? Nach so viel Lob stellt sich die Frage, ob Java nicht auch negative Eigenschaften besitzt. Diese gibt es in der Tat, und sie sollen nicht verschiegen werden. • Mangelnde Geschwindigkeit: Durch die Interpretation des Bytecodes in der virtuellen Maschine, ergibt sich natürlich ein Geschwindigkeitsverlust gegenüber Maschinensprache. Dies ist der Preis für die Plattformunabhängigkeit. Allerdings wurde die Performance der JVM ständig verbessert, z.B. durch sogenannte just-in-time-Compiler, die geschwindigkeitsrelevante Teile des Bytecodes in Maschinencode übersetzen. Dadurch hält sich der Geschwindigkeitsvorteil von C++ in Grenzen. Allerdings sollte man für langwierige numerische Berechnungen lieber eine andere Sprache als Java einsetzen. Die Stärke von Java liegt im Bereich Grafik, Netzwerke, Multimedia. • Es wird auch behauptet, dass umfangreiche grafische Oberflächen zu viele Resourcen schlucken und dadurch die System-Performance herabsetzen. • Ein definitiver Nachteil ist natürlich, dass man erst die Java-Laufzeitumgebung JRE installiert haben muss, um Java-Programme laufen lassen zu können. Diese ist aktuell unter Windows ca. 15 MB groß. Bei kommerziellen Programmen sollte das JRE mit ausgeliefert werden, da man nicht davon ausgehen kann, dass es auf allen Rechnern installiert ist. Mittlerweile ist Java allerdings so weit verbreitet, dass das JRE auf den meisten Rechnern verfügbar sein dürfte. Falls nicht, kann es leicht heruntergeladen und installiert werden. 15 3 Die Sprache Java Ziel dieses Kapitels ist es, einen knappen Überblick über den Sprachkern von Java zu geben. Darunter sind die elementaren Sprachkonstrukte zu verstehen, aber nicht die im API (Application Programming Interface) enthaltenen Klassen und Funktionen. Wir werden auch nur die Konstrukte der nicht-objektorientierten Teile von Java besprechen; die Objektorientierung in Java wird in einem eigenen Kapitel behandelt. Auch die Ausnahmebehandlung wird in einem späteren Kapitel besprochen. Auf die Unterschiede und Gemeinsamkeiten zu C (teilweise auch C++) wird besonders eingegangen. Wie wir sehen werden, überwiegen die Gemeinsamkeiten, was den Umstieg von C/C++ auf Java sehr erleichtert. Um das Umschreiben von konsolenorientierten C/C++-Programmen (die cin und cout verwendeten) auf Java zu erleichtern, wurde ein eigentlich nicht hierher gehöriger Abschnitt über Ein-/Ausgabe aufgenommen. 3.1 Programmaufbau Programmbausteine Die Bausteine eines Programms sind reservierte Symbole, reservierte Wörter (Schlüsselwörter), selbstdefinierte Bezeichner und Konstanten. Wir werden in diesem Abschnitt einige reservierte Symbole auflisten und den Blockaufbau von Programmen besprechen. Die restlichen Programmbausteine werden in den folgenden Abschnitten und weiteren Kapiteln behandelt. Symbol(e) ; , ( [ ) ] . " ’ /* */ // Bedeutung in C/C++/Java Ende von Anweisungen Listentrenner Arithmetische und Argumentenklammern Indexklammern bei Feldern Zugriff auf die Komponente eines Objekts oder einer Klasse (Struktur nur bei C/C++) Anfang und Ende von Zeichenketten (Strings) Anfang und Ende von Einzelzeichen (Typ char) Kommentaranfang Kommentarende Kommentar bis Zeilenende (nur C++ und Java) 3 Die Sprache Java { } 3.1 Programmaufbau Blockbeginn Blockende Benutzerdefinierte Bezeichner können z. B. für Namen von Variablen, Funktionen und Klassen verwendet werden. Sie sind nach folgenden Regeln aufgebaut: • Sie bestehen aus Buchstaben und Ziffern. Dabei zählt der Unterstrich _ als Buchstabe, in Java sind sogar alle alphabethischen Unicode-Zeichen erlaubt (also auch Umlaute etc.) • Das erste Zeichen darf keine Ziffer sein. • Schlüsselwörter (reservierte Wörter) sind verboten. Java unterscheidet wie C und C++ zwischen Groß- und Kleinbuchstaben! Blockaufbau von Funktionen ‹Modifizierer› ‹Typ› ‹Name› (‹Parameterliste›) // Funktionkopf { // Beginn Funktionskörper ‹Variablen-/Konstantendef.› // lokale Variablen u. Konstanten ... ‹Anweisungen› // Anweisungen ... } // Ende Funktionskörper Dabei steht ‹Modifizierer› für Schlüsselwörter wie static, public, etc., die in Kapitel 4 erklärt werden. ‹Typ› ist der Datentyp des Rückgabewertes der Funktion (oder void falls kein Wert zurückgegeben wird) und ‹Parameterliste› ist eine durch Kommata separierte Liste der formalen Funktionsparameter in der Form ‹Typ1 › ‹Name1 ›, ‹Typ2 › ‹Name2 ›, ... wobei ‹Typi › der Datentyp und ‹Namei › der Name des i-ten Parameters ist. Der Funktionskopf legt die Schnittstelle einer Funktion nach außen fest. Beim Aufruf der Funktion werden aktuelle Parameter für die in der Parameterliste angegebenen formalen Parameter eingesetzt. Die Anweisungen im Funktionskörper definieren den Ablauf der Funktion, wobei auf die formalen Parameter in der Parameterliste wie auf Variablen zugegriffen werden kann. Der Rückgabewert wird mittels einer return-Anweisung zurückgegeben. Die lokalen Variablen und Konstanten sind nur innerhalb der Funktion verwendbar und die Variablen verlieren ihre Werte nach Beendigung des Funktionsaufrufs (Ausnahme: static-Variablen). Beispiele: 17 3 Die Sprache Java 3.1 Programmaufbau double sinx (double x) { double erg; if (x > -1.0E-20 & x < 1.0E-20) erg = 1.0; else erg = Math.sin (x) / x; return erg; } int quersumme (int zahl) { int qs, z, ziffer; qs = 0; z = zahl; while (z != 0) { ziffer = z % 10; qs += ziffer; z = z / 10; } return qs; } Im Gegensatz zu C++ können Parameter nicht wahlweise als Wert oder als Referenz übergeben werden. Einfache Datentypen (siehe nächster Abschnitt) werden immer als Wert übergeben, Referenztypen (Objekte, Arrays) immer als Referenz. Ein weiterer Unterschied zwischen C/C++ und Java ist folgender: Während in C/C++ Funktionen (einschließlich main-Funktion) auf der obersten Ebene definiert werden können, befinden sich in Java sämtliche Funktionsdefinitionen in Klassen. Man kommt deshalb in Java um die Objektorientierung nicht herum! Das soll allerdings nicht heißen, dass jedes Programm, das Klassen verwendet, wirklich objektorientiert ist. Blockaufbau eines Programms Hier der Aufbau eines einfachen nur pro forma objektorientierten Java-Programms: class XYZ // Kopf der Klassendefinition. { ‹Variablendef.› // Klassenvariablen ‹Funktionsdef.› // Funktionen public static void main (String [] args) // main-Funktion { ‹Variablendef.› // lokale Variablen ... ‹Anweisungen› // Anweisungen ... 18 3 Die Sprache Java 3.2 Einfache Datentypen, Variablen und Konstanten 19 } ‹weitere Funktionsdef.› // weitere Funktionen } Man beachte hierbei den von C/C++ verschiedenen Kopf der Funktion main (). 3.2 Einfache Datentypen, Variablen und Konstanten C/C++ und Java haben vordefinierte einfache Datentypen für ganzzahlige, reelle, logische und Zeichenvariablen. Allerdings ist das Format dieser Datentypen teilweise leicht unterschiedlich. Ganzzahlige (Integer-) Datentypen: Datentyp Bedeutung in C/C++ Bedeutung in Java byte short int long unsigned short unsigned long unsigned int unsigned char signed char char — 16 Bit mit Vorzeichen 16 oder 32 Bit∗ mit Vorzeichen 32 Bit mit Vorzeichen 16 Bit ohne Vorzeichen 32 Bit ohne Vorzeichen 16 oder 32 Bit∗ ohne Vorzeichen 8 Bit ohne Vorzeichen 8 Bit mit Vorzeichen 8 Bit mit oder ohne Vorzeichen∗ , auch 8-Bit-Zeichen 8 Bit mit Vorzeichen 16 Bit mit Vorzeichen 32 Bit mit Vorzeichen 64 Bit mit Vorzeichen — — — — — 16-Bit-Unicode-Zeichen, auch 16-Bit-Zahl ohne Vorzeichen ∗) Compiler-abhängig Reelle Datentypen: Datentyp Bedeutung in C/C++/Java float double 32 Bit, einfache Genauigkeit (IEEE 754) 64 Bit, doppelte Genauigkeit (IEEE 754) Sonstige Datentypen: Wie schon aus der Tabelle der ganzzahligen Datentypen ersichtlich, wird für Einzelzeichen der Datentyp char verwendet. In C/C++ sind dies 8-Bit (meist ASCII-) Zeichen, in Java 16-Bit-Unicodes, die die Schriftzeichen vieler verbrei- Einzelzeichen 3 Die Sprache Java 3.2 Einfache Datentypen, Variablen und Konstanten 20 teter Sprachen enthalten. Für logische Werte (wahr oder falsch) existiert in Java der Datentyp boolean, der dem Datentyp bool in C++ entspricht. Variablen dieses Typs können die Werte true und false annehmen. Java unterscheidet im Gegensatz zu C++ streng zwischen Integer-Datentypen und dem Typ boolean. Es findet keine automatische Konversion zwischen diesen Typen statt. Konstanten (Beispiele in C/C++/Java): Konstante Bemerkung 1234 0xA3F0 012 1678267L 578493789267836L -0.8765 1.234E-10 12. .003 17.2F ’A’ ’\n’ ’\x7’ ’\x10’ ’\u2297’ "Hallo" int-Konstante (dezimal) int-Konstante (hexadezimal) int-Konstante (oktal) long-Konstante long-Konstante (nur Java) Reelle double-Konstanten Reelle float-Konstante Einzelzeichen (A) Neue Zeile ASCII-Zeichen 7 (nur C/C++) ASCII-Zeichen 10 (hex) = 16 (nur C/C++) Unicode-Zeichen mit Code 2297 (nur Java) Zeichenkette (String) Variablen Variablen können in Java und C/C++ innerhalb von Funktionen vereinbart werden. Die Lebenszeit solcher Variablen ist auf die Dauer des Funktionsablaufs beschränkt, es sei denn, sie sind als statische Variablen definiert (mit dem Schlüsselwort static). In C und C++, aber nicht in Java können sie auch außerhalb von Funktionen definiert werden (globale Variablen). In den objektorientierten Sprachen C++ und Java können Variablen auch innerhalb einer Klassendefinition (außerhalb von Funktionen) vereinbart werden (Instanz- oder Klassenvariablen, siehe Kapitel 4). Variablenvereinbarung: ‹datentyp› oder: ‹datentyp› ‹name›; ‹name1 ›, ‹name2 ›, ... ; logische Werte 3 Die Sprache Java 3.3 Operatoren, Ausdrücke und einfache Anweisungen 21 Beispiele: int breite, hoehe; float zeit, x, y; double laenge; char buchstabe, c, z; bool iswas; boolean iswas; // Ganzahlig // Reell (32 Bit) // Reell (64 Bit) // Zeichen (8 Bit in C/C++, 16 Bit in Java) // Logisch (in C/C++) bzw. // Logisch (in Java) Symbolische Konstanten Symbolische Konstanten sind Namen, die für konstante Werte stehen. Sie werden in Java ähnlich wie Variablen vereinbart, nur mit dem zusätzlichen Schlüsselwort final und einer Wertzuweisung: final ‹datentyp› ‹name› = ‹wert›; Dies weicht von C/C++ ab, wo statt final das Schlüsselwort const oder die Präprozessor-Anweisung #define verwendet wird. Beispiele: final double PI = 3.14159265358979323; final long BIG_LONG = 0x7FFFFFFFFFFFFFFF; final String MELDUNG = "Bitte eine Taste drücken."; Das letzte Beispiel zeigt den Einsatz des Datentyps String (einer Klasse) zur Speicherung von Zeichenketten. 3.3 3.3.1 Operatoren, Ausdrücke und einfache Anweisungen Operatoren Operatoren dienen dazu, Werte zu verknüpfen (wie z.B. in 3 + 5) oder einzelne Werte zu verändern (wie in -x). Mit (einem oder mehreren Operatoren verknüpfte Werte bezeichnet man als Ausdrücke. Die in einem Ausdruck stehenden Werte können Konstanten, Variablen oder Funktionsaufrufe (von Funktionen, die einen Wert zurückgeben) sein. Prioritäten Die Operatoren in Java sind in verschiedene Prioritätsgruppen gegliedert (1. Spalte in der nachfolgenden Tabelle). Je kleiner die Nummer, desto höher ist die Priorität und desto stärker bindet der entsprechende Operator in einem Ausdruck und die Operation wird entsprechend früher ausgeführt. Natürlich lassen sich die Prioritätsregeln wie in anderen Programmiersprachen auch durch das Setzen von Klammern außer Kraft setzen. Ausdrücke 3 Die Sprache Java 3.3 Operatoren, Ausdrücke und einfache Anweisungen Vorrang Operator Beschreibung 1 1 1 1 1 2 2 ++, -+, ~ ! (‹typ›) 2 3 3 4 4 4 5 5 6 7 % +, + << >> >>> <, <=, >, >= instanceof ==, != & 8 ^ 9 | 10 11 12 13 13 && || ? : = *=, /=, %=, +=, -=, <<=, >>=, >>>=, &=, ^=, |= Inkrement und Dekrement unäres Plus und Minus Bit-Komplement (Integer) Negation (boolean) Typumwandlung (type cast) Multiplikation Division bei Integer Ganzzahl-Division Restbildung (Modulo-Operator) Addition und Subtraktion String-Verkettung Links-Verschiebung (Integer) Rechts-Verschiebung mit Vorzeichen (Integer) Rechts-Verschiebung ohne Vorzeichen (Integer) Arithmetische Vergleiche Typvergleich bei Objekten Gleichheit und Ungleichheit bitweises Und (Integer) logisches Und (boolean) bitweises XOR (Integer) logisches XOR (boolean) bitweises Oder (Integer) logisches Oder (boolean) konditionelles logisches Und (boolean) konditionelles logisches Oder (boolean) 3-stelliger Bedingungsoperator Zuweisung Zuweisung mit Operation * / Die meisten Operationen der Tabelle sind links-assoziativ, d.h., mehrere Operationen dieser Art werden von links nach rechts ausgeführt. Ausnahmen sind die Zuweisungsoperatoren (Gruppe 13), der Operator ? : (Gruppe 12) sowie die unären Operationen. Unterschiede zu C und C++ Die Einteilung in Prioritätsgruppen ist fast die gleiche wie in C/C++. Es gibt aber einige Operatoren, die in einer der Programmiersprachen nicht vorhanden sind, oder in Java anders arbeiten als in C/C++: 22 3 Die Sprache Java 3.3 Operatoren, Ausdrücke und einfache Anweisungen • Es gibt in Java keinen Adressoperator (&) und keinen Inhaltsoperator (*). Dies liegt daran, dass es in Java keine Pointer gibt. Variablen, die Objekte enthalten, sind zwar Referenzen, also etwas ähnliches wie Zeiger auf Objekte, diese werden aber automatisch dereferenziert, so dass keine Notwendigkeit für die oben genannten Operatoren besteht. Aus dem gleichen Grund gibt es auch den C/C++-Operator -> nicht. • Logische Operatoren wie &&, ||, und ! arbeiten in Java nur mit dem Typ boolean, nicht mit Integer-Typen wie in C/C++. Die Operatoren &, ^ und | gibt es in zwei Versionen: als Bit-Operationen für Integer-Typen und als logische Operationen für den Typ boolean. • Es gibt in Java zwei Operatoren für die bitweise Rechts-Verschiebung: eine vorzeichenbehaftete Verschiebung >>, die das oberste Bit der 2er-Komplement-Darstellung nachschiebt und eine vorzeichenlose Verschiebung >>>, die 0-Bits von links nachschiebt. Hier zwei Operationen zur Auswahl zu haben ist sehr sinnvoll. In C/C++ ist das Verhalten des entsprechenden Operators >> nämlich nicht festgelegt, sondern compilerabhängig, was die Portabilität stark einschränkt. 3.3.2 Anweisungen Während mit Ausdrücken meist nur Werte berechnet werden, die in irgendeiner Form weiter verarbeitet werden, sind Anweisungen eigenständige Programmteile, die in einer definierten zeitlichen Reihenfolge abgearbeitet werden, und meist eine Veränderung (z.B. von Variablen) bewirken. Man unterscheidet einfache und komplexe Anweisungen. Einfache Anweisungen können folgendes sein: • Mit verschiedenen Operatoren gebildete Ausdrücke, wie Zuweisungen, Inkrement- und Dekrement-Ausdrücke. • Funktionsaufrufe von Funktionen, die keinen Wert zurückliefern (Typ void) • return-Anweisungen, die einen Funktionsaufruf beenden und einen Wert zurückliefern können • Mit den Schlüsselwörtern break und continue gebildete Anweisungen Komplexe Anweisungen sind die Blockanweisung, d.h. die Zusammenfassung mehrerer Anweisungen mit Hilfe geschweifter Klammern und die Kontrollstrukturen, die wir im nächsten Abschnitt behandeln. 23 3 Die Sprache Java 3.4 3.4 Kontrollstrukturen Kontrollstrukturen Die Kontrollstrukturen von Java sind bis auf die Möglichkeiten, aus Schleifen herauszuspringen (mit break) oder einen neuen Schleifendurchlauf zu bewirken (mit continue), die hier nicht behandelt werden, mit denen von C und C++ identisch. Zu den Kontrollstrukturen gehört auch die Ausnahmebehandlung mit try ... catch, die wir in diesem Abschnitt aber nicht behandeln. 3.4.1 Auswahl (Selektion) if-Anweisungen if (‹Bedingung›) { ‹if-Block› } // einseitige Verzweigung // eine oder mehrere Anweisungen oder: if (‹Bedingung›) { ‹if-Block› } else { ‹else-Block› } // Alternative // eine oder mehrere Anweisungen // eine oder mehrere Anweisungen Ist die angegebene Bedingung wahr, so werden die im if-Block stehenden Anweisungen ausgeführt. Die Anweisungen des else-Blocks (falls vorhanden) werden nicht ausgeführt. Ist die angegebene Bedingung nicht wahr, so werden die Anweisungen im ifBlock nicht ausgeführt. Statt dessen werden, falls ein else vorhanden ist, die Anweisungen des else-Blocks ausgeführt. Ist kein else vorhanden, so wird nach der if-Anweisung fortgefahren. if- und else-Block dürfen jeweils aus einer oder mehreren Anweisungen bestehen. Bei nur einer Anweisung in einem Block dürfen die jeweiligen geschweiften Klammern entfallen. Beispiele: if (konto >= 1000000) status = MILLIONAER; // einseitig, eine Anweisung if (jahr % 4 == 0) // einseitig, mehrere Anweisungen { System.out.println (jahr + " ist Schaltjahr"); schaltjahre++; 24 3 Die Sprache Java 3.4 Kontrollstrukturen } if (stunde >= 12) ampm = "p.m."; else ampm = "a.m."; // Alternative, je eine Anweisung switch-Anweisung switch (‹Ausdruck›) { case ‹Konstante1 ›: ... ‹Anweisungen› ... break; // 1. Fall // Ende 1. Fall ... // weiter Fälle case ‹Konstanten ›: ... ‹Anweisungen› ... break; // n-ter Fall default: ... ‹Anweisungen› ... break; // sonst-Fall // Ende n-ter Fall } Die switch-Anweisung ist eine Mehrfach-Fallunterscheidung. Es wird untersucht, ob der am Anfang angegebene Ausdruck einen der angegebenen Werte annimmt und die entsprechenden Anweisungen werden ausgeführt. Der Ausdruck und die Werte können vom Typ char, byte, short oder int sein (alle müssen vom gleichen Typ sein). Im Inneren der switch-Anweisung befinden sich Sprungmarken (durch das Schlüsselwort case eingeleitet) mit möglichen Werten des Ausdrucks. Dies müssen Konstanten sein. Je nach aktuellem Wert des Ausdrucks wird zu der Sprungmarke mit dem entsprechenden Wert gesprungen und die nachfolgenden Anweisungen werden bis zur break-Anweisung ausgeführt. Tritt der aktuelle Wert des Ausdrucks nicht bei einer der caseMarken auf, so wird, falls vorhanden, die default-Marke angesprungen. Fehlt diese, so ist die switch-Anweisung beendet. Beispiel: switch (tag) { case 1: text = "Montag"; 25 3 Die Sprache Java 3.4 Kontrollstrukturen break; case 2: text = "Dienstag"; break; . . . case 5: text = "Freitag"; break; default: text = "Wochenende"; break; } Je nach Wert der Integer-Variablen tag (1, 2, ... 5) erhält die String-Variable text den Wert "Montag" bis "Freitag" zugewiesen. Bei allen anderen Werten erfolgt die Zuweisung von "Wochenende" im default-Fall. Die break-Anweisungen innerhalb der switch-Anweisung dienen zum Verlassen der switch-Anweisung (Sprung ans Ende). Wird das break vergessen, so tritt kein Syntax-Fehler auf, sondern nach Ausführung der Anweisungen des entsprechenden Falls wird einfach mit dem nächsten Fall fortgefahren (so lange bis ein break auftritt). Dies kann auch gewollt sein, wie das nächste Beispiel zeigt: Beispiel: switch (buchstabe) { case ’a’: case ’e’: case ’i’: case ’o’: case ’u’: vokal = true; break; default: vokal = false; break; } Die char-Variable buchstabe wird daraufhin untersucht, ob es sich um einen Vokal handelt oder nicht. Die boolesche Variable vokal wird entsprechend gesetzt. Der Trick hierbei ist, dass die Fälle ’a’, ... ’u’ ohne break-Anweisung hintereinander stehen. In all diesen Fällen wir somit die Anweisung vokal = true ausgeführt. 26 3 Die Sprache Java 3.4.2 3.4 Kontrollstrukturen Schleife (Iteration) while- und do..while-Schleife Schleife mit Kopfprüfung: while (‹Bedingung›) { ‹Schleifenkörper› } // Kopfprüfung // eine oder mehrere Anweisungen Schleife mit Fußprüfung: do { ‹Schleifenkörper› } while (‹Bedingung›) // eine oder mehrere Anweisungen // Fußprüfung Die im Schleifenkörper stehenden Anweisungen werden so lange wiederholt, wie die angegebene Bedingung wahr ist. Bei der while-Schleife wird die Bedingung schon zu Anfang getestet (Kopfprüfung) und falls sie nicht erfüllt ist, werden die Anweisungen im Schleifenkörper gar nicht ausgeführt. Ist die Bedingung erfüllt, so werden abwechselnd die Anweisungen des Schleifenkörpers ausgeführt und die Bedingung getestet, so lange, bis die Bedingung nicht mehr erfüllt ist. Bei der do..while-Schleife werden zunächst die Anweisungen des Schleifenkörpers ausgeführt und dann erst die Bedingung getestet (Fußprüfung). Dies geschieht abwechselnd, so lange, bis die Bedingung nicht mehr erfüllt ist. Durch die Fußprüfung ist gewährleistet, dass der Schleifenkörper mindestens einmal durchlaufen wird. Der Schleifenkörper darf aus einer oder mehreren Anweisungen bestehen. Bei nur einer Anweisung dürfen die geschweiften Klammern weggelassen werden. Beispiele: while (z < n) z = z * 2; while (k <= 10) { summe += k; k++; } do { // nur eine Anweisung // zwei Anweisungen // Beispiel: Passwort-Eingabe // auch bei einer Anweisung eingabe (text); // dürfen geschweifte Klammern // verwendet werden. } while (! text.equals(password)); 27 3 Die Sprache Java 3.4 Kontrollstrukturen for-Schleife for (‹Startanweisung›; ‹Bedingung›; ‹Iterationsanweisung›) { ‹Schleifenkörper› } // wird einmal am Anfang ausgeführt // Kopfprüfung // jedesmal nach Schleifenkörper // eine oder mehrere Anweisungen Dies ist äquivalent zu folgender while-Schleife: ‹Startanweisung›; while (‹Bedingung›) { ‹Schleifenkörper› ‹Iterationsanweisung›) } // wird einmal am Anfang ausgeführt // Kopfprüfung // eine oder mehrere Anweisungen // jedesmal nach Schleifenkörper In runden Klammern werden nach dem Schlüsselwort for drei Ausdrücke, getrennt durch Semikolons, angegeben: Der erste ist eine Startanweisung, die einmal ganz zu Anfang ausgeführt wird. Danach steht die Schleifenbedingung. Die im Schleifenkörper stehenden Anweisungen werden so lange wiederholt, wie die Bedingung erfüllt ist. Diese wird schon zu Anfang getestet (Kopfprüfung) und falls sie nicht erfüllt ist, wird der Schleifenkörper gar nicht durchlaufen. Als drittes steht eine Iterationanweisung, die automatisch nach jedem Durchlauf des Schleifenkörpers ausgeführt wird. Der Schleifenkörper darf aus einer oder mehreren Anweisungen bestehen. Ist nur eine Anweisung vorhanden, so dürfen die geschweiften Klammern entfallen. Wie oben angegeben, kann eine for-Schleife durch eine Konstuktion mit while-Schleife ausgedrückt werden. Die for-Schleife hat gegenüber der Ersatzkonstruktion den Vorteil, dass bei Zählschleifen (Schleifen, bei denen eine Laufvariable einen bestimmten Laufbereich durchläuft) alle relevanten Werte (Anfangswert, Endwert, Schrittweite) zusammen im Kopf der for-Schleife stehen. Beispiele: Beispiel 1: aufwärts zählen for (k = 1; k <= 10; k++) System.out.print (" " + k); // Ausgabe: 1 2 3 4 5 6 7 8 9 10 Beispiel 2: abwärts zählen for (k = 10; k >= 1; k--) System.out.print (" " + k); // Ausgabe: 10 9 8 7 6 5 4 3 2 1 Beispiel 3: aufwärts zählen mit Schrittweite 6= 1 for (n = 10; n <= 40; n = n + 5) System.out.print (" " + n); // Ausgabe: 10 15 20 25 30 35 40 28 3 Die Sprache Java 3.5 Standardein- und Ausgabe Beispiel 4: x durchläuft den Bereich von a bis e und wird bei jedem Durchlauf mit dem Faktor s multipliziert. Funktioniert für s < 1 und s > 1. for (x = a; (s-1)*x <= (s-1)*e; x = x * s) { ... } 3.5 Standardein- und Ausgabe Die Beschreibung der Ein- und Ausgabefunktionen gehört eigentlich nicht in ein Kapitel über den Sprachkern. Da ein Zweck dieses Kapitels aber ist, einen schnellen Umstieg von C/C++ auf Java zu ermöglichen, wurde dieser Abschnitt hier angesiedelt. Die Standardausgabe ist leicht von C++ auf Java umzusetzen. Standardausgabe Dabei werden die Ausgabeanweisungen mit cout in C++ durch Aufrufe der Funktion System.out.print () in Java ersetzt: cout << ‹Ausdruck›; wird ersetzt durch: System.out.print (‹Ausdruck›); // C++ // Java cout << ‹Ausdruck1 › << ‹Ausdruck2 › << ...; wird ersetzt durch: System.out.print (‹Ausdruck1 › + ‹Ausdruck2 › + ...; // C++ // Java Das +-Zeichen im Argument der print-Funktion steht für die String-Verkettung. Steht in C++ am Ende ein « endl, so muss man in Java anstelle der print-Funktion die Funktion println verwenden, die am Ende eine neue Zeile beginnt. Es besteht aber auch die Möglichkeit, hierfür das Steuerzeichen ’\n’ auszugeben. Beispiel: Mit den int-Variablen stunde und minute wird C++: cout << "Es ist " << stunde << " Uhr und " << minute << " Minuten" << endl; zu Java: System.out.println ("Es ist " + stunde + " Uhr und " + minute + " Minuten"); Die Formatierung von Ausgaben (in C++ mit den Manipulatoren setw, setprecision, etc.) ist nicht so leicht auf Java zu übertragen und wird hier nicht weiter behandelt. 29 3 Die Sprache Java 3.5 Standardein- und Ausgabe Standardeingabe Das Lesen von der Standardeingabe erfolgt seit JDK 5 am einfachsten mit Hilfe eines Scanner-Objekts (Klasse Scanner, definiert im Paket java.util). Hier ein Beispiel: Beispiel 3.1: Ein Programm mit Ein- und Ausgabe 1 import java.util.Scanner; // Scanner-Klasse aus Util-Paket verwenden 2 3 4 class Eingabe { // Dieses Programm liest Name und Alter einer Person ein // und gibt einen entsprechenden Text aus. 5 6 public static void main (String [] args) { int alter; String name; 7 8 9 10 11 12 // Vereinbaren eines Scanner-Objekts zum Einlesen: 13 Scanner in = new Scanner (System.in); 14 System.out.print ("Wie heisst Du: name = in.nextLine (); 15 16 "); // Lesen einer Textzeile 17 System.out.print ("Wie alt bist Du: "); alter = in.nextInt (); // Lesen eines int-Werts 18 19 20 System.out.println ("Hallo, " + name + ", Du bist " + alter + " Jahre alt."); 21 22 } 23 24 } Dieses Programm liest einen String und einen Integerwert ein. Die in Zeile 13 vereinbarte Variable in ist ein Scanner-Objekt, mit dessen Hilfe sich Werte verschiedener Datentypen einlesen lassen. In Zeile 16 wird mit der Funktion nextLine () eine Textzeile (String) eingelesen. In Zeile 19 wir mit nextInt () ein Integerwert gelesen. Es können auch andere Datentypen eingelesen werden, z. B. ein double-Wert mit der Funktion nextDouble () oder ein einzelnes Zeichen (char) mit der Funktion nextChar (). 30 4 Objektorientierung bei Java Grundsätzlich kann man sagen, dass Java zwar strenger objektorientiert ist als C++ (Objektorientierung ist bei Java Pflicht!), in objektorientierter Hinsicht aber einfacher als C++ ist. Dies liegt daran, dass einige Eigenschaften vom C++ bei Java nicht vorhanden sind (z.B. befreundete Klassen, Mehrfachvererbung, Überladen von Operatoren). Trotzdem gibt es auch in Java einige objektorientierte Features (z.B. Interfaces, innere Klassen) die in C++ nicht vorhanden sind. Wegen dieser zahlreichen Unterschiede sollen in diesem Kapitel nicht die Differenzen zwischen Java und C++ bezüglich der OOP aufgelistet werden. Es soll statt dessen eine eigenständige Einführung in die OOP mit Java gegeben werden. 4.1 Klassen Eine Klassendefinition ist in Java in ihrer einfachsten Form wie folgt aufgebaut: class ‹Klasse› { // ... hier Vereinbarung von Variablen und Methoden } Dabei steht ‹Klasse› für einen beliebigen Klassennamen (meist anders als Variablen- oder Methodennamen mit großem Anfangsbuchstaben geschrieben). Die Vereinbarung von Variablen und Methoden in der Klassendefinition kann in beliebiger Reihenfolge (auch gemischt) erfolgen. Im Gegensatz zu C++ stehen in der Klassendefinition nicht nur Funktionsprototypen (Funktionsdeklarationen), sondern vollständige Funktionsdefinitionen, wie im folgenden Beispiel zu erkennen ist: Beispiel 4.1: Die Klasse Kreis class Kreis { public double mx, my; public double radius; // x- und y-Koordinate des Mittelpunkts // Radius des Kreises public Kreis (double x, double y, double r) { mx = x; // Konstruktor 4 Objektorientierung bei Java 4.2 Objekte 32 my = y; radius = r; } public double flaeche () { return Math.PI * radius * radius; } // Fläche berechnen public double umfang () { return 2 * Math.PI * radius; } // Umfang berechnen } Wie zu erkennen ist, enthält die Klassendefinition von Kreis Vereinbarungen von Variablen (mx, my und radius) und Methoden (Kreis (), flaeche () und umfang ()). Eine Klasse ist eine Blaupause für die Erzeugung eines Objekts, ähnlich wie ein Datentyp eine Blaupause für eine Variable dieses Typs darstellt. Man nennt ein Objekt vom Typ einer Klasse eine Instanz der Klasse. Variablen und Methoden, die wie im Beispiel ohne das Schlüsselwort static definiert werden, bezeichnet man als Instanzvariablen bzw. Instanzmethoden, weil sie nur sinnvoll auf Instanzen der Klasse angewandt werden können. Instanzvariablen werden für jede Instanz einer Klasse (d.h. für jedes erzeugte Objekt) angelegt. Entsprechend wirken Instanzmethoden auf Instanzen einer Klasse. 4.2 Instanzvariable Instanzmethode Objekte Es stellt sich die Frage: Wie erstellt man mit Hilfe der Klassendefinition eine Instanz der Klasse, oder, auf unser Beispiel bezogen, wie erzeugt man ein KreisObjekt? Dafür ist eine spezielle Methode zuständig, der sogenannte Konstruktor. Dies ist eine Methode, mit dem gleichen Namen wie die Klasse, aber ohne Rückgabewert (auch ohne void), in unserem Beispiel die Methode Kreis (). Diese Konstruktor-Methode wird zur Initialisierung aufgerufen, wenn eine Instanz der Klasse Kreis erzeugt wird. Das eigentliche Anlegen des Objekts geschieht mit dem new-Operator, der dynamisch Speicherplatz für das Objekt reserviert. In unserem Kreis-Beispiel könnten wir schreiben: new Kreis (2.0, 3.5, 1.0); Hierdurch wird eine (unbenannte) Instanz der Klasse Kreis erzeugt. Der newOperator reserviert dynamisch Platz für ein Kreis-Objekt, das durch den Konstruktor-Aufruf Kreis (2.0, 3.5, 1.0) initialisiert wird. D.h. das neue Kreis-Objekt hat den Mittelpunkt (2.0, 3.5) und hat einen Radius von 1.0 (alles in Konstruktor new 4 Objektorientierung bei Java 4.3 Zugriff auf Instanzvariablen und -methoden 33 willkürlichen Einheiten). new-Operator und Konstruktor treten in Java immer zusammen auf. Statt des in der Klasse Kreis definierten Konstruktors (mit drei double-Parametern) könnte man auch den sogenannten Default-Konstruktor Kreis () verwenden, der automatisch in einer Klasse vorhanden ist. Er initialisiert die numerischen Instanzvariablen auf 0. Im Gegensatz zu C++, wo Objekte auch ohne new-Operator erzeugt werden können (statisch oder automatisch auf dem Stack), können Objekte in Java nur dynamisch über den newOperator angelegt werden. Meist ist es nicht sinnvoll, ein unbenanntes Objekt zu erzeugen, denn man möchte nach Erzeugung des Objekts ja auch darauf zugreifen. Dazu braucht man in der Regel eine Variable mit einem Namen. Der Typ dieser Objekt-Variablen ist die Klasse. Die Vereinbarung einer solchen Variablen könnte in unserem Beispiel so aussehen: Kreis k; Durch diese Vereinbarung wird (im Gegensatz zu C++) allerdings kein Objekt angelegt (das geschieht ja durch den new-Operator und den Konstruktor), sondern eine sog. Referenz (vornehme Version eines Pointers) auf ein Objekt. Der Grund dafür ist, dass alle Klassen (und auch Arrays) in Java sogenannte Referenztypen sind. Variablen eines Referenztyps enthalten nicht direkt ein Objekt dieses Typs sondern einen Verweis (= Referenz) auf ein mögliches Objekt. Nach Vereinbarung einer Referenzvariablen (hier die Variable k) ist die Referenz noch leer, d.h. k zeigt auf kein gültiges Objekt. Dies wird durch den Wert null signalisiert, den die Variable am Anfang automatisch erhält. Erst die Zuweisung eines mit new erzeugten Objekts an die Variable bewirkt, dass diese auf das Objekt zeigt, und man somit später darauf zugreifen kann. In unserem KreisBeispiel können wir also schreiben: Kreis k; k = new Kreis (2.0, 3.5, 1.0); // Refernzvariable wird angelegt // neue Instanz wird zugewiesen Dies lässt sich auch zu einer Anweisung zusammenfassen: Kreis k = new Kreis (2.0, 3.5, 1.0); Durch diese zwei Zeilen (bzw. eine Zeile) wird eine Instanz der Klasse Kreis erzeugt auf die man mit der Variablen k zugreifen kann. 4.3 Zugriff auf Instanzvariablen und -methoden Refernzvariablen wie k können in Java ganz ähnlich wie Referenzvariablen in C++ verwendet werden: Man kann auf die Instanzkomponenten (d.h. Instanzvariablen und -methoden) mit Hilfe des .-Operators zugreifen, z.B.: double x, y, f; Kreis k = new Kreis (2.0, 3.5, 1.0); // Instanz von Kreis erzeugen k.radius = 0.5; // Radius des Kreis-Objekts verändern Refernztypen 4 Objektorientierung bei Java x = k.mx; y = k.my; f = k.flaeche (); 4.4 Statische Variablen und Methoden 34 // Mittelpunktskoordinaten lesen ... // ... und in lokale Variablen kopieren // Fläche von k berechnen Natürlich kann man innerhalb der Klasse, also in Methoden der Klasse ohne .Operator auf Instanzvariablen zugreifen, so wie das z.B. im Konstruktor oder in den beiden anderen Methoden der Falls ist. In seltenen Fällen kann es vorkommen, dass man innerhalb der Klasse Bezug auf eine Instanz nehmen muss, nämlich auf diejenige Instanz, mit der die jeweilige Instanzmethode aufgerufen wird. Dies geschieht dann mit dem Schlüsselwort this. this Wie in C++ gibt es auch in Java die Möglichkeit, die Zugriffsrechte auf Variablen und Methoden mit Hilfe der Schlüsselwörter public, protected und private zu regeln. Diese sogenannten Zugriffsmodifizierer werden, wie in der Kreis-Klasse zu sehen, den entsprechenden Variablen oder Methoden in der Vereinbarung vorangestellt. (Dies ist anders als bei C++, wo der Einflussbereich eines dieser Schlüsselwörter sich auf alle nachfolgenden Vereinbarungen erstreckt.) Die einfachsten Fälle sind folgende: Zugriffsrechte public bedeutet, dass die Variable oder Methode überall nach außen sichtbar ist. So kann z.B. auf die als public vereinbarte Instanzvariable radius der Klasse Kreis über eine Instanz k einfach mit k.radius zugegriffen werden. public Der gleiche Zugriff würde zu einer Fehlermeldung beim Compilieren führen, wäre die Instanzvariable radius als private vereinbart. In diesem Falle ist nur der Zugriff aus Methoden der eigenen Klasse zulässig (wie in unserem Beispiel im Konstruktor). private Entsprechendes gilt auch für Methoden. So darf z.B. eine als private vereinbarte Methode nur von anderen Methoden der eigenen Klasse aufgerufen werden. Komplizierter sind die Fälle, wo kein Zugriffsmodifizierer oder protected bei der Vereinbarung verwendet wird. Diese Fälle werden im Kapitel ?? behandlt. 4.4 Statische Variablen und Methoden Instanzvariablen sind Teile der jeweiligen Objekte. Der Zugriff auf eine Instanzvariable ist nur möglich, wenn auch ein Objekt angegeben wird. Daneben gibt es auch (wie in C++) die Möglichkeit Variablen mit der Klasse und nicht mit Instanzen der Klasse zu verbinden. Man nennt diese Variablen statische Variablen oder Klassenvariablen. Eine statische Variable existiert nur einmal für die Klasse, unabhängig von der Zahl der Instanzen. Statische Variablen werden wie Instanzvariablen in der Klassendefinition vereinart, enthalten aber zusätzlich das Schlüsselwort static. Nehmen wir als Beispiel an, wir wollten bei unserer Kreis-Klasse mitzählen, statische Variablen static 4 Objektorientierung bei Java 4.4 Statische Variablen und Methoden wie viele Instanzen bisher erzeugt wurden. Wir benötigen dazu eine ganzzahlige Variable, z.B. int anzahl. Diese Variable kann keine Instanzvariable sein, da sie unabhängig von der Zahl der Instanzen nur einen einzigen Wert hat. Sie muss deshalb als Klassenvariable mit dem Zusatz static angelegt werden: class Kreis { ... static int anzahl = 0; ... } // Anzahl der erzeugten Instanzen von Kreis Dabei ist es in den meisten Fällen sinnvoll, gleich einen Anfangswert zuzuweisen. (Dies kann nicht im Konstruktor geschehen, sonst würde der Wert der Variablen mit jeder neuen Instanz zurückgesetzt.) Der Zugriff auf eine statische Variable erfolgt ebenfalls über den .-Operator und zwar mit vorangestelltem Klassennamen. (Sind Instanzen der Klasse vorhanden, so darf auch eine dieser vorangestellt werden.) Auf anzahl können wir mit Kreis.anzahl zugreifen. Um die erzeugten Instanzen mit zu zählen, muss man lediglich die Variable anzahl im Konstruktor erhöhen: class Kreis { ... static int anzahl = 0; // Anzahl der erzeugten Instanzen von Kreis public Kreis (double x, double y, double r) { mx = x; my = y; radius = r; anzahl++; // Anzahl um 1 erhöhen } ... // Konstruktor } Testen wir dieses Verhalten der Klasse Kreis mit einem Hauptprogramm, das wir entweder zur Klasse Kreis hinzufügen, oder in eine eigene Klasse packen können: public static void main (String [] args) { System.out.println ("Anzahl Kreise: " + Kreis.anzahl); Kreis k1 = new Kreis (1.4, 2.0, 1.0); Kreis k2 = new Kreis (5.0, 0.0, 3.0); Kreis k3 = new Kreis (3.2, 8.5, 5.0); System.out.println ("Anzahl Kreise: " + Kreis.anzahl); } Dieses Programm erzeugt als Ausgabe: 35 4 Objektorientierung bei Java 4.4 Statische Variablen und Methoden 36 Anzahl Kreise: 0 Anzahl Kreise: 3 Statische Variablen finden auch häufig Verwendung als symbolische Konstanten wo sie zusammen mit dem Schlüsselwort final eingesetzt werden. final hat die gleiche Bedeutung wie const in C++, also unveränderlich. Wir könnten z.B. in unser Kreis-Beispiel folgende Zeile aufnehmen: symbolische Konstanten final class Kreis { final static double PI = 3.1415926535323; ... } Dies wäre sinnvoll wenn die Konstante π nicht schon in der Klasse Math definiert wäre. Wir könnten dann auf π über Kreis.PI zugreifen. Den statischen Variablen entsprechend gibt es statische Methoden, die nicht auf bestimmten Instanzen einer Klasse operieren, sondern auf der Klasse selbst. Solche Methoden können deshalb nur auf statische Variablen der Klasse zugreifen. Statische Methoden, auch Klassenmethoden genannt, werden wie statische Variablen, durch das Schlüsselwort static gekennzeichnet. Beispiel: zur Ergänzung unseres Kreis-Beispiels können wir eine statische Methode zur Ausgabe der Anzahl der Kreise erstellen: class Kreis { ... static void printAnzahl () { System.out.println ("Anzahl Kreise: " + anzahl); } } Der Aufruf einer statischen Methode geschieht mit dem .-Operator und vorangestelltem Klassennamen. (Sind Instanzen der Klasse vorhanden, so darf auch eine dieser vorangestellt werden.) Wir mit der Methode printAnzahl () können wir die main-Methode von oben folgendermaßen umändern: public static void main (String [] { Kreis.printAnzahl (); Kreis k1 = new Kreis (1.4, 2.0, Kreis k2 = new Kreis (5.0, 0.0, Kreis k3 = new Kreis (3.2, 8.5, Kreis.printAnzahl (); } args) 1.0); 3.0); 5.0); Ein weiteres Beispiel einer statischen Methode ist die eben angegebene mainMethode. Jedes Java-Programm muss eine main-Methode enthalten, die sich statische Methoden 4 Objektorientierung bei Java 4.5 Ableitung von Klassen, Vererbung 37 in irgendeiner Klasse befinden kann. Diese Methode entspricht in der konventionellen Programmierung dem Hauptprogramm insofern, als das Java-Programm mit der main-Methode gestartet wird. Da zu diesem Zeitpunkt noch keine Instanzen irgendwelcher Klassen erzeugt wurden, muss die main-Methode statisch sein. 4.5 Ableitung von Klassen, Vererbung Der zu Anfang des Kapitels angegebene Aufbau einer Klassendefinition kann wie folgt erweitert werden: class ‹Unterklasse› extends ‹Oberklasse› { // ... hier Vereinbarung von Variablen und Methoden } Dabei ist ‹Unterklasse› die neu definierte Klasse, die von der Klasse ‹Oberklasse› abgeleitet wird. Man bezeichnet die Klasse ‹Oberklasse› als Oberklasse (Superclass) der Klasse metaUnterklasse. Entsprechend ist die Klasse ‹Unterklasse› eine Unterklasse (Subclass) der Klasse ‹Oberklasse›. Das Ableiten von Klassen bedeutet, dass die Unterklasse alle Variablen und Methoden der Oberklasse übernimmt (erbt). Man spricht deshalb auch von Vererbung. Zusätzlich erhält ‹Unterklasse› die in der Klassendefinition angegebenen Variablen und Methoden. Oberklasse Unterklasse Vererbung Die Unterklasse ist also eine Erweiterung der Oberklasse. Alles was man mit einer Instanz der Oberklasse tun kann, kann auch mit einer Instanz der Unterklasse getan werden, aber nicht umgekehrt. Man kann sogar noch weitergehen und sagen: eine Instanz der Unterklasse ist gleichzeitig auch eine Instanz der Oberklasse, da sie ja alle Elemente (Variablen und Methoden) der Oberklasse besitzt. Der Mechanismus der Vererbung ist der einer Spezialisierung. Man kann in dieser Hinsicht die Klassen mit Kathegorien der realen Welt vergleichen: So ist z.B. ein Katze (=Unterklasse) ein Tier (=Oberklasse). Ein Tier ist durch bestimmte Eigenschaften gekennzeichnet (wie z.B. die Fähigkeit zu atmen, fressen, sich fortzupflanzen, etc.). Diese Eigenschaften „erbt“ auch die Katze. Hinzu kommen aber auch neue Eigenschaften (wie z.B. die Fähigkeit zu laufen, Mäuse zu fangen, zu miauen, etc.), die nicht jedes Tier besitzt. Deshalb ist jede Katze ein Tier, aber nicht jedes Tier eine Katze. Klassen die voneinander abgeleitet sind bilden eine Hierarchie: Die Oberklasse steht, wie der Name schon sagt, oberhalb der Unterklasse. Eine Klasse kann auch indirekt von einer Oberklasse abgeleitet sein: Wenn die Klasse B von der Klasse A abgeleitet ist und die Klasse C von der Klasse B, so sagt man dass C auch von A abgeleitet ist (über dem Umweg B), denn C erbt ja auch die Eigenschaften von A. Eine Klasse kann durchaus mehrere Unterklassen haben (die alle in separaten Klassendefinitionen von ihr abgeleitet sind), aber nur ei- Klassenhierarchie 4 Objektorientierung bei Java 4.5 Ableitung von Klassen, Vererbung 38 ne Oberklasse. (Dies ist anders als in C++, wo es die sogenannte Mehrfachvererbung gibt.) Wir werden allerdings sehen, dass es in Java einen Mechanismus gibt, durch den gleichzeitig zu den Eigenschaften der Oberklasse noch andere Eigenschaften an eine Klasse vererbt werden können (durch sogenannte interfaces, siehe Abschnitt 4.7). Kommen wir nun wieder auf das Kreis-Beispiel zurück: Wir können vom Kreis einen Ring ableiten, der zusätzlich noch durch einen Innenradius gekennzeichnet ist (der Kreisradius ist der äußere Ringradius). Beispiel 4.2: Die Klasse Ring 1 2 3 class Ring extends Kreis { public double innenRadius; // innerer Ringradius 4 public Ring (double x, double y, double ir, double ar) { super (x, y, ar); // Konstruktor der Oberklasse innenRadius = ir; } 5 6 7 8 9 10 public double dicke () // Dicke des Rings berechnen { return radius - innenRadius; } 11 12 13 14 15 public double flaeche () // Ringfläche berechnen { return Math.PI * (radius * radius - innenRadius * innenRadius); } 16 17 18 19 20 21 } So banal das Beispiel auch scheinen mag, man kann eine Menge daraus lernen: Zunächst erbt die Klasse Ring (fast) alles, was in der Klasse Kreis enthalten ist. So sind sie Variablen mx, my und radius auch in Ring enthalten (folgt aus Zeile 1). Hinzu kommt die neue Instanzvariable innenRadius (Zeile 3), sowie die Methode dicke () (Zeile 12), die auf Variablen der Klassen Kreis und Ring zugreift und der Konstruktor (Zeile 5). Der Aufbau des Konstruktors ist interessant: Wir könnten hier einfach die vier übergebenen Werte den vier Instanzvariablen zuweisen. Statt dessen wird anders vorgegangen: Drei der Werte (alle außer ir) sind auch schon für die Kreis-Klasse relevant, so dass es nahe liegt, deren Konstruktor zu verwenden. Der Konstruktoraufruf steht in Zeile 7 und muss die erste Anweisung des Konstruktors sein. Er wird über das das Schüsselwort super, aufgerufen, das Bezug auf die Oberklasse nimmt. (super ist noch allgemeiner einsetzbar, da es analog zu this auch Bezug auf eine konkrete Instanz einer Klasse nehmen kann.) super 4 Objektorientierung bei Java 4.5 Ableitung von Klassen, Vererbung Die zweite interessante Stelle ist die Methode flaeche () in Zeile 16. Wenn wir uns die Anfang des Kapitels definierte Klasse Kreis ansehen, finden wir ein Methode gleichen Namens. Diese wird quasi erstetzt durch die Methode der Ring-Klasse. Man nennt diesen Mechanismus Überscheiben von Methoden. Das ist sehr praktisch, denn je nachdem, ob ein Objekt ein reiner Kreis oder ein Ring ist, wird zur Berechnung der Fläche die eine oder die andere Methode aufgerufen. Die Java-Maschine entscheidet dynamisch zur Laufzeit des Programms, welche der Methoden aufgerufen wird. (Dies ist wie bei virtuellen Methoden in C++.) Wollte man in der Ring-Klasse die Fläche der äußeren Kreises berechnen, so könnte man einfach aufrufen: super.flaeche () . Von außerhalb der Klasse ist allerdings für ein Instanz der Klasse Ring kein Zugriff auf die ursprüngliche flaeche-Methode möglich. Sehen wir uns ein Beispiel für eine main-Methode an (die wir in die RingKlasse oder in eine andere Klasse integrieren können), mit der wir das Verhalten der Ring-Klasse testen können: 1 2 3 4 public static void main (String [] args) { Kreis k1 = new Kreis (2.0, 1.4, 1.0); Kreis k2 = new Ring (7.3, -2.5, 0.8, 1.0); 5 System.out.println ("k1: Flaeche = " + k1.flaeche () + ", Umfang = " + k1.umfang ()); 6 7 8 // die folgende Anweisung führt zu einem Laufzeitfehler: 9 System.out.println ("Dicke = " + ((Ring) k1).dicke ()); 10 System.out.println ("k2: Flaeche = " + k2.flaeche () + ", Umfang = " + k2.umfang ()); System.out.println ("Dicke = " + ((Ring) k2).dicke ()); 11 12 13 14 // die folgende Anweisung führt zu einem Fehler beim Übersetzen: 15 System.out.println ("Dicke = " + k2.dicke ()); 16 } Dieses Programm erzeugt ein Kreis- und ein Ring-Objekt und gibt dann Fläche, Umfang sowie beim Ring die Dicke aus. Hier treten wieder einige bemerkenswerte Stellen auf: In Zeile 4 wird ein Ring-Objekt erzeugt, die Referenz darauf aber der Variablen k2 vom Typ Kreis zugewiesen. Das ist durchaus legitim, denn ein Ring ist gleichzeitig auch ein Kreis (nicht notwendigerweise umgekehrt, siehe Zeile 9). In den Zeilen 6 und 11 werden die Flächen der beiden Objekte berechnet und ausgegeben. Obwohl k2 vom Typ Kreis ist, wird trotzdem die überschriebene flaeche-Methode der Klasse Ring verwendet. Dies liegt an der dynamischen Form des Überschreibens: Die Java-Laufzeit-Umgebung stellt fest, dass die Referenzvariable k2 auf ein Ring-Objekt zeigt und verwendet automatisch die überschriebene Methode. Anders sieht es in Zeile 13 bzw. 15 aus: Die Methode dicke () ist nur in der Klasse Ring vorhanden. Hier gibt es keine dynamische Zuordnung von Methoden. Der Java-Compiler überprüft die Zugehörigkeit der Methode zur Klasse 39 Überscheiben v. Methoden 4 Objektorientierung bei Java 4.6 Objekte, Referenzen und Werte 40 beim Übersetzen, und stellt in Zeile 15 fest, dass ein Objekt vom Typ Kreis auf eine Methode dicke zugreifen will, die dort gar nicht definiert ist. (Dass dort ein Ring-Objekt abgespeichert ist, ergibt sich erst zur Laufzeit und ist für den Compiler nicht (allgemein) überprüfbar.) Anders in Zeile 13: Hier wird dem Compiler durch den type cast (explizite Typumwandlung) (Ring) k2 mitgeteilt, dass die Variable k2 auf ein Ring-Objekt erwartet wird. Der Compiler geht davon aus, dass dies der Fall ist, und kann die Methode dicke zuordnen. In Zeile 9, wo der gleiche type cast auf die Variable k1 angewandt wird, geht die Sache schief. Denn k1 zeigt nicht auf ein Ring-Objekt! Dies stellt der Compiler allerdings nicht fest. Erst zur Laufzeit des Programms tritt bei der Typumwandlung ein Fehler auf. Wird eine Klasse nicht von einer anderen abgeleitet, sondern ohne den Zusatz extends ‹Oberklasse› definiert, so würde man erwarten, dass diese Klasse nur an der Spitze einer Klassenhierarchie stehen kann, d.h. nur Oberklasse von anderen Klassen sein kann. Anders als in C++ wird in Java eine solche Klasse aber auch in diesem Fall abgeleitet, nämlich von der vordefinierten Klasse Object, die in der Klassenhierarchie an oberster Stelle steht. D.h. alle Klassen sind in Java letztlich von der Klasse Object abgeleitet. 4.6 Objekte, Referenzen und Werte Zum Verständnis einer Programmiersprache ist es sehr wichtig, zu wissen, was in einer Variablen abgespeichert ist. In dieser Hinsicht gibt es durchaus Unterschiede zwischen verschiedenen Programmiersprachen, z. B. auch zwischen Java und C++. So wird in C++ im Speicherplatz einer Variablen immer der Wert dieser Variablen gespeichert (außer bei Referenzen, die auf eine andere Variable verweisen). Dies gilt auch für Objekte. Wird in C++ eine Objekt-Variable einer anderen Objekt-Variablen zugewiesen, so wird das Objekt kopiert, d. h., es entsteht ein neues Objekt mit dem gleichen Inhalt. 4.6.1 Wertdatentypen und Referenzdatentypen Anders sieht es bei Java aus: Hier muss man unterscheiden zwischen Wertdatentypen und Referenzdatentypen (kurz Referenztypen). Bei Wertdatentypen wird in einer Variablen direkt deren Wert gespeichert (wie bei C++). Wertdatentypen sind in Java alle einfachen (primitiven) Datentypen: byte, short, int, long, float, double, char und boolean. Alle anderen Datentypen, also selbstdefinierte oder vordefinierte Klassen (wie z. B. String oder Integer) und alle Arrays sind Referenzdatentypen. Wertdatentypen Referenzdatentypen In einer Variablen von einem Referenzdatentyp, einer so genannten Referenzvariablen, wird nicht das Objekt selbst gespeichert, sondern eine Referenz, d. h. ein Verweis, auf das Objekt. Einen solchen Verweis kann man sich wie einen Pointer in C++ auf das Objekt vorstellen (allerdings gibt es in Java keine Poin- Referenzvariable 4 Objektorientierung bei Java 4.6 Objekte, Referenzen und Werte 41 terarithmetik). Eine Referenzvariable enthält als Wert also eine Adresse, die auf das Objekt zeigt (verweist). Beispiel: Legt man ein Objekt vom Typ Kreis an Kreis k1 = new Kreis (10.0, 15.0, 5.0); So kann man sich dies bildlich so vorstellen: Kreis-Objekt bei Adresse 1 Kreis k1 mx: 10.0 my: 15.0 radius: 5.0 Adresse 1 Im Gegensatz dazu wird bei einer Variablen eines Wertdatentyps der Wert direkt in der Variablen gespeichert: int n = 1234; int n 1234 Ein besonderer Wert für Referenzvariablen ist die Nullreferenz null, die bedeutet, dass die Variable auf kein Objekt verweist. Instanzvariablen von Referenzdatentypen werden, falls nichts anderes angegeben wird, mit null initialisiert. 4.6.2 Zuweisung, Identität und Gleichheit Bei der Zuweisung var2 = var1; wird der Wert der Variablen var1 in die Variable var2 kopiert. Während dies bei Wertdatentypen das von anderen Programmiersprachen wie C++ gewohnte Verhalten ist, bedeutet es bei Referenztypen, dass die in var1 enthaltene Adresse in var2 kopiert wird. D. h., nach der Zuweisung verweisen beide Referenzvariablen auf dasselbe Objekt (oder beide enthalten null). Beispiel: Zuweisen von Referenzvariablen Kreis k1 = new Kreis (10.0, 15.0, 5.0); Kreis k2 = k1; null 4 Objektorientierung bei Java Kreis k1 Adresse 1 Kreis k2 4.6 Objekte, Referenzen und Werte 42 Kreis-Objekt bei Adresse 1 mx = 10.0 my = 15.0 radius = 5.0 Adresse 1 Obwohl die Variablen k1 und k2 nicht direkt ein Objekt enthalten, sondern auf ein Objekt verweisen, spricht man meist, nicht ganz korrekt, von den „Objekten“ k1 und k2. Im vorangehenden Beispiel sind die Objekte k1 und k2 identisch, es handelt sich also um ein und dasselbe Objekt. Identität Betrachten wir das nächste Beispiel: Kreis k3 = new Kreis (10.0, 15.0, 5.0); Kreis k4 = new Kreis (10.0, 15.0, 5.0); Kreis-Objekt bei Adresse 3 Kreis k3 Adresse 3 mx: 10.0 my: 15.0 radius: 5.0 Kreis-Objekt bei Adresse 4 Kreis k4 Adresse 4 mx: 10.0 my: 15.0 radius: 5.0 In diesem Beispiel sind die Objekte k3 und k4 nicht identisch, aber gleich, d. h., es handelt sich um zwei Objekte an verschiedenen Speicherplätzen, die aber die gleichen Daten enthalten. Was geschieht bei einem Vergleich (mit ==) von Objekten? Dabei wird die Identität der Objekte geprüft. Mit den Variablen k1, k2, k3 und k4 aus den beiden vorangehenden Beispielen ergibt sich: k1 == k2 k3 == k4 // –> true // –> false Manchmal möchte man nicht die Identität, sondern die Gleichheit von Objekten testen. Bei einigen Klassen der Standardbibliothek (wie z. B. String) ist hierfür die Methode equals() definiert. Möchte man z. B. testen, ob zwei StringVariablen s1 und s2 gleich sind, so verwendet man anstelle der Bedingung s1 == s2 die Bedingung s1.equals(s2). Gleichheit 4 Objektorientierung bei Java 4.6 Objekte, Referenzen und Werte String s1, s2; ... // Zuweisung von String-Objekten an s1 und s2 s1 == s2 // Test auf Identität s1.equals(s2) // Test auf Gleichheit 4.6.3 Erzeugen und Freigeben von Objekten Der Speicherplatz für Variablen von Wertdatentypen wird, wenn es sich um lokale Variablen von Funktionen handelt, automatisch auf dem Stack angelegt. Sind es Instanzvariablen, so werden Sie als Teil eines Objekts angelegt. Bei Referenzvariablen wird der Speicherplatz für die Adresse genauso reserviert wie bei Variablen von Wertdatentypen. Solche Variablen haben im Fall lokaler Variablen zunächst einen undefinierten Wert, im Fall von Instanzvariablen den Wert null. Sie verweisen also zunächst auf kein gültiges Objekt. Man kann Referenzvariablen ein mit new erzeugtes Objekt oder ein schon existierendes Objekt zuweisen. Objekte werden immer mit new dynamisch auf dem Heap angelegt. Es gibt in Java keine lokalen Objekte auf dem Stack. Im Gegensatz zu C++ kann ein dynamisch erzeugtes Objekt nicht explizit (wie in C++ mit delete) freigegeben werden. Das Freigeben von Objekten wird bei Java automatisch durch die so genannte Garbage Collection (GC) erledigt. Durch Zuweisung oder Übergabe an eine Funktion (s. nächster Abschnitt) ist es möglich, dass mehrere Referenzvariablen auf dasselbe Objekt verweisen. Gibt es keine Referenzen mehr auf ein Objekt, so wird dieses zur Freigabe durch die Garbage Collection markiert. Wann der Speicher des Objekt dann tatsächlich freigegeben wird, ist eine andere Frage. Dies hängt von Algorithmus und Timing der Garbage Collection ab und kann vom Programmierer nicht direkt beeinflusst werden. Eine Besonderheit ist das Erzeugen von Feldern von Objekten, das solche Felder Referenzen auf Objekte enthalten. Näheres hierzu in Abschnitt 4.7.3 über Felder. 4.6.4 Übergabe von Parametern an Funktionen Im Gegensatz zu C++ kann man bei Java nicht wählen, ob ein Parameter als Wert oder als Referenz an eine Funktion übergeben wird. In Java werden alle Wertdatentypen als Wert übergeben, also kopiert, alle Referenzdatentypen als Referenz. D. h., die Übergabe an eine Funktion verhält sich wie eine Zuweisung der aktuellen Parameter (die beim Aufruf der Funktion angegeben werden) an die formalen Parameter (die in der Funktionsdefinition stehen). Ebenso verhält es sich bei der Rückgabe mit return. Wird ein Wertdatentyp zurückgegeben, so wird der Wert in die aufrufende Funktion kopiert. Wird dagegen ein Referenzdatentyp zurückgegeben, so erhält die aufrufende Funktion eine Referenz auf das Objekt in der aufgerufenen Funktion. 43 4 Objektorientierung bei Java 4.7 Weitere Möglichkeiten 44 Möchte man eine Variable von einem Wertdatentyp wie int oder double als Referenz an eine Funktion übergeben, so ist das nur indirekt möglich. Z. B. kann man eine Klasse definieren, die den betreffenden Wert enthält, und dann eine Instanz dieser Klasse an die Funktion übergeben. Oder man kann ein Array der Größe 1 vom Typ der Variablen übergeben. Leider lassen sich die so genannten Wrapper-Datentypen wie Integer oder Double nicht für eine Referenzübergabe verwenden, da diese Datentypen unveränderbar sind, d. h., der in einem einmal erzeugten Objekt gespeicherte Wert lässt sich nachträglich nicht mehr verändern. 4.7 Weitere Möglichkeiten der Objektorientierung 4.7.1 Abstrakte Klassen Abstrakte Klassen, die mit dem Modifizierer abstract vereinbart werden, sind Klassen die nur als Oberklasse für neu definierte Klassen dienen, von denen man aber direkt keine Instanzen erzeugen kann. Von einer nicht-abstrakten Unterklasse einer abstrakten Klasse können dagegen Instanzen erzeugt werden. abstrakte Klassen abstract Meist enthalten abstrakte Klassen abstrakte Methoden, denen ebenfalls das Schlüsselwort abstract vorangestellt wird. Diese Methoden bestehen nur aus der Kopfzeile (wie die Funktionsprototypen bei C/C++), der Funktionskörper fehlt. Eine solche Klasse ist natürlich unvollständig. Eine nicht-abstrakte Unterklasse kann nur gebildet werden, wenn sie alle abstrakten Methoden der Oberklasse durch nicht-abstrakte Methoden (also mit Funktionskörper) überschreibt. abstrakte Methoden Das folgende Beispiel beschreibt die abstrakte Klasse GrafObjekt. Diese dient als Oberklasse für Klassen, die konkrete Grafikobjekte darstellen (z. B. Quadrat, Kreis, etc.) Natürlich kann man ein Graphikobjekt nicht zeichnen, wenn man nicht weiß, um welchen Typ es sich handelt. Trotzdem ist es sinnvoll, in der Klasse GrafObjekt eine abstrakte Methode zeichne () zu definieren, die dann in den konkreten Unterklassen ausgefüllt wird. Denn es ist eine Eigenschaft aller Grafikobjekte, dass man sie zeichnen kann. Da die Klasse GrafObjekt eine abstrakte Methode enthält, ist sie auch selber abstrakt. Beispiel 4.3: Die abstrakte Klasse GrafObjekt abstract class GrafObjekt { public double posx, posy; // x- und y-Koordinate des Objekts public abstract void zeichne (); // Prototyp: Objekt zeichnen public void setzePos (double x, double y) // nicht-abstrakte Methode um Objekt zu verschieben { posx = x; 4 Objektorientierung bei Java 4.7 Weitere Möglichkeiten 45 posy = y; } } Das nächste Beispiel gibt eine konkrete Unterklasse von GrafObjekt an, die Klasse Quadrat. Hierin wird die Methode zeichne () überschrieben durch eine Methode, die im Funktionskörper das Quadrat zeichnet (ist hier nur angedeutet). Da in der Klasse Quadrat keine abstrakten Methoden mehr offen sind, ist die Klasse auch nicht abstrakt und man kann Quadrat-Instanzen erzeugen. Beispiel 4.4: Die Klasse Quadrat class Quadrat extends GrafObjekt { public double kante; // Kantenlänge public Quadrat (double x, double y, double k) { setzePos (x, y); kante = k; } public void { drawLine drawLine drawLine drawLine } // Konstruktor zeichne () // Überschreiben der abstrakten Methode (...); (...); (...); (...); // die vier Seiten zeichnen } Das Gegenteil von abstrakten Klassen sind Klassen, die mit dem Modifizierer final vereinbart werden. Eine solche Klasse steht am Ende einer Klassenhierarchie, d. h. es kann keine weitere Klasse von ihr abgeleitet werden. Es stellt sich die Frage, warum man einer Klasse eine solche Einschränkung auferlegen sollte. Die Antwort ist, dass der Java-Compiler bei final-Klassen in der Lage ist, gewisse Optimierungen vorzunehmen. Ist dies erwünscht und will man keine Unterklassen bilden, so kann man die Klasse als final definieren. Die Klasse String ist ein Beispiel dafür. 4.7.2 Interfaces und Mehrfachvererbung Ein Interface ist etwas ähnliches wie eine abstrakte Klasse. Auch ein Interface enthält Prototypen von Methoden (diese verzichten bei Interfaces allerdings auf das Schlüsselwort abstract). Allerdings ist ein Interface keine Klasse und darf keine nicht-abstrakten Methoden und keine Variablen (wohl aber Konstanten) enthalten. Man kann auch keine (direkten) Instanzen eines Interfaces erzeugen. Ein Interface wird folgendermaßen definiert: final-Klassen 4 Objektorientierung bei Java 4.7 Weitere Möglichkeiten 46 interface ‹InterfaceName› { // ... Konstanten (mit final static ... definiert) // ... Prototypen von Methoden } Die angegebenen Eigenschaften zeigen, dass ein Interface die eingeschränkte Version einer abstrakten Klasse ist, eine Art rein abstrakte Klasse. Es stellt sich deshalb die Frage: Weshalb ein Interface verwenden und nicht gleich eine abstrakte Klasse? Die Antwort liegt in einer Eigenschaft von Interfaces, die weitreichende Möglichkeiten eröffnet: Sie liefern einen Ersatz für Mehrfachvererbung. Wie schon in der Einleitung des Kapitels erwähnt gibt es in Java keine Möglichkeit, eine Klasse von mehreren Klassen abzuleiten. Anders ausgedrückt: eine Klasse kann nur eine Oberklasse haben. Diese in C++ vorhandene Möglichkeit bezeichnet man als Mehrfachvererbung. Das Verbot der Mehrfachvererbung gilt für Klassen, nicht aber für Interfaces. Interfaces werden mit dem Schlüsselwort implements an Klassen vererbt: class ‹Klasse› implements ‹Interface› { // ... Variablen und Methoden } Dabei müssen in der Klasse alle Methoden des Interfaces überschrieben werden. Es ist auch möglich, eine Klasse von einer Oberklasse und einem Interface abzuleiten: class ‹Klasse› extends ‹Oberklasse› implements ‹Interface› { // ... Variablen und Methoden } Oder von einer Oberklasse und mehreren Interfaces: class ‹Klasse› extends ‹Oberklasse› implements ‹Interface1 ›, ‹Interface2 ›, ... { // ... Variablen und Methoden } Kennzeichnend für ein Interface ist, dass es keine Funktionalität liefert, sondern nur eine Schnittstelle (engl. interface) darstellt. Interfaces werden innerhalb der Java-API-Pakete extensiv für alle möglichen Zwecke eingesetzt, unter anderem für das Event-Handling bei grafischen Benutzeroberflächen, das in Kapitel 5 behandelt wird. Wir verzichten deshalb hier auf ein Beispiel und verweisen auf das genannte Kapitel. Mehrfachvererbung 4 Objektorientierung bei Java 4.7.3 4.7 Weitere Möglichkeiten 47 Felder (Arrays) Felder sind in Java als Datentyp Klassen, die (wie alle Klassen) von der Klasse Object abgeleitet sind (weshalb sie in diesem Kapitel behandlt werden). Instanzen eines Feldtyps werden, wie andere Objekte auch, dynamisch, also zur Laufzeit, mit dem new-Operator angelegt. Dabei wird die Feldgröße angegeben. Eine eindimensionale Feldvariable wird wie folgt vereinbart: ‹Datentyp› [] ‹Name›; Eindimensionale Felder Dadurch wird noch kein Feld erzeugt, es wird lediglich eine Referenzvariable angelegt. Erzeugt wird das Feld folgendermaßen: ‹Name› = new ‹Datentyp› [‹Größe›]; Beispiel: int n; double [] messwert; // Variable für Anzahl der Messwerte // Feldvariable vereinbaren ... // Eingabe der Anzahl n messwert = new double [n]; // Feld anlegen Der Feldzugriff erfolgt wie in C++ durch Angabe eines Index in eckigen Klammern. Dabei wird überprüft, ob der Index im erlaubten Bereich liegt (zwischen 0 und Größe-1). Praktischerweise ist, anders als bei C++, die Größe des Feldes im Feld enthalten. Man kann darauf durch die Objektvariable length zugreifen. Wird in unserem Beispiel das Feld messwert an eine Funktion übergeben, so ist es nicht notwendig, (wie in C++) zusätzlich die Größe des Feldes zu übergeben. Man erhält sie folgendermaßen: System.out.println ("Anzahl der Messwerte: " + messwert.length); // Feldgröße Seit JDK 5 ist es möglich, alle Elemente eines Feldes mit einer speziellen Art der for-Schleife zu durchlaufen. Um beispielsweise alle eingelesenen Messwerte auszugeben, kann man schreiben: for (double wert : messwert) System.out.println (wert); Dabei durchläuft die Variable vor dem Doppelpunkt alle Elemente des Feldes nach dem Doppelpunkt. Die angegebene Schleife bewirkt das gleiche wie die folgende herkömmliche Schleife, ist aber wesentlich prägnanter: for (int i = 0; i < messwert.length; i++) System.out.println (messwert [i]); Wie Felder mit Werten initialisiert werden können, lässt sich den nachfolgenden Beispielen entnehmen: length 4 Objektorientierung bei Java 4.7 Weitere Möglichkeiten Folgendes geht nur bei der Felddefinition: int [] a = {10, 20, 30}; Dies funktioniert auch später: int [] a; ... a = new int [] {10, 20, 30}; Man kann auch beides kombinieren: int [] a = new int [] {10, 20, 30}; Das ist eine ausführliche Schreibweise der ersten Version. Der folgende Ausdruck ist ein anonymes Array, das z. B. an eine Funktion übergeben werden kann: new int [] {10, 20, 30} Mehrdimensionale Feldvariablen werden folgendermaßen vereinbart: zweidimensionales Feld: ‹Datentyp› [] [] ‹Name›; n-dimensionales Feld: ‹Datentyp› [] ... [] ‹Name›; Das Erzeugen des entsprechenden Array-Objekts kann auf verschiedene Arten geschehen. Diese werden wir uns am Beispiel eines zweidimensionalen Feldes ansehen. Bei der ersten Methode wird das komplette Feld auf einen Schlag erzeugt: int [] [] b; b = new int [10] [20]; Dadurch wird ein int-Feld der Größe 10 x 20 angelegt. Bei der zweiten Methode legt man zunächst ein eindimensionales Feld von eindimensionalen Feldvariablen (für die Zeilen) an. Im zweiten Schritt werden dann die einzelnen Zeilen als eindimensionale Arrays angelegt: int [] [] b; b = new int [10] []; // 10 „leere“ Zeilen anlegen for (int i = 0; i < b.length; i++) b [i] = new int [20]; // je eine Zeile anlegen Man kann sich natürlich die Frage stellen, warum man das zweidimensionale Feld so kompliziert anlegen sollte, wenn es (nach der ersten Methode) auch einfach geht. Der Grund ist, dass man nach der zweiten Methode auch in der Lage ist, Felder anzulegen, die nicht rechteckig sind. Hier ein Beispiel eines dreieckigen Feldes: int [] [] dreieck; 48 4 Objektorientierung bei Java dreieck = new int [10] []; 4.7 Weitere Möglichkeiten // 10 „leere“ Zeilen anlegen for (int i = 0; i < b.length; i++) dreieck [i] = new int [i+1]; // je eine Zeile anlegen Die einzelnen Zeilen dieses Feldes haben eine Länge von 1 bis 10. Soll ein Feld von Objekten angelegt werden, so muss nach Abschnitt 4.6 berücksichtigt werden, dass das Feld nur Referenzen auf mögliche Objekte enthält. Man muss deshalb für jedes Element des Feldes ein Objekt vom ElementDatentyp anlegen. Hier ein Beispiel mit einem Feld von Kreis-Objekten: Kreis [] kreise; kreise = new Kreis [10]; // 10 Referenzen auf mögliche Kreis-Objekte // An dieser Stelle wurden noch keine Kreis-Objekte erzeugt. // Dies wird jetzt gemacht: for (int i = 0; i < kreise.length; i++) kreise [i] = new Kreis (0.0, 0.0, 0.5*i); // Kreis-Objekt // mit Radius 0.5 * i Wie man sieht, ist die Erzeugung eines solchen Feldes ein zweistufiger Prozess. Zunächst muss das Feld selbst erzeugt werden, danach in einer Schleife die einzelnen Objekte. 49 Grafische Benutzeroberflächen 5 Grafische Benutzeroberflächen (Graphical User Interface = GUI) sind bei Anwendungsprogrammen heutzutage nicht mehr weg zu denken. Dies kommt daher, dass alle modernen Betriebssysteme eine grafische Oberfläche unterstützen. Allerdings unterscheiden sich die verschieden Betriebssysteme im Erscheinungsbild, in der Bedienung und insbesondere in der Programmierschnittstelle der Oberflächen voneinander. GUI Wie wir gesehen haben, hat Java gegenüber C++ den Vorteil, dass die Programmierung von GUIs, die normalerweise stark auf Eigenschaften des jeweiligen Betriebssystems zurückgreift, schon im Sprachstandard enthalten ist. In Java geschriebene GUI-Programme können also mühelos von einer Plattform auf die andere übertragen werden. Dies gilt sogar für übersetzte Java-Programme, da diese ja auf der virtuellen Java-Maschine ablaufen. Allerdings ist die Sache nicht ganz so einfach wie bisher dargestellt: Die GUIFunktionen von Java sind jeweils in zwei Gruppen von Paketen enthalten: Den AWT-Paketen (AWT steht für Abstract Window Toolkit) und den Swing-Paketen. In den älteren AWT-Paketen griff Sun zunächst auf die eingebauten Betriebssystem-Komponenten (Buttons etc.) für GUIs zurück. Da die Programme aber auf allen Betriebssystemen laufen sollten, musste sich das AWT auf die Schnittmenge der Komponenten aller unterstützten Betriebssysteme beschränken, was für viele Anwendungen unbefriedigend war. Die neueren Swing-Pakete enthalten ebenfalls GUI-Funktionen, greifen aber nicht direkt auf die jeweiligen Betriebssystem-Komponenten zurück, sondern bauen diese (soweit dies möglich ist) selber nach. Dadurch wird eine viel größere Unabhängigkeit von den verschiedenen Betriebssystemen erreicht. Außerdem ist es möglich, Komponenten oder Funktionalitäten anzubieten, die im einen oder anderen Betriebssystem nicht enthalten sind (oder sogar in gar keinem). Natürlich haben die Swing-Pakete auch Nachteile gegenüber den AWT-Paketen (z. B. die Performance) aber es überwiegen die Vorteile. Wir werden uns deshalb in diesem Kapitel auf die GUI-Programmierung mit Swing (einige Teile sind nach wie vor in AWT-Paketen) beschränken. AWT und Swing 5 Grafische Benutzeroberflächen 5.1 5.1 Überblick 51 Überblick: Aufbau und Programmierung von GUIs Grafische Benutzeroberflächen sind aus Komponenten aufgebaut. Dies sind sämtliche optisch sichtbaren Bestandteile einer Oberfläche, wie z. B. Fenster, Buttons, Menüs, Listen, Textfelder, etc. Es gibt hierbei sowohl vordefinierte als auch selbstdefinierte Komponenten. Komponenten werden in Java – wie sollte es anders sein – durch Instanzen vordefinierter oder selbstdefinierter Klassen dargestellt. So gibt es im Swing-Paket beispielsweise eine Klasse JButton zur Erzeugung von Button-Komponenten. Die vordefinierte Klasse JComponent ist abstrakt (d. h. es können keine direkten Instanzen davon erzeugt werden) und dient als Oberklasse für die meisten anderen Komponenten. Insbesondere können von ihr eigene Komponenten-Klassen abgeleitet werden. Komponenten Die meisten Komponenten können auch als Behälter (Container) für andere Komponenten dienen. So kann z. B. eine Fenster-Komponente Button-Komponenten enthalten. Oder ein Fenster kann mehrere Unterfenster enthalten. Dieses Enthaltensein hat aber nichts mit dem Verhältnis Oberklasse – Unterklasse zu tun. Die Fenster-Klasse ist nicht Oberklasse der Button-Klasse. Das Enthaltensein bezieht sich lediglich auf die grafische Anordung der Komponenten und somit auch nur auf konkrete Objekte und nicht auf Klassen. Um eine Komponente in einen Container zu stecken, muss man die add-Methode des Container-Objekts mit der Komponente als Argument aufrufen. Der Container (z. B. ein Fenster) hat dann die Verantwortung für die richtige Anordung der in ihm enthaltenen Komponenten (z. B. Buttons). Näheres hierzu in Abschnitt 5.2. Container Damit sind wir beim nächsten Punkt, nämlich der Anordnung von Komponenten innerhalb des Containers. Während in C++ meist nur die Möglichkeit besteht, die (Pixel-) Koordinaten und Größen von Komponenten anzugeben, ist die Anordnung in Java etwas abstrakter. Jeder Container erhält einen sogenannten Layout-Manager, der die Anordnung der enthaltenen Komponenten regelt. Man hat dabei die Wahl zwischen mehreren vordefinierten Layout-Managern für verschiedene Aufteilungen des vorhandenen Raums. Ein LayoutManager wird dem Container durch Aufruf der setLayoutManager-Methode zugeordnet. Der Vorteil bei der Verwendung eines Layout-Managers ist, dass sich die Aufteilung zum einen an der aktuellen Container-Größe orientiert, zum anderen am minimalen Platzbedarf der enthaltenen Komponenten. Dadurch ist gewährleistet, dass die Komponenten auch bei unterschiedlichen Bildschirmauflösungen vernünftig angeordnet werden. Näheres hierzu in Abschnitt 5.3. Wir haben uns bisher nur damit beschäftigt, wie Komponenten grafisch dargestellt und angeordnet werden. Eine wichtige Aufgabe vieler Komponenten ist es aber, mit dem Benutzer zu interagieren. So muss z. B. ein Button auf einen Mausklick reagieren, und in der Lage sein, dieses Ereignis an andere Programmteile weiterzuleiten. GUI-Programme laufen meist nicht, wie Konsolenanwendungen von Anfang bis zum Ende ab, sondern werden Ereignisgesteuert ausgeführt, das heißt sie reagieren jeweils auf Benutzeraktionen. Um Ereignisse (Events) an einer geeigneten Stelle im Programm verarbeiten zu kön- Layout-Manager Events 5 Grafische Benutzeroberflächen 5.2 Komponenten und Container nen, gibt es in Java das Konzept des Listeners. Listener, die es in verschiedenen Versionen gibt, sind Interfaces, die Methoden enthalten, die beim Auftreteten eines entsprechenden Events aufgerufen werden. Dabei wird ein entsprechendes Event-Objekt an die Methode übergeben. Es gibt Listener für Button-Events, Maus-Events (Bewegungen, Clicks, etc.) Tastatur-Events, etc. Soll eine Komponente ein Event verarbeiten, so muss dieser durch Aufruf einer addXYZListener-Methode ein entsprechender Listener zugeordnet werden. Näheres hierzu in Abschnitt 5.4. Schließlich gibt es noch einen eigenständigen Bereich, der bei GUIs eine wichtige Rolle spielt: Die freie Gestaltung von Flächen mit Hilfe von Texten, Formen, und Bildern. Dies wird in der paint-Methode einer Komponente erledigt, wobei eine Vielzahl von Methoden der Grafik-Kontext-Klasse Graphics2D zur Verfügung stehen. Näheres hierzu in Abschnitt 5.5. 5.2 Komponenten und Container Swing-Komponenten sind Objekte vordefinierter Klassen, die grafische Elemente in der Benutzeroberfläche repräsentieren. Beispiele sind Buttons (Klasse JButton), Beschriftungen (JLabel), Textfelder (JTextField), Listen, Menüs. Aber auch Flächen (Panels, Klasse JPanel), auf denen etwas gezeichet werden kann, sind Komponenten. Der folgende Screen-shot zeigt verschiedene häufig verwendete Swing-Komponenten mit ihren Namen: 52 Listener 5 Grafische Benutzeroberflächen 5.2 Komponenten und Container Um die Kurznamen der Swing-Objekte verwenden zu können, muss man das Swing-Paket importieren: import javax.swing.*; Das folgende Beispielprogramm zeigt, wie ein Button erzeugt wird. Dieses Programm kann zwar übersetzt werden, ist ansonsten allerdings wertlos, da der Button nicht angezeigt wird: Beispiel 5.1: Unsichtbarer Button import javax.swing.*; class Test { static public void main (String [] args) { JButton butty = new JButton ("Drück mich!"); } } Natürlich würden wir den Button mit der Beschriftung „Drück mich!“ gerne sehen. Dies funktioniert leider nicht so einfach. Ein Button-Objekt kann nicht alleine (sozusagen frei schwebend) auf dem Bildschirm erzeugt werden. Es kann nur innerhalb eines Fensters erscheinen. D. h. wir benötigen ein Fenster-Objekt um das Button-Objekt darin zu plazieren. Damit sind wir schon beim zweiten Begriff dieses Abschnitts, dem Container. Ein (Swing-)Container ist ein Objekt, das Komponenten enthalten kann, typischerweise ein Fenster o. ä. Auch Container müssen meist wieder in einem Container enthalten sein (s. u.), es sei denn, es sind so genannte Top-Level-Container. Dies sind z. B. Applets, Dialoge und Frames. Ein Frame ist das (Haupt)Fenster eines Anwendungsprogramms das einen Rahmen (Frame) hat, eine Kopfzeile mit Titel und Icons zum Minimieren, Maximieren und Schließen. Einen solchen Frame (Klasse JFrame) können wir erzeugen und anzeigen: Beispiel 5.2: Erster Frame import javax.swing.*; class FrameTest { static public void main (String [] args) { JFrame frame = new JFrame (); frame.setTitle ("Mein erster Frame"); // Titel setzen frame.setSize (400, 300); // Größe: 400 x 300 Pixel frame.setVisible (true); // sichtbar machen } } 53 5 Grafische Benutzeroberflächen 5.2 Komponenten und Container Wird dieses Programm gestartet, so erscheint folgender Frame: Wir sind jetzt in der Lage, einen Button innerhalb des Frames zu platzieren (Vor JDK 5 war es nicht möglich, den Button direkt zum Frame hinzuzufügen. Dies musste über den Umweg eines dem Frame zugeordneten Containers geschehen.): Beispiel 5.3: Frame mit Button (Version 1) import java.awt.*; import javax.swing.*; class FrameButton1 // Version 1 { static public void main (String [] args) { JFrame frame = new JFrame (); JButton butty = new JButton ("Drück mich!"); frame.setLayout (new FlowLayout ()); // s. nächster Abschnitt frame.add (butty); // Button zum Frame hinzufügen frame.setTitle ("Frame mit Button"); // Titel setzen frame.setSize (400, 300); // Größe: 400 x 300 Pixel frame.setVisible (true); // sichtbar machen } } In diesem Beispiel sieht man auch, wie man eine Komponente (hier butty) zu einem Container (hier frame) hinzufügen kann: Man ruft die add-Methode des Containers auf und übergibt als Parameter die Komponente: ‹container›.add (‹Komponente›); 54 5 Grafische Benutzeroberflächen 5.2 Komponenten und Container Es gibt noch eine zweite Methode das Programm zu schreiben, mit der gleichen Wirkung: Beispiel 5.4: Frame mit Button (Version 2) import java.awt.*; import javax.swing.*; // Version 2 class FrameButton2 extends JFrame // ableiten von JFrame { public FrameButton2 () // Konstruktor { JButton butty = new JButton ("Drück mich!"); setLayout (new FlowLayout ()); // s. nächster Abschnitt add (butty); // Button zum Frame hinzufügen setTitle ("Frame mit Button"); // Titel setzen setSize (400, 300); // Größe: 400 x 300 Pixel setVisible (true); // sichtbar machen } static public void main (String [] args) { // FrameButton2-Objekt erzeugen: FrameButton2 fb = new FrameButton2 (); } } Im Gegensatz zur ersten Version des Programms wird hier eine eigene Klasse von JFrame abgeleitet. Während in der ersten Version alles in der statischen main-Methode erledigt wurde, wird in der zweiten Version ein Objekt der selbstdefinierten Klasse erzeugt. In gewisser Weise ist Version 2 „objektorientierter“, den hier wird eine Klasse definiert für das was erzeugt werden soll, nämlich ein Frame mit Button, und es wird eine Instanz dieser Klasse erzeugt. Bei Version 1 dagegen ist die Klasse (FrameButton1) nur eine Dummy-Klasse, die notwendig ist um die main-Funktion zu beinhalten. Startet man das Beispielprogramm (Version 1 oder 2), so fällt folgendes auf: Man kann den Button bereits drücken, d. h. er reagiert auf Mausklicks, aber es passiert ansonsten nichts, wenn der Button gedrückt wird. Wie man mit einem Ereignis wie dem Drücken des Buttons eine Aktion verbindet, werden wir im übernächsten Abschnitt sehen. Dort werden wir auf der Version 2 aufsetzen. Zunächst wollen wir uns ansehen, wie man Komponenten innerhalb eines Containers positioniert. Dabei werden wir wegen des kürzeren Programmcodes auf Version 1 zurückgreifen. 55 5 Grafische Benutzeroberflächen 5.3 5.3 Layout-Manager Layout-Manager Um Komponenten innerhalb eines Containers anzuordnen, könnte man ihre jeweilige Größe und Position in Form von Längen und Koordinaten (z. B. in Pixeln oder cm) angeben. Dieser Weg wird aber in Java normalerweise nicht beschritten. Statt dessen gibt es sogenannte Layout-Manager, die sich um die Anordnung der Komponenten kümmern. Dies sind Objekte, die einem Container mit Hilfe der Methode setLayout (‹Layout-Manager›) zugeordnet werden. Ein Layout-Manager hat bestimmte Regeln, nach denen er die Komponenten anordnet. Er kann flexibel auf die Größe und Größenänderung des Containers reagieren. Die am häufigsten benutzten Layout-Manager sind: das Flow-Layout, das Border-Layout und das Grid-Layout. Um die Kurznamen der LayoutManager verwenden zu können, muss man das awt-Paket importieren: import java.awt.*; Das Flow-Layout Das einfachste Beispiel eines Layout-Managers ist das Flow-Layout (Klasse FlowLayout). Dieses ordnet seine Komponenten einfach neben- oder untereinander an, je nachdem, welche Form der umgebende Container hat. Die Komponenten haben dabei ihre natürliche Größe. Um einem Container con ein FlowLayout-Objekt zuzuordnen könnte man schreiben: FlowLayout fl = new FlowLayout (); con.setLayout (fl); Da man später aber normalerweise nicht mehr auf das Layout-Manager-Objekt zugreifen muss, ist es nicht notwendig, sich dieses in einer Variablen (fl) zu merken. Man kann deshalb einfach schreiben: con.setLayout (new FlowLayout ()); Das folgende Beispielprogramm demonstriert, wie vier Buttons in einem Frame mit Hilfe des Flow-Layouts angeordnet werden: Beispiel 5.5: Flow-Layout import java.awt.*; import javax.swing.*; class FrameFlow { static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new FlowLayout ()); // Buttons erzeugen und zum Container hinzufügen: // FlowLayout zuordnen 56 5 Grafische Benutzeroberflächen frame.add frame.add frame.add frame.add (new (new (new (new JButton JButton JButton JButton 5.3 Layout-Manager ("Drück mich!")); ("Nein lieber mich!")); ("Ich will auch mal drankommen!")); ("An mich denkt wieder keiner!")); frame.setTitle ("Frame mit FlowLayout"); // Titel setzen frame.setSize (400, 300); // Größe: 400 x 300 Pixel frame.setVisible (true); // sichtbar machen } } Wenn Sie dieses Beispiel ausprobieren, sollten sie die Größe des Frames auf möglichst viele Arten variieren (mit der Maus auf den Rand klicken, gedrückt halten und ziehen) um einen guten Eindruck davon zu bekommen, was das Flow-Layout leistet. Nachfolgend einige Beispiele für Anordnungen bei verschiedenen Frame-Größen. Man sieht, dass die Buttons innerhalb des Frames wie beim Schreiben von links nach rechts und dann von oben nach unten angeordnet werden: Das Border-Layout Zweck des Border-Layouts (Klasse BorderLayout) ist es, fünf Komponenten so anzuordnen, dass eine in der Mitte, und jeweils eine oben, unten, links und rechts platziert wird. Es können auch weniger Komponenten verwendet werden, wenn ein oder mehrere Plätze nicht belegt werden. Zu beachten ist beim Border-Layout, dass beim Hinzufügen einer Komponente mit der add-Methode als zusätzlicher Parameter die Position angegeben wird. Dazu gibt es die in der Klasse BorderLayout definierten Konstanten BorderLayout.CENTER, BorderLayout.NORTH, BorderLayout.SOUTH, BorderLayout.WEST und BorderLayout.EAST. Das folgende Programmbeispiel demonstriert das Border-Layout anhand von fünf Buttons. Der Übersichlichkeit halber werden die Buttons nicht (wie im vorangehenden Beispiel) in der add-Methode erzeugt sondern jeweils vorher. Dazu wird immer die gleiche Variable (b) verwendet. Beispiel 5.6: Border-Layout 57 5 Grafische Benutzeroberflächen 5.3 Layout-Manager import java.awt.*; import javax.swing.*; class FrameBorder { static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new BorderLayout ()); // BorderLayout zuordnen // Buttons erzeugen und zum Container hinzufügen: JButton b; b = new JButton ("Mitte (CENTER)"); frame.add (b, BorderLayout.CENTER); b = new JButton ("Oben (NORTH)"); frame.add (b, BorderLayout.NORTH); b = new JButton ("Unten (SOUTH)"); frame.add (b, BorderLayout.SOUTH); b = new JButton ("Links (WEST)"); frame.add (b, BorderLayout.WEST); b = new JButton ("Rechts (EAST)"); frame.add (b, BorderLayout.EAST); frame.setTitle ("Frame mit BorderLayout"); // Titel setzen frame.setSize (400, 200); // Größe: 400 x 200 Pixel frame.setVisible (true); // sichtbar machen } } Das Resultat ist hier zu sehen: Verändert man die Größe des Frames, so passen sich die Buttons (oder auch andere Komponenten) automatisch in der Größe an. Das Grid-Layout Das Grid-Layout (Klasse GridLayout) dient zur gleichmäßigen Anordnung von Komponenten in einem rechteckigen Gitternetz (engl. grid). Dazu wird 58 5 Grafische Benutzeroberflächen 5.3 Layout-Manager beim Konstruktor des Grid-Layouts als Parameter die Anzahl von Komponenten in vertikaler und horizontaler Richtung angegeben. Wir wollen uns das an einem Beispiel ansehen, in dem 20 Buttons in einem 5x4-Gitter angeordnet werden. Die Namen der Buttons werden in einer Schleife automatisch generiert. Beispiel 5.7: Grid-Layout import java.awt.*; import javax.swing.*; class FrameGrid { static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new GridLayout (5, 4)); // GridL. zuordnen // Buttons erzeugen und zum Container hinzufügen: for (int i = 1; i <= 20; i++) { JButton b = new JButton ("Button " + i); frame.add (b); } frame.setTitle ("Frame mit GridLayout"); // Titel setzen frame.setSize (400, 200); // Größe: 400 x 200 Pixel frame.setVisible (true); // sichtbar machen } } Hier das Ergebnis diese Programms: Wie man sieht, werden die Buttons erst von links nach rechts und dann von oben nach unten dem Container hinzugefügt. Es gibt noch einen zweiten Konstruktor für das Grid-Layout. Dieser enthält zwei zusätzliche Parameter, die den horizontalen und vertikalen Abstand (in Pixeln) enthalten, der zwischen den Komponenten eingefügt werden soll. Schreibt man im vorangehenden Programm 59 5 Grafische Benutzeroberflächen 5.3 Layout-Manager frame.setLayout (new GridLayout (5, 4, 8, 4)); so erhält man folgende Anordung: Kombination von Layouts Die bisher vorgestellten drei Layout-Manager erfüllen sehr spezielle Bedürfnisse und sind bei weitem nicht ausreichend, um die Komponenten eines komplexen Anwendungsprogramms in geeigneter Weise anzuordnen. In der Tat gibt es noch andere Layout-Manager (z. B. das Gridbag-Layout) sowie die Möglichkeit eigene Layout-Manager zu definieren. Eine andere Möglichkeit ist aber, die drei Layouts, die wir kennen gelernt haben, miteinander zu kombinieren. Wir wollen uns das an einem Beispiel, der Einfachheit halber wieder nur mit Buttons, ansehen: Beispiel 5.8: Geschachtelte Layouts import java.awt.*; import javax.swing.*; class FrameSchachtel { static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new BorderLayout ()); // BorderLayout zuordnen // Panel für Buttons in der Mitte: JPanel panC = new JPanel (); // Panel für Buttons unten: JPanel panS = new JPanel (); // Komponenten zum Hauptcontainer hinzufügen: JButton b; b = new JButton ("Oben"); frame.add (b, BorderLayout.NORTH); b = new JButton ("Links"); frame.add (b, BorderLayout.WEST); 60 5 Grafische Benutzeroberflächen 5.3 Layout-Manager b = new JButton ("Rechts"); frame.add (b, BorderLayout.EAST); frame.add (panC, BorderLayout.CENTER); frame.add (panS, BorderLayout.SOUTH); // Layouts für Panels, Buttons zu Panels panC und panS hinzufügen: panC.setLayout (new GridLayout (4,3)); // GridLayout für panC for (int i = 1; i <= 9; i++) { b = new JButton ("" + i); panC.add (b); } panC.add (new JButton ("*")); panC.add (new JButton ("0")); panC.add (new JButton ("#")); panS.setLayout (new GridLayout (1,2)); // GridLayout für panS panS.add (new JButton ("Cancel")); panS.add (new JButton ("OK")); frame.setTitle ("Frame mit geschachtelten Layouts"); frame.setSize (400, 200); frame.setVisible (true); } } Sehen wir uns zunächst an, welches Layout dieses Beispiel erzeugt: Wie wird diese Anordnung erreicht? Zunächst wird der Container des Frames mit dem Border-Layout aufgeteilt. Es werden Buttons oben, links und rechts platziert. In der Mitte und unten werden allerdings keine Buttons sondern Panels gesetzt, also (zunächst) leere Flächen, die ebenfalls Komponenten sind, aber auch als Container dienen können. Diesen Panels (panC und panS) werden eigene Layout-Manager zugeordnet. Dem Panel in der Mitte wird ein Grid-Layout für 4 x 3 Komponenten, dem Panel unten ein Grid-Layout für 1 x 2 Komponenten zugeordnet. In die Mitte werden dann 12 Buttons platziert (1, ... 9, *, 0 und #), unten 2 Buttons (Cancel und OK). 61 5 Grafische Benutzeroberflächen 5.4 Ereignisse (Events) Die so erzielte Anordnung von Buttons ist wesentlich komplexer, als es mit einem einzigen Layout-Manager zu erreichen wäre. Trotzdem passt sich die Anordnung sehr flexibel an die Größe des Frames an, wie man durch Ausprobieren des Programms leicht feststellen kann. Natürlich ist damit noch nicht das Ende erreicht. Da Panels (und andere Komponenten) sowohl Komponenten als auch Container sind, lassen sich diese beliebig in Unter-Panels aufteilen, diese lassen sich wieder aufteilen usw. Für jede Aufteilung muss ein eigener Layout-Manager zugeordnet werden. In Zusammenhang mit dem Layout sind einige Funktionen nützlich: • setSize: Damit lässt sich die Größe einer Komponente einstellen. Wir haben diese Funktion in unseren Beispielen für die Frame-Größe schon verwendet. Im Allgemeinen wird sie so aufgerufen: ‹Komponente›.setSize (‹x›, ‹y›); Dabei sind ‹x› und ‹y› die Größe (in Pixeln) in horizontaler und vertikaler Richtung. Bei Komponenten innerhalb von Containern ist die Wirkung von setSize allerdings nur von begrenzter Dauer. Sobald der LayoutManager eine neue Anordnung seiner Komponenten durchführt, wird die eingestellte Größe wieder überschrieben. Besser geeignet ist deshalb: • setPreferredSize: Damit lässt sich angeben, dass eine Komponente am besten die angegebene Größe hat. Diese Größe ist kein Muss, wird aber, wenn möglich, vom Layout-Manager respektiert und als eingestellter Wert nicht verändert. Die Funktion wird so aufgerufen: ‹Komponente›.setPreferredSize (new Dimension (‹x›, ‹y›)); Dabei sind ‹x› und ‹y› die Größe (in Pixeln) in horizontaler und vertikaler Richtung. Die Funktion verlangt als Parameter allerdings nicht zwei Größenangaben, sondern ein Dimension-Objekt, das die beiden Größen enthält und hier on the fly erzeugt wird. • pack: Diese Funktion wird bei Frames angewandt und bewirkt, dass sich die Größe den enthaltenen Komponenten anpasst. Der Frame wird so groß gemacht, dass alle Komponenten gerade hinein passen. Die Größe hängt natürlich auch vom eingestellten Layout-Manager ab. Wir werden im nächsten Abschnitt ein Beispiel dafür kennenlernen. 5.4 Ereignisse (Events) Wir haben bisher gesehen, wie man Komponenten erzeugt, in einen Container packt und dort in der gewünschten Weise anordnet. Wir wissen aber noch nicht, wie wir die Komponenten dazu kriegen, auf eine Benutzeraktion zu reagieren. Zwar ist eine gewisse Minimalreaktion schon vorhanden, z. B. wird der Druck auf einen Button optisch sichtbar gemacht, und in ein Textfeld kann man etwas 62 5 Grafische Benutzeroberflächen 5.4 Ereignisse (Events) 63 hineinschreiben, aber wir wissen nicht, wie wir z. B. eine beliebe Aktion auf Button-Druck ausführen können. Um zu verstehen, wie ein Benutzer eine Aktion auslösen kann, muss man zunächst das Konzept von Ereignissen (Events) verstehen, das in Betriebssystemen mit GUI eine wichtige Rolle spielt und auch in Java unterstützt wird. Events Programme mit GUI laufen nicht einfach vom Start bis zum Ende ab, sondern ereignisgesteuert: Alle Benutzer-Interaktionen mit einem Programm werden in Ereignisse aufgelöst. Ein einzelnes Ereignis kann z. B. eine Maus-Bewegung oder das Drücken einer Maus-Taste sein, es kann aber auch das Drücken eines Buttons sein. In Java werden Events durch Objekte der Klasse EventObject und ihrer Unterklassen dargestellt. Der Typ eines Event-Objekts gibt gleichzeitig auch an, um welche Art von Event es sich handelt: Wird die Maus bewegt oder ein MausButton gedrückt (oder losgelassen) so wird ein Event der Klasse MouseEvent erzeugt. Wird ein Button gedrückt, so tritt ein Event der Klasse ActionEvent auf. Es gibt viele weitere Unterklassen von EventObject, z. B.: KeyEvent (Tastendrücke), WindowEvent (Fenster schließen, etc.). In den Event-Objekten wird je nach Typ alle Information abgespeichert, die notwendig ist, um das Event sinnvoll zu bearbeiten zu können, z. B. die genaue Art des Events (bei Maus-Events: Maustaste gedrückt oder losgelassen, etc. ?), der Ort des Events (wo genau war die Maus als das Ereignis ausgelöst wurde), die Quelle des Events (z. B. der Button auf den gedrückt wurde). Die Event-Objekte werden durch die entsprechende Benutzer-Interaktion erzeugt. D. h. die Event-Quellen schicken Event-Objekt ab. Dies geschah auch schon bei den Beispielprogrammen der letzten Abschnitte. Es wurden aber noch keine Aktionen ausgeführt, weil die Event-Objekte einfach weggeworfen wurden. Was jetzt noch fehlt, ist das Abfangen der Events, so dass ihnen Aktionen zugeordnet werden können. Dies geschieht durch sogenannte Event-Listener-Objekte. Die Verarbeitung geschieht in Java nicht zentral, sondern wird auf Event-Listener-Objekte verteilt. Dies geschieht so, dass jedem Event-Quell-Objekt ein Event-Listener-Objekt (es können auch mehrere sein) zugeordnet wird. Die von der Event-Quelle ausgesandten Events werden dann automatisch zu dem angegebenenen Event-Listener-Objekt geleitet. Technisch gesehen ist ein Event-Listener ein Interface, das bestimmte Methoden zur Bearbeitung von Events enthält. Dabei sind die Methoden aber noch nicht programmiert (der Programmierer soll ja selber bestimmen können, welche Aktionen ausgeführt werden sollen), sondern liegen, wie das bei Interfaces der Fall ist, nur als Funktionsprototyp vor. Definiert man eine Klasse, die ein solches Interface implementiert, so müssen alle Methoden des Interfaces ausgefüllt werden! Je nach Event-Typ gibt es auch entspechende Event-Listener-Typen, die unterschiedliche Methoden enthalten. Z. B. gibt es ein MouseListener-Interface Event-Listener 5 Grafische Benutzeroberflächen 5.4 Ereignisse (Events) für Maus-Drücke, ein MouseMotionListener-Interface für Maus-Bewegungen, ein ActionListener-Interface für Button-Drücke. Um die Kurznamen der Event-Typen und Listener-Interfaces verwenden zu können, muss man das awt.event-Paket importieren: import java.awt.event.*; Konkret benötigt man folgendes, um bestimmte Events erfolgreich zu empfangen und entsprechende Aktionen auszuführen (Im folgenden steht Xyz für die Art des Listeners, z. B. Mouse, Key, etc.): 1. Eine Klasse, die das geeignete Listener-Interface implementiert. Dies kann eine Klasse sein, die nur zu diesem Zweck definiert wird, oder eine Klasse die auch für andere Zwecke verwendet wird, z. B. eine Klasse, die eine selbstdefinierte Komponente repräsentiert. In jedem Fall ist die Klasse in der folgenden Art zu definieren class ... implements XyzListener { ... } 2. Eine oder mehrere Methoden, die in dem Listener-Interface als Funktionsprototyp vorgegeben sind, müssen in der obigen Klasse definiert werden. Hier werden die Aktionen angegeben, die beim Auftreten eines Events ausgeführt werden sollen. Solche Methoden haben das Event, das geschickt wird, als Parameter: public void ‹XyzListener-Methode› (XyzEvent e) { // reagiere auf Event e } 3. Die Zuordnung zwischen Quell-Objekt und Listener-Objekt. Dies geschieht, ähnlich wie die Zuordnung einer Komponente zu einem Container mit der add-Methode, durch Aufruf der geeigneten addXyzListenerMethode: ‹Quell-Objekt›.addXyzListener (‹Listener-Objekt›); Nach diesen allgemeinen Überlegungen ein erstes einfaches Beispiel: Es soll ein Frame mit Button erzeugt werden. Die Button-Drücke sollen gezählt werden und der Einfachheit halber als Beschriftung des Buttons angezeigt werden. Beispiel 5.9: Button zum Zählen 1 2 3 4 import java.awt.*; import java.awt.event.*; import javax.swing.*; 64 5 Grafische Benutzeroberflächen 5 6 7 8 5.4 Ereignisse (Events) class FrameCount extends JFrame implements ActionListener { int count; // Zähler für Button-Text JButton butty; // Button muss Instanz-Variable sein 9 public FrameCount () // Konstruktor { setLayout (new FlowLayout ()); 10 11 12 13 count = 0; // Anfangswert für Zähler butty = new JButton ("" + count + " mal gedrückt"); add (butty); // Button zum Container hinzufügen 14 15 16 17 18 // Zuordnung Quell-Objekt –> Listener-Objekt: 19 butty.addActionListener (this); 20 setTitle ("Hochzählen"); // Titel setzen setSize (400, 200); // Größe: 400 x 200 Pixel setVisible (true); // sichtbar machen 21 22 23 } 24 25 26 // Die folgende Methode ist im ActionListener-Interface enthalten: 27 public void actionPerformed (ActionEvent e) { count++; // Zähler hochsetzen; butty.setText ("" + count + " mal gedrückt"); // neuer Text } 28 29 30 31 32 static public void main (String [] args) { FrameCount fc = new FrameCount (); } 33 34 35 36 37 } Wir greifen in diesem Beispiel auf Version 2 des Beispiels in Abschnitt 5.2 zurück (Beispiel 5.4 auf Seite 55). Dies hat den Vorteil, dass wir schon ein Objekt erzeugen. Diese Objekt kann gleichzeitig auch als Listener-Objekt dienen. Man muss das nicht so machen – bei komplexen Programmen ist es sogar ratsam, ein eigenes Objekt zum Abfangen der Events zu verwenden – das Programm wird dadurch aber kompakter. Deshalb dient die Klass FrameCount gleichzeitig als Frame-Klasse mit Button und als Interface zur Aufnahme der vom Button geschickten Action-Events (Zeile 5). Der Zähler count muss als Instanzvariable in der Klasse definiert werden, damit er dauerhaft (solange wie das Objekt) existiert. Der Button butty ist ebenfalls eine Instanzvariable, damit man von verschiedenen Methoden aus darauf zugreifen kann. Die Zuordung zwischen dem Button butty als Quelle und dem Listener-Objekt als Ziel der Events findet in Zeile 19 statt. Zu beachten, ist hierbei, dass 65 5 Grafische Benutzeroberflächen 5.4 Ereignisse (Events) das Listener-Objekt gleichzeitig das Frame-Objekt ist, das wir in diesem Konstruktor erzeugen. Um auf dieses aktuelle Objekt zuzugreifen benötigen wir den this-Pointer. Implementiert man das ActionListener-Interface, so muss man auch die zugehörige Methode actionPerformed() definieren (Zeile 27). Diese Methode wird jedesmal aufgerufen, wenn der Button gedrückt wird. Hier wird der Zähler erhöht und ein neuer Text in den Button geschieben (mit setText()). Damit ist unser erstes Programm vollständig. Wir haben bisher nur Events von einer Quelle bearbeitet. Man kann aber auch Events (gleichen Typs) von mehreren Quellen zum selben Listener-Objekt schicken. Dann muss man gegebenenfalls im Listener unterscheiden, von welcher Quelle die Events stammen. Wie dies gemacht wird, zeigt das nächste Programmbeispiel. Hier gibt es zwei Buttons und ein Label. Mit dem Plus-Button wird der Zähler nach oben, mit dem Minus-Button nach unten gezählt. Im Label wird der Zählerstand angezeigt. Beispiel 5.10: Zwei Buttons zum Zählen import java.awt.*; import java.awt.event.*; import javax.swing.*; class FrameCountPM extends JFrame implements ActionListener { int count; // Zähler JButton plusBut; // Button zum Hochzählen JButton minusBut; // Button zum Runterzählen JLabel countLab; // zum Anzeigen des Zählerstands public FrameCountPM () // Konstruktor { setLayout (new FlowLayout ()); // Anfangswert für Zähler // Komponenten erzeugen und zum Container hinzufügen: count = 0; minusBut = new JButton ("Minus"); add (minusBut); countLab = new JLabel ("Zählerstand (Anfang): " + count); add (countLab); plusBut = new JButton ("Plus"); add (plusBut); // Zuordnung Quell-Objekte –> Listener-Objekt: minusBut.addActionListener (this); plusBut.addActionListener (this); setTitle ("Hoch und runter zählen"); // Titel setzen pack (); // Größe den Komponenten anpassen setVisible (true); // sichtbar machen } // Die folgende Methode ist im ActionListener-Interface enthalten: 66 5 Grafische Benutzeroberflächen 5.4 Ereignisse (Events) public void actionPerformed (ActionEvent e) { String com = e.getActionCommand (); // unterscheiden, welcher Button gedrückt wurde: if (com.equals ("Minus")) count--; // nach unten zählen else if (com.equals ("Plus")) count++; // nach oben zählen countLab.setText ("Zählerstand: " + count); // Text anzeigen } static public void main (String [] args) { FrameCountPM fc = new FrameCountPM (); } } Man sieht, wie in der actionPerformed-Methode die beiden Buttons unterschieden werden können: Bei jedem ActionEvent wird ein Action-Command mitgeschickt, den man mit der getActionCommand-Methode erhält. Dieser Action-Command ist ein String, der im einfachsten Fall mit der Button-Beschriftung zusammenfällt. Er wird hier in der String-Variablen com abgespeichert. Man kann ihn dann mit „Plus“ oder „Minus“ vergleichen. Dazu sollte man aber nicht den ==-Operator verwenden, das dieser nicht die Inhalte zweier Objekte vergleicht, sondern überprüft, ob auf beiden Seiten das selbe Objekt steht. Statt dessen nimmt man zum Vergleich von Objekten die equals-Methode. Der Action-Command muss nicht mit der Beschriftung eines Buttons übereinstimmen. Man kann ihn folgendermaßen ändern: ‹button›.setActionCommand (‹ein Text›); Dies hat den Vorteil, dass man auch bei langen Beschriftungen (die bei mehrsprachigen Programmen auch noch von der Landessprache abhängig sein sollen) trotzdem prägnante Namen als Action-Commands verwenden kann. Das obige Programmbeispiel würde auch dann noch funktionieren, wenn die beiden Button-Variablen lokal im Konstruktor definiert wären, denn in der actionPerformed-Methode wird nicht auf diese Variablen zugegriffen (sondern nur auf die Action-Commands via Events). Eine andere Methode die Buttons zu unterscheiden wäre folgende: public void actionPerformed (ActionEvent e) { Object obi = e.getSource (); // unterscheiden, welcher Button gedrückt wurde: if (obi == minusBut) count--; // nach unten zählen else if (obi == plusBut) count++; // nach oben zählen 67 5 Grafische Benutzeroberflächen 5.5 2D-Grafik countLab.setText ("Zählerstand: " + count); // Text anzeigen } Hier wird mit der getSource-Methode die Quelle des Events geholt und in der Variablen obi abgespeichert. Dann wird die Quelle mit den beiden ButtonObjekten verglichen. Hier ist der Vergleich mit dem ==-Operator in Ordnung, da es um die Objekte selbst und nicht um deren Inhalt geht. Der Objekt-Vergleich funktioniert hier aber nur, weil die Button-Objekte als Instanz-Variablen definiert wurden. 5.5 2D-Grafik Wir haben bisher einiges über GUIs gelernt und könnten mit diesem Wissen schon recht anspruchsvolle Programme entwickeln. Dabei haben wir aber noch keine Grafik direkt erzeugt, die Komponenten der Oberfläche haben sich immer selbst gezeichnet. In diesem Abschnitt soll nun gezeigt werden, wie man selbst auf einer Flächen (z. B. einem Panel) zeichnen kann. Dabei geht es um Texte, Linien, Formen und Bilder. Dabei werden wir auch lernen, wie man eine Komponente selbst definiert. Grundlegende Techniken Im folgenden Beispielprogramm wird ein Frame erzeugt und ein blauer Kreis hinein gemalt: Beispiel 5.11: Gegenbeispiel: Zeichnen ohne paint-Funktion import java.awt.*; import javax.swing.*; // Achtung: so sollte man nicht malen! class FrameDraw extends JFrame { public FrameDraw () // Konstruktor { setTitle ("Gegenbeispiel!"); // Titel setzen setSize (200, 220); // Größe einstellen setVisible (true); // sichtbar machen // Grafik-Kontext holen: Graphics g = getGraphics (); Graphics2D g2 = (Graphics2D) g; g2.setPaint (Color.blue); // Farbe wählen g2.fillOval (10, 30, 180, 180); // Kreis malen } 68 5 Grafische Benutzeroberflächen 5.5 2D-Grafik 69 static public void main (String [] args) { FrameDraw fd = new FrameDraw (); } } Sämtliche Grafik-Funktionen (hier z. B. fillOval()) sind Methoden der Graphics2D-Klasse. Sie werden weiter unten besprochen. Bevor man auf eine Fläche (hier den Frame) zeichnen kann, benötigt man deshalb ein Graphics2DObjekt. Dies kann mit der getGraphics-Funktion gemacht werden, allerdings liefert diese wegen der Kompatibilität zu älteren Java-Versionen ein GraphicsObjekt, das dann durch einen Type-cast in ein Graphics2D-Objekt umgewandelt werden kann. Man bezeichnet das Graphics- bzw. Graphics2D-Objekt als Grafik-Kontext des Frames oder der Komponente. Grafik-Kontext Startet man das Beispielprogramm, so fällt folgendes auf: Wird der Kreis verdeckt, oder ist der Frame nicht zu sehen und wird dann wieder sichtbar, so wird der Kreis nicht neu gemalt. Dies ist unschön und demonstriert, dass man im GUI-Bereich nicht so arbeiten kann, wie bei Konsolen-Programmen. D. h., man kann ein Objekt nicht einmal zeichnen und sich dann darauf verlassen, dass es immer zu sehen ist. Unser Beispielprogramm demonstriert zwar die Technik der Verwendung des Grafik-Kontextes zum Zeichnen, ist aber in der gezeigten Form nicht in Ordnung. Wir werden deshalb eine zweite Version des Programms erstellen, bei der der Kreis immer dann neu gemalt wird, wenn er vorher verdeckt war. Aber wie soll das Programm wissen, dass der Kreis verdeckt war? Um das herauszufinden liefert Java Unterstützung: Will man ein JComponent-Objekt automatisch neu zeichnen lassen, so muss man nur die paintComponentMethode überschreiben, und sämtlichen Zeichen-Operationen dort hinein packen. Die paintComponent-Methode wird automatisch aufgerufen, wenn die Komponente verdeckt war und nun wieder zu sehen ist. Bei einem JFrame, der kein JComponent-Objekt ist, muss man statt dessen die paint-Methode überschreiben. Hier die korrigierte Version des Programms: Beispiel 5.12: Zeichnen mit paint-Methode import java.awt.*; import javax.swing.*; // So ist´s ok! class FramePaint extends JFrame { public FramePaint () // Konstruktor { setTitle ("Grafik mit paint-Funktion!"); setSize (200, 220); // Größe einstellen setVisible (true); // sichtbar machen } paintComponentMethode 5 Grafische Benutzeroberflächen 5.5 2D-Grafik 70 public void paint (Graphics g) // paint-Methode { // Grafik-Kontext holen: Graphics2D g2 = (Graphics2D) g; g2.setPaint (Color.blue); // Farbe wählen g2.fillOval (10, 30, 180, 180); // Kreis malen System.out.print (" paint()"); // zum Debuggen } static public void main (String [] args) { FramePaint fp = new FramePaint (); } } Startet man das Programm, so sieht man, dass es die gewünschte Eigenschaft besitzt, den Kreis neu zu zeichnen, wenn er verdeckt war, oder wenn die Fenstergröße verändert wird. Um zu zeigen, dass in diesen Fällen wirklich die paint-Methode aufgerufen wird, ist am Ende dieser Methode eine Ausgabe auf System.out enthalten, die bewirkt, dass im Konsolenfenster bei jedem Aufruf „paint()“ ausgegeben wird. Wie man sieht, erhält die paint-Methode ein Graphics-Objekt übergeben, dessen Zeichen-Methoden man aufrufen kann. Möchte man über einen erweiterten Satz von Zeichen-Methoden verfügen, so sollte man das das GraphicsObjekt zu einem Graphics2D-Objekt casten (wie schon im ersten Beispiel). Die Klasse Graphics2D ist von der Klasse Graphics abgeleitet. Aus Kompatibilitätsgründen zu älterer Java-Software wird das Graphics2D-Objekt nicht mit dem Datentyp Graphics2D sondern mit dem Datentyp Graphics an die paint- bzw. paintComponent-Methode übergeben. Wir wollen uns noch eine dritte Methode ansehen, die Kreis-Grafik darzustellen: Es wird eine eigene Komponente definiert, die den Kreis zeichnet und in einen Frame eingebettet wird. Die Definition der Klasse als Komponente (statt als Frame) hat den Vorteil, dass man sie überall einbetten kann. Um eine eigene Komponente zu definieren, geht man von der (abstrakten) Klasse JComponent aus, die auch die Oberklasse der vordefinierten Komponenten ist. Im folgenden Beispiel muss nun die paintComponent-Methode überschrieben werden. Beispiel 5.13: Definition einer eigenen Komponente import java.awt.*; import javax.swing.*; class CircleComp extends JComponent { public CircleComp () // Konstruktor { setPreferredSize (new Dimension (200, 200)); } Definition eigener Komponenten 5 Grafische Benutzeroberflächen 5.5 2D-Grafik public void paintComponent (Graphics g) // paintComponent-Methode { Graphics2D g2 = (Graphics2D) g; // Grafik-Kontext g2.setPaint (Color.blue); // Farbe wählen g2.fillOval (10, 10, 180, 180); // Kreis malen } static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new FlowLayout ()); // Eigene Komponente erzeugen und in Frame packen: CircleComp cc = new CircleComp (); frame.add (cc); frame.setTitle ("Eigene Komponente"); // Titel frame.pack (); // Größe anpassen frame.setVisible (true); // sichtbar machen } } Die hier definierte Komponenten-Klasse CircleComp überschreibt die paintComponent-Methode die für das Darstellen der Komponente verantwortlich ist. Hierin wird der Kreis gezeichnet. Im Konstuktor wird lediglich die Standardgröße dieser Komponente festgelegt (und zwar so, dass der Kreis gerade hineinpasst). Damit ist alles notwendige über die Komponente festgelegt. Die main-Funktion übernimmt die Aufgabe, ein Objekt der CircleComp-Klasse zu erzeugen, in einen Frame zu packen und anzuzeigen. Um die Flexibilität einer solchen Komponente zu demonstrieren, kann man z. B. folgende Änderung an der main-Funktion vornehmen: ... static public void main (String [] args) { JFrame frame = new JFrame (); frame.setLayout (new GridLayout (3, 3)); // 9 eigene Komponenten erzeugen und in Frame packen: for (int i = 1; i <= 9; i++) frame.add (new CircleComp ()); frame.setTitle ("9 eigene Komponenten"); // Titel frame.pack (); // Größe anpassen frame.setVisible (true); // sichtbar machen } } 71 5 Grafische Benutzeroberflächen 5.5 2D-Grafik 72 Hier werden 9 Komponenten erzeugt und in einem 3x3-Gitter im Frame platziert. Einige Methoden des Grafik-Kontexts Methode (Aufruf) Wirkung drawLine (x1 ,y1 ,x2 ,y2 ) drawRect (xl ,yt ,w,h) zeichne gerade Linie von (x1 , y1 ) nach (x2 , y2 ) zeichne Rechteck, linke Kante bei xl , obere Kante bei yt , Breite w, Höhe h wie drawRect, aber gefüllt zeichne Ellipse, Parameter wie bei drawRect wie drawOval, aber gefüllt zeichne mehrere zusammenhängende gerade Linien x-Koordinaten im Feld ax , y-Koordinaten in ay , n Punkte wie drawPolyline, aber geschlossener Linienzug (Polygon) wie drawPolygon, aber gefüllt zeichne Shape-Objekt s fülle Shape-Objekt s Schreibe den String text an die Stelle (x, y) Male das Image-Objekt bild an die Stelle (x, y), io ist ein sog. ImageObserver-Objekt Verwende Paint-Objekt p zum Färben Verwende Stroke-Objekt s als Zeichenstift Verwende Font-Objekt f als Font zum Schreiben Font zum Schreiben von Texten (bei drawString) fillRect (xl ,yt ,w,h) drawOval (xl ,yt ,w,h) fillOval (xl ,yt ,w,h) drawPolyline (ax ,ay ,n) drawPolygon (ax ,ay ,n) fillPolygon (ax ,ay ,n) draw (s) fill (s) drawString (text,x,y) drawImage (bild,x,y,io) setPaint (p) setStroke (s) setFont (f ) Die in der Tabelle erwähnten Paint-, Stroke-, Font-, Image- und ShapeObjekte müssen gegebenenfalls erzeugt werden: Paint ist ein Interface, so dass man nicht direkt ein Paint-Objekt erzeugen kann. Statt dessen muss man ein Objekt einer Klasse verwenden, die das Paint-Interface implementiert. Im einfachsten Fall ist dies die ColorKlasse: Man kann hier vordefinierte Farben verwenden, z. B. Color.RED, Color.GREEN oder Color.BLUE. Man kann aber auch eine eigene Farbe erzeugen mit: Paint new Color (‹r›, ‹g›, ‹b›) Dabei sind ‹r›, ‹g›, und ‹b› der Rot-, Grün- und Blauanteil. Diese können entweder alle als int-Werte im Bereich 0 bis 255, oder alle als float-Werte im Bereich 0.0 bis 1.0 angegeben werden. Auch Stroke ist ein Interface, man kann aber die Klasse BasicStroke verwenden. Ein Objekt dieser Klasse steht für einen Zeichenstift oder Pinsel, mit Stroke 5 Grafische Benutzeroberflächen 5.5 2D-Grafik 73 dem gemalt wird. Der einfachste Konstruktor eines BasicStroke-Objekts enthält nur die Dicke des Stifts (in Pixeln, als float-Wert): new BasicStroke (‹Dicke›) Es gibt noch weitere Konstuktoren, die die Form des Pinsels und die Art der Verbindung von Liniensegmenten spezifizieren. Ein Font-Objekt entspricht einer Schriftart und wird folgendermaßen erzeugt: Font new Font (‹Name›, ‹Stil›, ‹Größe›) Dabei ist ‹Name› der Fontname, z. B. Arial oder Courier. ‹Stil› ist die Schriftvariante: z. B. Font.PLAIN (normal), Font.BOLD (fett), Font.ITALIC (kursiv), oder Kombinationen davon (mit bitweisem Oder (|), da es sich um Bitmasken handelt). ‹Größe› ist die Schriftgröße in Punkten (1/72 Zoll). Ein Image-Objekt steht für eine Bild und wird meistens aus einer Datei geladen, z. B.: Image Image ‹Bild› = Toolkit.getDefaultToolkit ().getImage (‹Datei›); Das in der Tabelle erwähnte ImageObserver-Objekt dient dazu, Aktionen auszuführen, wenn das Bild geladen ist (z. B. über Internet). Man kann hier einfach die Komponente angeben, die das Bild enthält. Zu Shape-Objekten sei hier nur soviel gesagt: Es handelt sich um Objekte, die eine beliebige Form (engl. shape) darstellen, die aus geraden oder gebogenen Linienstücken zusammengesetzt sein kann. Ein Beispiel zum Abschluss Zum Schluss dieses Abschnitts sollen in einem ausführlichen Programmbeispiel die wichtigsten Grafik-Methoden demonstriert werden: Beispiel 5.14: Grafik-Demo import java.awt.*; import javax.swing.*; class GrafDemo extends JComponent { public GrafDemo () // Konstruktor { setPreferredSize (new Dimension (850, 500)); } public void paintComponent (Graphics g) // paintComponent-Methode { Graphics2D g2 = (Graphics2D) g; // Polygon füllen: int [] x5 = new int [] { 175, 325, 325, 25, 25 }; Shape 5 Grafische Benutzeroberflächen 5.5 2D-Grafik int [] y5 = new int [] { 30, 100, 300, 300, 100 }; g2.setPaint (Color.GRAY); // Farbe Grau g2.fillPolygon (x5, y5, 5); // füllen // Rechtecke und Ellipsen: g2.setStroke (new BasicStroke (5)); g2.setPaint (Color.BLUE); g2.drawRect (100, 100, 50, 50); g2.setPaint (Color.GREEN); g2.fillRect (200, 100, 80, 40); g2.setPaint (Color.RED); g2.drawOval (100, 200, 50, 50); g2.setPaint (Color.YELLOW); g2.fillOval (200, 200, 80, 40); // Strichdicke // Rechteck // Rechteck gefüllt // Ellipse // Ellipse gefüllt // Kreuz mit Farbübergang: g2.setStroke (new BasicStroke (10)); // Strichdicke g2.setPaint (new GradientPaint // Farbübergang (100, 175, Color.GREEN, 175, 100, Color.RED, true)); g2.drawLine (175, 100, 175, 250); g2.drawLine (100, 175, 250, 175); // Text: g2.setFont (new Font ("Arial", Font.PLAIN, 30)); g2.setPaint (Color.BLUE); g2.drawString ("Schriftart: Arial 30 Punkt", 400, 50); // Funktionsplot: Sinus int y0 = 400; int h = 150; int xa = 25, xb = 325; // Rahmen zeichnen: g2.setPaint (Color.BLUE); g2.setStroke (new BasicStroke (5)); g2.drawRect (xa, y0 - h/2, xb - xa, h); g2.setStroke (new BasicStroke (2)); g2.drawLine (xa, y0, xb, y0); // Koordinaten des Sinus in Feld schreiben: final int N = 51; int [] x = new int [N]; int [] y = new int [N]; // Felder anlegen ... for (int i = 0; i < N; i++) // ... und beschreiben { x [i] = xa + i * (xb - xa) / (N-1); y [i] = (int) (y0 - h/2 * Math.sin (2 * Math.PI * i / (N-1))); } // Funktionskurve zeichnen: g2.setPaint (Color.RED); 74 5 Grafische Benutzeroberflächen 5.5 2D-Grafik g2.setStroke (new BasicStroke (5)); g2.drawPolyline (x, y, N); // Linienzug zeichnen // Bild anzeigen: Image lisa = Toolkit.getDefaultToolkit () // Bild laden .getImage ("mona_lisa.jpg"); g2.drawImage (lisa, 400, 80, this); // Originalgröße g2.drawImage (lisa, 725, 80, 100, 100, this); g2.drawImage (lisa, 725, 215, 100, 250, this); } static public void main (String [] args) { JFrame frame = new JFrame (); GrafDemo gd = new GrafDemo (); frame.add (gd); frame.setTitle ("Grafik Demo"); // Titel frame.pack (); // Größe anpassen frame.setVisible (true); // sichtbar machen } } Das Ergebnis dieses Programms sieht folgendermaßen aus: Man kann erkennen, dass es innerhalb der paint-Methode auf die Reihenfolge der Grafik-Befehle ankommt: Was später gezeichnet wird, überdeckt (normalerweise) das, was vorher schon gezeichnet war. So wurde in der linken oberen 75 5 Grafische Benutzeroberflächen 5.5 2D-Grafik Ecke zuerst das graue hausförmige Polygon gezeichnet, danach die Rechtecke, Ellipsen und das Kreuz. Wäre die Reihenfolge umgekehrt, so würde das graue Haus alles unter sich bedecken. Das Beispielprogramm enthält auch einige Techniken, die bisher noch nicht besprochen wurden: Mit der Klasse GradientPaint lassen sich Farbübergänge zum Färben verwenden (Beispiel: Kreuz). Der Konstruktor enthält die beiden Farben zusammen mit den Koordinaten der Punkte, an denen diese Farben jeweils in Reinform auftreten sollen. Dazwischen wird interpoliert. Der letzte (boolesche) Parameter gibt an, ob die Farbänderung periodisch fortgesetzt werden soll (außerhalb des Bereichs zwischen den beiden Punkten). Bei der Darstellung des Image-Objekts (Mona Lisa) wird ein weiterer Konstruktor verwendet, der die Größe des Bildes auf der Zeichenfläche angibt (die beiden letzten Parameter). Da diese Größen nicht mit der Originalgröße des Bildes übereinstimmen müssen, kann das Bild vergrößert, verkleinert oder verzerrt werden. 76 6 Pakete 6.1 Wofür Pakete? Bei der hierarchischen Gliederung von Programmen bietet die OOP gegenüber der konventionellen Programmierung den Vorteil der Zusammenfassung von Funktionen (und Daten) zu Klassen, sodass sich umfangreiche Programme zunächst in verschiedene Klassen unterteilen lassen. Bei sehr umfangreichen Programmen und Programmbibliotheken erweist es sich als nützlich, oberhalb der Klassenebene eine weiter Hierarchiebene zur Verfügung zu haben. In Java sind dies die sogenannten Pakete. Sie können von Anwendungsprogrammierern zur Stukturierung des Programmcodes eingesetzt werden und finden auch in der Standardbibliothek Verwendung. Letzteres ist schon aus dem vorausgehenden Kapitel bekannt, denn die importierten Bibliotheken java.awt oder javax.swing sind nichts anderes als Pakete. Hierarchien Ein weiterer wichtiger Grund für die Verwendung von Paketen ist, dass man dadurch Mehrdeutigkeiten bei Klassennamen vermeiden kann. Dies ist vergleichbar mit der Verwendung von Namensräumen in C++ (vgl. namespace std). So gibt es z. B. in der Standardbibiothek zwei Klassen mit dem Namen Timer. Möchte man eine dieser Klassen in einem Anwendungsprogramm verwenden, so ist zunächst nicht klar, welche von beiden gemeint ist. Die beiden Timer-Klassen befinden sich aber in verschiedenen Paketen. Gibt man zum Klassennamen noch zusätzlich den Paketnamen an, so wird der Namenskonflikt aufgelöst: Die eine Klasse ist java.util.Timer, die andere javax.swing.Timer. Mehrdeutigkeiten In verschärfter Form tritt dieses Problem auf, wenn man Bibliotheken zweier (oder mehrerer) verschiedener Hersteller in sein Programmeinbinden möchte. Da diese Hersteller in der Regel unabhängig voneinander arbeiten, kann es immer wieder zu Namensgleichheiten von Klassen kommen, die sich aber erst bemerkbar machen, wenn die Bibliotheken der beiden Hersteller gleichzeitig verwendet werden. Werden von den Herstellern Paktete verwendet (die natürlich verschiedene Namen haben müssen!), so lässt sich die gewünschte Klasse durch Angabe des Pakets eindeutig identifizieren. Damit die Paketnamen verschiedener Hersteller verschieden sind, empfielt SUN, die Hersteller-URL in umgedrehter Reihenfolge als Beginn des Paketnamens zu verwenden. So sollten z. B. Pakete eines Herstellers mit URL www.superjava.de mit de.superjava beginnen, z. B. de.superjava.testpaket. 6 Pakete 6.2 6.2 Verwendung von existierenden Paketen 78 Verwendung von existierenden Paketen In einer Klasse hat man Zugriff auf alle öffentlichen Klassen aus anderen Paketen (öffentliche Klassen sind die mit public definierten Klassen). Um eine Klasse aus einem Paket zu verwenden gibt es drei Möglichkeiten: • Direkte Verwendung. Der Paketname wird dem Klassennamen mit einem Punkt vorangestellt: . . . ‹Paketname›.‹Klassenname› . . . z. B.: javax.swing.Timer tim = new javax.swing.Timer (); • Importieren der Klasse. Dazu muss in einer import-Anweisung zu Beginn des Programms der vollständige Klassenname angegeben werden. Später kann der Klassenname dann ohne vorangestellten Paketnamen verwendet werden: import import ‹Paketname›.‹Klassenname›; ... . . . ‹Klassenname› . . . z. B.: import javax.swing.Timer; ... Timer tim = new Timer (); • Importieren des kompletten Pakets. Dazu muss in einer importAnweisung zu Beginn des Programms der Paketname mit angehängtem .* angegeben werden. Der Klassenname kann dann ohne vorangestellten Paketnamen verwendet werden: import ‹Paketname›.*; ... . . . ‹Klassenname› . . . z. B.: import javax.swing.*; ... Timer tim = new Timer (); 6.3 Definition von eigenen Paketen Um eine Klasse in einem Paket zu definieren, ist folgendes zu bachten: 1. Zu Beginn einer Java-Datei, die eine zum Paket gehörige Klassendefinition enthält, muss eine package-Anweisung mit dem Paketnamen stehen: package ‹Paketname›; z. B.: package de.superjava.testpaket; package 6 Pakete 6.3 Definition von eigenen Paketen 2. Die Java-Datei mit der Klassendefinition muss in einem Unterverzeichnis stehen, das wie der Paketname aufgebaut ist (wobei statt der Punkte / bzw. \ steht): So muss sich z. B. eine zum Paket de.superjava.testpaket gehörige Java-Datei im Unterverzeichnis de\superjava\testpaket befinden. 3. Zu Beachten ist weiterhin: Jede Java-Datei darf nur eine öffentliche Klassendefinition enthalten. Der Name dieser Klasse muss mit dem Dateinamen (ohne .java) übereinstimmen (Groß-/Kleinschreibung beachten!). Zum Schluss noch ein Hinweis: Zu beachten ist, dass z. B. java.awt und java.awt.event verschiedene Pakete sind. So ist es auch nicht möglich, als eine import-Anweisung in der Form import java.awt.*.*; // Achtung Falsch! zu schreiben. Die Gemeinsamkeit von java.awt und java.awt.event besteht lediglich darin, dass sich der Quellcode des java.awt.event-Pakets im Unterverzeichnis event des Verzeichnisses des java.awt-Pakets befindet. 79 Ausnahmebehandlung (Exception handling) 7 7.1 Worum geht es? Treten in einem Programm Fehler auf, so ist es nicht immer sinnvoll, dass das Programm einfach abbricht. Häufig möchte man auf den Fehler reagieren (z. B. bei Eingabefehlern die Eingabe wiederholen) oder vor dem Beenden des Programms noch wichtige Daten abspeichern. Typische Fehlerquellen sind die Eingabe (Buchstabe statt Zahl eingegeben), Geräte (kein Papier im Drucker), physikalische Grenzen (Festplatte voll) und auch Programmierfehler (Array-Index außerhalb des erlaubten Bereichs). Mit den bisher bekannten Mitteln war es nur möglich, auf Fehler zu reagieren, indem man bei Funktionen, in denen ein Fehler auftreten konnte, einen Fehlercode zurückgibt und diesen im aufrufenden Programmstück auswertet. Dies führt aber zu sehr umständlichen Programmcode, wenn man über mehrer Ebenen Funktionen aufruft, der Fehler in der untersten Ebene auftritt, man aber in der obersten Ebenen auf den Fehler reagieren möchte. Umständlicher Programmcode ergibt sich ebenfalls, wenn man ein längeres Programmstück hat (z. B. Werte in eine Datei schreiben) in dem an sehr vielen Stellen Fehler auftreten können (z. B. Festplatte voll). Dann müsste man an all diesen Stellen überprüfen, ob ein Fehler aufgetreten ist. Mit dem Mechanismus der Ausnahmebehandlung (den es auch schon bei C++ gibt) werden diese Probleme elegant gelöst. 7.1.1 Der Ausnahmemechanismus Ausnahmen (Exceptions) ermöglichen einen alternativen Rückweg aus Funktionen (neben return) oder aus anderen Programmstücken. Die Ausnahme selbst ist dabei ein Objekt, das Fehlerinformationen enthält. Dieses Objekt wird im Fehlerfall erzeugt und „geworfen“ (throw). Es kann dann an einer anderen Stelle, die auch viele Ebenen über der Fehlerstelle liegen kann, aufgefangen werden (catch). Es gibt viele vordefinierte Exception-Klassen. Alles sind von der Klasse Exception abgeleitet, die wiederum von Throwable abgeleitet ist. Man unterschei- throw catch 7 Ausnahmebehandlung (Exception handling) 7.2 Auslösen einer Ausnahme det zwischen geprüften (checked) und ungeprüften (unchecked) Ausnahmen. Bei geprüften Ausnahmen (z. B. IOException) stellt der Compiler sicher, dass diese, sofern sie auftreten können, auch abgefangen werden. Ungeprüfte Ausnahmen müssen nicht abgefangen werden, führen dann aber zum Abbruch des Programms, oder zumindest des parallelen Prozesses in dem der Fehler auftrat. Ungeprüfte Ausnahmen sind von der Klasse RuntimeException abgeleitet und stellen Programmierfehler dar (unerlaubter Array-Index, Zugriff auf null-Pointer, etc.). 7.2 Auslösen einer Ausnahme Es gibt verschiedene Möglichkeiten wie eine Ausnahme entstehen kann: • Durch einen Programmierfehler. So erzeugt z. B. folgendes kurze Programmstück eine Ausnahme vom Typ ArrayIndexOutOfBoundsException: ... int [] a = new int [5]; a [10] = 777; ... • Aufrufen einer Funktion, die eine Ausnahme werfen kann. So gibt es z. B. in der KlasseThread eine statische Funktion sleep() , die dazu dient, eine bestimmte Anzahl von Millesekunden zu warten. Diese Funktion kann eine InterruptedException auslösen, wenn das Programm von einem externen Ergeignis unterbrochen wird. Die Funktionsdeklaration sieht folgendermaßen aus: public static void sleep(long millis) throws InterruptedException; Der Zusatz throws InterruptedException weist den Compiler darauf hin, dass die Funktion eine Ausnahme vom Typ InterruptedException werfen kann. • Explizites Werfen einer Ausnahme mit throw. Durch die folgenden beiden Anweisungen wird jeweils eine Ausnahme vom vordefinierten Typ IOException geworfen: throw new IOException (); oder: throw new IOException ("Falsches Dateiformat"); Im zweiten Fall wird ein Konstruktor verwendet, dem man eine Fehlerbeschreibung übergeben kann. Es gibt auch die Möglichkeit, eine eigene Ausnahmeklasse von der Oberklasse Exception oder einer anderen Ausnahmeklasse (hier IOException) abzuleiten: 81 7 Ausnahmebehandlung (Exception handling) 7.3 Abfangen von Ausnahmen class FileFormatException extends IOException { public FileFormatException () {} public FileFormatException (String text) { super (text); } } Wie man sieht, wird nichts Neues in der Klasse gemacht, es wird lediglich ein Standardkonstruktor definiert und einer mit String-Parameter, der aber nur den entsprechenden Konstuktor der Oberklasse aufruft. Trotzdem kann eine solche Definition sinnvoll sein, da die wesentliche Information über die Ausnahme im Datentyp der Exception steckt. Wie wir gleich sehen werden, kann man Exceptions selektiv nach ihrem Typ abfangen. 7.3 Abfangen von Ausnahmen 7.3.1 Abfangen eines bestimmten Ausnahmetyps Ausnahmen werden mit Hilfe einer try-catch-Anweisung abgefangen: try { // Code in dem die Ausnahme // auftreten kann } catch (‹Exceptiontyp› e) { // Ausnahmebehandlung } Tritt keine Ausnahme auf, so wird der try-Block komplett ausgeführt. Der catch-Block wird in diesem Fall übersprungen. Tritt im try-Block eine Ausnahme vom Typ ‹Exceptiontyp› (oder einem abgeleiteten Typ) auf, so wird der try-Block sofort verlassen und der catch-Block wird ausgeführt. Dabei wird das Exception-Objekt wie bei einem Funktionsaufruf an den catch-Block übergeben. Alternativ zum direkten Abfangen von Ausnahmen kann man auch zunächst gar nichts unternehmen (d. h. keine try-catch-Anweisung). Dann wird die Ausnahme von der Funktion, in der sie auftritt, nach außen weitergereicht, d. h. zu der aufrufenden Funktion. In diesem Fall muss allerdings bei der Funktion, die die Ausnahme nach außen weiterreicht, im Funktionskopf der Zusatz throws ‹Exceptiontyp› (zumindest bei geprüften Ausnahmen). Um zu entscheiden, ob man eine Ausnahme abfängt, oder nach außen weiterreicht, ist folgende Faustregel nützlich: 82 7 Ausnahmebehandlung (Exception handling) 7.3 Abfangen von Ausnahmen • Ausnahmen, bei denen man weiß, wie sie zu behandeln sind, sollen direkt behandelt werden (also abgefangen werden). • Ausnahen, bei denen man das nicht weiß, sollen nach außen weitergereicht werden. 7.3.2 Abfangen mehrerer Ausnahmetypen Nehmen wir an, in einem Programmstück können verschiedene Ausnahmetypen auftreten, die aber verschieden behandelt werden sollen. In diesem Fall kann man einfach mehrere catch-Blöcke an den try-Block anhängen: try { // Code in dem die verschiedenen // Ausnahmetypen auftreten können } catch (‹Exceptiontyp 1› e) { // Ausnahmebehandlung für Ausnahmetyp 1 } catch (‹Exceptiontyp 2› e) { // Ausnahmebehandlung für Ausnahmetyp 2 } ... // etc. Je nach Ausnahmetyp wird nach Abbruch des try-Blocks entweder der erste oder der zweite catch-Block durchlaufen. 7.3.3 Aufräumarbeiten bei Ausnahmen Es besteht auch die Möglichkeit zu gewährleisten dass nach einem Programmstück, das durch eine Ausnahme unterbrochen wurde, noch ein zweites Programmstück (z. B. zum Aufräumen, Dateien schließen, etc.) auf jeden Fall ausgeführt wird. Dies geschieht bei der try-Anweisung mit der sog. finallyKlausel: try { // Code in dem die Ausnahme // auftreten kann } catch (‹Exceptiontyp› e) { // Ausnahmebehandlung } finally { // wird auf jeden Fall 83 7 Ausnahmebehandlung (Exception handling) 7.3 Abfangen von Ausnahmen // am Ende ausgeführt } Man könnte annehmen, dass man die finally-Klausel nicht benötigt, und den darin enthaltenen Programmcode einfach hinter die try-Anweisung setzten könnte. Dies ist aber falsch: Wenn nämlich eine Ausnahme von einem anderen Typ als dem im catch-Block angegebenen Typ auftritt, so wird nicht nur die try-Anweisung verlassen, sondern die Funktion beendet, in der sich die try-Anweisung befindet. D. h. der Programmcode nach der try-Anweisung wird nicht mehr ausgeführt. 84 8 Multithreading 8.1 Was ist Multithreading? Unter dem Begriff „Multithreading“ versteht man, dass ein Programm sich in mehrere parallele Abläufe, Threads (engl. Fäden) genannt, aufspaltet. Threads Multithreading ist verwandt mit Multitasking. Allerdings bestehen große Unterschiede: Beim Multitasking, das eine Eigenschaft des Betriebssystems darstellt, laufen eigenständige Programme (Tasks oder Prozesse genannt) parallel ab. Jedes dieser Programme hat seinen eigenen Speicherplatz und seine eigenen Variablen. Es gibt spezielle, vom Betriebssystem zur Verfügung gestellte Mechanismen der Prozesskommunikation (z. B. Pipes). Anders sieht es beim Multithreading aus: Die verschiedenen Threads sind keine eigenständigen Programme, sondern Teile eines Programms. Sie können auf gemeinsame Variablen zugreifen und über diese kommunizieren. 8.1.1 Wofür ist es gut? Viele interaktive Programme kommen nicht ohne Multithreading aus. Stellen wir uns z. B. einen Web-Browser vor. Dieser soll auf Benutzerinteraktionen (Mausklicks) ohne Verzögerung reagieren. Auf der anderen Seite muss er aber warten, bis HTML-Seiten und darin enthaltene Objekte (z. B. Bilder) geladen sind, um diese dann grafisch darzustellen. Dies ist mit einem Tread nicht möglich, da ein Thread nur entweder auf die Benutzterinteraktion warten kann oder auf das Laden der HTML-Seite warten kann. Sinnvoll ist es sogar, mehr als zwei Threads zu haben: Einen für die Benutzeroberfläche, jeweils einen für jedes zu ladende Objekt und einen für die graphische Darstellung der Seite. Viele moderne Programmiersprachen Wie Java oder C# haben die Fähigkeit zum Multithreading bereits eingebaut. Ohne es zu wissen haben wir bisher auch schon Programme mit mehreren Threads entwickelt. Wenn man nämlich ein Programm mit paintComponent()Funktion entwickelt, bei dem z. B. über Mausklicks etwas gemalt wird, so findet die Behandlung der Mausklicks (actionPerformed()-Funktion) und das Malen (paintComponent()-Funktion) in verschiedenen Threads statt. Dies geschieht dadurch, dass die paintComponent()-Funktion nicht direkt aufgerufen wird, sondern statt dessen die repaint()-Funktion, die dafür sorgt, dass der Inhalt der Komponente als veraltet markiert wird und im parallelen Thread bei günstiger Gelegenheit die paintComponent()-Funktion Ein Beispiel 8 Multithreading 8.2 Starten eines Threads 86 aufgerufen wird. 8.2 Starten eines Threads Um ein Programmstück parallel in einem Thread ablaufen zu lassen, muss man folgendermaßen vorgehen: 1. Definieren einer Klasse, die das Interface Runnable implementiert. Dieses Interface ist so definiert, dass es eine Methode run() enthält, die somit auch in der selbstdefinierten Klasse vorhanden sein muss: Interface Runnable class RunTest implements Runnable { ... public void run () { // hier steht der Programmcode, // der im Thread ausgeführt wird. } ... } Die run()-Funktion wird später in einem eigenen Thread gestartet (aber nicht direkt aufgerufen!). 2. Ein Objekt der eben definierten Klasse wird angelegt: RunTest rt = new RunTest (); oder, da es nur auf die Runnable-Eigenschaft ankommt: Runnable rt = new RunTest (); 3. Ein Objekt der vordefinierten Klasse Thread wird angelegt. Die Klasse Thread dient dazu Threads zu starten und zu verwalten. Der Konstruktor des Threads erhält als Parameter das Runnable-Objekt: Thread th = new Thread (rt); 4. Nun ist alles vorbereitet und der Thread muss nur noch gestartet werden: th.start (); Dadurch wird die run()-Funktion in einem eigenen Thread aufgerufen. Hier ein kleines Beispielprogramm, das einen Thread erzeugt, der jede Sekunde eine Meldung auf dem Bildschirm ausgibt: Beispiel 8.1: Ein einfaches Beispiel zum Starten eines Threads public class RunTest implements Runnable { public void run () // run-Methode, wird später gestartet Klasse Thread 8 Multithreading 8.3 Beenden eines Threads 87 { int i = 1; while (true) { System.out.println ("Meldung von run () " + i++); try { Thread.sleep (1000); // 1000 ms warten } catch (InterruptedException e) {} } } public static void main (String [] args) { RunTest rt = new RunTest (); // Runnable Objekt erzeugen Thread th = new Thread (rt); // Thread erzeugen th.start (); // Thread starten // Dadurch wird die run-Methode im neuen Thread aufgerufen. } } 8.3 Beenden eines Threads Ein gestarteter Thread läuft so lange, bis seine run()-Methode beendet ist. Sind alle Threads eines Programms abgelaufen (einschließlich des Threads, in dem die main()-Funktion ausgeführt wird), so wird das Programm beendet. Häufig wird in der run()-Methode eine Endlosschleife ausgeführt, in der periodisch wiederkehrende Aufgaben bearbeitet werden. In diesem Fall wird der zugehörige Thread nicht von selbst aufhören, sondern erst dann, wenn das Programm abgebrochen wird. Typischerweise sieht eine solche run()-Methode so aus: public void run () { while (true) { // Verrichte laufende Arbeiten } } Manchmal möchte man die Möglichkeit haben, von außen einen solchen Thread zu beenden. Man kann den Thread allerdings nicht mit Gewalt abbrechen, sondern nur eine Abbruch-Anforderung stellen. Dies geschieht mit der Methode interrupt(), die in der Klasse Thread enthalten ist. Diese Methode hat zwei Auswirkungen: 1. Im Thread-Objekt wird ein internes Flag gesetzt, das sich in der run()- Abbruch Threads eines 8 Multithreading 8.3 Beenden eines Threads Methode abfragen lässt: Thread.interrupted () // statische Methode // Nebenwirkung: das Interrupt-Flag wird zurückgesetzt. oder: Thread.currentThread().isInterrupted () // nichtstatische Methode Beide Aufrufe liefern true zurück, wenn der aktuelle Thread mit interrupt() unterbrochen wurde, sonst false. 2. Befindet sich der Thread in einem blockierten Zustand, d. h. in einem Zustand in dem auf etwas gewartet wird (z. B. durch Aufruf von Thread.sleep() oder durch eine Ein-/Ausgabeanweisung), so wird eine InterruptedException ausgelöst. Diese lässt sich in der run()Methode durch eine try-catch-Anweisung abfangen (vgl. Abschnitt 7). Je nachdem ob die „Endlos“-Schleife der run()-Methode einen sleep()Aufruf enthält, oder nicht, lässt sich die Schleife auf zwei verschiedene Arten abbrechen: 1. Falls die run()-Methode keinen sleep()-Aufruf enthält, kann man das Interrupt-Flag einfach in der Schleifenbedingung testen: public void run () { while (!Thread.currentThread().isInterrupted ()) { // Verrichte laufende Arbeiten } // Wenn Schleife verlassen wird, wird auch der Thread beendet. } 2. Falls die run()-Methode periodisch die sleep()-Methode aufruft, kann man einfach eine try-catch-Anweisung um die Schleife herum legen: public void run () { try { while (true) { // Verrichte laufende Arbeiten Thread.sleep (millis); } } catch (InterruptedException e) { // Interrupt aufgetreten } // Wenn Schleife verlassen wird, wird auch der Thread beendet. } 88 8 Multithreading 8.4 8.4 Wettlaufsituationen Wettlaufsituationen Unter einer Wettlaufsituation (Race Condition) versteht man eine Situation, in der zwei oder mehr parallele Abläufe (hier betrachten wir nur Threads) auf eine Variable zugreifen. Dabei kann sich in Abhängigkeit von den Umschaltzeitpunkten zwischen den Threads ein unterschiedliches Verhalten des Programms ergeben. Dies kann zu schwer aufzufindenden Programmfehlern führen. Z. B. kann das Programm in 99% aller Fälle das erwartete Ergebnis liefern und nur in 1% ein falsches. Wir betrachten als Beispiel das folgende Programm: Beispiel 8.2: Eine Race Condition public class ThreadCollision implements Runnable { private static int count = 0; public ThreadCollision () { Thread th = new Thread (this); th.start (); } public void run () { for (int i = 1; i <= 100000000; i++) count++; System.out.println ("count = " + count); } public static void main (String [] args) { new ThreadCollision (); new ThreadCollision (); } } Das Programm startet zwei Threads, die beide die gleiche run()-Methode haben. Dies wäre nicht schlimm, wenn nicht beide Threads auf die gleiche statische Variable count schreibend zugreifen würden. So kann die Operation count++ falsche Ergebnisse liefern, wenn sie durch sich selbst unterbrochen wird. Sehen wir uns an, was die run()-Methode macht: In einer Schleife, die 100000000 mal durchlaufen wird, wird der Zähler count jeweils um 1 erhöht. D. h. jeder der Threads erhöht den Zähler um 100000000, so dass er am Ende einen Wert von 200000000 haben sollte. Nach Ende der Schleife wird der Zählerstand ausgegeben. Natürlich wissen wir nicht, welcher Thread früher fertig ist und wir rechnen damit, dass der erste Thread einen nicht vorhersagbaren Wert ausgeben wird, weil nicht klar ist, wie weit der zweite Thread in seiner 89 8 Multithreading 8.4 Wettlaufsituationen Schleife schon gekommen ist. Aber wir würden erwarten, dass der zweite Thread am Ende den Wert 200000000 ausgibt. Leider ist das nicht der Fall. Ein Testlauf lieferte folgendes Ergebnis: count = 160681379 count = 164286799 Wie ist dies zu erklären? Wir wollen hier nicht auf die Java-Bytecodes eingehen, sondern stellen uns die Übersetzung der Anweisung count++ in eine imaginäre Maschinensprache vor, die wir als Assemblerbefehle ausdrücken: ld r1, (count) inc r1 st r1, (count) ; lade Wert von count in Register r1 ; inkrementiere Register r1 ; speichere Register r1 in count ab Nehmen wir an, Thread 1 wird nach Ausführung des ersten Maschinenbefehls unterbrochen, und Thread 2 kommt an die Reihe. Er führt die drei obigen Maschinenbefehle aus und danach kommt wieder Thread 1 an die Reihe. Der Einfachheit halber verwenden wir für Thread 2 ein anderes Register als für Thread 1. In Assemblersprache sieht das ganze dann so aus: ld r1, (count) ; ld r2, (count) inc r2 st r2, (count) inc r1 ; st r1, (count) ; Thread 1: lade Wert von count in Reg. r1 ; Thread 2: lade Wert von count in Reg. r2 ; Thread 2: inkrementiere Reg. r2 ; Thread 2: speichere Reg. r2 in count ab Thread 1: inkrementiere Reg. r1 Thread 1: speichere Reg. r1 in count ab Analysiert man dieses Programmstück, so stellt man fest, dass count nur um 1 erhöht wird, nicht um 2, obwohl ja beide Threads den count++-Befehl ausgeführt haben. Um die Race Condition zu beseitigen, muss man dafür sorgen, dass ein Thread in der kritischen Phase (hier count++) nicht unterbrochen werden kann. Dies geht am einfachsten mit dem Schlüsselwort synchronized. Dazu umschließt man die Programmstücke, die sich nicht unterbrechen dürfen mit einem synchronized-Block (s. nachfolgendes Beispiel). Nach dem Schlüsselwort synchronized wird dabei ein Objekt angegeben, das zur Identifizierung der Sperre verwendet wird. Programmstücke, die sich gegenseitig nicht unterberechen dürfen, müssen dazu das selbe Objekt angeben: Beispiel 8.3: Race Condition beseitigt public class ThreadNoCollision implements Runnable { private static int count = 0; private static Object lock = new Object (); public ThreadNoCollision () { Thread th = new Thread (this); 90 8 Multithreading 8.4 Wettlaufsituationen th.start (); } public void run () { for (int i = 1; i <= 100000000; i++) synchronized (lock) { count++; } System.out.println ("count = " + count); } public static void main (String [] args) { new ThreadNoCollision (); new ThreadNoCollision (); } } Nach Beseitigung der Race Condition ergab ein Testlauf folgendes Ergebnis: count = 199732336 count = 200000000 D. h. nach Beendigung beider Threads wurde der Zähler tatsächlich auf den erwarteten Wert erhöht. Allerdings wurde dafür ein hoher Preis gezahlt: Wie man bei Ausführung des geänderten Programms feststellen wird, hat sich die Laufzeit auf etwa das 50fache erhöht! 91 9 Collections 9.1 Was sind Collections? Collections sind Behälter, die, ähnlich wie Arrays, zum Aufbewahren vieler Elemente eines Datentyps dienen. Sie sind aber in vieler Hinsicht flexibler einsetzbar als Arrays. So hat z. B. ein einmal erzeugten Array eine feste Größe, die nicht mehr geändert werden kann. Eine Collection dagegen kann eine variable Anzahl von Elementen aufnehmen. Es gibt Methoden um Elemente hizuzufügen und zu löschen. Das im Paket java.util definierte System von Collections, Collections Framework genannt, enthält für viele Verwendungszwecke geeignete Collections. Die wichtigsten sind: • Liste (List): Eine Liste definiert eine lineare Anordnung der Elemente wie in einem Array. D. h. die Reihenfolge der Elemente vom ersten bis zum letzten steht fest. Das gleiche Element kann mehrfach in der Liste vorkommen. • Menge (Set): Wie bei einer mathematischen Menge, kann diese Collection Elemente enthalten, aber jedes Element darf höchstens einmal enthalten sein. Eine Reihenfolge ist nicht definiert. • (Warte)schlange (Queue): Bei einer Schlange können Elemente nur am Ende eingefügt und nur am Kopf herausgenommen werden. Man bezeichnet sie auch als FIFO (First In First Out). • Zuordnung (Map): Eine Map stellt eine Zuordnung von SchlüsselElementen (Key) und Wert-Elementen (Value) dar. D. h. es werden Paare der Form (Schlüssel, Wert) abgespeichert. Z. B. könnte man mit einer Map ein Telefonbuch abspeichern das Paare der Form (Name, Telefonnummer) enhält. Das Besondere der Map ist, dass man durch Angabe eines Schlüssels (also im Beispiel eines Namens) den zugeordneten Wert (im Beispiel die Telefonnummer) erhält. Man bezeichnet dies auch als assoziatives Array. Die genannten Typen von Collections sind allerdings keine Klassen sondern Interfaces, die (mit Ausnahme von Map) vom Ober-Interface Collection abgeleitet sind. Im jeweiligen Interface ist angegeben, wie man auf die Elemente des Collection-Typs zugreifen kann. Um ein konkretes Collection-Objekt zu erzeugen, kann man entweder eine eigene Klasse erstellen, die eines der Interfaces 9 Collections 9.2 Generische Klassen ... implementiert (was sehr viel Arbeit ist), oder man verwendet eine der vordefinierten Collection-Klassen, die eines der Interfaces implementiert. Wir werden in den folgenden Abschnitten verschiedene Collection-Klassen und -Interfaces aus dem Collections Framework kennenlernen. Es stellt sich natürlich die Frage, warum das Collections Framework in dieser Weise (also mit getrennten Interfaces und Klassen) aufgebaut ist. Dafür gibt es mehrere Gründe: Zum einen ermöglicht dies, auch eigene Collection-Klassen zu definieren, die sich nahtlos in das Framework einfügen. Zum anderen wurden im vordefinierten Framework für jedes Interface meist schon mehrere implementierende Klassen definiert, die sich in ihrem Zuschnitt auf bestimmte Problemstellungen unterscheiden, insbesondere in der Performance bestimmter Operationen (z. B. Einfügen, Löschen, direkter Zugriff auf Elemente). Durch die Trennung von Interface und Implementierung ist es leicht möglich, nachträglich eine andere Implementierung zu verwenden, ohne am Programm viel ändern zu müssen. 9.2 Generische Klassen / Interfaces und das Collection-Interface Vom Interface Collection sind alle anderen Interfaces des CollectionFrameworks (mit Ausnahme von Map) abgeleitet. Collection und die anderen Interfaces sind so genannte generische Interfaces auch die entsprechenden Klassen sind generische Klassen. Was damit gemeint ist, soll im Folgenden kurz erläutert werden. Eine generische Klasse ist eine Klasse, die nicht in einer festgelegten Version existiert, sondern in beliebig vielen Versionen in Abhängigkeit von einer anderen Klasse. Was das bedeutet, macht man sich am besten an einem Array klar. Es gibt nicht nur einen Datentyp „Array“, sondern beliebig viele ArrayDatentypen, in Abhängigkeit von Element-Datentyp (es gibt z. B. int-Arrays, double-Arrays, char-Arrays, etc.). Seit Java Version 5 ist es möglich, eine solche Typabhängigkeit auch bei beliebigen Klassen und Interfaces einzubauen. Allerdings beschränkt sich die Abhängigkeit auf Referenztypen, also Typen, die von Object abgeleitet sind (dazu gehören auch alle Arrays). Man könnte z. B. eine Klasse Paar definieren, die zwei Objekte eines bestimmten Datentyps enthält. Definiert man diese Klasse als generische Klasse, so kann man später (z. B. wenn ein Objekt der Klasse Paar angelegt wird) noch genau angeben, Objekte welchen Typs in der Klasse Paar enthalten sein sollen. Die Definition der generischen Klasse Paar sieht folgendermaßen aus: class Paar<T> { private T eins; private T zwei; public Paar (T e, T z) // Konstruktor 93 9 Collections 9.2 Generische Klassen ... { eins = e; zwei = z; } ... } Dabei steht der zwischen < und > (spitze Klammern) angegebene Datentyp, hier prgT, für den Datentyp von dem die Klasse Paar abhängt, also den Datentyp der einzelnen Elemente eines Paars. Damit könnte leicht Paare von Objekten verschiedener Datentypen angelegt werden: // Ein Integer-Paar: Paar<Integer> ipaar = new Paar<Integer> (new Integer(53), new Integer(77)); // Ein String-Paar Paar<String> spaar = new Paar<String> ("Rhein", "Main"); Der aktuelle Datentyp für die Elemente (im Beispiel Integer und String) wird hinter Paar in spitzen Klammern angegeben (auch beim KonstruktorAufruf!). Zu beachten ist, dass das obige Beispiel mit int anstelle von Integer nicht funktioniert, da es sich bei int nicht um einen Referenzdatentyp handelt (d. h. int-Werte sind keine Objekte). Was für generische Klassen gilt, lässt sich leicht auf generische Interfaces übertragen: Diese hängen ebenfalls von einer anderen Klasse ab, die in spitzen Klammern angegeben wird. Nach diesem kurzen Ausflug zu den generischen Klassen und Interfaces sehen wir und die Anwendung auf das Collection-Interface an. Collections sollen ja zum Speichern von Elementen eines bestimmten Datentyps dienen. Deshalb ist es natürlich sinnvoll, dass das Collection-Interface (und auch alle davon abgeleiteten Interfaces) als generisches Interface den Element-Datentyp enthält. Bezeichnet man den Element-Datentyp mit E, so ist der Name des Interfaces Collection<E>. Hier die wichtigsten Methoden des Collection<E>Interfaces: • boolean add (E e) Fügt ein Element e zur Collection hinzu. Gibt true zurück, wenn das Element hinzugefügt werden konnte. Wenn ein schon vorhandenes Element nochmals hinzugefügt werden soll und die Collection darf keine Duplikate enthalten (z. B. bei Mengen), so wird false zurückgegeben. • void clear () Entfernt alle Elemente aus der Collection. • boolean contains (Object o) Gibt true zurück, wenn das Objekt o in der Collection enthalten ist (Vergleich mit equals()), sonst false. 94 9 Collections 9.3 Listen • boolean remove (Object o) Entfernt das Objekt o aus der Collection, wenn es enthalten ist (Vergleich mit equals()). In diesem Fall gibt die Funktion true zurück, sonst false. • boolean isEmpty () Gibt true zurück, wenn die Collection leer ist, sonst false. • int size () Gibt die Anzahl der Elemente in der Collection zurück. • T [] toArray (T [] a) Liefert die Elemente der Collection als Array zurück. T steht dabei für den Datentyp der Arrayelemente (meist identisch mit E). Das als Parameter übergebene Array a dient lediglich dazu, die Typ-Information an die Funktion zu übertragen. • Iterator<E> iterator () Gibt einen Iterator über der Elemente der Collection zurück. Dies ist ein Objekt mit dessen Hilfe die Elemente der Collection durchlaufen werden können (mehr dazu in Abschnitt 9.4). 9.3 Listen Das List-Interface ist vom Collection-Interface abgeleitet. Eine Liste stellt eine linear geordnete Collection dar. D. h. die Elemente sind durchlaufend nummeriert (von 0 ab). Entsprechend stellt das Interface List<E> zusätzlich folgende Methoden zur Verfügung (hier nur die wichtigsten): • void add (int index, E e) Fügt ein Element e an Position index in die Liste ein. Das Element das vorher an dieser Position war sowie alle Elemente mit höherem Index werden um eine Position nach oben verschoben. • E get (int index) Liefert das Element an Position index zurück. index muss zwischen 0 und Listengröße-1 liegen. • E remove (int index) Liefert das Element an Position index zurück und entfernt diese aus der Liste. Dabei werden alle Elemente mit höherem Index um eine Position nach unten verschoben. index muss zwischen 0 und Listengröße-1 liegen. • E set (int index, E e) Ersetzt das Listen-Element an Position index durch das Element e. Das ursprüngliche Element wird zurückgegeben. index muss zwischen 0 und Listengröße-1 liegen. 95 9 Collections 9.3 Listen • ListIterator<E> listIterator () ListIterator<E> listIterator (int index) Gibt einen Listen-Iterator über der Elemente der Liste zurück (entweder ab Position 0 (ohne Parameter) oder ab Position index (mit Parameter index)). Mehr dazu in Abschnitt 9.4. Es gibt zwei vordefinierte generische Klassen die das List-Interface implementieren: Die Klasse ArrayList und die Klasse LinkedList. Bei ArrayList wird intern ein Array verwendet, so dass der wahlfreie Zugriff auf Elemente mit beliebigem Index sehr effektiv ist. Dagegen ist es bei großen Arrays uneffektiv, Elemente einzufügen oder zu löschen. Bei LinkedList wird intern eine doppelt verkettete Liste verwendet. Dadurch können sehr effektiv Elemente eingefügt oder gelöscht werden, aber der wahlfreie Zugriff auf Elemente mit beliebigem Index ist uneffektiv. Sowohl für ArrayList als auch für LinkedList gibt es Konstruktoren ohne Parameter und mit einer Liste als Parameter. Bei ArrayList gibt es zusätzlich einen Konstruktor mit int-Parameter, der die Anfangsgröße des interen Arrays angibt. Sehr nützlich ist in diesem Zusammenhang auch die statische Funktion List<T> Arrays.asList(T... a) in der Klasse Arrays. Diese hat als Parameter beliebig viele Werte eines Datentyps T (was durch die drei Punkte angedeutet wird) und liefert eine Liste vom Typ List<T> mit den Werten zurück. Dabei werden int-Werte automatisch in Integer-Objekte verpackt und andere primitive Datentypen in die entsprechenden Wrapper-Typen. Hier zwei kurze Programmauszüge zum Thema Listen: // Eine leere String-Liste: List<String> slist = new ArrayList<String> (); // Einige Strings hinzufügen: slist.add ("Ein"); slist.add ("Text"); // Kommt ans Ende der Liste slist.add (1, "kurzer"); // Kommt an Pos. 1 // Ausgabe der Liste: for (String s : slist) // Ausgabe: System.out.print (s + " "); // Ein kurzer Text // Eine Integer-Liste mit mehreren Werten erzeugen: List<Integer> ilist = new ArrayList<Integer> ( Arrays.asList (3, 5, 7, 9, 11, 13) ); // Die 9 (bei Index 4) aus der Liste löschen: ilist.remove (4); // Die 2 bei Index 0 einfügen: ilist.add (0, 2); // Ausgabe der Liste: for (Integer i : ilist) // Ausgabe: 96 9 Collections 9.4 Iteratoren System.out.print (i + " "); // 2 3 5 7 11 13 Zu beachten ist hierbei dass als Datentyp von slist List<String> angegeben wurde und nicht ArrayList<String>, während das konkrete ListenObjekt (new ArrayList<String>()) eine ArrayList ist. Dadurch lässt sich das Programm ganz leicht auf eine LinkedList umstellen. Man muss nur als konkretes Listen-Objekt new LinkedList<String>() angeben. Durch diese Technik erreicht man eine gewisse Unabhängigkeit des Programms von der Listen-Implementierung. Sehr praktisch ist, dass die for-each-Schleife (mit dem Doppelpunkt) zum Durchlaufen der Liste verwendet werden kann. 9.4 Iteratoren Ein Iterator ist ein Objekt, das dazu dient, eine Collection zu durchlaufen. Allerdings ist Iterator keine Klasse, sondern ein (generisches) Interface. Es gibt auch keine vordefinierten Klassen, die das Iterator-Interface implementieren, sondern konkrete Iterator-Objekte werden von der Methode iterator() (die im Collection-Interface definiert ist) zurückgeliefert. Das generische Interface Iterator<E> hat nur drei Methoden: • boolean hasNext () Gibt true zurück, wenn die Collection noch nicht komplett durchlaufen wurde, d. h. wenn noch weitere Elemente durchlaufen werden können. Ist der Iterator schon am Ende angekommen, so wird false zurückgegeben. • E next () Liefert das nächste Elemente aus der Collection zurück und bewegt sich eine Position weiter. • void remove () Entfernt das Element, das der letzte Aufruf von next() zurückgegeben hat. Unter Verwendung der ersten beiden Methoden lässt sich eine Collection mit einem Iterator nach folgendem Schema durchlaufen: // E steht für einen beliebigen Referenztyp Collection<E> coll = . . . Iterator<E> iter = c.iterator (); // Collection erzeugen // Iterator zu coll while (iter.hasNext ()) // solange noch Elemente vorhanden { E elem = iter.next (); // nächstes Element holen // tue etwas mit elem } Dies lässt sich mit einer for-each-Schleife abkürzen, was aber äquivalent zu der obigen while-Schleife ist: 97 9 Collections 9.4 Iteratoren // E steht für einen beliebigen Referenztyp Collection<E> coll = . . . for (E elem : coll) { // Collection erzeugen // läuft über komplette Collection // tue etwas mit elem } Dabei wird implitzit auch ein Iterator verwendet. In der ausführlichen Schreibweise mit while-Schleife lässt sich das oben angeführte String-Beispiel so schreiben: // Eine leere String-Liste: List<String> slist = new ArrayList<String> (); // Einige Strings hinzufügen: slist.add ("Ein"); slist.add ("Text"); // Kommt ans Ende der Liste slist.add (1, "kurzer"); // Kommt an Pos. 1 // Ausgabe der Liste: Iterator<String> iter = slist.iterator (); while (iter.hasNext) { String s = iter.next (); // Ausgabe: System.out.print (s + " "); // Ein kurzer Text } 98 Die wichtigsten Interfaces und Klassen des Collection-Frameworks 9 Collections 9.4 Iteratoren 99