Kapitel 2 Grundlagen der objektorientierten Programmierung
In diesem Kapitel erhalten Sie eine Einführung
in die objektorientierte Programmierung. Diejenigen von Ihnen, die bereits
mit der objektorientierten Programmierung vertraut sind, können
diesen Abschnitt überspringen.
Beim objektorientierten Entwurf gibt es verschiedenste
Ansätze, was durch die Anzahl der zu diesem Thema veröffentlichten
Bücher belegt wird. Die folgende Einleitung geht von einem recht
pragmatischen Ansatz aus und beschäftigt sich weniger mit dem Design,
auch wenn genau diese entwurfsbezogenen Ansätze für Anfänger
recht nützlich sein können.
2.1 Was ist ein Objekt?
 
Ein Objekt ist eine Sammlung zueinander in Beziehung
stehender Informationen und Funktionen. Ein Objekt kann etwas sein,
das über ein entsprechendes Äquivalent in der tatsächlichen
Welt verfügt (z. B. ein Mitarbeiter-Objekt),
etwas, das virtuelle Bedeutung hat (beispielsweise ein Fenster auf dem Bildschirm),
oder es kann einfach ein abstraktes Element innerhalb eines Programms
darstellen (etwa eine Liste der zu erledigenden Aufgaben).
Ein Objekt setzt sich aus den Daten zusammen, die
das Objekt selbst und die Operationen beschreiben, die für das
Objekt ausgeführt werden können. Die in einem Mitarbeiter-Objekt
gespeicherten Informationen beispielsweise können Informationen
zur Person (Name, Adresse), arbeitsbezogene Informationen (Position,
Gehalt) usw. enthalten. Zu den ausgeführten Operationen gehört
vielleicht das Erstellen einer Gehaltsabrechnung oder die Beförderung
des Mitarbeiters.
Im objektorientierten Entwurf besteht der erste
Schritt darin, die Bedeutung der Objekte zu definieren. Bei der Verwendung
von Objekten, die auch im wirklichen Leben vorkommen, ist dies einfach,
wenn Sie jedoch in der virtuellen Welt arbeiten, verschwimmen die Grenzen.
Hier zeigt sich die Kunst eines guten Designs, und dies ist auch der
Grund dafür, weshalb eine gute Architektur gefordert ist.
2.2 Vererbung
 
