Kapitel 9 Mehr zu Klassen
In diesem Kapitel werden verschiedene Probleme bei
der Verwendung von Klassen behandelt, einschließlich Erstellungsroutinen, Verschachtelung sowie Überladungsregeln.
9.1 Verschachtelte Klassen
 
Gelegentlich ist es hilfreich, Klassen in anderen
Klassen zu verschachteln, beispielsweise dann, wenn eine Helferklasse nur von einer weiteren Klasse verwendet wird. Die
Zugänglichkeit der verschachtelten Klasse folgt ähnlichen
Regeln wie denen für die Interaktion von Klassen- und Mitgliedsmodifikatoren. Wie bei Mitgliedern auch, definieren die Zugriffsmodifikatoren
einer verschachtelten Klasse den Zugriff auf die verschachtelte Klasse
von außerhalb. Ebenso wie ein privates Feld innerhalb einer Klasse
immer sichtbar ist, so ist auch eine private verschachtelte Klasse innerhalb
der umgebenden Klasse sichtbar.
Im folgenden Beispiel verfügt die Parser-Klasse über die Klasse Token, die intern
verwendet wird. Ohne Klassenverschachtelung könnte der entsprechende Code so aussehen:
public class Parser
{
Token[] tokens;
}
public class Token
{
string name;
}
In diesem Beispiel sind sowohl die Parser-
als auch die Token-Klasse öffentlich zugänglich,
was nicht optimal ist. Nicht nur, dass die Klasse Token
bei der Auflistung von Klassen Platz einnimmt, sie ist darüber
hinaus nicht besonders nützlich. Daher ist es sinnvoll, eine Klassenverschachtelung vorzunehmen, um die Klasse als private
zu deklarieren und sie damit nur gegenüber der Klasse Parser
offen zu legen.
Hier der überarbeitete Code:
public class Parser
{
Token[] tokens;
private class Token
{
string name;
}
}
Nun ist die Klasse Token für niemanden
außer für Parser sichtbar. Eine andere Möglichkeit
besteht darin, die Klasse Token als internal
zu deklarieren, damit sie außerhalb der Assemblierung nicht sichtbar
ist. Bei dieser Lösung wird Token allerdings innerhalb
der Assemblierung weiterhin offen gelegt.
Des Weiteren kann bei dieser Lösung ein wichtiger
Vorteil der Klassenverschachtelung nicht genutzt werden. Bei einer verschachtelten
Klasse kann der Leser des Quellcodes davon ausgehen, dass die Token-Klasse
gefahrlos ignoriert werden kann, solange nicht die internen Details
von Parser von Bedeutung sind. Wird dieser Aufbau auf eine
vollständige Assemblierung angewendet, kann der Code erheblich
vereinfacht werden.
Die Verschachtelung kann auch als Mittel zur Strukturierung
eingesetzt werden. Wenn die Parser-Klasse sich beispielsweise
in einem Namespace namens Language befände, müssten
Sie vielleicht einen separaten Namespace namens Parser bereitstellen,
um die Klassen für Parser gut zu strukturieren. Dieser
neue Namespace würde dann die Token-Klasse und eine
umbenannte Parser-Klasse enthalten. Durch Verwendung verschachtelter
Klassen kann die Klasse Parser im Namespace Language
verbleiben und die Klasse Token enthalten.
9.2 Weitere Verschachtelungen
 
Es können neben Klassen auch Schnittstellen,
struct- oder enum-Schlüsselwörter
innerhalb von Klassen verschachtelt werden.
9.3 Erstellung, Initialisierung, Zerstörung
 
In allen objektorientierten Systemen spielen Erstellung,
Initialisierung und Zerstörung eines Objekts eine wichtige Rolle.
In der .NET-Laufzeitumgebung kann der Programmierer die Zerstörung von
Objekten nicht steuern, es ist jedoch wichtig zu wissen, welche Bereiche
gesteuert werden können.
9.4 Erstellungsroutinen
 
