Advanced 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 bzw. 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 und/oder Eigenschaften (Properties) // 4) Destruktor }
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 passend 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 public string vor ; public 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" ; Console.WriteLine(p1.vor); Console.WriteLine(p1.nach); // das geht string test; //Console.WriteLine(test); // das geht nicht ! p2.vor = "Max" ; Console.WriteLine(p2.vor); } }
Das Beispiel lehrt einiges. In den ersten beiden Zeilen haben wir einen sogenannten
Defaultkonstruktor ( new Person() ) aufgerufen, ja aufrufen müssen. Ohne Aufruf des Defaultkonstruktors
läßt sich die Klasse Persondemo1 gar nicht compilieren, man bekommt die Fehlermeldung
"Use of unassigned local variable". Zu was ein Konstruktor dient, werden weiter unten klären.
Offensichtlich erzwingt der Compiler dessen Verwendung. Da unsere Klasse nur aus zwei Strings
besteht hat offensichtlich der Compiler diesen Konstruktor generiert.
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 "Use of unassigned local variable".
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. Die Variablen, die diesen
Datenteil bilden nennen wir Instanzvariablen.
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 "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 C#-Programm, daß diese Klasse verwendet, kann so aussehen:
public class PersonDemo2 { public static void Main(string[] args) { Person p = new Person() ; // Aufruf eines Defaultkonstruktors Console.Write("Vorname : "); p.SetVorname( Console.ReadLine() ); Console.Write("Nachname : "); p.SetNachname( Console.ReadLine() ); Console.WriteLine("Ihr Vorname : " + p.GetVorname() ); Console.WriteLine("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 Console.WriteLine() 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() { Console.WriteLine( nach + " " + vor ); } }
Die Verwendung der Methode Println() in einem einfachen Programm sieht wie folgt aus.
class PersonDemo3 { static void Main(string[] args) { Person p = new Person(); // Aufruf eines Defaultkonstruktors Console.Write("Vorname : "); p.SetVorname(Console.ReadLine()); Console.Write("Nachname : "); p.SetNachname(Console.ReadLine()); Console.WriteLine("Ihr Name : "); p.Println(); } }
C# verfügt im Gegensatz zu Java über sogenannte Properties. Diese bilden eine alternative Möglichkeit um auf die privaten Daten lesend und/oder schreibend zuzugreifen. Properties erscheinen syntaktisch wie Variablen. Es hat den Anschein, als greife man auf die privaten Daten zu. Wir bauen unser Beispiel um und verwenden Properties an Stelle von Methoden.
public class Person { // Membervariablen private string vorname ; private string nachname; // Properties für lesenden und schreibenden Zugriff public string Vorname { get { return vorname; } set { vorname = value; } } public string Nachname { get { return nachname; } set { nachname = value; } } // Ausgabe des Namens auf die Konsole public void Println() { Console.WriteLine( nachname + " " + vorname ); } }
Das Schlüsselwort "value" steht für den "von außen" kommenden Wert und entspricht dem in einer Set-Methode übergebenen Parameter. Tatsächlich werden Properties vom Compiler in Methodenaufrufe übersetzt. Die Verwendung der Properties in einem einfachen Programm sieht wie folgt aus.
class PersonDemo4 { static void Main(string[] args) { Person p = new Person(); // Aufruf eines Defaultkonstruktors Console.Write("Vorname : "); p.Vorname = Console.ReadLine(); Console.Write("Nachname : "); p.Nachname = Console.ReadLine(); Console.WriteLine("Ihr Name : "); p.Println(); } }
Hinweis:
Da eine Property immer eine Schnittstelle zu privaten Daten darstellt, kann der Typ einer Property
nicht void sein
(Compilerfehler: property or indexer cannot have void type).
Das Codieren von Properties ist immer eine Routinearbeit und erfolgt stets nach dem gleichen Schema. Wenn man sich an den Standard hält, beginnen die privaten Variablen mit einem kleinen Buchstaben, die Properties übernehmen dann diesen Namen, beginnen aber mit einem großen Buchstaben. Ab der Version 3.0 von C# gibt es eine Kurzschreibweise mit der man die Arbeit dem Compiler übertragen kann. Das sieht wie folgt aus.
public class Person { // auto-implemented properties public string Vorname { get; set; } public string Nachname { get; set; } // Ausgabe des Namens auf die Konsole public void Println() { Console.WriteLine( Nachname + " " + Vorname ); } }
Hinweis:
Man beachte, daß auto-implemented properties den Access-Modifier public haben.
Man beachte weiter, daß bei der Vereinbarung der automatisch generierten Properties nach der schließenden geschweiften
Klammer kein Semikolon steht (und auch keines stehen darf)!
Man vereinbart nun im privaten Datenteil keine Membervariablen mehr, sondern deklariert gleich die Namen der Properties, also mit einem großen Buchstaben beginnend. Dahinter steht dann in geschweiften Klammern nur noch get und set. Main sieht genauso aus wie vorher.
class PersonDemo4 { static void Main(string[] args) { Person p = new Person(); // Aufruf eines Defaultkonstruktors Console.Write("Vorname : "); p.Vorname = Console.ReadLine(); Console.Write("Nachname : "); p.Nachname = Console.ReadLine(); Console.WriteLine("Ihr Name : "); p.Println(); } }
Wir betrachten eine Situation, in der ein Entwickler für eine Property nach außen nur einen lesenden Zugriff erlauben will, diese aber innerhalb der Klasse manipulieren muß. Der folgende Versuch mißlingt.
class Catch22 { public int Moves { get; } private void MoveIncrement() { Moves++; // Error: Property cannot be assigned to, it is read only } //.. }
Zusätzlich zu dem oben erwähnten Fehler, der vom Compiler sofort moniert wird, ergibt ein Build einen weiteren Fehler. Error: Automatically implemented properties must define both get and set accessors. Also muß auch ein set geschrieben werden. Es ist allerdings möglich innerhalb der geschweiften Klammern den Zugriff durch Accessmodifier einzuschränken. Dies führt zu folgender Lösung.
class Catched
{
public int Moves { get; private set; }
private void MoveIncrement()
{
Moves++; // OK
}
//..
}
Unser Auftraggeber ist zufrieden bis er eine kleine Variation in sein Programm einbauen möchte. Er möchte nur dann eine Ausgabe haben, wenn der Nachname bekannt ist und probiert daher folgendes:
Person p = new Person(); if (p.Nachname.Length > 0) p.Println();
Das läßt sich fehlerfrei compilieren, führt aber zu einem Laufzeitfehler: NullReferenceException 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. Der Konstruktor hat nämlich die Aufgabe, den Datenteil eines in einen definierten und sinnvollen Anfangszustand zu setzen. Der vom Compiler generierte Defaultkonstruktor dagegen sichert nur das formale Vorhandensein, aber er initialisiert keine Variablen. Eine sinnvolle Initialisierung kann ja nur der Entwickler der Klasse selbst vornehmen. 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() { Console.WriteLine( nach + " " + vor) ); } }
Damit haben wir eine Klasse geschaffen, deren Objekte immer in einem definierten Anfangszustand erscheinen. Ein C#-Programm, das diese Klasse verwendet, kann so aussehen:
public class PersonDemo { public static void Main(string[] args) { Person p = new Person("Straub") ; Console.Write("Vorname : "); p.SetVorname( Console.readLine() ); p.Println() ; } }
Da Properties in C# oft bevorzugt verwendet werden hier nochmal unsere Klasse Person, diesmal aber mit Properties im Datenteil. Die Setters und Getters kann man sich in dieser Variante sparen. Auch hier ist es natürlich die Aufgabe des Konstruktors den Datenteil zu initialisieren.
public class Person { // Membervariablen // auto-implemented properties public string Vorname { get; set; } public string Nachname { get; set; } // Konstruktoren public Person() { Vorname = Nachname = "" ; } public Person(string n) { Vorname = "" ; Nachname = n ; } public Person(string v, string n) { Vorname = v ; Nachname = n ; } // Ausgabe des Namens auf die Konsole public void Println() { Console.WriteLine( nach + " " + vor) ); } }
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 Console.WriteLine("a = " + a); // a ist 17 // 4 Person p1 = new Person("Heinrich", "Heine") , p2; // 5 p2 = p1 ; // 6 p2.SetNachname("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 C# werden Kopierkonstruktoren weniger häufig eingesetzt als in C++ , trotzdem sind sie auch
hier durchaus nützlich.
Wir fassen zusammen.
Wie in C++ so gibt es auch in C# einen Destruktor. Dieser wird automatisch vom Garbage Collector gerufen, wenn ein Objekt im Hauptspeicher gelöscht wird, weil es keine Referenz mehr auf dieses Objekt gibt. Wann der Garbage Collector das genau macht, kann man nicht beeinflussen, spätestens beim Programmende wird aber der gesamte vom Programm belegte Speicher freigegeben. Die klassische Aufgabe des Destruktors ist es, Resourcen freizugeben, die das Objekt in seiner Lebenszeit angefordert hat.
~Person() { // Freigeben von Resourcen Console.WriteLine("Destruktor"); }
Für einen Destruktor ist folgendes zu beachten.
Der Compiler übersetzt einen Destruktor in einen Aufruf der
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 C# 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