Javakurs2006/Tag 2/Vorlesung 2
Inhaltsverzeichnis
Kleine Wiederholung
Klassen beinhalten zusammengehörige Daten und Methoden. Mit new erzeugt man Instanzen dieser Klassen. Jede Instanz kann eigene Werte der zugehörigen Daten enthalten. Ein Teil der im Quellcode dieses Textes enthaltenen Kommentare erklären Syntax und hätten in einem "echten" Programm nichts zu suchen.
//file: Apfel.java class Apfel { String farbe; int gewicht; //in Gramm Apfel (String farb, int gew) { //Konstuktor farbe = farb; //eigentlich this.farbe =farb, ist aber unnötig gewicht = gew; } void essen() { //eine Methode gewicht = 0; } } //file: Beispiel.java public Beispiel { public static void main( String args[] ) { Apfel boskop1 = new Apfel("rot", 175); Apfel boskop2 = new Apfel("grün", 180); System.out.println("boskop1 (" + boskop1.farbe + "): " + boskop1.gewicht + " gramm"); System.out.println("boskop2 (" + boskop2.farbe + "): " + boskop2.gewicht + "gramm"); } }
Vererbung
In objektorientierten Programmiersprachen gibt es ein Konzept, dass sich Vererbung nennt. Eine Klasse erbt dann das Verhalten (also die Methoden) und auch die Daten von einer anderen Klasse. In Java benutzt man zum Vererben das Schlüsselwort extends.
//File: Obst.java class Obst { String farbe; int gewicht; //in Gramm void essen() { gewicht = 0; } } //File: Apfel.java class Apfel extends Obst { String geerntetIn; void schaelen() { gewicht = gewicht - 10; } }
Die Klasse Apfel im Beispiel hat alle Methoden und Daten von der Klasse Obst geerbt. Das heißt Instanzen dieser Klasse besitzen eine Farbe, ein Gewicht und man kann sie essen.
Wer mehr über Objektorientierung und Vererbung erfahren möchte, kann entweder eins der unzähligen verfügbaren Bücher zu dem Thema zur Hand nehmen. Alternativ könnte man auch diesen link (http://java.sun.com/docs/books/tutorial/java/concepts/index.html) verfolgen.
Die Klasse Object
In Java gibt es eine Klasse Object, von der jede andere Klasse abgeleitet ist. Wenn man kein extends hinschreibt, wird vom Compiler sozusagen das "extends Object" ergänzt. Dies geschieht intern, also ohne Veränderung in eurem Quellcode.
Einige Methoden von Object sind für uns sehr nützlich, weshalb sie hier kurz vorgestellt werden sollen. Doch eins noch zum Wort Objekt: Die Bezeichnung Objekt wird oftmals sowohl für Klassen als auch für Instanzen von Klassen benutzt, deshalb immer schauen welche Bedeutung wohl gerade gemeint ist.
Im folgenden wird hier Objekt in der Bedeutung von Instanz benutzt und Object, wenn die besondere Klasse Object gemeint ist.
Die Methode toString()
Nun aber zu den genannten nützlichen Mehtoden: Die Methode toString() liefert einen String, der die Instanz beschreibt. Doch woher soll die Methode (die ja in Object angelegt wurde) wissen, wie man unsere spezielle Instanz treffend in einem String beschreibt?
Dazu kann man die Methode überschreiben. Man macht das, indem man sie einfach in der eigenen Klasse noch einmal definiert.
//File: Apfel.java class Apfel extends Obst { String geerntetIn; void schaelen() { gewicht = gewicht - 10; } String toString() { String description = "Ein Apfel, geerntet in " + geerntetIn; return description; } }
Ruft man nun auf einer Instanz von Apfel toString auf, so wird die in Apfel definierte Version benutzt. Die Methode toString() ist unter anderem deshalb so wichtig, weil sie von System.out.println() benutzt wird, um Instanzen beliebiger Nachfahren von Object auf der Konsole auszugeben. Die Methode printMe() aus der vorhergehenden Übung hatte die gleiche Funktion. Nur arbeitet println() mit toString(), weshalb es sinnvoll wäre printMe() in toString() umzubenennen.
Die Methode equals(Object o)
Eine weitere wichtige Methode von Object ist equals(Object o). Hiermit kann, wie der Name schon sagt die Gleichheit zweier Objekte festgestellt werden. Nun wird sich vielleicht der ein oder andere fragen, warum man nicht den Vergleichsoperator == benutzt, sondern eine eigene Methode dafür schreibt. Warum das so ist, soll an einem Codebeispiel verdeutlicht werden.
public static void main(String []args) { Apfel apfel1 = new Apfel(); apfel1.farbe = "grün"; apfel1.geerntetIn = "Berlin"; apfel1.gewicht = 215; Apfel apfel2 = new Apfel(); apfel2.farbe = "grün"; apfel2.geerntetIn = "Brandenburg"; apfel2.gewicht = 215; Apfel apfel3 = apfel2; }
Der Operator == gibt genau dann true zurück, wenn die Werte der beiden verglichenen Dinge identisch sind. Bei eingebauten Datentypen (int, double) verhält er sich wie erwartet. Der Wert eines Objektes hingegen ist die Identität des Objektes, und nicht sein Inhalt. Deshalb würde apfel1 == apfel2 false zurückgeben, weil es sich um zwei verschiedene Objekte handelt. Wir hätten aber gern eine Methode, die uns sagt, dass diese Äpfel insofern gleich sind, dass sie die gleichen Eigenschaften besitzen. Dabei ist zu beachten, dass wir für die Gleichheit zweier Äpfel hier nur das Gewicht und die Farbe heranziehen.Genau dies sollte equals realisieren.
class Apfel extends Obst { String geerntetIn; void schaelen() { gewicht = gewicht - 10; } String toString() { String description = "Ein Apfel, geerntet in " + geerntetIn; return description; } boolean equals(Object o) { Apfel andererApfel = (Apfel)o; return farbe.equals(andererApfel.farbe) && gewicht == andererApfel.gewicht; } }
Im Beispiel für equals gibt es gleich zwei Dinge, die einer weiteren Erklärung bedürfen: In der ersten Zeile machen wir aus der Intanz von Object, mit dem wir vergleichen einen Apfel. Diesen Vorgang nennt man casten. Das Programm wird an dieser Stelle mit einer entsprechenden Fehlermeldung beendet, wenn der Parameter o kein Apfel ist. Das ist zwar nicht schön, macht aber trotzdem Sinn, da eine Gleichheit zwischen Äpfeln und beliebigen Objekten nicht zwangsläufig definiert ist. Dies ist zugegebenermaßen noch nicht ganz zufriedenstellend, da das Programm einfach beendet würde, wenn Apfel mit Autos zu vergleichen, obwohl man sicher sagen könnte, dass die beiden unterschiedlich sind. Wer hierzu noch etwas mehr lesen möchte, kann sich hier (http://www.galileocomputing.de/openbook/javainsel3/javainsel_030003.htm#Rxxjavainsel_030003281GleichheitvonObjektenunddieMethodeequals) vertiefen.
Die zweite Sache, die auffällt: das Gewicht wird mit == verglichen, warum nicht auch mit equals? Die Antwort darauf ist eigentlich recht einfach: gewicht ist vom Typ int, der ein primitiver Datentyp ist. Damit erbt er nicht von Object, und bietet demnach auch keine Methode equals an. Wie oben schon erwähnt liefert == bei primitiven Datentypen ja auch das gewünschte Ergebnis.
Eine Anmerkung hierzu ist noch wichtig: Die oben vorgestellte Implementierung von equals funktioniert für unsere Zwecke. Dennoch ist sie unsauber und erfüllt nicht die Anforderungen, die sun in seiner Klassenbibliothek an equals stellt. Dies genauer zu erläutern würde den Rahmen unseres Kurses sprengen, näheres dazu findet man unter anderem im Link zwei Absätze höher. Die genauen Anforderungen an die Implementierung der Methode equals sind zu finden in der Java API Referenz (http://java.sun.com/j2se/1.4.2/docs/api/java/lang/Object.html).
Comparable
Manchmal reicht es nicht aus, zu wissen zwei Objekte gleich sind. Oft wollen wir Objekte ordnen, wozu wir eine Funktion brauchen die uns mitteilt welches von zwei Objekten "größer" ist. Eine solche Funktion ist compareTo(Object o). Im Gegensatz zu equals und toString ist sie allerdings keine Methode von Object, da es Klassen gibt, deren Objekte man nicht ordnen kann. Ein Beispiel hierfür wären Farbtöne, für die es schwer fällt eine natürliche Ordnung zu finden. Nun könnten wir für unsere eigenen Klassen, wo das Ordnen Sinn macht einfach ein compareTo implementieren. Würden wir dies einfach so tun könnten wir allerdings nicht einen Algorithmus benutzen, den jemand anderes zum Ordnen seiner eigenen Klasse, die compareTo implementiert geschrieben hat:
//File: MyClass.java class MyClass { ... compareTo(MyClass o) { ... } ... } //File: SomeonesClass.java class SomeonesClass { ... compareTo(SomeonesClass o) { ... } ... } //File Utility.java class Utility { static sort(SomeonesClass daten[]) { //sortiere daten mittels compareTo } }
Hier kann man kein array vom Typ MyClass[] als parameter von sort übergeben, da es sich um zwei verschiedene Typen handelt. Um dieses Manko zu umgehen gibt es in Java Interfaces. Sie stellen eine Vereinbarung darüber dar, welche Methoden eine Klasse anbietet. Im Gegensatz zur Vererbung gibt es aber hier beim Interface noch keine Vorgabe, wie diese Methoden funktionieren. Das Interface schreibt nur die Namen, Art der Parameter und des Rückgabewertes von Methoden vor. Comparable ist ein solches Interface, dass schon in der Java API vorgegeben ist. Es schreibt vor, dass Klassen die dieses Interface implementieren die Methode int compareTo(Object o) anbieten müssen.
Modifizieren wir also unser Beispiel so, dass wir das Interface Comparable nutzen.
//File: MyClass.java class MyClass implements Comparable{ ... compareTo(Object o) { MyClass parameter = (MyClass)o; ... } ... } //File: SomeonesClass.java class SomeonesClass implements Comparable{ ... compareTo(Object o) { SomeonesClass parameter = (SomeonesClass)o; ... } ... } //File Utility.java class Utility { static sort(Comparable daten[]) { //sortiere daten mittels compareTo } }
Nun kann die Methode sort beliebige arrays übergeben bekommen, deren Inhalt das Interface Comparable implementiert. Darüber wird dieser Mehtode zugesichert, dass jedes Element dieses arrays über die Methode compareTo verfügt und somit sich mit anderen Elementen des arrays vergleichen lässt.
Noch ein paar Worte zum compareTo selbst: Diese Methode liefert uns als Rückgabewert eine ganze Zahl. Ist diese Zahl kleiner als 0, so ist die Instanz, auf der sie aufgerufen wurde kleiner als der übergebene Parameter (this < o). Ist der Rückgabewert genau 0, so ist this gleich o. Letzlich ist this größer als o, wenn der Rückgabewert von compareTo größer als 0 ist. Auch an die Methode compareTo gibt es in der API Referenz definierte Andorderungen (http://java.sun.com/j2se/1.4.2/docs/api/java/lang/Comparable.html) , die wir im Rahmen unserer Veranstaltung aber außen vor lassen wollen.
Java API - Javas Klassenbibliothek
Ein großer Vorteil von Java gegenüber anderen Programmiersprachen besteht darin, dass es eine Vielzahl vereinheitlichter und schon mitgelieferter Klassen und Interfaces gibt. Die Sammlung dieser Klassen und Interfaces nennt man Java API. Es gibt auf der Website von Sun(java.sun.com) eine sehr umfassende Dokumentation dieser API(http://java.sun.com/j2se/1.4.2/docs/api/), die allerdings nicht immer einfach zu bedienen ist. Wenn man weiß, nach welcher Klasse man sucht findet man meist recht schnell die gewünschten Informationen. Hat man aber nur eine vage Idee, was die Klasse tun soll muss man meist noch andere Quellen nutzen, um an den Namen der gesuchten Klasse heranzukommen. Hierzu bieten sich natürlich Suchmaschinen wie google an.
Im Folgenden wollen wir einige besonders Häufig verwendete Module und Konzepte der Java API vorstellen. Wie man ein solches Modul in eigenen Programmen verwendet, werden wir im ersten vorgestellten Modul etwas näher beleuchten.
Math
Das Modul Math bietet, wenig überraschend, alle möglichen Methoden und Konstanten die man für mathematische Berechnungen brauchen könnte. Einige Konstanten sind zum Beispiel
public static final double E; public static final double PI;
Diese beiden Konstanten sind jeweils mit drei zusätzlichen Schlüsselwörtern gekennzeichnet, die hier zumindest grob erklärt werden sollen.
public bedeutet, dass die entsprechende Variable oder Methode auch außerhalb der definierenden Klasse und außerhalb von Math benutzt werden kann.
Von Daten, die satic definiert sind hat nicht jede Instanz eine eigene Ausprägung, sondern alle Instanzen der Klasse teilen sich diese Variable. Mehtoden die static definiert sind, brauchen keine Instanz der Klasse um aufgerufen zu werden. Demnach kennen sie allerdings auch this nicht und können daher nicht auf Daten einer Instanz der Klasse arbeiten.
Das Schlüsselwort final an einer Variable macht diese zur Konstante. Das heißt sie wird einmal bei der Deklaration festgelegt und kann dann nicht mehr verändert werden.
Die einzelnen Methoden, die Math bietet sollen hier nicht im Detail besprochen werden, da sie recht einfach in der API-Dokumentation von Java zu finden sind. Dennoch soll anhand des Sinus ein Aufruf einer Methode aus Math hier gezeigt werden, um die nötige Syntax zu zeigen.
public static void main(String args[]) { System.out.print( Math.sin(Math.PI / 2) ); }
Ausgabe: 0
Eine sehr nützliche Methode aus Math soll hier noch kurz vorgestellt werden, da man sie nicht unbedingt hier vermuten würde: Die Methode random liefert ohne Parameter zu erwarten eine Zufallszahl zwischen 0 und 1. Mit Hilfe dieser Methode kann man gleichverteilte und beliebige Zufallszahlen in den Bereichen von float und int erzeugen.
public static void main(String args[]) { double random1 = Math.random(); //erzeugt eine Zahl im Bereich [0;1) double random2 = Math.random() * 30 + 5; //erzeugt eine Zahl im Bereich [5;34) int random3 = (int) (Math.random() * 13); //erzeugt eine natürliche Zahl im Bereich [0;12] }
Streams
Ein weiteres nützliches Konzept, dass die Java API uns bietet sind Streams. Streams (zu deutsch: Datenströme) beschreiben Datenquellen oder Ziele, an die man Daten schicken kann. Dabei wird von der tatsächlichen Datenquelle oder vom tatsächlichen Ziel abstrahiert, um für alle "Sender" oder "Empfänger" von Daten die gleichen Mehtoden nutzen zu können. So können sich hinter einem Datenstrom beispielsweise eine Datei, die von der Tastatur kommenden Zeichen oder aber eine Netzwerkverbindung verbergen. Der Programmierer muss sich aber um diese Details, wenn er erstmal ein entsprechendes Datenstrom-Objekt erstellt hat keine Gedanken mehr machen, weil er ebend auf einem Datenstrom arbeitet und nicht auf einer bestimmten Datenquelle. Hier ein paar Beispiele, wie man verschiedene Datenströme erstellt. Dabei ist generell zwischen InputStream und OutputStream zu unterscheiden, wobei der erstere Daten von außen an unser Programm heranträgt und der zweite Daten von unserem Programm an einen Empfänger übermittelt
import java.io.* //prints the ascii code for a specified number of keys pressed on the keyboard to the console static void printAsciiCodes(int keysToPrint) { InputStream keyboard = System.in; //get standard input stream (the keyboard) for (int i = 0; i < keysToPrint; i++) { int asciiCode = keyboard.read(); //wait for a key to be pressed System.out.print(asciiCode + " "); //print it's ascii code } } //prints the content of a file given by Name to the console bytewise static void printFile(String fileName) { InputStream file = new FileInputStream(fileName); //open a stream representing the file int byteRead = file.read(); //read first byte from the file while (byteRead != -1) { //while there is something to read System.out.print(byteRead + " "); //print the byte we read at last byteRead = file.read(); //read a new byte } }
Im Beispiel kann man schön sein, dass der Mechanismus zum lesen eines bytes bei beiden Datenströmen der gleiche ist. Lediglich in der Erstellung des Datenstromes gibt es je nach Quelle Unterschiede. Die Funktionsweise ist für Ausgabeströme recht ähnlich. Nähere Informationen dazu findet man in der API-Reference, wenn man nach den Klassen OutputStream und FileOutputStream sucht.
BufferedReader
Im letzten Abschnitt haben wir gesehen, wie leicht es ist mit Hilfe von Streams Bytes aus verschiedenen Quellen zu lesen. Oftmals wollen wir aber keine einzelnen Bytes lesen, sondern wissen dass unsere Datenquelle uns einen Text liefert. Da Text meist in Zeilen strukturiert ist, wäre es schön eine Funktion zu haben die Zeilenweise Strings aus unserem Stream liest. Auch hierfür bietet uns die Java Klassenbibiliothek bereits eine Lösung: den BufferedReader. Seine readLine Methode liest eine Zeile aus. Das Problem an dem BufferedReader ist, dass wir zu seiner Instantiierung einen Reader benötigen. Schauen wir in der API Dokumentation nach, welche Arten von Readern es gibt, so fällt der InputStreamReader auf. Schaut man sich diesen genauer an, so enthält er auch einen Konstruktor, der einen InputStream als Parameter akzeptiert.
Nun bleibt es unsere Aufgabe zuerst einen Datenstrom aus unserer Datenquelle zu erzeugen, dann darauf einen Reader zu konstruieren um am Schluß darauf einen BufferedReader zu setzen. Dieses Vorgehen wirkt zwar etwas klobig, doch es gibt in der Klassenbibliothek bisher (bis Java 1.4.2) keine bessere Lösung dafür. Es folgt ein kleines Beispiel, dass den Inhalt einer Textdatei zeilenweise auf der Konsole ausgibt.
import java.io.*; static void printTextFile(String fileName) { InputStream fileStream = new FileInputStream(fileName); Reader reader = new InputStreamReader(fileStream); //wrap a Reader around the InputStream BufferedReader lineReader = new BufferedReader(reader); //wrap a BufferedReader around the Reader String line = lineReader.readLine(); while (line != null) { //if readLine returns null, file end is reached System.out.println(line); line = lineReader.readLine(); } }
Exceptions
Versucht man die Programme aus den letzten beiden Abschnitten zu compilieren, so wird man feststellen dass sie nicht lauffähig sind. Mit der Tatsache, dass Streams so universell sind, handelt man sich natürlich auch alle möglichen Probleme der verschiedenen Medien, die ein Stream verstecken kann, ein. Um die Behandlung dessen zu vereinfachen und dem Programmierer die Chance zu geben an der richtigen Stelle auf Fehler zu reagieren gibt es Exceptions. An dieser Stelle soll es genügen zu erkennen, wann man von einer Methode erwarten muss, dass sie eine Exception auslöst und wie man damit umgeht.
Methoden, die eine Exception auslösen können müssen dies ihrer Umgebung mitteilen. Dies geschieht, mit dem Schlüsselwort throws gefolgt von einer kommaseparierten Liste aller Exceptions, die sie auslösen können.
public class InputStream { ... public int read() throws IOException { ... } ... }
Bei den Klassen aus der Java Klassenbibliothek kann man der API-Reference entnehmen, welche Exceptions sie auslösen können. Jede mögliche Exception muss behandelt werden, dazu gibt es zwei Wege.Der erste funktioniert folgendermaßen: Man fasst den Teil einer Mehtode, der eine Exception auslösen kann in ein try-catch-Konstrukt ein. Wie dies aussieht soll gleich am Beispiel der Methode printTextFile gezeigt werden.
import java.io.*; static void printTextFile(String fileName) { InputStream fileStream = null; try { fileStream = new FileInputStream(fileName); } catch (FileNotFoundException) { System.out.println("The file " + fileName + " could not be found."); return; //stop execution of method... cannot print from non existing file } Reader reader = new InputStreamReader(fileStream); //wrap a Reader around the InputStream BufferedReader lineReader = new BufferedReader(reader); //wrap a BufferedReader around the Reader try { String line = lineReader.readLine(); while (line != null) { //if readLine returns null, file end is reached System.out.println(line); line = lineReader.readLine(); } } catch (IOException e) { System.out.println("An unknown IOException occured while reading the file."); } catch (EOFException e) { System.out.println("Unexpected end of File found."); } }
Jetzt würde der Javacompiler die Methode compilieren. Sollte eine Exception auftreten, so wird dies dem Benutzer auf der Konsole mitgeteilt und die Ausführung der Methode abgebrochen. Der try-catch-Mechanismus funktioniert folgendermaßen: Wenn in dem Block zwischen try und catch eine Exception auftritt, wird die Ausführung des Blocks abgebrochen und es geht im passenden catch-Block weiter. Dabei ist passend derjenige Block, dessen gefangene Klasse derjenige Vorfahre der tatsächlich ausgelösten Exceptionklasse ist, der in der Vererbungshierarchie am nächsten daran ist.
Man sollte die Blöcke zwischen try und catch so klein wie sinnvoll möglich halten. Insbesondere sollte man sich davor hüten, ganze Methoden in ein großes try-catch einzubauen, weil dann die Fehlerbehandlung ungenau und die Fehlersuche schwieriger wird. Eine gute Möglichkeit zum Fehler finden ist es, im catch Block die Methode printStackTrace der Exception aufzurufen. Andererseits sollte in einem fertigen Programm das try-catch Konzept nur dann genutzt werden, wenn man auch tatsächlich den Fehler behandeln kann.
Aus dieser Anforderung ergibt sich Notwendigkeit, die zweite Möglichkeit Exceptions zu behandeln zu benutzen. Hier werden auftretende Exceptions nicht per try-catch gefangen, weil man ebend keine sinnvolle Fehlerbehandlung im catch-Block anbieten kann. Dem Nutzer unserer Funktion muss man dies mitteilen, indem man dies hinter der Parameterliste per throws ankündigt. Wird dann in unserer Methode eine entsprechende Exception ausgelöst und nicht behandelt, so wird sie einfach an die aufrufende Methode "weitergeworfen".
Weiter Informationen für Interessierte gibt es hier (http://java.sun.com/docs/books/tutorial/essential/exceptions/index.html).
Das wars zu diesem Thema. Falls jemand so weit durchgehalten hat, danken wir für die Aufmerksamkeit :) Wir hoffen, dass ihr jetzt ein klein wenig mehr mit den Mechanismen die Java anbietet vertraut seid und regen Gebrauch von der großen Klassenbibliothek machen werdet.
TODOs
eventuell Konstruktor für Apfel und Obst überall einbauen und nutzen
Link für Streams und "Aufsätze"