2.7 Methoden einer Klasse
In objektorientierten Programmen interagieren zur Laufzeit Objekte miteinander und senden sich gegenseitig Nachrichten als Aufforderung, etwas zu machen. Diese Aufforderungen resultieren in einem Methodenaufruf, in dem Anweisungen stehen, die dann ausgeführt werden. Das Angebot eines Objekts, das, was es »kann«, wird in Java durch Methoden ausgedrückt. Die Begriffe »Methode« und »Funktion« wollen wir in diesem Tutorial gleichwertig benutzen.
Wir haben schon mindestens eine Methode kennen gelernt: println(). Sie ist eine Methode vom out-Objekt. Ein anderes Programmstück schickt nun eine Nachricht an das out-Objekt, die println()-Methode auszuführen. Im Folgenden werden wir den aktiven Teil des Nachrichtenversendens nicht mehr so genau betrachten, sondern wir sagen nur noch, dass eine Methode aufgerufen wird.
Die Operationen einer Klasse, also das Angebot eines Objekts, sind ein Grund für Funktionsdeklarationen in einer objektorientierten Programmiersprache. Daneben gibt es aber noch weitere Gründe, die für Methoden sprechen:
|
Komplexe Programme werden in kleine Teilprogramme zerlegt, damit die Komplexität des Programms heruntergebrochen wird. Damit ist der Kontrollfluss leichter zu erkennen. In klassischen Programmen heißen die Methoden daher auch Unterprogramme. Dieses Wort wollen wir hier allerdings nicht benutzen. |
|
Wiederkehrende Programmteile sollen nicht immer wieder programmiert, sondern an einer Stelle angeboten werden. Änderungen an der Funktionalität lassen sich dann leichter durchführen, wenn der Code lokal zusammengefasst ist. |
2.7.1 Bestandteil einer Funktion
Eine Funktion besteht aus mehreren Bestandteilen. Dazu gehören der Methodenkopf (kurz Kopf) und der Methodenrumpf (kurz Rumpf). Der Kopf besteht aus einem Rückgabetyp (auch Ergebnistyp genannt), dem Funktionsnamen und einer optionalen Parameterliste. Der Methodenname, die Parameter und die Typen der Parameter definieren die Signatur einer Methode - der Rückgabetyp gehört nicht dazu1. Pro Klasse darf es nur eine Methode mit derselben Signatur geben, sonst meldet der Compiler einen Fehler.
Beispiel Die Funktionen
Object habHunger( Object o )
|
und
void habHunger( Object p )
haben die Signatur (habHunger, Object), werden vom Compiler als gleich angesehen und können deshalb nicht zusammen in einer Klasse vorkommen.
|
Schauen wir uns noch zwei Beispiele aus der Java-API an.
Beispiel Betrachten wir eine Funktion, die es schon gibt und die in der API-Hilfe bei Math dokumentiert ist.
|
public static double max(double a, double b)
Returns the greater of two double values. That is, the result is the argument closer to positive infinity. If the arguments have the same value, the result is that same value. If either value is NaN, then the result is NaN. Unlike the numerical comparison operators, this method considers negative zero to be strictly smaller than positive zero. If one argument is positive zero and the other negative zero, the result is positive zero: |
Parameters:
a - a double value.
b - a double value.
Returns:
the larger of a and b.
|
Die Hilfe gibt Informationen über die komplette Signatur der Methode. Der Rückgabetyp ist ein double, die Funktion heißt max(), und sie erwartet genau zwei double-Zahlen. Verschwiegen haben wir die Schlüsselwörter public static, die so genannten Modifzierer. public gibt die Sichtbarkeit an und sagt, wer diese Funktion nutzen kann. Im Fall von public bedeutet es, dass jeder diese Funktion verwenden kann. Das Gegenteil ist private. Dann kann nur das Objekt selbst diese Funktion nutzen. Das ist sinnvoll in dem Fall, wenn Funktionen benutzt werden, um die Komplexität zu verkleinern und Teilprobleme zu lösen. Private Funktionen werden in der Regel nicht in der Hilfe angezeigt. Das Schlüsselwort static zeigt an, dass sich die Funktion mit dem Klassennamen nutzen lässt, also kein Exemplar eines Objekts nötig ist.
Beispiel Es gibt Funktionen, die noch andere Modifizierer und eine erweiterte Signatur besitzen. Ein weiteres Beispiel aus der API:
|
protected final void implAccept(Socket s)
throws IOException
Subclasses of ServerSocket use this method to override accept() to return their own subclass of socket. So a FooServerSocket will typically hand this method an empty FooSocket. On return from implAccept the FooSocket will be connected to a client: |
|
Parameters:
s - the Socket
Throws:
IOException - if an I/O error occurs when waiting for a connection.
Since:
JDK1.1
|
Die Sichtbarkeit dieser Funktion ist protected. Das bedeutet, nur abgeleitete Klassen und Klassen im gleichen Verzeichnis (Paket) können diese Funktion nutzen. Ein zusätzlicher Modifizierer ist final, der in einer Vererbung der Unterklasse nicht erlaubt, die Funktion zu überschreiben und ihr neuen Programmcode zu geben. Zum Schluss folgt hinter dem Schlüsselwort throw eine Ausnahme. Diese sagt etwas darüber aus, welche Fehler die Funktion verursachen kann und worum sich der Programmierer kümmern muss. Im Zusammenhang mit der Vererbung werden wir noch über protected und final sprechen. Der Ausnahmebehandlung widmet sich ein eigenes Kapitel.
2.7.2 Aufruf
Da eine Funktion immer zu einer Klasse gehört, muss deutlich sein, zu wem die Methode gehört. Im Fall von System.out.println() ist println() eine Methode vom out-Objekt. Wenn wir das Maximum zweier Gleitkommazahlen mit Math.max(a, b) bilden, dann ist max() eine Funktion der Klasse Math. Für den Aufrufer ist damit immer ersichtlich, wer diese Methode anbietet, also auch, wer diese Nachricht entgegennimmt. Was der Aufrufer nicht sieht, ist die Arbeitsweise der Funktion. Der Funktionsaufruf verzweigt in den Programmcode, aber der Aufrufer weiß nicht, was dort geschieht. Er betrachtet nur das Ergebnis.
Die aufgerufene Funktion wird mit ihrem Namen genannt. Die Parameterliste wird durch ein Klammerpaar umschlossen. Diese Klammern müssen auch dann gesetzt werden, wenn die Methode keine Parameter enthält. Eine Funktion wie System.out.println() gibt nichts als Ergebnis einer Berechnung zurück. Anders ist die Funktion max(); sie liefert ein Ergebnis. Damit ergeben sich vier unterschiedliche Funktionentypen:
Funktion
|
Ohne Rückgabewert
|
Mit Rückgabewert
|
ohne Parameter
|
System.out.println()
|
System.currentTimeMillis()
|
mit Parameter
|
System.out.println(4)
|
Math.max(12, 33)
|
Tabelle 2.8 Funktionen mit Rückgabewerten und Parametern
Die Methode System.currentTimeMillis() gibt die Anzahl der verstrichenen Millisekunden ab dem 1.1.1970 als long zurück.
2.7.3 Methoden ohne Parameter
Die einfachste Funktion besitzt keinen Rückgabewert und keine Parameter. Im mathematischen Sinn ist dann vielleicht auch der Name »Funktion« falsch, wenn sie keinen Wert zurückliefert, aber das soll uns nicht kümmern. Im klassischen Sinn ist dieser Typ von Funktion unter dem Namen »Prozedur« bekannt, die von der Aufgabe abstrahiert, indem sie Funktionalität hinter einem Namen verbirgt. Der Begriff »Prozedur« ist jedoch in der Objektwelt nicht anzutreffen.
Der Funktionscode wird in geschweiften Klammern hinter den Kopf geschrieben und bildet damit den Körper der Methode. Gibt eine Funktion nichts zurück (das mathematische Dilemma), dann wird void vor dem Funktionsnamen geschrieben. Falls die Funktion etwas zurückgibt wird der Typ der Rückgabe anstelle von void geschrieben. An dieser Stelle sollte bemerkt werden, dass void in Java kein Typ ist.
Beispiel Eine Funktion ohne Rückgabe und Parameter, die einfach etwas auf dem Bildschirm ausgibt.
Listing 2.7 SimpleFunction.java
class SimpleFunction
{
static void tollHier()
{
System.out.println( "Toll hier im Java-Land" );
}
public static void main( String args[] )
{
tollHier();
}
|
Am Aufruf der Funktion lässt sich ablesen, dass hier kein Objekt gefordert ist, das mit der Methode verbunden werden soll. Das ist möglich, denn die Funktion ist als static deklariert, und innerhalb der Klasse lassen sich alle Funktionen einfach mit ihrem Namen nutzen.
Tipp Die Vergabe eines Funktionsnamens ist gar nicht so einfach. Nehmen wir zum Beispiel an, wir wollen eine Funktion schreiben, die eine Datei kopiert. Spontan kommen uns zwei Wörter in den Sinn, die zu einem Funktionsnamen verbunden werden wollen: »file« und »copy«. Doch in welcher Kombination? Soll die Funktion copyFile() oder fileCopy() heißen? In Fällen dieses Konflikts solle das Verb die Aktion anführen, unserer Wahl also auf copyFile() fallen. Funktionsnamen sollte immer das Tu-Wort vorne haben und das Was, das Objekt, an zweiter Stelle.
|
Hier klicken, um das Bild zu Vergrößern
Eine gedrückte (Strg)-Taste und ein Mausklick auf einen Bezeichner lässt Eclipse zur Deklaration springen. Ein Druck auf (F3) hat den gleichen Effekt. Steht der Cursor in unserem Beispiel auf dem Methodenaufruf tollHier(), und (F3) wird gedrückt, dann springt Eclipse zur Definition in Zeile 3 und hebt den Funktionsnamen hervor.
2.7.4 Statische Methoden (Klassenmethoden)
Bisher arbeiten wir nur mit statischen Methoden (auch Klassenmethoden genannt), also mit Methoden, die ohne ein erzeugtes Objekt auskommen. Leicht fällt das Schlüsselwort static unter den Tisch, denn static muss nicht zwingend vor der Methodendeklaration stehen - sondern nur dann, wenn wir eine Methode aus einer anderen statischen Methode wie main() nutzen wollen. Fehlt static, so erzeugt der Compiler etwa folgende Fehlermeldung: »non-static method hui() cannot be referenced from a static context«.
2.7.5 Parameter und Wertübergabe
Einer Funktion können Werte übergeben werden, die sie dann in ihre Arbeitsweise einbeziehen kann. Der Funktion println(2001) ist zum Beispiel ein Wert übergeben worden. Sie wird damit zur parametrisierten Funktion.
Beispiel Werfen wir einen Blick auf die Funktionsdefinition max() für Gleitkommazahlen. Die Methode soll später den größeren Wert auf dem Bildschirm ausgeben.
static void max( double a, double b )
{
// Hier kommt die Implementierung hinein.
}
|
Der Bezeichner, der innerhalb der Methode verwendet wird, um den übergebenen Wert anzusprechen, heißt formaler Parameter. a und b sind in unserem Beispiel die formalen Parameter. Sie werden mit dem Komma getrennt aufgelistet. Für jeden Parameter muss ein Typ angegeben sein, und eine Abkürzung wie bei der Variablendeklaration Typ V1,V2 ist nicht möglich. Jeder Parameter muss mit seinem eigenen Typ aufgelistet werden. Mehrere Bezeichner dürfen nicht den gleichen Namen tragen, andernfalls ergibt sich ein Übersetzungsfehler.
Der Aufrufer der Funktion gibt für jeden Parameter ein Argument an. Rufen wir unsere Methode max() etwa mit max(10, y) auf, so ist das Literal 10 und die Variable y ein Argument und ein aktueller Parameter der Funktion. Die Argumente müssen vom Typ her passen. Die Ganzzahl 10 kann auf ein double konvertiert werden, und y muss ebenfalls automatisch angepasst werden können. Für die Typanpassung gelten die bekannten Regeln.
Hinweis Anzahl der Parameter: Im Gegensatz zu C(++) muss beim Aufruf der Funktion die Anzahl der Parameter exakt stimmen. Eine variable Parameteranzahl - wie in C(++) durch »...« angedeutet - ist in Java nicht möglich. Alle Parameter sind fest und folglich typsicher.
|
Wertübergabe: Copy by value
Wenn eine Funktion aufgerufen wird, dann gibt es in Java ein bestimmtes Verfahren, in dem jedes Argument einem Parameter übergeben wird. Diese Technik heißt im Allgemeinen Parameterübergabemechanismus und die meisten Programmiersprachen besitzen eine ganze Reihe von verwirrenden Möglichkeiten. Java definiert nur einen Mechanismus, der Wertübergabe (engl. copy by value) genannt wird. Der in der Methode definierte Parameter wird als lokale Variable betrachtet, die zum Zeitpunkt des Aufrufs mit dem Argument initialisiert ist. Das Ende des Blocks bedeutet dann auch das Ende für die Parametervariable.
Beispiel Die Implementierung der Funktion max():
static void max( double a, double b )
{
if ( a > b )
System.out.println( a );
else
System.out.println( b );
}
|
Innerhalb des Funktionskörpers nutzen wir einfach die übergebenen Werte über die Variable. Beim Aufruf werden die Werte des Arguments in die Variablen kopiert.
Der Wert von 10 gelangt in die Variable a, und der Inhalt von i wird ausgelesen und der Variablen b in der Methode zugänglich gemacht:
int i = 2;
max( 10, i );
Da der Wert der Variablen übergeben wird, heißt das insbesondere, dass es keine Übereinstimmung der Variablennamen geben muss. Die Variable i muss nicht b heißen. Wegen dieser Aufrufart kommt auch der Name »copy by value« zustande. Lediglich der Wert wird übergeben und kein Verweis auf die Variable, wie dies Referenzen in C++ tun.
Auswertung der Argumentenliste von links nach rechts
Bei einem Methodenaufruf werden erst alle Argumente ausgewertet und anschließend der Methode übergeben. Das bedeutet im Besonderen, dass Unterfunktionen ausgewertet und Zuweisungen gemacht werden können. Fehler führen dann zu einem Abbruch des Funktionsaufrufs. Bis zum Fehler werden alle Ausdrücke ausgewertet.
2.7.6 Methoden vorzeitig mit return beenden
Läuft eine Methode bis zum Ende durch, dann ist die Methode damit beendet und es geht zurück zum Aufrufer. In Abhängigkeit einer Bedingung kann eine Methode jedoch vor dem Ende des Ablaufs mit einer return-Anweisung beendet werden. Das ist nützlich bei Methoden, die in Abhängigkeit von Parametern vorzeitig aussteigen wollen. Wir können uns vorstellen, dass vor dem Ende der Funktion automatisch ein verstecktes return steht.
Beispiel Eine Methode soll die Wurzel einer Zahl auf dem Bildschirm ausgeben. Bei Zahlen kleiner null erscheint eine Meldung, und die Methode wird verlassen. Andernfalls wird die Wurzelberechnung gemacht:
|
static void sqrt( double d )
{
if ( d < 0 )
{
System.out.println( "Keine Wurzel aus negativen Zahlen!" );
return;
}
System.out.println( Math.sqrt( d ) );
}
Die Realisierung wäre auch mit einer else-Anweisung möglich gewesen.
|
Eigene Methoden können natürlich wie Standardfunktionen heißen, da sie zu unterschiedlichen Klassen gehören.
2.7.7 Nicht erreichbarer Quellcode bei Funktionen
Folgt direkt hinter einer return-Anweisung Quellcode, so ist dieser nicht erreichbar - im Sinne von nicht ausführbar. return beendet also immer die Methode und kehrt zum Aufrufer zurück. Folgt nach dem return noch Quelltext, meldet der Compiler einen Fehler. In manchen Fällen ist das jedoch gewollt. Soll etwa eine Methode in der Testphase nicht komplett durchlaufen, sondern in der Mitte beendet werden, so lässt sich Folgendes nicht schreiben:
void faul()
{
int i = 1;
return;
i = 2; // Fehler!
}
Reduzieren wir eine Anweisung bis auf das Nötigste, das Semikolon, so führt dies bisweilen zu amüsanten Ergebnissen:
void f() {
;
return;;
}
In diesem Beispiel sind zwei Null-Anweisungen enthalten. Eine vor dem return und eine dahinter. Doch das zweite Semikolon hinter dem return ist illegal, da es nicht erreichbarer Code ist.
2.7.8 Rückgabewerte
Funktionen wie die Math.max() liefern in Abhängigkeit von den Parametern ein Ergebnis zurück. Für den Aufrufer ist die Implementierung egal, er abstrahiert und nutzt lediglich die Methode als einen Ausdruck. Damit Methoden wie echte Funktionen Rückgabewerte an den Aufrufer weitergeben können, müssen zwei Dinge gelten:
|
Eine Methode muss mit einem Rückgabetyp und nicht mit void definiert werden. |
|
Sie muss eine return-Anweisung besitzen, die einen geeigneten Wert zurückgibt. |
Die allgemeine Syntax ist
return Ausdruck;
Ein allgemeines return ohne Ausdruck ist in einer Funktion mit Ergebnis ein Programmfehler. Der Rückgabewert muss an der Aufrufstelle jedoch nicht zwingend benutzt werden. Berechnet unsere Methode jedoch das Maximum zweier Zahlen, ist es unsinnig, den Rückgabewert nicht zu verwenden.
Hinweis Obwohl einige Programmierer den Ausdruck gerne klammern, ist das nicht nötig und soll auch nur komplexe Ausdrücke besser lesbar machen. Geklammerte Ausdrücke erinnern sonst nur an einen Funktionsaufruf, und diese Verwechslungsmöglichkeit sollte bei Rückgabewerten nicht bestehen.
|
Beispiel Eine Methode bildet aus drei Ganzzahlen das Maximum und gibt dieses zurück. Wir nutzen dazu eine Methode, die es schon gibt: Math.max().
|
static double max( double a, double b, double c )
{
return Math.max( Math.max(a, b), c );
}
Innerhalb der Funktion steckt wieder ein Funktionsaufruf. Der Typ hinter dem Rückgabewert muss kompatibel zum angegebenen Rückgabewert sein. Das passt in unserem Beispiel, denn die Math.max()-Funktion liefert ein double. Für die Anpassung gelten sonst wieder die bekannten Typanpassungsregeln.2
Eclipse erkennt, ob ein Rückgabetyp fehlt, und schlägt einen Typ vor. (Der in diesem Fall nicht passt!)
Hier klicken, um das Bild zu Vergrößern
Beispiel Die nächste Funktion isLeap() stellt nach der Methode CMI fest, ob es sich bei einem Jahr um ein Schaltjahr handelt. Die Funktion arbeitet mit dem gregorianischen Kalender und gibt nur eine korrekte Antwort, wenn das Jahr zwischen 1583 und 20000 liegt.
|
Listing 2.8 LeapYear.java
class LeapYear
{
/**
* @return true wenn year ein Schaltjahr ist.
* false sonst.
*/
static boolean isLeap( int year )
{
return year % 4 == 0
&& ( year % 100 != 0 || year % 400 == 0 );
}
public static void main( String args[] )
{
System.out.println( isLeap( 2000 ) );
}
}
Mehrere return-Anweisungen
Für Methoden mit Rückgabewert gilt ebenso wie für void-Methoden, dass es mehr als ein return geben kann. Nach der Abarbeitung von return geht es im Programmcode des Aufrufers weiter wie bei den normalen void-Methoden.
Beispiel In if-Anweisungen mit weiteren else-if-Alternativen und Rücksprung ist die Semantik oft die Gleiche, wenn das else-if durch ein einfaches if ersetzt wird.
|
Der nachfolgende Programmcode zeigt das:
if ( a == 1 && b == 2 )
return 0;
else if ( a == 2 && b == 1 ) // mit else
return 1;
Dies lässt sich durch Folgendes ersetzen:
if ( a == 1 && b == 2 )
return 0;
if ( a == 2 && b == 1 ) // ohne else
return 1;
Passt die erste Bedingung, so endet die Funktion, und das nachfolgende if würde sowieso nicht vom Interpreter beachtet.
Wichtig ist nur, dass jeder denkbare Programmfluss mit einem return beendet wird. Der Compiler besitzt ein scharfes Auge und merkt, wenn es einen Programmpfad gibt, der nicht mit einem return-Ausdruck beendet wird.
Beispiel Die Funktion gerade() soll bei geraden Zahlen 1 und bei ungeraden Zahlen 0 liefern.
static int gerade( int i )
{
switch ( i % 2 ) {
case 0: return 1;
case 1: return 0;
}
}
|
Die Funktion lässt sich nicht compilieren, obwohl für uns der Rest der Division nur 0 oder 1 sein kann. Bei den Dingen, die für den Benutzer meistens offensichtlich sind, muss der Compiler passen, da er nicht hinter die Bedeutung sehen kann.
Ein weiteres Beispiel ist etwa eine Wochen-Funktion, die den Ganzzahl-Rückgabewert einer Funktion mit einem Wochentag als String kodiert verbindet. Wenn wir die Fälle 0 = Sonntag bis 6 = Samstag beachten, dann kann in unseren Augen ein Wochentag nicht 99 sein. Der Compiler kennt aber die Funktion nicht und weiß nicht, dass der Wertebereich beschränkt ist. Das Problem ließe sich mit einem default leicht beheben.
Beispiel Die Funktion posOrNeg() soll eine Zeichenkette mit der Information liefern, ob die übergebene Gleitkommazahl positiv oder negativ ist.
static String posOrNeg( double d )
{
if ( d >= 0 )
return "pos";
if ( d < 0 )
return "neg";
}
|
Es wird überraschen, aber dieser Programmcode ist ebenfalls fehlerhaft. Denn obwohl er offensichtlich für positive oder negative Zahlen den passenden String zurückgibt, gibt es einen Fall, den diese Funktion nicht abdeckt. Wieder gilt, dass der Compiler nicht erkennen kann, dass der zweite Ausdruck eine Negation des Ersten sein soll. Es gibt aber noch einen zweiten Grund, der damit zu tun hat, dass es in Java spezielle Werte gibt, die keine Zahlen sind. Denn die Zahl d kann auch eine NaN (Not-a-Number) aus einer negativen Wurzel sein. Diesen speziellen Wert überprüft posOrNeg() nicht. Als Lösung für den einfachen Fall ohne NaN reicht es, aus dem zweiten if einfach ein else zu machen.
Bei Methoden, die einen Fehlerwert wie -1 zurückliefern, ist es eine häufig aufzufindende Implementierung, dass am Ende immer automatisch der Fehlerwert zurückgeliefert und dann in der Mitte die Methode bei passendem Ende verlassen wird.
Wir wollen nun eine Methode programmieren, die testet, ob ein Wert zwischen zwei Grenzen liegt. Dazu gibt es zwei Lösungen, wobei die meisten Programmierer zur ersten Lösung neigen.
Nennen wir unsere untere Schranke a und die obere Schranke b. Dann soll die Funktion between() testen, ob x zwischen a und b liegt. Bei Funktionen dieser Art ist es immer sehr wichtig, darauf zu achten und zu dokumentieren, ob der Test auf echt kleiner (<) oder kleiner gleich (<=) gemacht werden soll. Wir wollen hier auch die Gleichheit betrachten.
Die erste Lösungsidee zeigt sich in einer mathematischen Gleichung. Wir möchten gerne a <= x <= b schreiben, doch dies ist in Java nicht erlaubt.3 So müssen wir einen Und-Vergleich anstellen, der etwa so lautet: Ist a <= x && x <= b dann liefere true zurück.
Die zweite Methode zeigt, dass sich das Problem auch ohne Und-Vergleich durch das Ausschlussprinzip lösen lässt:
static boolean between( int x, int a, int b )
{
if ( x < a )
return false;
if ( x <= b )
return true;
return false;
}
Mit geschachtelten Anfragen sieht das dann so aus:
static boolean between( int x, int a, int b )
{
if ( a <= x )
if ( x <= b )
return true;
return false;
}
2.7.9 Methoden überladen
Eine Funktion ist gekennzeichnet durch Rückgabewert, Name, Parameter und unter Umständen durch Ausnahmefehler, die sie auslösen kann. Java erlaubt es, den Namen der Funktion gleich zu lassen, aber andere Parameter einzusetzen. Eine überladene Methode ist eine Funktion mit dem gleichen Namen wie eine andere Funktion, aber einer davon verschiedenen Parameterliste. Das geht auf zwei Arten:
|
Eine Funktion kann gleich lauten, aber für den Compiler unterscheidbare Typen annehmen. |
|
Eine Funktion kann eine unterschiedliche Anzahl von Parametern akzeptieren. |
Anwendungen für den ersten Fall gibt es viele. Der Name einer Funktion soll ihre Aufgabe beschreiben, aber nicht die Typen der Parameter, mit denen sie arbeitet, extra erwähnen. Das ist bei anderen Sprachen üblich, jedoch nicht in Java. Zum Beispiel bei der in der Mathe-Klasse Math angebotenen Funktion max(). Sie ist definiert auf unterschiedlichen Typen, zum Beispiel int und double. Das ist viel schöner als die separaten Funktionen maxInt() und maxDouble() zu benutzen.
Beispiel Eine unterschiedliche Anzahl von Parametern ist ebenfalls eine sinnvolle Angelegenheit. Die Funktion max() könnten wir so für drei Parameter definieren.
|
Schreiben wir unsere eigene Variante für zwei und drei Parameter:
static int max( int i, int j ) {
return Math.max( i, j );
}
static int max( int i, int j, int k ) {
return max( i, max(j, k) ); // Methode von oben aufrufen.
}
Variable Parameterlisten wie in C(++) werden durch die Möglichkeit der überladenen Methoden nahezu unnötig.
Kommen wir nun zu zwei weiteren Beispielen, die etwas komplizierter sind und übersprungen werden können.
print() und println() sind überladen
Das bekannte print() ist eine überladene Funktion, die etwa wie folgt definiert ist:
class PrintStream
{
void print( Object arg ) { ... }
void print( String arg ) { ... }
void print( char arg[] ) { ... }
}
Wird nun die Funktion print() mit irgendeinem Typ aufgerufen, dann wird die am besten passende Funktion herausgesucht. Versucht der Programmierer beispielsweise die Ausgabe eines Objekts Date, dann stellt sich die Frage, welche Methode sich darum kümmert. Glücklicherweise ist die Antwort nicht schwierig, denn es existiert auf jeden Fall eine print()-Methode, welche Objekte ausgibt. Und da Date, wie auch alle anderen Klassen, eine Unterklasse von Object ist, wird diese print()-Funktion gewählt. Natürlich kann nicht erwartet werden, dass das Datum in einem unbestimmten Format (etwa nur das Jahr) ausgegeben wird, jedoch wird eine Ausgabe auf dem Schirm sichtbar. Denn jedes Objekt kann sich durch den Namen identifizieren, und dieser würde in dem Fall ausgegeben. Obwohl es sich so anhört, als ob immer die Funktion mit dem Parameter-Objekt aufgerufen wird, wenn der Datentyp nicht angepasst werden kann, ist dies nicht ganz richtig. Wenn der Compiler keine passende Klasse findet, dann wird die nächste Oberklasse im Ableitungsbaum gesucht, für die in unserem Fall eine Ausgabefunktion existiert.
Negative Beispiele und schlaue Leute
Oft verfolgt auch die Java-Bibliothek die Strategie mit gleichen Namen und unterschiedlichen Typen. Es gibt allerdings ein paar Ausnahmen. In der Grafik-Bibliothek finden sich die Funktionen
|
drawString( String str, int x, int y ), |
|
drawChars( char data[], int offset, int length, int x, int y ) und |
|
drawBytes( byte data[], int offset, int length, int x, int y ). |
Das ist äußerst hässlich und schlechter Stil.
Ein anderes Beispiel findet sich in der Klasse DataOutputStream. Hier heißen die Methoden etwa writeInt(), writeChar() und so weiter. Obwohl wir dies auf den ersten Blick verteufeln würden, ist diese Namensgebung sinnvoll. Ein Objekt vom Typ DataOutputStream dient zum Schreiben von primitiven Werten in einen Datenstrom, und davon gibt es bekannterweise einige, mit unterschiedlichen Längen. Gäbe es in DataOutputStream etwa eine Methode write(int) und write(short), und wir fütterten sie mit write(21), dann hätten wir das Problem, dass eine Typkonvertierung die Daten automatisch anpassen und der Datenstrom mehr Daten beinhalten würde als wir wünschen. Denn write(21) ruft etwa nicht write(short) auf und schreibt zwei Bytes, sondern es ruft write(int) auf und schreibt somit vier Bytes. Um also die Übersicht über die geschriebenen Bytes zu behalten, ist eine ausdrückliche Kennzeichnung der Datentypen in manchen Fällen gar nicht so dumm.
2.7.10 Vorinitialisierte Parameter bei Funktionen
Überladene Funktionen lassen sich auch in dem Fall verwenden, wenn vorinitialisierte Werte bei nicht vorhandenen Parametern genutzt werden sollten. Ein Beispiel: Wir möchten eine Funktion zum Konvertieren von Zahlen kodiert als Zeichenkette in ein Zahlenformat erlauben. Dazu implementieren wir toDecimal(String s, int radix). Wir möchten, dass radix automatisch 10 ist, wenn keine Basis angegeben ist. Die Sprache C++ erlaubt dies, Java jedoch nicht. Doch in Java überladen wir einfach die Funktion und rufen die andere Funktion mit 10 auf.
int toDecimal( String s, int radix )
{
// Umwandlung
}
int toDecimal( String s )
{
toDecimal( s, 10 );
}
2.7.11 Finale lokale Variablen
In einer Methode können Parameter oder lokale Variablen mit dem Modifizierer final deklariert werden. Dieses zusätzliche Schlüsselwort verbietet nochmalige Zuweisungen an diese Variable, sodass diese nicht mehr verändert werden kann. Dies gibt dem Compiler die Chance, zusätzliche Optimierungen vorzunehmen.
int foo( final int a )
{
int i = 2;
final int j = 3;
i = 3;
// j = 4; führt zu einem Fehler
// a = 2; führt zu einem Fehler
}
Aufgeschobene Initialisierung
Java definiert die so genannte aufgeschobene Initialisierung. Das heißt im Zusammenhang mit finalen Werten, dass nicht zwingend zum Zeitpunkt der Variablendeklaration ein Wert zugewiesen werden muss. Dies kann auch genau einmal im Programmcode geschehen. Folgendes ist gültig:
final int a;
...
a = 2;
In der Vergangenheit enthielten Java- und Jikes-Compiler Fehler, sodass mehrfache Zuweisungen fälschlicherweise erlaubt waren.4
Obwohl auch Objektvariablen und Klassenvariablen final sein können, gibt es dort nur beschränkt eine aufgeschobene Initialisierung. Bei der Deklaration müssen wir die Variablen entweder direkt belegen oder im Konstruktor zuweisen. Wir werden uns dies später noch einmal genauer ansehen. Werden finale Variablen vererbt, so können Unterklassen diesen Wert auch nicht mehr überschreiben. (Das wäre ein Problem, aber vielleicht auch ein Vorteil für manche Konstanten.)
final in der Vererbung
In der Vererbung spielt das final bei Parametern keine Rolle. Wir können es als zusätzliche Information für die jeweilige Methode sehen. Eine Unterklasse kann demnach beliebig das final hinzufügen oder auch wegnehmen. Alte Bibliotheken lassen sich so leicht weiterverwenden.
2.7.12 Finale Referenzen in Objekten und das fehlende const
Wir haben gesehen, dass finale Variablen dem Programmierer vorgeben, dass er Variablen nicht beschreiben darf. Das heißt, Zuweisungen sind tabu. Leider löst das noch nicht das Problem, dass eine Methode mit übergebenen Referenzen Objektveränderungen vornehmen kann. Greifen wir etwas vor:
Beispiel Die foo()-Methode ändert ein Attribut von einem Point-Objekt.
public void foo( final Point p )
{
// p = new Point();
p.x = 2;
}
|
Die Zuweisung ist für eine Referenz nicht weiter tragisch, da wir den Objektzustand dadurch nicht verändern, sondern lediglich die lokale Variable auf ein neues Objekt lenken. Nur Zuweisungen lässt final nicht zu. Was final nicht überprüft, ist, dass wir die Referenz auf der linken Seite einer Zuweisung haben können und dadurch in der Lage sind, das Objekt verändern zu können, wie im oberen Beispiel. final erfüllt demnach nicht die gewünschte Aufgabe, schreibende Objektzugriffe zu verhindern. Die Dokumentation muss also immer ausdrücklich beschreiben, wann die Funktion den Zustand eines Objekts modifiziert.
Obwohl die Java-Entwickler das Schlüsselwort const reserviert haben, ist diese Funktionalität noch nicht in die Sprache eingeflossen. Schade eigentlich. Wir sollten jedoch bemerken, dass es höchstens Schreibzugriffe anmerken könnte, Änderungen aber oft über setXXX()-Methoden realisiert werden.
2.7.13 Rekursive Funktionen
Wir wollen den Einstieg in die Rekursion mit einem kurzen Beispiel beginnen.
Auf dem Weg durch den Wald begegnet uns eine Fee. Sie spricht zu uns: »Du hast drei Wünsche frei«. Tolle Situation. Um das ganze Unglück aus der Welt zu räumen, entscheiden wir uns nicht für eine egozentrische Wunscherfüllung, sondern für die sozialistische. »Ich möchte Frieden für alle, Gesundheit und Wohlstand für jeden.« Und schwupps, so war es geschehen, und alle lebten glücklich bis ...
Einige Leser werden vielleicht die Hand vor den Kopf schlagen und sagen: »Quatsch! Ein Haus, ein Auto und einen Lebenspartner, der die Trägheit des Morgens duldet«. Glücklicherweise können wir das Dilemma mit der Rekursion lösen. Die Idee ist einfach - und in unseren Träumen schon erprobt -, nämlich den letzten Wunsch als »Nochmal drei Wünsche frei« zu formulieren.
Beispiel Eine kleine Wunsch-Funktion:
static void fee()
{
wunsch();
wunsch();
fee();
}
|
Durch den dauernden Aufruf der fee()-Funktion haben wir unendlich viele Wünsche frei. Rekursion ist also das Aufrufen der eigenen Methode, in der wir uns befinden. Dies kann auch über einen Umweg funktionieren. Das nennt sich dann nicht mehr direkte Rekursion, sondern indirekte Rekursion. Sie ist ein sehr alltägliches Phänomen, das wir auch von der Rückkopplung Mikrofon/Lautsprecher oder dem Blick mit einem Spiegel in den Spiegel kennen.
Abbruch der Rekursion
Wir müssen nun die Fantasie-Programme (deren Laufzeit und Speicherbedarf auch sehr schwer zu berechnen sind) gegen Java-Funktionen austauschen.
Beispiel Eine Endlos-Rekursion:
static void runter( int n )
{
System.out.print( n + ", " );
runter( n - 1 );
}
|
Rufen wir runter(10) auf, dann wird die Zahl 10 auf dem Bildschirm ausgegeben und anschließend runter(9) aufgerufen. Führen wir das Beispiel fort, so ergibt sich eine endlose Ausgabe, die so beginnt:
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1, -2, ...
An dieser Stelle erkennen wir, dass Rekursion prinzipiell etwas Unendliches ist. Für Programme ist dies aber ungünstig, wir müssen daher ähnlich wie bei Schleifen eine Abbruchbedingung formulieren und dann keinen Rekursionsaufruf mehr starten.
Beispiel Die Abbruchbedingung einer Rekursion:
static void runter( int n )
{
if ( n == 0 ) // Rekursionsende
return;
System.out.print( n + ", " );
runter( n - 1 );
}
Die runter()-Methode ruft jetzt nur noch so lange runter(n-1) auf, wie das n ungleich null ist.
|
Unterschiedliche Rekursionsformen
Kennzeichen der bisherigen Programme war, dass nach dem Aufruf der Rekursion keine Anweisung stand, sondern die Methode mit dem Aufruf beendet wurde. Diese Rekursionsform nennt sich Endrekursion. Diese Form ist verhältnismäßig einfach zu verstehen. Schwieriger sind Rekursionen, bei denen hinter dem Methodenaufruf Anweisungen stehen. Betrachten wir folgende Methoden, in der die Erste bekannt und die Zweite neu ist:
static void runter1( int n )
{
if ( n == 0 ) // Rekursionsende
return;
System.out.print( n + ", " );
runter1( n - 1 );
}
static void runter2( int n )
{
if ( n == 0 ) // Rekursionsende
return;
runter2( n - 1 );
System.out.print( n + ", " );
}
Der Unterschied besteht darin, dass runter1() zuerst die Zahl n ausgibt und anschließend rekursiv runter1() aufruft. Die Methode runter2() steigt jedoch erst immer tiefer ab, und die Rekursion muss beendet sein, bis es zum ersten print() kommt. Daher gibt im Gegensatz zu runter1() die Methode runter2() die Zahlen in aufsteigender Reihenfolge aus:
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
Dies ist einleuchtend, wenn wir die Ablaufreihenfolge betrachten. Beim Aufruf runter2(10) ist der Vergleich von n mit null falsch, also wird ohne Ausgabe wieder runter2(9) aufgerufen. Ohne Ausgabe deshalb, da print() ja erst nach dem Funktionsaufruf steht. Es geht rekursiv tiefer bis n gleich null ist. Dann beendet die letzte Methode mit return, und die Ausgabe wird nach dem runter2(), dem Aufrufer, fortgeführt. Dort ist print() die nächste Anweisung. Da wir nun noch tief verschachtelt stecken, gibt print(n) die Zahl 1 aus. Dann ist die Methode runter2() wieder beendet (ein unsichtbares, nicht direkt geschriebenes return), und sie springt zum Aufrufer zurück. Das war wieder die Methode runter2(), aber mit der Belegung n = 2. Das geht so weiter, bis es zurück zum Aufrufer kommt, der runter(10) aufgerufen hat, zum Beispiel die main()-Methode. Der Trick bei der Sache ist nun darin zu sehen, dass jede Methode ihre eigene lokale Variable besitzt.
Ausblick
Der niederländische Maler Maurits Cornelis Escher (1898-1972) machte die Rekursion auch in Bildern berühmt. Seiten mit Bildern und Vita finden sich zum Beispiel unter folgenden Webadressen:
Zwei weitere klassische Beispiele für Rekursionen sollen nachfolgend diskutiert werden.
2.7.14 Die Ackermann-Funktion
Wir wollen als mathematisch orientiertes Beispiel für eine rekursive Funktion die Ackermann-Funktion kennen lernen.5 Sie ist nach F. Wilhelm Ackermann (1886-1962) benannt. Viele Funktionen der mathematischen Praxis sind primitiv rekursiv6, und David Hilbert stellte 1926 die Frage, ob alle Funktionen, deren Argumente und Werte natürliche Zahlen sind, primitiv rekursiv sind. Die Ackermann-Funktion steigt sehr stark an und ist für Theoretiker ein Beispiel dafür, dass es berechenbare Funktionen gibt, die aber nicht primitiv rekursiv sind. Im Jahre 1928 zeigte Ackermann dies an einem Beispiel: der Ackermann-Funktion.7 Sie wächst stärker als es Substitution und Rekursion ermöglichen und nur für kleine Argumente lassen sich die Funktionswerte noch ohne Rekursion berechnen. Darin bestand auch die Beweisidee von Ackermann, eine Funktion zu definieren, die schneller wächst als alle primitiv rekursiven Funktionen. Wir wollen hier nicht die originale Version von Ackermann benutzen, die durch die Funktionalgleichung
f(n', x', y') = f(n, f(n', x, y), x)
ausgedrückt wird, sondern die vereinfachte Variante von Hans Hermes. Wir wollen die Version von Hermes aber fortan auch Ackermann-Funktion nennen, da sie direkt aus dem Original gewonnen wird. Für die oben angegebene Funktion muss in der Abhandlung von Ackermann nachgeblättert werden, um den Nachweis des Nicht-primitiv-rekursiv-Seins zu finden.
Die neue Ackermann-Funktion ist eine Abbildung von zwei ganzen Zahlen auf eine ganze Zahl a(n,m). Sie ist mathematisch durch folgende Gesetzmäßigkeit definiert:
a(0,m) = m + 1
a(n,0) = a(n - 1, 1)
a(n,m) = a(n - 1, a(n, m - 1)).
Die Ackermann-Funktion ist dafür berühmt, die Rechenkapazität ganz schnell zu erschöpfen. Sehen wir uns die Implementierung in Java an und testen wir das Programm mit ein paar Werten.
Listing 2.9 Ackermann.java
class Ackermann
{
public static long ackermann( long n, long m )
{
if ( n == 0 )
return m + 1;
else
if ( m == 0 )
return ackermann( n - 1, 1 );
else
return ackermann( n - 1, ackermann(n, m - 1) );
}
public static void main( String args[] )
{
int x = 2,
y = 2;
System.out.println( "ackermann(" + x + "," + y + ")=" + ackermann(x,y) );
}
}
Für den Aufruf ackermann(1, 2) veranschaulicht die folgende Ausgabe die rekursive Eigenschaft der Ackermann-Funktion. Die Stufen der Rekursion sind durch Einrückungen deutlich gemacht:
a(1,2):
a(0,a(1,1)):
a(0,a(1,0)):
a(1,0):
a(0,1)=2
a(1,0)=2
a(0,2)=3
a(0,a(1,0))=3
a(0,3)=4
a(0,a(1,1))=4
a(1,2)=4
Bei festen Zahlen lässt sich der Wert der Ackermann-Funktion direkt angeben.
a(1,n) = n + 2
a(2,n) = 2n + 3
a(3,n) = 2n+3 - 3
Für große Zahlen übersteigt die Funktion aber schnell alle Berechnungsmöglichkeiten.
2.7.15 Die Türme von Hanoi
Die Legende der Türme von Hanoi soll erstmalig von Ed Lucas in einem Artikel der französischen Zeitschrift »Cosmo« im Jahre 1890 veröffentlicht worden sein. Wir halten uns hier an eine Überlieferung von C. H. A. Koster aus dem Buch »Top-down Programming with Elan«, Ellis Horwood 1987.
Der Legende nach standen vor langer Zeit im Tempel von Hanoi drei Säulen. Die erste Säule war aus Kupfer, die zweite aus Silber und die dritte aus Gold. Auf der Kupfersäule waren einhundert Scheiben aufgestapelt. Die Scheiben hatten in der Mitte ein Loch und waren aus Porphyr8. Die Scheibe mit dem größten Umfang lag unten und alle kleiner werdenden Scheiben oben auf. Ein alter Mönch stellte sich die Aufgabe, den Turm der Scheiben von der Kupfersäule zur Goldsäule zu bewegen. In einem Schritt sollte aber nur eine Scheibe bewegt werden und zudem war die Bedingung, dass eine größere Scheibe niemals auf eine kleinere bewegt werden durfte. Der Mönch erkannte schnell, dass er die Silbersäule nutzen musste; er setzte sich an einen Tisch, machte einen Plan, überlegte und kam zu einer Entscheidung. Er konnte sein Problem in drei Schritten lösen.
Am nächsten Tag schlug der Mönch die Lösung an die Tempeltür:
|
Falls der Turm aus mehr als einer Scheibe besteht, bitte deinen ältesten Schüler, einen Turm von (n - 1) Scheiben von der ersten zur dritten Säule unter Verwendung der zweiten Säule umzusetzen. |
|
Trage selbst die erste Scheibe von einer zur anderen Säule. |
|
Falls der Turm aus mehr als einer Scheibe besteht, bitte deinen ältesten Schüler, einen Turm aus (n - 1) Scheiben von der dritten zu der anderen Säule unter Verwendung der ersten Säule zu transportieren. |
Und so rief der alte Mönch seinen ältesten Schüler zu sich und trug ihm auf, den Turm aus 99 Scheiben von der Kupfersäule zur Goldsäule unter Verwendung der Silbersäule umzuschichten und ihm den Vollzug zu melden.
Nach der Legende würde das Ende der Welt nahe sein, bis der Mönch seine Arbeit beendet hätte. Nun, so weit die Geschichte. Wollen wir den Algorithmus zur Umschichtung der Porphyrscheiben in Java programmieren, so machen wir dies sicherlich rekursiv.
Listing 2.10 Hanoi.java
class Hanoi
{
static void bewegeScheibe( int n, String von, String nach )
{
System.out.println( "Scheibe " + n + " von " + von +
" nach " + nach );
}
static void versetzeTurm( int n, String kupfer,
String silber, String gold )
{
if ( n > 1 )
{
versetzeTurm( n-1, kupfer, gold, silber );
bewegeScheibe( n, kupfer, gold );
versetzeTurm( n-1, silber, kupfer, gold );
}
else
bewegeScheibe( n, kupfer, gold );
}
public static void main( String args[] )
{
versetzeTurm( 4, "Kupfer", "Silber", "Gold" );
}
}
Starten wir das Programm mit vier Scheiben, so bekommen wir folgende Ausgabe:
Scheibe 1 von Kupfer nach Silber
Scheibe 2 von Kupfer nach Gold
Scheibe 1 von Silber nach Gold
Scheibe 3 von Kupfer nach Silber
Scheibe 1 von Gold nach Kupfer
Scheibe 2 von Gold nach Silber
Scheibe 1 von Kupfer nach Silber
Scheibe 4 von Kupfer nach Gold
Scheibe 1 von Silber nach Gold
Scheibe 2 von Silber nach Kupfer
Scheibe 1 von Gold nach Kupfer
Scheibe 3 von Silber nach Gold
Scheibe 1 von Kupfer nach Silber
Scheibe 2 von Kupfer nach Gold
Scheibe 1 von Silber nach Gold
Schon bei vier Scheiben haben wir 15 Bewegungen. Nun wollen wir uns die Komplexität bei n Porphyrscheiben überlegen. Bei einer Scheibe haben wir nur eine Bewegung zu machen. Bei zwei Scheiben aber schon doppelt so viele wie vorher und noch eine zusätzlich. Formaler:
S1 = 1
S2 = 1 + 2S1 = 3
S3 = 1 + 2S2 = 7
Führen wir die Berechnung induktiv fort, so folgt für Sn, dass 2n - 1 Schritte auszuführen sind, um n Scheiben zu bewegen. Nehmen wir an, unser Prozessor arbeitet mit 100 MIPS, also 100 Millionen Operationen pro Sekunde, dann ergibt sich für n = 100 eine Zeit von 4*1013 Jahren (etwa 20.000 geologische Erdzeitalter). An diesem Beispiel wird uns wie beim Beispiel mit der Ackermann-Funktion eines deutlich: Die Funktionen sind im Prinzip berechenbar, nur praktisch ist so ein Algorithmus nicht.
1 Das ist in C(++) anders. Dort ist die Signatur einer Funktion auch durch den Rückgabetyp definiert.
2 Das dürfte relativ zukunftssicher sein.
3 ... im Gegensatz zur Programmiersprache Python.
4 Ein Beispiel, das den Fehler reproduziert, findet der Leser unter>http://java-tutor.com/java/faq/index.html.
5 Das macht dann auch die Informatiker glücklich ...
6 Für Theoretiker: Eine Funktion heißt »primitiv-rekursiv«, falls sie elementar ist oder in endlich vielen Schritten aus einer elementaren Funktion durch Ersetzung (Substitution) und Rekursion hervorgeht.
7 Nachzulesen in »Zum Hilbertschen Aufbau der reellen Zahlen« von Ackermann, W., Math. Ann. 99, 118-133 (1928).
8 Gestein, das aus der porphyrischen Etschplattform gewonnen wird. Dies ist eine Gebirgsgruppe vulkanischen Ursprungs in Trentino/Südtirol. Besondere Eigenschaften von Porphyr sind: hohe Bruchfestigkeit, hohe Beständigkeit gegen physikalisch-chemische Wirkstoffe und hohe Wälz- und Gleitreibung.
|