In C# gibt es keine Standarderstellungsroutine zur
Erstellung von Objekten. Für Klassen kann ggf. eine standardmäßige
(z. B. eine parameterlose) Erstellungsroutine geschrieben werden.
Eine Erstellungsroutine kann über die Syntax
base eine Erstellungsroutine des Basistyps auslösen:
using System;
public class BaseClass
{
public BaseClass(int x)
{
this.x = x;
}
public int X
{
get
{
return(x);
}
}
int x;
}
public class Derived: BaseClass
{
public Derived(int x): base(x)
{
}
}
class Test
{
public static void Main()
{
Derived d = new Derived(15);
Console.WriteLine("X = {0}", d.X);
}
}
In diesem Beispiel leitet die Erstellungsroutine
für die Derived-Klasse lediglich die Erstellung des Objekts an die Erstellungsroutine
BaseClass weiter.
Gelegentlich ist es für eine Erstellungsroutine
hilfreich, die Erstellung an eine andere Erstellungsroutine im selben
Objekt weiterzuleiten.
using System;
class MyObject
{
public MyObject(int x)
{
this.x = x;
}
public MyObject(int x, int y): this(x)
{
this.y = y;
}
public int X
{
get
{
return(x);
}
}
public int Y
{
get
{
return(y);
}
}
int x;
int y;
}
class Test
{
public static void Main()
{
MyObject my = new MyObject(10, 20);
Console.WriteLine("x = {0}, y = {1}", my.X, my.Y);
}
}
9.4.1 Initialisierung
 
Wenn es sich beim Standardwert des Feldes nicht
um den gewünschten Wert handelt, kann dieser in der Erstellungsroutine
gesetzt werden. Sind mehrere Erstellungsroutinen für das Objekt
vorhanden, kann es bequemer sein, den Wert statt durch eine Festlegung
in jeder Erstellungsroutine über eine Initialisierung festzulegen.
Die Initialisierung ist gleichzeitig weniger fehleranfällig.
Hier ein Beispiel zur Funktionsweise der Initialisierung:
public class Parser
{
public Parser(int number)
{
this.number = number;
}
int number;
}
class MyClass
{
public int counter = 100;
public string heading = "Top";
private Parser parser = new Parser(100);
}
Die Handhabung ist einfach, die Anfangswerte können
bei der Deklaration eines Mitglieds gesetzt werden. Gleichzeitig vereinfacht
dieses Vorgehen die Verwaltung, da klar ist, wie der anfängliche
Wert eines Mitglieds lautet.
9.4.2 Zerstörungsroutinen
 
Streng genommen hat C# keine Zerstörungsroutinen
(Destruktoren), zumindest nicht in dem Sinne, dass eine Zerstörungsroutine
aufgerufen wird, wenn das Objekt gelöscht werden soll.
Die in C# verwendete Zerstörungsroutine ist
mit den so genannten Finalisierungsroutinen anderer Sprachen vergleichbar und wird von der
Speicherbereinigung aufgerufen, wenn das Objekt bereinigt wird. Dies
bedeutet, dass der Programmier keine direkte Kontrolle darüber
hat, wann die Zerstörungsroutine aufgerufen wird. Aus diesem Grund
ist die Zerstörungsroutine auch weniger hilfreich als in Sprachen
wie beispielsweise C++. Wenn die Bereinigung über eine Zerstörungsroutine
durchgeführt wird, sollte eine weitere Methode vorhanden sein,
mit der der Benutzer die gleiche Operation auch direkt durchführen
kann.
Weitere Informationen zu diesem Thema finden Sie
im Abschnitt zur Speicherbereinigung in Kapitel 31, C# im Detail.
9.5 Überladung und Namensausblendung
 
In C#-Klassen – und in der Common Language Runtime im Allgemeinen – werden Mitglieder basierend
auf Anzahl und Typ der zugehörigen Parameter überladen. Das
Überladen erfolgt nicht auf Grundlage des Rückgabetyps der
Funktion1 .
// Fehler
using System;
class MyObject
{
public string GetNextValue(int value)
{
return((value + 1).ToString());
}
public int GetNextValue(int value)
{
return(value + 1);
}
}
class Test
{
public static void Main()
{
MyObject my = new MyObject();
Console.WriteLine("Next: {0}", my.GetNextValue(12));
}
}
Dieser Code kann nicht kompiliert werden, da die
überladenen GetNextValue()-Funktionen sich nur im Rückgabetyp unterscheiden und
der Compiler nicht ermitteln kann, welche Funktion aufgerufen werden
muss. Es ist deshalb ein Fehler, Funktionen zu deklarieren, die sich
lediglich hinsichtlich des Rückgabetyps unterscheiden.
9.5.1 Namensausblendung
 
