Programmieren in C#: Einführung
Dieses Buch ist unter einer Creative Commons-Lizenz lizensiert.
Die Objektorientierung versucht, die Softwareentwicklung zu vereinfachen, indem Entwickler tatsächlich existierende Objekte in einer Programmiersprache nachbilden. Dies geschieht wie Sie inzwischen wissen über Klassen, die Eigenschaften und Fähigkeiten von Objekten beschreiben.
Es ist nun auch möglich, Klassen zueinander in Beziehung zu setzen. Die Objektorientierung stellt hierzu ein Instrument zur Verfügung, das Vererbung heißt. Auch dieses Instrument dient dem alleinigen Zweck, die Wirklichkeit besser abzubilden, indem tatsächlich existierende Beziehungen zwischen Objekten in den Quellcode hinüber genommen werden.
Die Vererbung macht es möglich, Klassen in eine gemeinsame Klassenhierarchie einzuordnen. Dabei werden Klassen zu Eltern- oder Kindklassen anderer Klassen.
In C# hat jede Klasse genau eine einzige Elternklasse. Sie kann jedoch beliebig viele Kindklassen haben. Auf diese Weise entsteht eine Verästelung, die sich noch unten auffächert und an deren Spitze eine einzige Elternklasse steht. Diese Klasse, die in der Hierarchie ganz oben steht, ist die einzige, die ihrerseits keine Elternklasse hat.
Alle existierenden Klassen im .NET-Framework sind Bestandteil einer einzigen Klassenhierarchie, an deren Spitze die Klasse Object
aus dem Namensraum System steht. Alle Klassen - egal, ob von Microsoft im .NET-Framework oder von Ihnen in Programmen definiert - sind direkte oder indirekte Kindklassen von Object
. Sie sind alle direkt oder indirekt von Object
abgeleitet.
So sind zum Beispiel die in diesem Buch in verschiedenen Beispielprogrammen verwendeten Klassen Console
und String
direkte Kindklassen von Object
. Die Klassen Int32
und Process
sind indirekte Kindklassen, da zwischen ihnen und der obersten Elternklasse Object
eine oder mehrere andere Klassen stehen.
Die Vererbung hat den Sinn, aus vielen einzelnen Klassen ein Gerüst zu erstellen, das Beziehungen zwischen Objekten in der Wirklichkeit nachahmen soll. Je weiter oben Klassen in der Klassenhierarchie stehen, desto allgemeiner sind sie. Spezialisierte Klassen finden Sie in den unteren Schichten.
Die allgemeinste Klasse im .NET-Framework ist Object
. Da alle Klassen Objekte beschreiben, macht es Sinn, wenn alle anderen Klassen Kindklassen von Object
sind. Eine allgemeinere Klasse als Object
kann es nicht geben.
Sie müssen nichts im Code angeben, wenn Sie eine Klasse erstellen und Ihre Klasse von Object
abgeleitet sein soll. Jede Klasse ist standardmäßig von Object
abgeleitet, wenn nicht explizit eine andere Elternklasse angegeben wird. So war zum Beispiel auch die im vorherigen Kapitel entwickelte Klasse Favorite
eine direkte Kindklasse von Object
.
Um Vererbungslinien zu erstellen, die aus mehr als zwei Generationen bestehen, müssen für eine Klasse Elternklassen explizit angegeben werden. Dies ist zum Beispiel in der Vererbungslinie von Process
der Fall: Die Klasse Process
ist explizit von einer Klasse Component
abgeleitet, die wiederum explizit von einer Klasse MarshalByRefObject
abgeleitet ist. Da laut Dokumentation für MarshalByRefObject
keine Elternklasse explizit angegeben ist, ist sie automatisch von Object
abgeleitet.
Das Instrument der Vererbung bewirkt, dass Kindklassen Merkmale ihrer Elternklasse erben. So stehen in Kindklassen alle Merkmale einer Elternklasse zur Verfügung, so als ob diese Merkmale in der Kindklasse selbst definiert worden wären. Da alle Klassen direkt oder indirekt von Object
abgeleitet sind, sind die in Object
definierten Merkmale in allen existierenden Klassen verfügbar.
Wenn Sie sich die Dokumentation von Object
ansehen, finden Sie zum Beispiel eine Methode Equals()
, die in dieser Klasse definiert ist. Dieser Methode kann ein Objekt übergeben werden, um zu überprüfen, ob es mit dem Objekt, für das die Methode aufgerufen wird, identisch ist. Weil alle Klassen direkt oder indirekt von Object
abgeleitet sind, steht diese Methode allen Klassen zur Verfügung.
using System; using System.Diagnostics; class Favorite { string name; Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } } class Program { static void Main(string[] args) { var favHighscore = new Favorite("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); var favHighscore2 = new Favorite("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); Console.Out.WriteLine(favHighscore.Equals(favHighscore2)); } }
Im obigen Beispiel wird die im vorherigen Kapitel entwickelte Klasse Favorite
zweimal instantiiert und mit den gleichen Werten initialisiert. Es wird dann für das eine Objekt Equals()
aufgerufen und als Parameter das andere Objekt übergeben.
Obwohl Favorite
keine Methode Equals()
definiert, wird der Code vom C#-Compiler anstandslos übersetzt. Denn Favorite
erbt die Methode Equals()
von der Elternklasse Object
. Es ist zwar nirgendwo explizit angegeben, dass Object
die Elternklasse von Favorite
ist. Das muss es aber auch nicht, weil Object
in diesem Fall automatisch die Elternklasse ist.
Wenn Sie obiges Programm ausführen, wird False
ausgegeben. Das verwundert Sie möglicherweise, denn beide Favoriten speichern immerhin die exakt gleichen Werte. Der Grund, warum False
ausgegeben wird, ist, dass Equals()
bei Referenzvariablen vergleicht, ob diese identisch sind - und nicht, ob die Objekte identisch sind, auf die die Referenzvariablen verweisen. Die beiden Objekte vom Typ Favorite
speichern zwar die gleichen Werte. Da es sich aber um zwei unterschiedliche Objekte handelt und favHighscore auf das eine und favHighscore2 auf das andere Objekt verweist, wird False
ausgegeben.
Wenn Sie möchten, dass bei einem Vergleich zweier Objekte vom Typ Favorite
nicht Referenzvariablen, sondern die Objekte selbst auf Gleichheit überprüft werden, können Sie Equals()
für die Klasse Favorite
neu definieren. Eine derartige Neudefinition einer Methode in einer Kindklasse bezeichnet man als Überschreiben.
Die Klasse Favorite
soll nun die von Object
geerbte Methode Equals()
überschreiben, damit true
zurückgegeben wird, wenn zwei Objekte vom Typ Favorite
die gleichen Daten speichern.
using System; using System.Diagnostics; class Favorite { string name; Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } public override bool Equals(Object obj) { Favorite fav = (Favorite)obj; return name == fav.name && url == fav.url; } } class Program { static void Main(string[] args) { var favHighscore = new Favorite("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); var favHighscore2 = new Favorite("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); Console.Out.WriteLine(favHighscore.Equals(favHighscore2)); } }
Um eine Methode zu überschreiben, muss der Methodenkopf - also Rückgabewert, Name und Parameterliste - exakt mit dem Methodenkopf übereinstimmen, der von der Elternklasse vorgegeben wird. Für die Methode Equals()
bedeutet das, dass sie einen Rückgabewert vom Typ bool
besitzen und einen Parameter vom Typ Object
erwarten muss. Außerdem muss die Methode öffentlich sein - das Zugriffsattribut public
muss gesetzt sein. Genau so ist diese Methode nämlich in der Klasse Object
definiert.
Damit sich der Compiler nicht wundert, dass eine Methode definiert wird, die exakt so von der Elternklasse geerbt wird, muss außerdem das Schlüsselwort override
vorangestellt werden. Damit ist dem Compiler klar, dass Sie diese Methode tatsächlich überschreiben wollen und nicht versehentlich eine Methode definieren, die genauso geerbt wird.
Um herauszufinden, ob ein Favorit identisch mit einem anderen ist, müssen lediglich die Felder name und url verglichen werden. Wie Sie sehen erfolgt dieser Vergleich nicht mit Equals()
, sondern mit einem Operator, der aus zwei Gleichheitszeichen besteht. Es handelt sich hierbei um einen Vergleichsoperator: ==
gibt true
zurück, wenn links und rechts das gleiche steht - andernfalls false
.
Damit auf die Felder name und url zugegriffen werden kann, muss der Parameter obj von Object
in Favorite
umgewandelt werden. Denn nur Objekte vom Typ Favorite
besitzen die Felder name und url. Diese Umwandlung erfolgt, indem der entsprechende Datentyp - hier Favorite
- in runden Klammern vor die Referenzvariable obj gesetzt wird. Man bezeichnet diese Umwandlung als Casting.
Wie Sie anhand von Equals()
und dem Vergleichsoperator ==
sehen, gibt es mehrere Möglichkeiten, Variablen miteinander zu vergleichen. Grundsätzlich sind Equals()
und ==
gleich: Wenn sie auf Referenzvariablen angewandt werden, vergleichen sie, ob die Referenzvariablen auf das gleiche Objekt zeigen. Beide geben false
zurück, wenn sie auf unterschiedliche Objekte zeigen - selbst, wenn diese gleiche Daten speichern.
Bei Wertvariablen werden übrigens immer Objekte auf Gleichheit überprüft. Hier gibt es schließlich keine Unterscheidung zwischen Variable und Objekt, da Wertvariablen Objekte sind.
So wie Equals()
kann auch der Vergleichsoperator ==
überschrieben und für Klassen neudefiniert werden. Und dies hat Microsoft sowohl bei String
als auch bei Uri
getan. Für diese beiden Klassen bedeutet ein Vergleich mit ==
, dass true
zurückgegeben wird, wenn die Objekte die gleichen Daten speichern.
Genausogut könnte in diesem Fall jedoch auch Equals()
verwendet werden:
return name.Equals(fav.name) && uri.Equals(fav.uri);
Denn die Klassen String
und Uri
überschreiben nicht nur den Vergleichsoperator ==
, sondern auch Equals()
. Deswegen würde auch mit obiger Zeile der Vergleich so wie gewünscht durchgeführt werden.
Wenn Sie die Möglichkeiten, Variablen zu vergleichen, für verwirrend halten, dann ist das leider wahr. Von der Grundregel, dass sowohl ==
als auch Equals()
Referenzvariablen vergleichen und nicht Objekte, auf die sie zeigen, kann jederzeit abgewichen werden. Mit String
und Uri
haben Sie genau zwei Vertreter dieser Klassen, die von der Grundregel abweichen. Und nachdem Equals()
soeben in der Klasse Favorite
überschrieben wurde, weicht nun auch diese Klasse von der Grundregel ab.
Diese Abweichungen können durchaus Sinn machen. So möchten wir im obigen Beispiel bei einem Vergleich herausfinden, ob zwei Favoriten die gleichen Daten speichern - völlig egal, ob die zum Vergleich herangezogenen Referenzvariablen auf zwei unterschiedliche Objekte zeigen oder nicht. Das Überschreiben von Equals()
macht einen derartigen Vergleich erst möglich.
Problematisch wird es dann, wenn nicht mehr klar zu erkennen ist, was überschriebene Methoden genau tun. Beim Überschreiben sollte daher darauf geachtet werden, dass die Bedeutung der überschriebenen Methode nicht völlig geändert wird, sondern für Entwickler, die die Klasse später einsetzen werden, intuitiv erkennbar bleibt. Das ist nicht immer einfach. Versuchen Sie zum Beispiel für folgendes Programm, die Ergebnisse vorherzusagen.
using System; class Program { static void Main(string[] args) { var i = new Int32(); i = 1; var i2 = new Int32(); i2 = 1; Console.Out.WriteLine(i == i2); Console.Out.WriteLine(i.Equals(i2)); var array = new int[] { 1, 2, 3 }; var array2 = new int[] { 1, 2, 3 }; Console.Out.WriteLine(array == array2); Console.Out.WriteLine(array.Equals(array2)); } }
Die beiden Vergleiche der Variablen i und i2 ergeben true
. Das liegt daran, dass es sich bei i und i2 um keine Referenzvariablen handelt, sondern um Wertvariablen. Denn Int32
ist keine Klasse, sondern eine Struktur. Die beiden Variablen hätten auch anders definiert werden können, was üblicherweise in der Praxis auch gemacht wird, weil es klarer ist:
int i = 1; int i2 = 1;
Die beiden Vergleiche der Arrays, die gleich groß sind und die gleichen Zahlen speichern, ergeben false
. Die Klasse Array
überschreibt weder den Vergleichsoperator ==
noch die Methode Equals()
. In der Tat gibt es keine einfache Möglichkeit, Arrays auf gleiche Werte hin zu vergleichen.
Beachten Sie im obigen Beispiel auch die Möglichkeit, Arrays mit geschweiften Klammern zu initialisieren. In diesem Fall muss zwischen den eckigen Klammern keine Größe für das Array angegeben werden, weil der Compiler die Größe des Arrays automatisch anhand der in den geschweiften Klammern angegebenen Werte ermittelt.
Wenn Sie möchten, können Sie sogar das new int[]
weglassen. So kann ein Array auch wie folgt erstellt und gleichzeitig initialisiert werden:
int[] array = { 1, 2, 3 };
Im obigen Fall muss der Datentyp der Variablen jedoch explizit auf int[]
gesetzt werden. Das Schlüsselwort var
darf nicht mehr verwendet werden, weil der Compiler nicht herleiten könnte, welche Daten das Array eigentlich genau speichern soll.
Möchten Sie übrigens tatsächlich Referenzvariablen und nicht die Objekte, auf die sie zeigen, auf Gleichheit überprüfen - völlig unabhängig davon, ob ==
oder Equals()
überschrieben wurden - können Sie die Methode ReferenceEquals()
aufrufen. Diese Methode ist ebenfalls in der Klasse Object
definiert und steht somit jedem Objekt zur Verfügung. Diese Methode kann außerdem nicht überschrieben werden, weil sie statisch ist.
Vererbung bedeutet, dass für eine Kindklasse alle Merkmale einer Elternklasse verwendet werden können, so als wären sie in der Kindklasse definiert worden. Da die Kindklasse all das hat und kann, was die Elternklasse bietet, können Objekte vom Typ der Kindklasse überall da verwendet werden, wo eigentlich Objekte vom Typ der Elternklasse erwartet werden: Ein Objekt vom Typ einer Kindklasse kann ein Objekt vom Typ einer Elternklasse ersetzen. Es hat mehrere Typen.
Objekte, die sich wie Objekte anderer Klassen verhalten können, bezeichnet man als polymorph. Polymorphie ist demnach die Fähigkeit, mehr als einen Datentyp zu unterstützen. Wie das in der Praxis aussieht und welche Vorteile das hat, sehen Sie im Folgenden.
using System; using System.Diagnostics; class Favorite { string name; Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite { DateTime dateTime; public Favorite2(string name, Uri url) : base(name, url) { dateTime = DateTime.Now; } }
Die Klasse Favorite
erhält im obigen Beispielcode eine Kindklasse Favorite2
. Favorite2
soll grundsätzlich all das sein, was Favorite
ist - deswegen heißt die Klasse auch ähnlich. Während Favorite
aber lediglich einen Namen und eine Adresse speichert, soll Favorite2
zusätzlich den Zeitpunkt speichern, zu dem der Favorit angelegt wird.
Natürlich könnte hier auch direkt die Klasse Favorite
erweitert werden. Gehen Sie aber zum Beispiel davon aus, dass die Klasse Favorite
von einem anderen Entwickler stammt und Teil einer Klassenbibliothek ist, die Sie nicht ändern können.
Um die Klasse Favorite2
von Favorite
abzuleiten, muss hinter dem Klassennamen ein Doppelpunkt gefolgt vom Namen der Elternklasse gesetzt werden. Damit ist Favorite2
keine direkte Kindklasse von Object
mehr, sondern erbt von Favorite
.
Während Sie bisher davon ausgehen, dass eine Klasse sämtliche Merkmale von ihrer Elternklasse erbt, stimmt dies genaugenommen nicht: Konstruktoren und Destruktoren werden nie vererbt. Damit auch Objekte vom Typ Favorite2
mit einem Namen und einer Adresse initialisiert werden können, muss daher ein Konstruktor explizit definiert werden.
Wenn der Name und die Adresse dem Konstruktor von Favorite2
übergeben werden, müssen die Daten irgendwie in die von der Elternklasse geerbten Felder name und url abgelegt werden. Dies erfolgt üblicherweise über den Aufruf eines Konstruktors der Elternklasse.
Um den Konstruktor einer Elternklasse aufzurufen, müssen Sie hinter dem Methodenkopf einen Doppelpunkt und das Schlüsselwort base
setzen. base
bezieht sich auf die Elternklasse. Auf diese Weise kann ein Konstruktor der Elternklasse aufgerufen werden. Da Favorite
einen geeigneten Konstruktor anbietet, reicht der Konstruktor von Favorite2
die Parameter an diesen weiter. Der Konstruktor der Elternklasse - letztendlich auch nur eine ganz normale Methode - legt dann die Daten in den Feldern name und url ab.
Weil Favorite2
den Zeitpunkt speichern soll, zu dem ein Favorit angelegt wird, wird ein Feld dateTime vom Typ DateTime
definiert. Es handelt sich hierbei um eine Struktur, die im Namensraum System definiert ist. Diese Struktur bietet eine statische Eigenschaft Now an, die den aktuellen Zeitpunkt vom Typ DateTime
zurückgibt. Auf genau diese Eigenschaft wird im Konstruktor zugegriffen, um das Feld dateTime zu initialisieren.
Anstatt den Namen und die Adresse über den Konstruktor der Elternklasse zu initialisieren, könnten Sie auch auf die Idee kommen, die Felder name und url per Zuweisungsoperator zu initialisieren:
class Favorite2 : Favorite { DateTime dateTime; public Favorite2(string name, Uri url) : base(name, url) { this.name = name; this.url = url; dateTime = DateTime.Now; } }
Während obiger Code grundsätzlich in Ordnung ist, würde sich der Compiler trotzdem weigern, ihn zu kompilieren. Das Problem ist, dass name und url private Merkmale sind und private Merkmale ausschließlich in der Klasse verwendet werden können, in der sie definiert sind. Obwohl die Klasse Favorite2
die beiden Felder erbt, kann sie tatsächlich nicht direkt auf sie zugreifen.
Um der Kindklasse einen direkten Zugriff auf name und url einzuräumen, könnten die Felder öffentlich gemacht werden. Dies würde aber das Prinzip der Datenkapselung verletzen, weil dann wirklich jeder - also auch völlig andere Klassen - auf diese Felder zugreifen könnten.
Um Merkmale Kindklassen zugänglich zu machen, sie aber weiterhin gegenüber anderen Klassen zu schützen, bietet C# das Schlüsselwort protected
an. Indem vor die Felder name und url das Schlüsselwort protected
gesetzt wird, können sie in Kindklassen verwendet werden.
using System; using System.Diagnostics; class Favorite { protected string name; protected Uri url; public Favorite() { } public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite { DateTime dateTime; public Favorite2(string name, Uri url) { this.name = name; this.url = url; dateTime = DateTime.Now; } }
Nachdem im Konstruktor von Favorite2
direkt auf die geerbten Felder name und url zugegriffen wird, um sie zu initialisieren, ist kein expliziter Aufruf eines Konstruktors der Elternklasse mehr notwendig. Wenn kein Konstruktor explizit aufgerufen wird, wird jedoch der Standardkonstruktor implizit aufgerufen. Das wiederum funktioniert natürlich nur, wenn der Standardkonstruktor existiert. Deswegen muss im obigen Beispiel der Standardkonstruktor zur Klasse Favorite
hinzugefügt werden.
Wenn Sie eine Klasse erstellen, müssen Sie darüber nachdenken, ob Sie oder andere Entwickler eventuell später einmal eine neue Klasse von dieser Klasse ableiten möchten. Für diesen Fall müssen Sie sich überlegen, welche geschützten Merkmale in einer Kindklasse zugänglich sein sollen. Diese Merkmale dürfen Sie dann nicht mit private
schützen, sondern müssen sie mit protected
definieren.
Während public
-Merkmale die Schnittstelle einer Klasse nach außen darstellen, stellen protected
-Merkmale die Schnittstelle einer Klasse zu Kindklassen dar. private
-Merkmale hingegen können immer nur in der Klasse verwendet werden, in der sie definiert sind.
Im folgenden Beispiel wird ein Objekt vom Typ Favorite2
erstellt und an eine Methode übergeben, die eigentlich ein Objekt vom Typ Favorite
erwartet. Der Grund, warum das Programm problemlos kompiliert und ausgeführt werden kann, liegt darin, dass alle Objekte vom Typ Favorite2
all das sein können, was Objekte vom Typ Favorite
sind. Schließlich hat Favorite2
alle Merkmale von Favorite
geerbt.
using System; using System.Diagnostics; class Favorite { string name; Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite { DateTime dateTime; public Favorite2(string name, Uri url) : base(name, url) { dateTime = DateTime.Now; } } class Program { static void OpenFavorite(Favorite fav) { fav.Open(); } static void Main(string[] args) { var favHighscore = new Favorite2("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); OpenFavorite(favHighscore); } }
Im obigen Programm wird ein Objekt vom Typ Favorite2
erstellt und an eine Methode OpenFavorite()
übergeben, die einen Parameter vom Typ Favorite
erwartet. Weil das Objekt vom Typ Favorite2
auf einer Klasse basiert, die von Favorite
abgeleitet ist, kann es sich wie ein Objekt vom Typ Favorite
verhalten und daher problemlos an die Methode OpenFavorite()
übergeben werden. Das ist es, was man unter Polymorphie versteht.
Der Vorteil von Polymorphie ist, dass alter Code auf neuen Code zugreifen kann. Stellen Sie sich vor, ein anderer Programmierer hätte die Klasse Favorite
und die Methode OpenFavorite()
entwickelt. Sie können die Klasse Favorite
nicht verwenden, weil Sie den Zeitpunkt speichern müssen, zu dem ein Favorit erstellt wird - etwas, was die Klasse Favorite
nicht unterstützt. Sie können aber eine Kindklasse Favorite2
erstellen und Objekte vom Typ dieser Kindklasse an die Methode OpenFavorite()
übergeben, obwohl der Programmierer, der OpenFavorite()
entwickelte hatte, gar nicht wusste, dass Sie eines Tages eine Klasse Favorite2
entwickeln. Dank Polymorphie kann die früher entwickelte Methode OpenFavorite()
tatsächlich Objekte verwenden, die auf einer später entwickelten Klasse basieren.
Die im vorherigen Abschnitt entwickelte Klasse Favorite2
soll nun derart erweitert werden, dass jeder Aufruf des Favoriten gezählt wird. Jedesmal, wenn die Methode Open()
aufgerufen wird, soll eine Variable um 1 erhöht werden. Dies soll Ihnen helfen zu erkennen, welche Favoriten besonders beliebt sind und häufiger verwendet werden als andere.
Die Methode Open()
, die von Favorite
geerbt wird, muss irgendwie erweitert werden, um eine Zählvariable pro Aufruf zu erhöhen. Um die Methode zu ändern, muss sie, wie Sie bereits in diesem Kapitel erfahren haben, überschrieben werden. Dazu muss die Methode mit exakt dem Methodenkopf, der in der Elternklasse verwendet wird, in der Kindklasse definiert werden.
Sie hatten bereits gesehen, dass beim Überschreiben das Schlüsselwort override
angegeben werden muss. Das trifft jedoch nur auf Methoden zu, die in der Elternklasse mit dem Schlüsselwort virtual
definiert wurden. Welche Sinn und Zweck derartige virtuelle Methoden haben, wird Ihnen im Folgenden gezeigt.
using System; using System.Diagnostics; class Favorite { protected string name; protected Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public virtual void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite { DateTime dateTime; int i; public Favorite2(string name, Uri url) : base(name, url) { dateTime = DateTime.Now; i = 0; } public override void Open() { ++i; Console.Out.WriteLine(i); Process.Start(url.ToString()); } } class Program { static void Open(Favorite fav) { fav.Open(); } static void Main(string[] args) { var favHighscore = new Favorite2("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); Open(favHighscore); } }
Die Klasse Favorite2
überschreibt nun die Methode Open()
der Elternklasse Favorite
. Da jeder Aufruf des Favoriten mitgezählt werden soll, wird in dieser Methode ein Feld i vom Typ int
mit dem Inkrement-Operator ++
jeweils um 1 erhöht. Da Variablen vom Typ int
automatisch mit 0 initialisiert werden, muss i nicht explizit auf einen Startwert gesetzt werden. Zur Kontrolle wird außerdem der Wert von i bei jedem Aufruf von Open()
auf die Standardausgabe ausgegeben.
Damit Favorite2
die Methode Open()
überschreiben kann, muss die Methode in der Elternklasse Favorite
mit virtual
definiert worden sein. Es handelt sich somit bei Open()
um eine virtuelle Methode.
In der Klasse Program
wird wie zuvor ein Objekt vom Typ Favorite2
erstellt und an die Methode OpenFavorite()
übergeben. In dieser Methode wird für das entsprechende Objekt Open()
aufgerufen. Sie erwarten sicherlich, dass die in der Klasse Favorite2
definierte Methode Open()
aufgerufen wird - schließlich ist diese Methode extra überschrieben worden. In der Tat wird genau diese Methode aufgerufen und das Feld i dabei von 0 auf 1 erhöht. Wenn Sie das Programm ausführen, wird 1
ausgegeben.
Dass tatsächlich die in Favorite2
definierte Methode Open()
aufgerufen wird, liegt daran, dass diese Methode virtuell ist. Denn der Parameter von OpenFavorite()
hat eigentlich den Datentyp Favorite
. Weil Open()
jedoch virtuell ist, wird nicht stupide die Methode der Klasse aufgerufen, die als Datentyp des Parameters angegeben ist, sondern die .NET-Plattform entscheidet zur Laufzeit, welche Methode genau ausgeführt werden muss. Dies hängt vom Datentyp des Objekts ab, das als Parameter übergeben wird - völlig unabängig von dem Datentyp, der vor dem Parameter steht. So wird im obigen Beispiel ein Objekt vom Typ Favorite2
übergeben, obwohl der Parameter von OpenFavorite()
den Datentyp Favorite
hat.
Es ist auch möglich, die Methode Open()
zu überschreiben, wenn diese nicht virtuell ist. Das macht einen großen Unterschied, wie Ihnen folgendes Beispielprogramm zeigt.
using System; using System.Diagnostics; class Favorite { protected string name; protected Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite { DateTime dateTime; int i; public Favorite2(string name, Uri url) : base(name, url) { dateTime = DateTime.Now; i = 0; } public new void Open() { ++i; Console.Out.WriteLine(i); Process.Start(url.ToString()); } } class Program { static void Open(Favorite fav) { fav.Open(); } static void Main(string[] args) { var favHighscore = new Favorite2("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); Open(favHighscore); } }
Open()
ist nun in der Klasse Favorite
als gewöhnliche nicht-virtuelle Methode definiert. Um diese Methode in der Kindklasse Favorite2
neu zu definieren, muss nun das Schlüsselwort new
anstelle von override
verwendet werden. Während override
nur für virtuelle Methoden verwendet werden darf, um diese zu überschreiben, wird new
verwendet, um eine Methode in einer Kindklasse neu zu definieren.
Weil die Methode Open()
nicht virtuell ist, wird nicht mehr zur Laufzeit überprüft, welches Open()
in OpenFavorite()
eigentlich aufgerufen werden soll. Da der Parameter von OpenFavorite()
den Datentyp Favorite
hat, wird immer die Methode Open()
aufgerufen, die in Favorite
definiert ist. In diesem Fall würde also nie die in Favorite2
überschriebene Methode aufgerufen werden. Wenn Sie das Programm ausführen, wird nichts auf die Standardausgabe ausgegeben.
Wenn Sie Klassen erstellen und davon ausgehen, dass Sie oder andere Entwickler später Kindklassen ableiten werden, müssen Sie entscheiden, welche Methoden virtuell sein sollen und welche nicht. Für die Methoden, die Sie als virtuell kennzeichnen und die eventuell in Kindklassen überschrieben werden, ist sichergestellt, dass zur Laufzeit jeweils genau die richtige Methode ausgeführt wird - unabhängig davon, welchen Datentyp eine Referenzvariable hat. Bei virtuellen Methoden kommt es allein auf den Datentyp des entsprechenden Objekts an.
So schön dieser Automatismus ist: Er kostet Performance. Während bei nicht-virtuellen Methoden bereits zur Kompilierung klar ist, welche Methode ausgeführt werden muss, muss bei virtuellen Methoden zur Laufzeit überprüft werden, welchen Datentyp ein Objekt genau hat. Abhängig davon muss dann die richtige Methode aufgerufen werden. Weil bei jedem Aufruf einer virtuellen Methode der Datentyp des Objekts überprüft werden muss, ist der Aufruf virtueller Methoden langsamer als der nicht-virtueller Methoden. Deswegen sollten Sie als Entwickler nicht jede Methode virtuell machen, sondern nur die, von denen Sie ausgehen, dass Kindklassen sie eventuell überschreiben möchten.
Statische Methoden sind übrigens nie virtuell. Sie können auch nicht mit virtual
definiert werden. Wenn sie in Kindklassen überschrieben werden, muss das mit new
geschehen.
Die Vererbung ist ein Instrument, mit dem Sie in C# immer in Berührung kommen. Schließlich sind alle Klassen in die große Klassenhierarchie des .NET-Frameworks eingeordnet. Selbst dann, wenn Sie für eine Klasse keine Elternklasse angeben, ist Ihre Klasse automatisch von Object
abgeleitet.
Die Vererbung macht Objekte polymorph: Objekte haben nicht mehr nur einen einzigen Datentyp, sondern können überall da verwendet werden, wo Objekte vom Typ ihrer Elternklasse erwartet werden. Das ist von Vorteil, wenn Sie wie im Beispiel im vorherigen Abschnitt eine Methode aufrufen wollen, deren Parameter nicht den Datentyp besitzt, den Ihr Objekt hat. Basiert Ihr Objekt jedoch auf einer Klasse, in deren Vererbungslinie der Datentyp des Parameters auftaucht, können Sie das Objekt problemlos an die Methode übergeben.
Jede Methode im .NET-Framework erwartet als Parameter Objekte ganz bestimmter Datentypen. Sie könnten nun jeweils durch die Vererbung Ihre Klassen von genau den Klassen ableiten, die als Datentyp für Parameter von den verschiedenen Methoden verwendet werden, die Sie gerne aufrufen würden. C# macht Ihnen da jedoch einen Strich durch die Rechnung: In C# kann jede Klasse nur eine einzige Elternklasse haben.
C# unterstützt im Gegensatz zu anderen Programmiersprachen wie C++ lediglich die Einfachvererbung. Das Gegenteil von Einfachvererbung heißt Mehrfachvererbung. Diese ermöglicht, eine Klasse von mehreren Klassen abzuleiten. Es handelt sich dabei um ein Instrument, das so tatsächlich in anderen Programmiersprachen wie C++ existiert. Weil es jedoch zu einigen Komplikationen führen kann, hat Microsoft sich entschlossen, die Mehrfachvererbung in C# nicht zu unterstützen, um die Programmiersprache nicht unnötig zu verkomplizieren.
Stattdessen kennt C# ein anderes Instrument, das gewissermaßen die Mehrfachvererbung ersetzt: Interfaces sehen ähnlich aus wie Klassen, besitzen jedoch weder Felder noch Methodendefinitionen. Interfaces bestehen ausschließlich aus Methodenköpfen - also Methoden ohne geschweifte Klammern. Da Interfaces keine Methoden definieren und ihnen somit etwas fehlt, können sie nicht instantiiert werden. Es ist nicht möglich, mit new
ein Objekt vom Typ eines Interfaces zu erstellen.
Interfaces machen nur Sinn im Zusammenhang mit Vererbung. Sie müssen von einer Klasse implementiert werden, bevor sie sinnvoll eingesetzt werden können. Dabei muss die Klasse all die Methoden vollständig definieren, die im Interface vorgegeben sind. Wie das im Detail aussieht, erfahren Sie im Folgenden.
using System; using System.Diagnostics; class Favorite { protected string name; protected Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public string Name { get { return name; } } public virtual void Open() { Process.Start(url.ToString()); } } class Favorite2 : Favorite, IComparable { DateTime dateTime; int i; public Favorite2(string name, Uri url, int i) : base(name, url) { this.i = i; dateTime = DateTime.Now; } public override void Open() { ++i; Process.Start(url.ToString()); } public int CompareTo(object obj) { Favorite2 fav = (Favorite2)obj; if (fav.i > i) return 1; else if (fav.i < i) return -1; else return 0; } } class Program { static void Main(string[] args) { Favorite2[] favs = { new Favorite2("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/"), 10), new Favorite2("Microsoft Deutschland GmbH", new Uri("http://www.microsoft.de/"), 5), new Favorite2("Microsoft .NET Framework ", new Uri("http://www.microsoft.de/NET/"), 20) }; Array.Sort(favs); Console.Out.WriteLine(favs[0].Name); } }
Die Klasse Array
bietet eine statische Methode Sort()
an, der ein beliebiges Array übergeben werden kann, um Elemente zu sortieren. Damit Sort()
aber weiß, wie die Elemente im Detail sortiert werden sollen, benötigt diese Methode Unterstützung: Sort()
erwartet, dass alle Elemente in einem zu sortierenden Array vom Typ IComparable
sind.
Im obigen Beispielprogramm soll ein Array bestehend aus drei Elementen vom Typ Favorite2
sortiert werden. Das Array soll dabei derart sortiert werden, dass der Favorit, der am häufigsten aufgerufen wurde, nach der Sortierung auf der ersten Position steht. Der Einfachheit halber wurde der Konstruktor der Klasse Favorite2
erweitert, um das entsprechende Feld i unterschiedlich zu initialisieren. Zur Kontrolle wird außerdem der Name des ersten Favoriten im Array nach der Sortierung ausgegeben.
Das Problem bei Favorite2
ist, dass diese Klasse bereits eine Elternklasse hat - nämlich Favorite
. Sie kann daher nicht nochmal von einer Elternklasse abgeleitet werden. Schließlich unterstützt C# keine Mehrfachvererbung. Glücklicherweise ist jedoch IComparable
keine Klasse, sondern ein Interface. Sie erkennen das unter anderem am Anfangsbuchstaben I: Interfacenamen wird üblicherweise ein I vorangestellt.
Interfacedefinitionen sehen Klassen ähnlich. Sie werden aber nicht mit dem Schlüsselwort class
erstellt, sondern mit interface
. Die genaue Definition von IComparable
sieht wie folgt aus:
interface IComparable { int CompareTo(Object obj); }
IComparable
enthält eine einzige Methode CompareTo()
, die einen Parameter erwartet und ein Ergebnis vom Typ int
zurückgibt. Wie Sie sehen ist diese Methode jedoch nicht vollständig definiert - es ist nur der Methodenkopf angegeben. Klassen, die dieses Interface implementieren - man spricht hier nicht von Erben, sondern Implementieren - werden gezwungen, die Methode CompareTo()
vollständig zu definieren. Indem sie die Methode CompareTo()
vollständig definieren, unterstützen sie den Datentyp IComparable
und können überall dort verwendet werden, wo IComparable
erwartet wird - zum Beispiel in der Methode Sort()
der Klasse Array
.
Die Methode CompareTo()
muss so implementiert werden, dass eine positive oder negative Zahl zurückgegeben wird, wenn die zu vergleichenden Objekte größer oder kleiner sind - was immer das für die jeweilige Klasse genau bedeutet. Sind die zu vergleichenden Objekte gleich, muss 0 zurückgegeben werden. Im obigen Beispiel wird das Feld i verglichen. Je nachdem, welches i größer ist, wird 1 oder -1 zurückgegeben.
Wenn das Array favs, das in Main()
erstellt wird, an Sort()
übergeben wird, erwartet diese Methode, dass alle Elemente im Array vom Typ IComparable
sind. Ist das der Fall, steht für alle Elemente im Array eine Methode CompareTo()
zur Verfügung, die von Sort()
zur Sortierung des Arrays verwendet werden kann. Sort()
ruft dabei CompareTo()
für alle Elemente im Array auf und übergibt andere Elemente im Array als Parameter, um anhand der Rückgabewerte die richtige Sortierung vorzunehmen.
Methoden in Interfaces sind immer öffentlich und virtuell. Sie können und dürfen weder public
noch virtual
voranstellen. Wenn eine Klasse Methoden definiert, die in einem Interface vorgegeben werden, muss außerdem nicht override
angeben werden. Schließlich ist es Sinn und Zweck von Interfaces, Klassen zur Definition der entsprechenden Methoden zu zwingen.
Schnittstellen werden immer dann verwendet, wenn von Objekten ein bestimmtes Verhalten erwartet wird. Im obigen Beispiel erwartet Sort()
, dass die Elemente im Array sortiert werden können. Das können sie, wenn die Elemente vom Typ IComparable
sind, weil sie dann die Methode CompareTo()
anbieten und miteinander verglichen werden können. Das Interface IComparable
zwingt ein Objekt, eine bestimmte Fähigkeit zu unterstützen - mehr nicht.
Das Implementieren von Interfaces hat geringere Auswirkungen auf eine Klasse als das Erben. Denn beim Erben werden nicht nur vollständige Methodendefinitionen mit den entsprechenden Zugriffsattributen übernommen, sondern auch sämtliche Felder, die in der Elternklasse definiert sind. Die Vererbung ist durchaus ein sinnvolles Instrument. Da eine Klasse jedoch durch die Vererbung viel stärker verändert wird als durch Interfaces, werden Interfaces dann eingesetzt, wenn tatsächlich nur ein bestimmtes Verhalten von einer Klasse erwartet wird und keine Details wie Methodendefinitionen oder Felder vererbt werden sollen.
Sie können das auch von einer anderen Warte aus betrachten: Wenn Sie Methoden definieren, deren Parameter vom Typ eines Interfaces sind, ist es einfacher, beliebige Objekte als Parameter zu übergeben. Denn völlig egal, auf welchen Klassen die entsprechenden Objekte basieren, die als Parameter übergeben werden sollen - die entsprechenden Interfaces können immer auf alle Fälle implementiert werden.
Abstrakte Klassen sind herkömliche Klassen mit allen Merkmalen, die in herkömlichen Klassen verwendet werden dürfen, die jedoch zusätzlich Methoden ohne Rumpf besitzen können - also Methodenköpfe, wie sie in Interfaces vorgegeben werden. Sie werden mit dem Schlüsselwort class
definiert, dem das Schlüsselwort abstract
vorangestellt wird. So wie Interfaces können abstrakte Klassen nicht instantiiert werden.
Wie alle anderen Konstrukte, die die Objektorientierung anbietet und die Sie im Laufe dieses Buchs kennengelernt haben, sind auch abstrakte Klassen einfach nur ein Instrument, um Modelle zu erstellen, die die Wirklichkeit besser abbilden sollen. Während herkömliche Klassen verwendet werden, um herkömliche Objekte zu beschreiben, werden Interfaces verwendet, um ausschließlich das Verhalten von Objekten zu beschreiben. Abstrakte Klassen hingegen sind ein Mischgebilde. Sie verwenden sie beispielsweise, wenn Sie ein Objekt beschreiben möchten und ein Teil der Beschreibung, die das Verhalten des Objekts betrifft, nicht implementieren können oder wollen.
using System; using System.Diagnostics; abstract class Favorite { protected string name; protected Uri url; public Favorite(string name, Uri url) { this.name = name; this.url = url; } public abstract void Open(); } class Favorite2 : Favorite { DateTime dateTime; int i; public Favorite2(string name, Uri url) : base(name, url) { dateTime = DateTime.Now; } public override void Open() { ++i; Process.Start(url.ToString()); } } class Program { static void Main(string[] args) { var favHighscore = new Favorite2("Highscore - Programmieren lernen", new Uri("http://www.highscore.de/")); favHighscore.Open(); } }
Im obigen Beispielprogramm wird davon ausgegangen, dass ein Favorit aus Name und Adresse besteht und geöffnet werden kann. Während in der entsprechenden Klasse Favorite
wie zuvor zwei Felder name und url definiert werden, soll die Methode Open()
diesmal nicht implementiert werden. Es ist also klar, dass ein Favorit geöffnet werden können soll. Es ist aber nicht klar, wie dies im Detail geschieht. Ein Grund für dieses Design könnte zum Beispiel sein, dass es auf verschiedene Weise möglich ist, einen Favoriten zu öffnen, und bewusst keine Standardvorgehensweise definiert werden soll. So soll eine Klasse, die von Favorite
abgeleitet wird, selbst entscheiden, ob zum Beispiel der Standardbrowser verwendet werden soll oder der Browser eines bestimmten Herstellers.
Damit sich der Compiler nicht wundert, dass der Methode Open()
der Rumpf fehlt, muss ihr das Schlüsselwort abstract
vorangestellt werden. Dadurch wird die Methode außerdem automatisch virtuell. Das Schlüsselwort virtual
muss und darf nicht verwendet werden.
Besitzt eine Klasse mindestens eine abstrakte Methode, muss die Klasse selbst ebenfalls abstrakt sein. Das heißt, das Schlüsselwort abstract
muss auch vor class
gesetzt werden.
Indem die Klasse Favorite2
von Favorite
abgeleitet wird, wird sie gezwungen, die Methode Open()
zu implementieren. In dieser Hinsicht unterscheidet sich eine abstrakte Klasse nicht von einem Interface. Der einzige Unterschied ist, dass bei abstrakten Klassen im Gegensatz zu Interfaces override
verwendet werden muss.
In diesem Kapitel haben Sie die Vererbung kennengelernt, die es ermöglicht, Klassen zueinander in Beziehung zu setzen. Sie haben polymorphe Objekte kennengelernt, die mehrere Datentypen unterstützen, was eine Wiederverwendung von Code ermöglicht, bei dem alter Code auf neuen Code zugreift. Da C# lediglich die Einfachvererbung unterstützt, spielen Interfaces eine wichtige Rolle, die zwar keine Felder und vollständigen Methodendefinition besitzen können, jedoch ein bestimmtes Verhalten von Objekten definieren können. Abstrakte Klassen, die Sie abschließend kennengelernt haben, sind ein Mischgebilde und zwischen herkömlichen Klassen und Interfaces angesiedelt.
Sie können die Lösungen zu allen Aufgaben in diesem Buch als ZIP-Datei erwerben.
Überschreiben Sie in der im Abschnitt 4.2, „Vererbung“ entwickelten Klasse Favorite
die Methode ToString()
, die von der Klasse Object
definiert wird. Erstellen Sie ein Objekt vom Typ Favorite
und rufen Sie ToString()
auf. Geben Sie den Rückgabewert zur Kontrolle auf die Standardausgabe aus.
Schlagen Sie in der Dokumentation von Object
nach, wie die Methode ToString()
definiert ist. Da es die Aufgabe dieser Methode ist, ein Objekt als einen String zu beschreiben, würde es sich für die Klasse Favorite
anbieten, wenn diese Methode den Namen und die Adresse als String zurückgibt.
Implementieren Sie für die Klasse Favorite
aus Ihrer Lösung zur Aufgabe 1 das Interface IClonable
, das im Namensraum System definiert ist. Klonen Sie das Objekt, das Sie in Ihrer Lösung zur Aufgabe 1 erstellt haben, und rufen Sie für den Klon ToString()
auf. Geben Sie wieder zur Kontrolle den Rückgabewert auf die Standardausgabe aus.
Wenn Sie sich die Dokumentation von IClonable
ansehen, stellen Sie fest, dass Sie nur eine einzige Methode Clone()
definieren müssen. Diese muss eine Kopie des Objekts zurückgeben, für das sie aufgerufen wird.
Erstellen Sie ein Interface IFavorite
, das aus zwei Eigenschaften Name und URL und einer Methode Open()
ohne Parameter und Rückgabewert besteht. Die Klasse Favorite
aus Ihrer Lösung zur Aufgabe 2 soll dieses Interface implementieren. Das Programm soll nach dieser Änderung so funktionieren wie zuvor.
Während Sie für die Methode im Interface IFavorite
lediglich den Methodenkopf angeben dürfen, müssen Sie für die Eigenschaften die beiden Accessoren get
und set
in geschweiften Klammern angeben. Hinter get
und set
darf jedoch kein weiteres Paar geschweifter Klammern gesetzt werden. Auf diese Weise definieren Sie Eigenschaften, die gelesen und geschrieben werden können müssen, ohne jedoch eine Implementation vorzugeben.
Machen Sie die Klasse Favorite
aus Ihrer Lösung zur Aufgabe 3 abstrakt, indem Sie die Definition der Accessoren der Eigenschaften Name und URL entfernen. Leiten Sie eine Klasse Favorite2
von Favorite
ab, für die Sie als einzigen Konstruktor den Standardkonstruktor definieren. Verwenden Sie Favorite2
dann, um ein Objekt zu erstellen, und setzen Sie den Namen und die Adresse über einen Zugriff auf die beiden Eigenschaften. Rufen Sie dann ToString()
auf und geben Sie den String auf die Standardausgabe aus.
Sie müssen in der Klasse Favorite
die Accessoren der Eigenschaften als abstrakt kennzeichnen. Der C#-Compiler meckert sonst, weil Favorite
laut dem Interface IFavorite
die Accessoren eigentlich definieren muss. Indem Sie ihnen das Schlüsselwort abstract
voranstellen, erkennt der Compiler, dass Sie die Accessoren absichtlich nicht definieren wollen, sondern dies Kindklassen überlassen möchten. Die Felder name und url müssen außerdem mit protected
in Favorite
definiert werden, damit die Kindklasse Favorite2
auf sie zugreifen kann.
Beachten Sie, dass Sie für eine fehlerfreie Kompilierung das Interface ICloneable
von Favorite
entfernen müssen. Da Favorite
eine abstrakte Klasse ist, können keine Objekte erstellt werden. Ein Klonen macht daher auch keinen Sinn. Das Interface ICloneable
muss daher nun von Favorite2
implementiert werden.
Beachten Sie außerdem, dass Sie der Klasse Favorite
einen Standardkonstruktor hinzufügen müssen. Denn wenn die Klasse Favorite2
über den Standardkonstruktor initialisiert wird, wird automatisch der Standardkonstruktor der Elternklasse aufgerufen. Sie können zwar explizit über base
einen Konstruktor der Elternklasse aufrufen. Da der einzige Konstruktor von Favorite
jedoch zwei Parameter zum Initialisieren der Felder erwartet, macht ein Aufruf dieses Konstruktors vom Standardkonstruktor in Favorite2
kaum Sinn.
Copyright © 2009, 2010 Boris Schäling