Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 7. Auflage |
<< | < | > | >> | API | Kapitel 8 - OOP I: Grundlagen |
Methoden definieren das Verhalten von Objekten. Sie werden innerhalb einer Klassendefinition angelegt und haben Zugriff auf alle Variablen des Objekts. Methoden sind das Pendant zu den Funktionen anderer Programmiersprachen, arbeiten aber immer mit den Variablen des aktuellen Objekts. Globale Funktionen, die vollkommen unabhängig von einem Objekt oder einer Klasse existieren, gibt es in Java ebenso wenig wie globale Variablen. Wir werden später allerdings Klassenvariablen und -methoden kennenlernen, die nicht an eine konkrete Instanz gebunden sind.
Die Syntax der Methodendefinition in Java ähnelt der von C/C++:
{Modifier} Typ Name([Parameter]) { {Anweisung;} } |
Nach einer Reihe von Modifiern (wir kommen weiter in Abschnitt 9.2 darauf zurück) folgen der Typ des Rückgabewerts der Funktion, ihr Name und eine optionale Parameterliste. In geschweiften Klammern folgt dann der Methodenrumpf, also die Liste der Anweisungen, die das Verhalten der Methode festlegen. Die Erweiterung unserer Beispielklasse um eine Methode zur Berechnung des Alters des Auto-Objekts würde beispielsweise so aussehen:
001 public class Auto 002 { 003 public String name; 004 public int erstzulassung; 005 public int leistung; 006 007 public int alter() 008 { 009 return 2011 - erstzulassung; 010 } 011 } |
Hier wird eine Methode alter definiert, die einen ganzzahligen Wert zurückgibt, der sich aus der Differenz des Jahres 2011 und dem Jahr der Erstzulassung errechnet. Das funktioniert natürlich nur im Jahr 2011 richtig; eine allgemein gültige Methode zur Altersberechnung müsste etwas mehr Aufwand treiben.
Der Aufruf einer Methode erfolgt ähnlich der Verwendung einer Instanzvariablen in Punktnotation. Zur Unterscheidung von einem Variablenzugriff müssen zusätzlich die Parameter der Methode in Klammern angegeben werden, selbst wenn die Liste leer ist. Das folgende Programm würde demnach die Zahl 10 auf dem Bildschirm ausgeben.
001 Auto golf1 = new Auto(); 002 golf1.erstzulassung = 2001; 003 System.out.println(golf1.alter()); |
Wie an der Definition von alter zu erkennen ist, darf eine Methode auf die Instanzvariablen ihrer Klasse zugreifen, ohne die Punktnotation zu verwenden. Das funktioniert deshalb, weil der Compiler alle nicht in Punktnotation verwendeten Variablen x, die nicht lokale Variablen sind, auf das Objekt this bezieht und damit als this.x interpretiert. |
|
Bei this handelt es sich um einen Zeiger, der beim Anlegen eines Objekts automatisch generiert wird. this ist eine Referenzvariable, die auf das aktuelle Objekt zeigt und dazu verwendet wird, die eigenen Methoden und Instanzvariablen anzusprechen. Der this-Zeiger ist auch explizit verfügbar und kann wie eine ganz normale Objektvariable verwendet werden. Er wird als versteckter Parameter an jede nichtstatische Methode übergeben. Die Methode alter hätte also auch so geschrieben werden können:
001 public int alter() 002 { 003 return 2011 - this.erstzulassung; 004 } |
Manchmal ist es sinnvoll, this explizit zu verwenden, auch wenn es nicht unbedingt erforderlich ist. Dadurch wird hervorgehoben, dass es sich um den Zugriff auf eine Instanzvariable, und nicht eine lokale Variable, handelt. |
|
Eine Methode kann mit Parametern definiert werden. Dazu wird bei der Methodendefinition eine Parameterliste innerhalb der Klammern angegeben. Jeder formale Parameter besteht aus einem Typnamen und dem Namen des Parameters. Soll mehr als ein Parameter definiert werden, so sind die einzelnen Definitionen durch Kommata zu trennen.
Alle Parameter werden in Java per call by value übergeben. Beim Aufruf einer Methode wird also der aktuelle Wert in die Parametervariable kopiert und an die Methode übergeben. Veränderungen der Parametervariablen innerhalb der Methode bleiben lokal und wirken sich nicht auf den Aufrufer aus. Das folgende Beispiel definiert eine Methode printAlter, die das Alter des Autos insgesamt wieoft mal auf dem Bildschirm ausgibt:
001 public void printAlter(int wieoft) 002 { 003 while (wieoft-- > 0) { 004 System.out.println("Alter = " + alter()); 005 } 006 } |
Obwohl der Parameter wieoft innerhalb der Methode verändert wird, merkt ein Aufrufer nichts von diesen Änderungen, da innerhalb der Methode mit einer Kopie gearbeitet wird. Das folgende Programm würde das Alter des Objekts auto daher insgesamt neunmal auf dem Bildschirm ausgeben:
001 ... 002 int a = 3; 003 004 auto.printAlter(a); 005 auto.printAlter(a); 006 auto.printAlter(a); 007 ... |
Wie bereits erwähnt, sind Objektvariablen Referenzen, also Zeiger. Zwar werden auch sie bei der Übergabe an eine Methode per Wert übergeben. Da innerhalb der Methode aber der Zeiger auf das Originalobjekt zur Verfügung steht (wenn auch in kopierter Form), wirken sich Veränderungen an dem Objekt natürlich direkt auf das Originalobjekt aus und sind somit für den Aufrufer der Methode sichtbar. Wie in allen anderen Programmiersprachen entspricht die call by value-Übergabe eines Zeigers damit natürlich genau der Semantik von call by reference.
Die Übergabe von Objekten an Methoden hat damit zwei wichtige Konsequenzen:
|
|
Sollen Objekte kopiert werden, so muss dies explizit durch Aufruf der Methode clone der Klasse Object erfolgen.
Die Übergabe von Objekten und Arrays per Referenz kann leicht zu verdeckten Fehlern führen. Da die aufgerufene Methode mit dem Originalobjekt arbeitet, kann sie deren Membervariablen bzw. Elemente verändern, ohne dass der Aufrufer es merkt. Auch der final-Modifier (siehe Abschnitt 9.2) bietet dagegen keinen Schutz. Das unbeabsichtigte Ändern einer modifizierbaren Referenzvariable bei der Übergabe an eine Methode kann nur durch vorheriges Kopieren verhindert werden. |
|
Seit Java 5 gibt es die Möglichkeit, variable Parameterlisten zu definieren, in denen ein formaler Parameter für eine beliebige Anzahl aktueller Argumente steht. Dazu kann der letzte Parameter einer Methode (und nur dieser) nach dem Typbezeichner mit drei Punkten versehen werden. So wird angezeigt, dass an dieser Stelle beim Aufruf eine beliebige Anzahl Argumente des passenden Typs übergeben werden darf:
001 public static void printArgs(String... args) 002 { 003 for (int i = 0; i < args.length; ++i) { 004 System.out.println(args[i]); 005 } 006 } |
Technisch entspricht die Deklaration der eines Arrays-Parameters und
so wird auch auf die Elemente zugegriffen. Die Vereinfachung wird
sichtbar, wenn man sich den Aufruf der Methode ansieht. An
dieser Stelle darf nämlich nicht nur ein einzelnes Array übergeben
werden, sondern die einzelnen Elemente können auch separat angegeben
werden. Dabei erzeugt das Laufzeitsystem automatisch ein Array, in
das diese Werte übertragen werden. Die beiden folgenden Aufrufe
sind also gleichwertig:
printArgs(new String[]{"so", "wird", "es", "gemacht"});
printArgs("so", "wird", "es", "gemacht");
Nun wird auch deutlich, warum lediglich der letzte Parameter variabel sein darf. Andernfalls könnte der Compiler unter Umständen nicht mehr unterscheiden, welches aktuelle Argument zu welchem formalen Parameter gehört.
Praktischen Nutzen haben die variablen Parameterlisten bei Anwendungen, in denen nicht von vorneherein klar ist, wie viele Argumente benötigt werden. Tatsächlich wurde ihre Entwicklung durch den Wunsch motiviert, flexible Ausgabemethoden definieren zu können, wie sie etwa in C/C++ mit der printf-Familie zur Verfügung stehen (und seit der J2SE 5.0 mit der Klasse java.util.Formatter, die in Abschnitt 12.6 beschrieben wird). Sie können dann die in diesem Fall vielfach verwendeten überladenen Methoden ersetzen (siehe Abschnitt 8.3.6). Natürlich benötigt nicht jede Methode variable Parameterlisten, sondern ihre Anwendung sollte auf Spezialfälle beschränkt bleiben.
Wird eine Methode mit einem Parameter vom Typ Object... deklariert, entstehen in Zusammenhang mit dem ebenfalls seit der J2SE 5.0 verfügbaren Mechanismus des Autoboxing (siehe Abschnitt 11.2.3) Methoden, bei denen praktisch alle Typprüfungen des Compilers ausgehebelt werden. Da ein Element des Typs Object zu allen anderen Referenztypen kompatibel ist und primitive Typen dank des Autoboxing automatisch in passende Wrapper-Objekte konvertiert werden, kann an einen Parameter des Typs Object... eine beliebige Anzahl beliebiger Argumente übergeben werden.
Das folgende Listing zeigt eine Methode, die numerische Argumente jeweils so lange summiert, bis ein nichtnumerischer Wert übergeben wird. Dieser wird dann in einen String konvertiert und zusammen mit der Zwischensumme ausgegeben. Am Ende wird zusätzlich die Gesamtsumme ausgegeben. Nicht unbedingt eine typische Anwendung und erst recht kein empfehlenswerter Programmierstil, aber das Listing demonstriert, wie weitreichend die Möglichkeiten dieses Konzepts sind:
001 /* Listing0812.java */ 002 003 public class Listing0812 004 { 005 public static void registrierKasse(Object... args) 006 { 007 double zwischensumme = 0; 008 double gesamtsumme = 0; 009 for (int i = 0; i < args.length; ++i) { 010 if (args[i] instanceof Number) { 011 zwischensumme += ((Number)args[i]).doubleValue(); 012 } else { 013 System.out.println(args[i] + ": " + zwischensumme); 014 gesamtsumme += zwischensumme; 015 zwischensumme = 0; 016 } 017 } 018 System.out.println("Gesamtsumme: " + gesamtsumme); 019 } 020 021 public static void main(String[] args) 022 { 023 registrierKasse( 024 1.45, 0.79, 19.90, "Ware", 025 -3.00, 1.50, "Pfand", 026 -10, "Gutschein" 027 ); 028 } 029 } |
Listing0812.java |
Die Ausgabe des Programms ist:
Ware: 22.14
Pfand: -1.5
Gutschein: -10.0
Gesamtsumme: 10.64
Jede Methode in Java ist typisiert. Der Typ einer Methode wird zum Zeitpunkt der Definition festgelegt und bestimmt den Typ des Rückgabewerts. Dieser kann von einem beliebigen primitiven Typ, einem Objekttyp (also einer Klasse) oder vom Typ void sein. Die Methoden vom Typ void haben gar keinen Rückgabewert und dürfen nicht in Ausdrücken verwendet werden. Sie sind lediglich wegen ihrer Nebeneffekte von Interesse und dürfen daher nur als Ausdrucksanweisung verwendet werden.
Hat eine Methode einen Rückgabewert (ist also nicht vom Typ void), so kann sie mit Hilfe der return-Anweisung einen Wert an den Aufrufer zurückgeben. Die return-Anweisung hat folgende Syntax:
return Ausdruck; |
Wenn diese Anweisung ausgeführt wird, führt dies zum Beenden der Methode und der Wert des angegebenen Ausdrucks wird an den Aufrufer zurückgegeben. Der Ausdruck muss dabei zuweisungskompatibel zum Typ der Funktion sein. Die in Kapitel 6 erläuterte Datenflussanalyse sorgt dafür, dass hinter der return-Anweisung keine unerreichbaren Anweisungen stehen und dass jeder mögliche Ausgang einer Funktion mit einem return versehen ist. Der in C beliebte Fehler, einen Funktionsausgang ohne return-Anweisung zu erzeugen (und damit einen undefinierten Rückgabewert), kann in Java also nicht passieren.
In Java ist es erlaubt, Methoden zu überladen, d.h. innerhalb einer Klasse zwei unterschiedliche Methoden mit demselben Namen zu definieren. Der Compiler unterscheidet die verschiedenen Varianten anhand der Anzahl und der Typisierung ihrer Parameter. Haben zwei Methoden denselben Namen, aber unterschiedliche Parameterlisten, werden sie als verschieden angesehen. Es ist dagegen nicht erlaubt, zwei Methoden mit exakt demselben Namen und identischer Parameterliste zu definieren.
Der Rückgabetyp einer Methode trägt nicht zu ihrer Unterscheidung bei. Zwei Methoden, die sich nur durch den Typ ihres Rückgabewerts unterscheiden, werden also als gleich angesehen. Da Methoden auch ohne die Verwendung ihres Rückgabewerts aufgerufen werden können (was typischerweise wegen ihrer Nebeneffekte geschieht), hätte weder der Compiler noch der menschliche Leser in diesem Fall die Möglichkeit, festzustellen, welche der überladenen Varianten tatsächlich aufgerufen werden soll.
Das Überladen von Methoden ist dann sinnvoll, wenn die gleichnamigen Methoden auch eine vergleichbare Funktionalität haben. Eine typische Anwendung von überladenen Methoden besteht etwa in der Simulation von variablen Parameterlisten. Auch, um eine Funktion, die bereits an vielen verschiedenen Stellen im Programm aufgerufen wird, um einen weiteren Parameter zu erweitern, ist es nützlich, diese Funktion zu überladen, um nicht alle Aufrufstellen anpassen zu müssen. |
|
Das folgende Beispiel erweitert die Klasse Auto um eine weitere Methode alter, die das Alter des Autos nicht nur zurückgibt, sondern es auch mit einem als Parameter übergebenen Titel versieht und auf dem Bildschirm ausgibt:
001 public int alter(String titel) 002 { 003 int alter = alter(); 004 System.out.println(titel+alter); 005 return alter; 006 } |
Innerhalb dieser Methode wird der Name alter in drei verschiedenen Bedeutungen verwendet. Erstens ist alter der Name der Methode selbst. Zweitens wird die lokale Variable alter definiert, um drittens den Rückgabewert der parameterlosen alter-Methode aufzunehmen. Der Compiler kann die Namen in allen drei Fällen unterscheiden, denn er arbeitet mit der Signatur der Methode. Unter der Signatur einer Methode versteht man ihren internen Namen. Dieser setzt sich aus dem nach außen sichtbaren Namen plus codierter Information über die Reihenfolge und Typen der formalen Parameter zusammen. Die Signaturen zweier gleichnamiger Methoden sind also immer dann unterscheidbar, wenn sie sich wenigstens in einem Parameter voneinander unterscheiden.
In jeder objektorientierten Programmiersprache lassen sich spezielle Methoden definieren, die bei der Initialisierung eines Objekts aufgerufen werden: die Konstruktoren. In Java werden Konstruktoren als Methoden ohne Rückgabewert definiert, die den Namen der Klasse erhalten, zu der sie gehören. Konstruktoren dürfen eine beliebige Anzahl an Parametern haben und können überladen werden. Die Erweiterung unserer Auto-Klasse um einen Konstruktor, der den Namen des Auto-Objekts vorgibt, sieht beispielsweise so aus:
001 public class Auto 002 { 003 public String name; 004 public int erstzulassung; 005 public int leistung; 006 007 public Auto(String name) 008 { 009 this.name = name; 010 } 011 } |
Soll ein Objekt unter Verwendung eines parametrisierten Konstruktors instanziert werden, so sind die Argumente wie bei einem Methodenaufruf in Klammern nach dem Namen des Konstruktors anzugeben:
001 Auto dasAuto = new Auto("Porsche 911"); 002 System.out.println(dasAuto.name); |
In diesem Fall wird zunächst Speicher für das Auto-Objekt beschafft und dann der Konstruktor aufgerufen. Dieser initialisiert seinerseits die Instanzvariable name mit dem übergebenen Argument »Porsche 911«. Der nachfolgende Aufruf schreibt dann diesen Text auf den Bildschirm.
Explizite Konstruktoren werden immer dann eingesetzt, wenn zur Initialisierung eines Objekts besondere Aufgaben zu erledigen sind. Es ist dabei durchaus gebräuchlich, Konstruktoren zu überladen und mit unterschiedlichen Parameterlisten auszustatten. Beim Ausführen der new-Anweisung wählt der Compiler anhand der aktuellen Parameterliste den passenden Konstruktor und ruft ihn mit den angegebenen Argumenten auf. |
|
Wir wollen das vorige Beispiel um einen Konstruktor erweitern, der alle Instanzvariablen initialisiert:
001 public class Auto 002 { 003 public String name; 004 public int erstzulassung; 005 public int leistung; 006 007 public Auto(String name) 008 { 009 this.name = name; 010 } 011 012 public Auto(String name, 013 int erstzulassung, 014 int leistung) 015 { 016 this.name = name; 017 this.erstzulassung = erstzulassung; 018 this.leistung = leistung; 019 } 020 } |
Falls eine Klasse überhaupt keinen expliziten Konstruktor besitzt, wird vom Compiler automatisch ein parameterloser default-Konstruktor generiert. Seine einzige Aufgabe besteht darin, den parameterlosen Konstruktor der Superklasse aufzurufen (was eine »Superklasse« ist, wird im nächsten Kapitel ausführlich erläutert). Enthält eine Klassendeklaration dagegen nur parametrisierte Konstruktoren, wird kein default-Konstruktor erzeugt und die Klassendatei besitzt überhaupt keinen parameterlosen Konstruktor.
Unterschiedliche Konstruktoren einer Klasse können in Java verkettet werden, d.h., sie können sich gegenseitig aufrufen. Der aufzurufende Konstruktor wird dabei als eine normale Methode angesehen, die über den Namen this aufgerufen werden kann. Die Unterscheidung zum bereits vorgestellten this-Pointer nimmt der Compiler anhand der runden Klammern vor, die dem Aufruf folgen. Der im vorigen Beispiel vorgestellte Konstruktor hätte damit auch so geschrieben werden können:
001 public Auto(String name, 002 int erstzulassung, 003 int leistung) 004 { 005 this(name); 006 this.erstzulassung = erstzulassung; 007 this.leistung = leistung; 008 } |
Der Vorteil der Konstruktorenverkettung besteht darin, dass vorhandener Code wiederverwendet werden kann. Führt ein parameterloser Konstruktor eine Reihe von nichttrivialen Aktionen durch, so ist es natürlich sinnvoller, diesen in einem spezialisierteren Konstruktor durch Aufruf wiederzuverwenden, als den Code zu duplizieren.
Wird ein Konstruktor in einem anderen Konstruktor derselben Klasse explizit aufgerufen, muss dies als erste Anweisung innerhalb der Methode geschehen. Steht der Aufruf nicht an erster Stelle, gibt es einen Compiler-Fehler.
Es gibt noch eine zweite Form der Konstruktorenverkettung. Sie findet automatisch statt und dient dazu, abgeleitete Klassen während der Instanzierung korrekt zu initialisieren. In Abschnitt 9.1.4 werden wir auf die Details dieses Mechanismus eingehen. |
|
Beim Instanzieren eines neuen Objekts werden die Initialisierungschritte in einer genau festgelegten Reihenfolge ausgeführt:
Wir wollen dies an einem Beispiel veranschaulichen:
001 /* Listing0818.java */ 002 003 public class Listing0818 004 { 005 public static String getAndPrint(String s) 006 { 007 System.out.println(s); 008 return s; 009 } 010 011 public static void main(String[] args) 012 { 013 Son son = new Son(); 014 } 015 } 016 017 class Father 018 { 019 private String s1 = Listing0818.getAndPrint("Father.s1"); 020 021 public Father() 022 { 023 Listing0818.getAndPrint("Father.<init>"); 024 } 025 } 026 027 class Son 028 extends Father 029 { 030 private String s1 = Listing0818.getAndPrint("Son.s1"); 031 032 public Son() 033 { 034 Listing0818.getAndPrint("Son.<init>"); 035 } 036 } |
Listing0818.java |
Im Hauptprogramm wird eine neue Instanz der Klasse Son
angelegt. Durch die Konstruktorenverkettung wird zunächst zur
Vaterklasse Father verzweigt.
Darin wird zunächst die Membervariable s1
initialisiert und anschließend wird der Rumpf des Konstruktors
ausgeführt. Erst danach führt Son
dieselben Schritte für sich selbst durch. Die Ausgabe des Programms
ist demnach:
Father.s1
Father.<init>
Son.s1
Son.<init>
Neben Konstruktoren, die während der Initialisierung eines Objekts aufgerufen werden, gibt es in Java auch Destruktoren. Sie werden unmittelbar vor dem Zerstören eines Objekts aufgerufen.
Ein Destruktor wird als geschützte (protected) parameterlose Methode mit dem Namen finalize definiert:
001 protected void finalize() 002 { 003 ... 004 } |
Da Java über ein automatisches Speichermanagement verfügt, kommt den Destruktoren in Java eine viel geringere Bedeutung zu als in anderen objektorientierten Sprachen. Anders als etwa in C++ muss sich der Entwickler ja nicht um die Rückgabe von belegtem Speicher kümmern, was beispielsweise eine der Hauptaufgaben von Destruktoren in C++ ist.
Tatsächlich garantiert die Sprachspezifikation noch nicht einmal, dass ein Destruktor überhaupt aufgerufen wird. Wenn er aber aufgerufen wird, so erfolgt dies nicht, wenn die Lebensdauer des Objekts endet, sondern dann, wenn der Garbage Collector den für das Objekt reservierten Speicherplatz zurückgibt. Dies kann unter Umständen nicht nur viel später der Fall sein (der Garbage Collector läuft ja als asynchroner Hintergrundprozess), sondern eben auch gar nicht. Wird nämlich das Programm beendet, bevor der Garbage Collector das nächste Mal aufgerufen wird, werden auch keine Destruktoren aufgerufen. Selbst wenn Destruktoren aufgerufen werden, ist die Reihenfolge oder der Zeitpunkt ihres Aufrufs undefiniert. Der Einsatz von Destruktoren in Java sollte also mit der nötigen Vorsicht erfolgen. |
|
Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 7. Auflage, Addison Wesley, Version 7.0 |
<< | < | > | >> | API | © 1998, 2011 Guido Krüger & Heiko Hansen, http://www.javabuch.de |