In C# werden Methodennamen basierend auf dem Namen der Methode, nicht auf
Grundlage der Methodensignatur verborgen. Sehen Sie sich folgendes Beispiel an:
// Fehler
using System;
public class Base
{
public int Process(int value)
{
Console.WriteLine("Base.Process: {0}", value);
}
public class Derived: Base
{
public int Process(string value)
{
Console.WriteLine("Derived.Process: {0}", value);
}
}
class Test
{
public static void Main()
{
Derived d = new Derived();
d.Process("Hello");
d.Process(12); // Fehler
((Base) d).Process(12); // in Ordnung
}
}
Wenn die zwei überladenen Process()-Funktionen sich in derselben Funktion befänden, wären
sie beide zugänglich. Da sie sich jedoch in unterschiedlichen Klassen
befinden, wird durch die Definition von Process() in der
abgeleiteten Klasse der Name in der Basisklasse an allen
Stellen ausgeblendet, an denen er verwendet wird.
Um auf beide Funktionen zugreifen zu können,
müsste über Derived die in der Basisklasse enthaltene
Process()-Version überladen werden; anschließend
müsste der Aufruf an die Implementierung der Basisklasse weitergeleitet
werden.
9.6 Statische Felder
 
Es ist manchmal sinnvoll, Mitglieder eines Objekts
zu definieren, die nicht mit einer spezifischen Klasseninstanz, jedoch
mit der Klasse als Ganzes verknüpft sind. Solche Mitglieder werden
als statische (static) Mitglieder bezeichnet.
Ein statisches Feld ist die einfachste Form eines
statischen Mitglieds. Zur Deklaration eines statischen Feldes platzieren
Sie einfach den Modifikator static vor der Variablendeklaration. Der folgende Code könnte beispielsweise dazu
verwendet werden, die Anzahl der erstellten Klasseninstanzen zu verfolgen.
using System;
class MyClass
{
public MyClass()
{
instanceCount++;
}
public static int instanceCount = 0;
}
class Test
{
public static void Main()
{
MyClass my = new MyClass();
Console.WriteLine(MyClass.instanceCount);
MyClass my2 = new MyClass();
Console.WriteLine(MyClass.instanceCount);
}
}
Die Erstellungsroutine für das Objekt erhöht den Instanzenzähler,
und der Instanzenzähler kann referenziert werden, um die Anzahl
der bisher erstellten Objektinstanzen zu ermitteln. Auf ein statisches
Feld wird nicht über die Instanz der Klasse, sondern über
den Namen der Klasse zugegriffen; dies gilt für alle statischen
Mitglieder.
9.7 Statische Mitgliedsfunktionen
 
Im vorangegangenen Beispiel wird ein internes Feld
offen gelegt – eine nicht empfohlene Vorgehensweise. Das Codebeispiel
kann so geändert werden, dass statt eines statischen Feldes eine
statische Mitgliedsfunktion verwendet wird:
using System;
class MyClass
{
public MyClass()
{
instanceCount++;
}
public static int GetInstanceCount()
{
return(instanceCount);
}
static int instanceCount = 0;
}
class Test
{
public static void Main()
{
MyClass my = new MyClass();
Console.WriteLine(MyClass.GetInstanceCount());
}
}
Mit diesem Code wird das gewünschte Ziel erreicht,
und gleichzeitig wird das Feld den Benutzern der Klasse gegenüber
nicht offen gelegt, d. h., die Flexibilität hinsichtlich zukünftiger
Codeänderungen wird erhöht. Da es sich um eine statische Mitgliedsfunktion
handelt, wird sie über den Klassennamen und nicht über den Namen einer Klasseninstanz aufgerufen.
Im wirklichen Leben würde in diesem Fall wohl
eher eine statische Eigenschaft verwendet. Diese werden in Kapitel 18,
Eigenschaften, besprochen.
9.8 Statische Erstellungsroutinen
 
Ebenso wie es statische Mitglieder gibt, gibt es
auch statische Erstellungsroutinen. Eine statische Erstellungsroutine
wird vor der Erstellung der ersten Objektinstanz aufgerufen und ist
bei der Ausführung von einmalig anfallenden Setupaufgaben nützlich.
Eine statische Erstellungsroutine wird durch einfaches
Hinzufügen von static vor der Erstellungsroutinendefinition
deklariert. Eine statische Erstellungsroutine kann keine Parameter tragen.
class MyClass
{
static MyClass()
{
}
}
Es gibt zur Zerstörungsroutine keine analoge
statische Zerstörungsroutine.
9.9 Konstanten
 
C# ermöglicht die Definition von Werten als
Konstanten. Soll ein Wert als Konstante definiert werden, muss der Wert
etwas sein, dass als Konstante geschrieben werden kann. Diese Anforderung
schränkt die Konstantentypen auf die integrierten Typen ein, die
als literale Werte geschrieben werden können.
Das Platzieren von const vor einer
Variablen bedeutet, dass deren Wert nicht geändert werden kann.
Hier ein Konstantenbeispiel:
using System;
enum MyEnum
{
Jet
}
class LotsOLiterals
{
// const-Elemente können nicht geändert werden
// const impliziert static
public const int value1 = 33;
public const string value2 = "Hello";
public const MyEnum value3 = MyEnum.Jet;
}
class Test
{
public static void Main()
{
Console.WriteLine("{0} {1} {2}",
LotsOLiterals.value1,
LotsOLiterals.value2,
LotsOLiterals.value3);
}
}
9.10 Schreibgeschützte Felder
 
Aufgrund der Einschränkung für Konstantentypen, dass diese nämlich zur Kompilierungszeit
bekannt sein müssen, kann const nicht in vielen Situationen
eingesetzt werden.
In einer Color-Klasse kann es sehr hilfreich sein, Konstanten als Teil
der Klasse für die Standardfarben zu verwenden. Würden keine
Beschränkungen für const gelten, könnte
der folgende Code verwendet werden:
// Fehler
class Color
{
public Color(int red, int green, int blue)
{
this.red = red;
this.green = green;
this.blue = blue;
}
int red;
int green;
int blue;
// new-Aufruf kann nicht mit static verwendet werden
public static const Color Red = new Color(255, 0, 0);
public static const Color Green = new Color(0, 255, 0);
public static const Color Blue = new Color(0, 0, 255);
}
class Test
{
static void Main()
{
Color background = Color.Red;
}
}
Dies funktioniert nicht, da die statischen Mitglieder
Red, Green und Blue nicht zur
Kompilierungszeit berechnet werden können. Diese Mitglieder in
normale öffentliche Mitglieder zu ändern, funktioniert auch
nicht, da jedermann den Wert Rot in Olivgrün oder Braunrot ändern
könnte.
Der Modifikator readonly ist genau
für diese Situation gedacht. Durch das Verwenden von readonly
kann der Wert in der Erstellungs- oder Initialisierungsroutine gesetzt,
später jedoch nicht mehr bearbeitet werden.
Da die Farbwerte der Klasse und nicht einer spezifischen
Klasseninstanz angehören, werden sie in der statischen Erstellungsroutine
initialisiert.
class Color
{
public Color(int red, int green, int blue)
{
this.red = red;
this.green = green;
this.blue = blue;
}
int red;
int green;
int blue;
public static readonly Color Red;
public static readonly Color Green;
public static readonly Color Blue;
// statische Erstellungsroutine
static Color()
{
Red = new Color(255, 0, 0);
Green = new Color(0, 255, 0);
Blue = new Color(0, 0, 255);
}
}
class Test
{
static void Main()
{
Color background = Color.Red;
}
}
So wird das richtige Verhalten sichergestellt.
Wenn die Anzahl der statischen Mitglieder sehr hoch
oder deren Erstellung sehr ressourcenintensiv ist (entweder im Hinblick auf die Zeit oder den
Speicher), ist es eventuell sinnvoller, diese als readonly-Eigenschaften
zu deklarieren, damit die Mitglieder nach Bedarf erstellt werden können.
Auf der anderen Seite kann es einfacher sein, eine
Auflistung der unterschiedlichen Farbnahmen zu definieren und nach Bedarf
Instanzen der Werte zurückzugeben.
class Color
{
public Color(int red, int green, int blue)
{
this.red = red;
this.green = green;
this.blue = blue;
}
public enum PredefinedEnum
{
Red,
Blue,
Green
}
public static Color GetPredefinedColor(
PredefinedEnum pre)
{
switch (pre)
{
case PredefinedEnum.Red:
return(new Color(255, 0, 0));
case PredefinedEnum.Green:
return(new Color(0, 255, 0));
case PredefinedEnum.Blue:
return(new Color(0, 0, 255));
default:
return(new Color(0, 0, 0));
}
}
int red;
int blue;
int green;
}
class Test
{
static void Main()
{
Color background =
Color.GetPredefinedColor(Color.PredefinedEnum.Blue);
}
}
Diese Methode erfordert etwas mehr Eingaben, es
ergibt sich jedoch weder eine Starteinbuße noch sind viele Objekte
vorhanden, die Speicher belegen. Darüber hinaus wird die Klassenschnittstelle
einfach gehalten. Wenn die vordefinierten Farben 30 Mitglieder
umfassen würden, wäre die Klasse sehr viel schwerer zu verstehen.
In C# jedoch stellt dies kein Problem dar, da die
Laufzeitumgebung sich um die Speicherzuordnung kümmert. Im vorangegangenen
Beispiel wird das in der Color.GetPredefinedColor()-Funktion
erstellte Objekt sofort in die Hintergrundvariable kopiert und kann
dann entfernt werden.
|
9.11 Private Erstellungsroutinen
 
Da es in C# keine globalen Variablen oder Konstanten
gibt, müssen alle Deklarationen innerhalb einer Klasse vorgenommen
werden. Manchmal entstehen so Klassen, die vollständig aus statischen
Mitgliedern bestehen. In diesem Fall besteht keine Veranlassung dazu,
jemals ein Objekt der Klasse zu instanziieren. Um eine Instanziierung
zu verhindern, wird der Klasse eine private-Erstellungsroutine hinzugefügt.
// Fehler
using System;
class PiHolder
{
private PiHolder() {}
static double Pi = 3.1415926535;
}
class Test
{
PiHolder pi = new PiHolder(); // Fehler
}
Obwohl durch das Hinzufügen von private
vor der Erstellungsroutinendefinition der tatsächliche Zugriff
auf die Erstellungsroutine nicht geändert wird, wird durch die
explizite Kennzeichnung deutlich, dass die Klasse über eine private
Erstellungsroutine verfügen soll.
9.12 Parameterlisten variabler Länge
 
Gelegentlich ist es sinnvoll, einen Parameter zu
definieren, der eine variable Anzahl Parameter enthalten kann (Console.WriteLine()
ist ein gutes Beispiel). C# bietet für diese Fälle eine einfache
Lösung:
using System;
class Port
{
// Version mit einem einzigen Objektparameter
public void Write(string label, object arg)
{
WriteString(label);
WriteString(arg.ToString());
}
// Version mit einem Array aus Objektparametern
public void Write(string label, params object[] args)
{
WriteString(label);
for (int index = 0; index < args.GetLength(0); index++)
{
WriteString(args[index].ToString());
}
}
void WriteString(string str)
{
// Zeichenfolge wird an diesen Port (Anschluss) geschrieben
Console.WriteLine("Port debug: {0}", str);
}
}
class Test
{
public static void Main()
{
Port port = new Port();
port.Write("Single Test", "Port ok");
port.Write("Port Test: ", "a", "b", 12, 14.2);
object[] arr = new object[4];
arr[0] = "The";
arr[1] = "answer";
arr[2] = "is";
arr[3] = 42;
port.Write("What is the answer?", arr);
}
}
Das params-Schlüsselwort des letzten Parameters ändert die Art, mit
der der Compiler die Funktionen ermittelt. Wird ein Aufruf dieser Funktion
ermittelt, wird zunächst geprüft, ob eine exakte Übereinstimmung
mit dieser Funktion vorliegt. Der erste Funktionsaufruf passt:
public void Write(string, object arg)
Ebenso übergibt die dritte Funktion ein passendes
Objektarray:
public void Write(string label, params object[]
args)
Interessant wird es beim zweiten Funktionsaufruf.
Die Definition stimmt nicht mit dem Objektparameter überein, aber
ebenso wenig liegt eine Übereinstimmung mit dem Objektarray vor.
Wenn der Abgleich in beiden Fällen fehlschlägt,
erkennt der Compiler das params-Schlüsselwort. Der
Compiler versucht dann, die Parameterliste abzugleichen, indem er den
Arraybestandteil des params-Parameters entfernt und den
Parameter dupliziert, bis die Parameteranzahl übereinstimmt.
Führt dies zu einer übereinstimmenden
Funktion, wird der Code zum Erstellen des Objektarrays geschrieben. Mit anderen Worten, die Zeile
port.Write("Port Test: ", "a", "b", 12, 14.2);
wird umgeschrieben in
object[] temp = new object[4];
temp[0] = "a";
temp[1] = "b";
temp[2] = 12;
temp[3] = 14.2;
port.Write("Port Test: ", temp);
In diesem Beispiel handelte es sich bei dem params-Parameter
um ein object-Array, es kann jedoch ein Array beliebigen
Typs vorliegen.
Zusätzlich zu der Arrayversion ist es normalerweise
sinnvoll, eine oder mehrere spezifische Versionen der Funktion bereitzustellen.
Dies wirkt sich nicht nur positiv auf die Effizienz aus (das Objektarray
muss nicht erstellt werden): Sprachen ohne Unterstützung der params-Syntax müssen das Objektarray nicht für alle
Aufrufe verwenden. Das Überladen einer Funktion mit Versionen,
die einen, zwei oder drei Parameter tragen, sowie eine Arrayversion
stellen gute Faustregeln dar.
1
Mit anderen Worten, der C++-Rückgabetyp covariant
wird nicht unterstützt.
|