Die Vererbung ist ein fundamentales Leistungsmerkmal
eines objektorientierten Systems. Sie stellt die Fähigkeit dar,
Daten und Funktionen von einem übergeordneten Objekt an ein untergeordnetes
weiterzugeben, also zu vererben. Statt Objekte neu zu entwickeln, kann
neuer Code auf der Arbeit anderer Programmierer basieren. Es werden lediglich einige neue Funktionen
hinzugefügt. Das übergeordnete Objekt, auf dem der neue Code
beruht, wird als Basisklasse bezeichnet, das untergeordnete Objekt
als abgeleitete Klasse.
Der Vererbung kommt bei der Erläuterung des
objektorientierten Designs große Bedeutung zu, tatsächlich
verwendet wird die Vererbung jedoch seltener. Hierfür gibt es verschiedene
Gründe.
Zunächst ist die Vererbung ein Beispiel für
die im objektorientierten Design angeführte »Ist-Ein(e)«-Beziehung
(engl. »Is-A«). Wenn ein System über ein Tier-Objekt
und ein Katze-Objekt verfügt, kann das Katze-Objekt
vom Tier-Objekt erben, denn eine Katze »Ist-Ein(e)«
Tier. Bei der Vererbung ist die Basisklasse stets allgemeiner
gefasst als die abgeleitete Klasse. Die Katze-Klasse würde
die Essen-Funktion der Tier-Klasse erben und
über eine erweiterte Schlafen-Funktion verfügen.
Im wirklichen Leben sind derartige Beziehungen jedoch weniger gebräuchlich.
Als Zweites muss zur Verwendung der Vererbung die
Basisklasse mit dem Hintergedanken der Vererbung entworfen werden. Dies
ist aus verschiedenen Gründen wichtig. Wenn die Objekte keine geeignete
Struktur aufweisen, kann die Vererbung nicht richtig funktionieren.
Noch wichtiger: Ein Design, das die Vererbung verwendet, macht deutlich,
dass der Autor der Klasse damit einverstanden ist, dass andere Klassen
von dieser Klasse erben. Wenn eine neue Klasse von einer bestehenden
abgeleitet wird, bei der dies nicht der Fall ist, kann dies zu einer
Änderung der Basisklasse und damit zur Zerstörung der abgeleiteten
Klasse führen.
Einige weniger erfahrene Programmierer gehen fälschlicherweise
davon aus, dass die Vererbung in der objektorientierten Programmierung
breite Anwendung finden sollte und setzen sie daher
zu häufig ein. Die Vererbung sollte nur dann verwendet werden,
wenn die sich ergebenden Vorteile tatsächlich genutzt werden1 . Siehe hierzu auch den Abschnitt »Polymorphismus und virtuelle
Funktionen«.
In der .NET Common Language Runtime werden alle
Objekte von einer Basisklasse namens object abgeleitet,
und hierbei wird nur die Einfachvererbung unterstützt (d. h.,
ein Objekt kann nur von einer Basisklasse abgeleitet werden). Auf diese
Weise wird die Verwendung einiger gebräuchlicher Wendungen verhindert,
die in Mehrfachvererbungssystemen wie C++ genutzt werden. Gleichzeitig wird jedoch
der Missbrauch der Mehrfachvererbung verhindert und eine Vereinfachung
erreicht. In den meisten Fällen stellt dies einen guten Tausch
dar. Die .NET-Laufzeitumgebung ermöglicht eine Mehrfachvererbung
in Form von Schnittstellen ohne Implementierung. Die Schnittstellen
werden in Kapitel 10, Schnittstellen, behandelt.
2.3 Das Prinzip des Containments
 
Wenn also Vererbung nicht die richtige Wahl ist,
was dann?
Die Antwort lautet: Containment, auch Aggregation oder Enthaltenseinbeziehung genannt. Hierbei wird ein Objekt nicht als ein
Beispiel eines anderen Objekts betrachtet, sondern als eine Instanz
eines Objekts, die sich im Objekt befindet. Statt also über eine
Klasse zu verfügen, die wie eine Zeichenfolge aussieht, enthält
die Klasse eine Zeichenfolge (oder ein Array oder eine Hashtabelle).
Üblicherweise sollte beim Design der Ansatz
des Containments gewählt werden, auf die Vererbung sollte nur zurückgegriffen
werden, wenn dies erforderlich ist (z. B. wenn tatsächlich
eine »Ist-Ein(e)«-Beziehung vorliegt).
2.4 Polymorphismus und virtuelle Funktionen
 
