Advanced Java Services | Allgemeiner Aufbau einer Klasse |
Durch unseren bisherigen Umgang mit Klassen haben wir folgendes erfahren. Eine Klasse kann eine
Sammlung von statischen Methoden sein, also eine Art Funktionsbibliothek. Eine Klasse ist aber
auch ein Datentyp. Objekte dieses Datentyps werden durch einen Konstruktor initialisiert. Mit
nichtstatischen Methoden, die sich immer auf ein bestimmtes Objekt beziehen, kann man auf ein Objekt
zugreifen. Je nach Art der vorhandenen Methoden, kann man Datenteile des Objektes lesen oder auch
schreibend verändern. Eine Klasse kann auch statische und nichtstatische Methoden zusammen anbieten,
die Klasse String ist nur ein Beispiel für diese Situation.
Ein Objekt besitzt also einen Datenteil, dessen genaue Struktur dem Benutzer der Klasse in der Regel
verborgen bleibt. Dieser Vorgang nennt sich Datenkapselung und ist beabsichtigt. Wer Objekte anlegen
will, soll einen Mechanismus zur Hand haben, der es ihm ermöglicht, Objekte zu initialisieren und
sie dann zu verwenden. Dazu gibt es in einer Klasse Konstruktoren und Methoden. Daß diese richtig
arbeiten, liegt in der Verantwortung des Entwicklers der Klasse und nicht beim Anwender. Methoden und
Konstruktoren stellen also eine Schnittstelle dar, die der Entwickler einer Klasse bereitstellt, damit
ein Anwender dieser Klasse deren Objekte sinnvoll einsetzen kann.
Mit der Darstellung der Klassenaufbaus machen wir den Schritt vom Anwender einer Klasse zum
Entwickler einer Klasse.
Eine allgemeine Klasse besteht aus drei Teilen, einem Datenteil, den Konstruktoren und den Methoden. Meist sind die drei Teile wie folgt angeordnet.
public class MeinInnenLeben { // 1) Datenteil // 2) Konstruktoren // 3) Methoden }
Es müssen keineswegs alle Teile vorhanden sein. Der Entwickler der Klasse bestimmt, welche Teile er für notwendig erachtet. Auch spielt die Reihenfolge der Anordnung der drei Bestandteile keine Rolle. Die oben gewählte Anordnung ist jedoch die, an die sich die meisten Programmierer halten. Wenn man Objekte von Klassen anlegt, die man nicht selbst entworfen hat, so bleibt einem der Datenteil des Objektes verborgen. Einen Konstruktor verwendet man, um das Objekt zu initialisieren, die Methoden fungieren als Schnittstelle, mit denen man auf auf den Datenteil des Objektes zugreifen kann. Durch die Auswahl der Methoden bestimmt der Entwickler, welche Zugriffe auf die Daten eines Objektes er zuläßt. In dem wir nun selbst eine Klasse entwerfen, werden wir sehen, wie man einen Datenteil für ein Objekt entwirft, wie man diesen vor dem Anwender versteckt, und wie das Innenleben von Konstruktoren und Methoden aussieht, die zu einem Datenteil pasend entworfen werden müssen.
Im Datenteil einer Klasse kann man Variablen beliebigen Typs vereinbaren, also auch selbst wieder Objekte. Diese Variablen oder Objekte nennen wir Membervariablen. Wir wollen eine Klasse Person entwerfen. Objekte dieser Klasse sollen einen Vornamen und einen Nachnamen besitzen. Für den Anfang statten wir unsere Klasse nur mit einem Datenteil aus.
public class Person { // Membervariablen String vor ; String nach; }
Nun brauchen wir noch eine Hauptklasse, die die Klasse Person verwendet.
public class PersonDemo1 { public static void main(String args[]) { Person p1 = new Person() ; // Aufruf eines Defaultkonstruktors Person p2 = new Person() ; // Aufruf eines Defaultkonstruktors p1.vor = "Agapi" ; System.out.println(p1.vor); System.out.println(p1.nach); // das geht String test; //System.out.println(test); // das geht nicht ! p2.vor = "Max" ; System.out.println(p2.vor); } }
Das Beispiel lehrt einiges. Obwohl wir keinen Konstruktor in der Klasse Person sehen, akzeptiert der
Compiler den Aufruf des Defaultkonstruktors. Mehr noch, ohne Aufruf des Defaultkonstruktors
läßt sich die Klasse Persondemo gar nicht compilieren, man bekommt die Fehlermeldung
"p1 may not have been initialized". Dagegen können wir p1.nach ohne
Initialisierung ausgeben. Den ebenfalls nichtinitialisierten String test können wir aber
nicht ausgeben, hier erscheint erneut die Fehlermeldung "test may not have been initialized".
Auch das zweite Objekt p2 verfügt über einen Vor- und einen Nachnamen. Jedes Objekt hat
also seinen eigenen Datenteil. Die Datenteile habe jedoch alle den gleichen Aufbau. Die Struktur
des Datenteils wird in der Klasse festgelegt. Einen Datenteil, der für jedes Objekt einzeln
angelegt wird, wollen wir der Genauigkeit halber nichtstatisch nennen.
Nebenbei zeigt das Beispiel noch, wie man mit dem
Punktoprator auf den Datenteil eines Objektes zugreifen kann. Das scheinbar widersprüchliche
Verhalten des Compilers läßt sich leicht klären.
Unsere bisherige Klasse Person ist zwar formal eine solche, konzeptionell aber auf einer vorobjektorientierten Stufe. Sie ist, was man in C ein struct nennt oder noch älter in PASCAL ein record . Das liegt daran, daß wir einen direkten Zugriff auf den Datenteil in der Form objekt.member gestatten. Genau das werden wir nun unterbinden.
Wir stellen uns vor, daß die Entwicklung unserer Klasse Person eine Auftragsarbeit ist. Ein anderer Programmierer hat uns gebeten, für ihn diese Klasse zu entwickeln. Paradoxerweise verbieten wir ihm nun als erstes den direkten Zugriff auf den Datenteil eines Objektes und ändern unseren Entwurf wie folgt ab.
public class Person { // Membervariablen private String vor ; private String nach; }
Versucht man nun die Klasse PersonDemo zu compilieren, stößt man auf die Fehlermeldung "vor has private access in Person" . Der direkte Zugriff der Form objekt.member wird also durch den modifier private unterbunden. Damit unsere Klasse nicht unbrauchbar wird, müssen wir einen Ersatz für den direkten Zugriff bereitstellen. Mit Hilfe von Methoden schaffen wir eine Schnittstelle, die mit dem Datenteil kommuniziert. Der wesentliche Unterschied zur bisherigen Vorgehensweise ist, daß nun wir als Entwickler die Art und Weise des Zugriffs bestimmen. Wir können etwa nur einen lesenden Zugriff auf die Daten gestatten, das sieht dann so aus.
public class Person { // Membervariablen private String vor ; private String nach; //Zugriffsmethoden für lesenden Zugriff public String getVorname() { return vor; } public String getNachname() { return nach; } }
Das Schlüsselwort public erklärt sich nun fast von selbst. Es bedeutet, daß wir
den Zugriff objekt.methode() auf die beiden Methoden getVorname() und getNachname()
explizit erlauben. Die Namensgebung der Methoden richtet sich nach den Konventionen für
objektorientiertes Design. Methoden, die einen Teil des Datenteils liefern sind sog. get-Methoden
(englisch getter-methods). Nach dem kleingeschriebenen get sollte der Name dessen folgen, was die
Methoden liefern.
Der Umweg über eine eigene Schnittstelle wird sich als großer Vorteil erweisen. Will der
Entwickler z.B. nur einen lesenden Zugriff auf die Daten gestatten, stattet er die Klasse eben nur mit
get-Methoden aus. Solche feinen Abstufungen sind beim direkten Zugriff nicht möglich. Wir wollen
jedoch auch einen schreibenden Zugriff erlauben und bieten daher noch zwei set-Methoden an.
public class Person { // Membervariablen private String vor ; private String nach; //Zugriffsmethoden für lesenden Zugriff public String getVorname() { return vor; } public String getNachname() { return nach; } //Zugriffsmethoden für schreibenden Zugriff public void setVorname(String v) { vor = v ; } public void setNachname(String n) { nach = n ; } }
Ein einfaches Javaprogramm, daß diese Klasse verwendet, kann so aussehen:
public class PersonDemo2 { public static void main(String args[]) { Person p = new Person() ; // Aufruf eines Defaultkonstruktors System.out.print("Vorname : "); p.setVorname( Stdin.stringEingabe() ); System.out.print("Nachname : "); p.setNachname( Stdin.stringEingabe() ); System.out.println("Ihr Vorname : " + p.getVorname() ); System.out.println("Ihr Nachname : " + p.getNachname() ); } }
Bereits jetzt kann sich unsere Klasse sehen lassen. Unser Auftraggeber bittet uns um die Entwicklung einer weiteren Methode zum Ausgeben des Nach- und Vornamens auf die Konsole um nicht dauernd das umständliche System.out.println() schreiben zu müssen. Wir erfüllen ihm den Wunsch:
public class Person { // Membervariablen private String vor ; private String nach; // Zugriffsmethoden für lesenden Zugriff public String getVorname() { return vor; } public String getNachname() { return nach; } // Zugriffsmethoden für schreibenden Zugriff public void setVorname(String v) { vor = v ; } public void setNachname(String n) { nach = n ; } // Ausgabe des Namens auf die Konsole public void println() { System.out.println( getNachname() + " " + getVorname() ); } }
Unser Auftraggeber ist zufrieden bis er die Methode auf ein Objekt anwenden will, von dem er nur den Vornamen kennt. Er schreibt:
p.setVorname("Agapi"); p.println() ;
und erhält als Ausgabe
null Agapi
Der Grund ist schnell gefunden. Die Stringreferenz nach im Datenteil des Objektes ist nicht initialisiert und hat daher den Wert null. Nun sind wir an einem Punkt, wo wir einen Konstruktor sinnvoll einsetzen können. Wir schreiben zur Demonstration gleich mehrere Konstruktoren. Jeder Konstruktor hat die Aufgabe, den Datenteil des Objekts in einen definierten Anfangszustand zu setzen.
public class Person { // Membervariablen private String vor ; private String nach; // Konstruktoren public Person() { vor = nach = "" ; } public Person(String n) { vor = "" ; nach = n ; } public Person(String v, String n) { vor = v ; nach = n ; } // Zugriffsmethoden für lesenden Zugriff public String getVorname() { return vor; } public String getNachname() { return nach; } // Zugriffsmethoden für schreibenden Zugriff public void setVorname(String v) { vor = v ; } public void setNachname(String n) { nach = n ; } // Ausgabe des Namens auf die Konsole public void println() { System.out.println( getNachname() + " " + getVorname() ); } }
Damit haben wir eine Klasse geschaffen, deren Objekte immer in einem definierten Anfangszustand erscheinen. Ein Javaprogramm, daß diese Klasse verwendet, kann so aussehen:
public class PersonDemo { public static void main(String args[]) { Person p = new Person("Straub") ; System.out.print("Vorname : "); p.setVorname( Stdin.stringEingabe() ); p.println() ; } }
Im Zusammenhang mit Objekten ist bei Zuweisungen Vorsicht geboten. Das folgende kleine Beispiel soll die Unterschiede zu primitiven Datentypen verdeutlichen.
public class Vorsicht { public static void main(String args[]) { int a = 17 , b ; // 1 b = a ; // 2 b = 34 ; // 3 System.out.println("a = " + a); // a ist 17 // 4 Person p1 = new Person("Heinrich", "Heine") , p2; // 5 p2 = p1 ; // 6 p2.setNach("Mann"); // 7 p1.println(); // ergibt Heinrich Mann // 8 } }
Wir haben hier sinngemäß zweimal den gleichen Vorgang. Eine Variable wird mit einem Wert vorbelegt, eine zweite gleichen Typs hat noch keinen Wert (//1 und //5). Sodann wird die zweite Variable mit dem Wert der ersten Variablen initialisiert (//2 und //6) . Anschließend wird die zweite Variable (im zweiten Fall nur teilweise) neu belegt (//3 und //7) . Danach wird die erste Variable ausgegeben (//4 und //8) . Wir erwarten für a selbstverstzändlich den Wert 17. Die Ausgabe von p1 ergibt jedoch Heinrich Mann und nicht mehr Heinrich Heine. Die Zuweisung //6 ist nämlich lediglich (aber konsequenterweise) eine Zeigerkopie. Nach //6 zeigen also p1 und p2 auf das gleiche Objekt. Will man eine echte Kopie, so kann man das mit Hilfe von Konstruktoren erreichen. Wir ersetzen //6 durch
p2 = new Person( p1.getVorName() , p1.getNachName() ) ;
Das ist allerdings ziemlich aufwendig. Wenn eine Klasse einen sogenannten copy-constructor anbietet, wird der Vorgang einfacher. Für unsere Klasse kann ein Kopierkonstruktor folgendermaßen aussehen.
public Person(Person p) { vor = new String(p.vor) ; nach = new String(p.nach) ; }
Wir stützen uns dabei auf den Kopierkonstruktor der Klasse String. Damit vereinfacht sich Zeile //6 wie folgt
p2 = new Person(p1) ;
In Java werden Kopierkonstruktoeren weniger häufig eingesetzt als in C++ , trotzdem sind sie auch hier durchaus nützlich.
Wir fassen zusammen:
Die Membervariablen einer Klasse sind eine Art von globalen Variablen, denn ihr Gültigkeitsbereich erstreckt sich über die ganze Klasse. Dabei ist es gleichgültig, an welcher Stelle die Membervariablen vereinbart werden. Variablen, die dagegen in einer Methode vereinbart werden, existieren nur in dieser Methode. Hier allerdings erst ab der Stelle, an der sie vereinbart werden. Bei gleichen Namen überdeckt die lokale Variable die globale Variable.
public class LokalGlobal { public void methode1() { s = "Hello"; // methode1() kann auf s zugreifen // 1 //a = 17 ; // Hier ist a noch nicht bekannt // 2 int a ; a = 17 ; // Zugriff auf a nach Vereinbarung OK // 3 } String s ; double b ; public void methode2() { s = "Auch Hallo"; // methode2() kann auf s zugreifen // 4 //a = 17 ; // Hier ist a nicht bekannt // 5 int b ; b = -17 ; // lokales b überdeckt Membervariable b // 6 this.b = 17.5 ; // Zugriff auf Membervariable b mit Hilfe von this // 7 } public void methode3() { int c ; if (true) { // int c ; c is already defined in methode3() int d = 17 ; } } }
Man kann in Java nach jeder sich öffnenden geschweiften Klammer Variablen definieren. In methode3() gibt es ein if-Konstrukt, wo im true-Zweig Variablen vereinbart werden. Auf dieser Ebene gilt das Überdeckungsprinzip nicht mehr. Will man im true-Zweig die Variable c vereinbaren, erhält man die Fehlermeldung "c is already defined in methode3()".
Der Zugriff auf Membervariablen unter //1 oder //4 ist zwar einfach, aber im Grunde merkwürdig. Das Eingangsbeispiel PersonDemo1 zeigt uns, daß eine Membervariable ein Teil eines Objektes ist. Zu welchem Objekt also gehört dann s. Die Antwort ist die: s gehört zu dem gleichen Objekt, zu dem die nichtstatische Methode aufgerufen wird, in der s verwendet wird. Das heißt aber, daß innerhalb der Methode das zugehörigen Objekt bekannt sein muß. Dies wird folgendermaßen erreicht: Jede nichtstatische Methode erhält bei ihrem Aufruf generell einen zusätzlichen Parameter, der nicht in der Parameterliste steht. Dieser Parameter ist eine Kopie der Referenz auf das aktuelle Objekt. Egal, wie das aktuelle Objekt heißt, die kopierte Referenz hat innerhalb der Methode immer den gleichen Namen this. In //7 wird this dazu verwendet, den Namenskonflikt aufzulösen und auf die Membervariable b zuzugreifen. Die Verwendung von s in //1 oder //4 in der Form
s = "Hello";
ist also im Grunde eine (zulässige) Abkürzung für die genauere, aber längere Schreibweise
this.s = "Hello";
Es ist sehr wichtig zu wissen, daß nur nichtstatische Methoden diesen Zeiger erhalten, statische Methoden erhalten beim Aufruf keine Referenz auf this. Das ist der Grund, warum man statische Methoden zum Namen der Klasse aufrufen kann. Der Name der Klasse selbst stellt ja kein Objekt da, folglich kann eine statische Methode auch keine Referenz this erhalten.
Erweiterung der Klasse Person
Änderung des Datenteils der Klasse Person
Entwicklung einer Klasse Address