Vor einiger Zeit habe ich ein Musiksystem geschrieben,
und ich wollte hierbei sowohl WinAmp als auch den Windows Media
Player für die Wiedergabe unterstützen. Gleichzeitig sollte
nicht jeder Codebestandteil wissen müssen, welches Programm verwendet
wird. Ich definierte daher eine abstrakte Klasse, d. h. eine Klasse,
mit der die Funktionen beschrieben werden, die eine abgeleitete Klasse
implementieren muss. Auf diese Weise werden gelegentlich Funktionen
bereitgestellt, die für beide Klassen nützlich sind.
In diesem Fall hieß die abstrakte Klasse MusicServer
und verfügte über Funktionen wie Play(), NextSong(),
Pause() usw. Jede dieser Funktionen wurde als abstrakt
deklariert, damit sie durch die Playerklasse selbst implementiert würde.
Abstrakte Funktionen sind automatisch virtuelle
Funktionen, die dem Programmierer die Verwendung des Polymorphismus
zur Codevereinfachung ermöglichen. Ist eine virtuelle Funktion
vorhanden, kann der Programmierer einen Verweis auf die abstrakte statt
auf die abgeleitete Klasse erstellen, und der Compiler schreibt Code
zum Aufrufen der geeigneten Funktionsversion zur Laufzeit.
Ich möchte dies durch ein Beispiel verdeutlichen.
Das Musiksystem unterstützt für die Wiedergabe sowohl WinAmp als auch den Windows Media Player. Der nachstehende Code gibt einen kurzen Überblick
über das Aussehen der Klassen:
using System;
public abstract class MusicServer
{
public abstract void Play();
}
public class WinAmpServer: MusicServer
{
public override void Play()
{
Console.WriteLine("WinAmpServer.Play()");
}
}
public class MediaServer: MusicServer
{
public override void Play()
{
Console.WriteLine("MediaServer.Play()");
}
}
class Test
{
public static void CallPlay(MusicServer ms)
{
ms.Play();
}
public static void Main()
{
MusicServer ms = new WinAmpServer();
CallPlay(ms);
ms = new MediaServer();
CallPlay(ms);
}
}
Dieser Code erzeugt die folgende Ausgabe:
WinAmpServer.Play()
MediaServer.Play()
Polymorphismus und virtuelle Funktionen werden in
der .NET-Laufzeitumgebung an vielen Stellen eingesetzt. Das Basisobjekt object
beispielsweise verfügt über die virtuelle Funktion ToString(),
die zum Konvertieren eines Objekts in eine Zeichenfolgendarstellung
des Objekts verwendet wird. Wenn Sie die Funktion ToString()
für ein Objekt aufrufen, das nicht über eine eigene Version
von ToString() verfügt, wird die Version von ToString()
aufgerufen, die Teil der Klasse object ist2 , wodurch einfach der Name der Klasse zurückgegeben wird.
Wenn Sie die ToString()-Funktion überladen (eine eigene
Version schreiben), wird statt dessen diese Version aufgerufen, und Sie können
eine sinnvollere Operation ausführen. So könnten Sie beispielsweise
den Namen des Mitarbeiters ausschreiben, der im Mitarbeiter-Objekt
enthalten ist. Im Musiksystem bedeutete dies, die Funktionen Play(),
Pause(), NextSong() usw. zu überladen.
2.5 Kapselung und Sichtbarkeit
 
Beim Entwurf von Objekten muss der Programmierer
entscheiden, in welchem Ausmaß das Objekt für den Benutzer
sichtbar ist bzw. dem Benutzer verborgen bleibt. Details, die für
den Benutzer nicht sichtbar sind, bezeichnet man als in der Klasse gekapselt.
Im Allgemeinen besteht das Ziel beim Objektentwurf
darin, die Klasse in größtmöglichem Umfang zu kapseln.
Die Gründe hierfür lauten:
1.
|
Der Benutzer kann die als privat deklarierten
Elemente im Objekt nicht ändern, d. h. das Risiko, dass der
Benutzer diese Details im Code ändert oder von diesen abhängig
ist, wird verringert. Ist der Benutzer von diesen Details abhängig,
können Objektänderungen zur Beschädigung des Benutzercodes führen. |
2.
|
Änderungen, die an den öffentlichen
Bestandteilen eines Objekts vorgenommen werden, müssen eine weitere
Kompatibilität mit der vorherigen Version sicherstellen. Je mehr
Einsicht der Benutzer erhält, desto weniger Codeelemente können geändert werden, ohne den Benutzercode
zu zerstören. |
3.
|
Größere Schnittstellen erhöhen die Komplexität des gesamten
Systems. Auf private Felder kann nur von einer Klasse aus, auf öffentliche
Felder kann über eine beliebige Instanz der Klasse zugegriffen
werden. Der erforderliche Aufwand für die Fehlersuche steigt mit
der Anzahl der öffentlichen Felder. |
Dieses Thema wird in Kapitel 5, 101-Klassen,
weiter ausgeführt.
1
Vielleicht sollte eine Studie wie »Mehrfachvererbung
als schädlich eingestuft« veröffentlicht werden. Naja,
wahrscheinlich gibt es sie schon irgendwo.
2
Falls eine Basisklasse des aktuellen Objekts vorhanden
ist und diese ToString() definiert, wird diese Version
aufgerufen.
|