Kapitel 7 Basisklassen und Vererbung
Wie bereits in Kapitel 1, Grundlagen der objektorientierten
Programmierung, besprochen, ist es gelegentlich sinnvoll, eine Klasse
aus einer anderen abzuleiten, und zwar dann, wenn die abgeleitete Klasse
eine Sonderform der Basisklasse ist.
7.1 Die Engineer-Klasse
 
Nachfolgend werden die Klasse Engineer
(Techniker/Ingenieur) sowie Methoden zur Rechnungsstellung für
diesen Engineer implementiert.
using System;
class Engineer
{
// Erstellungsroutine
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
// Rechnungsbetrag auf Grundlage des Stundensatzes des Technikers
ermitteln
public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
// Name des Typs zurückgeben
public string TypeName()
{
return("Engineer");
}
private string name;
protected float billingRate;
}
class Test
{
public static void Main()
{
Engineer engineer = new Engineer("Hank", 21.20F);
Console.WriteLine("Name is: {0}", engineer.TypeName());
}
}
Für dieses Szenario dient Engineer als Basisklasse. Die Klasse Engineer
enthält das private Feld name sowie das geschützte Feld billingRate. Der Modifikator protected gewährt den gleichen Zugriff wie private,
abgesehen davon, dass die von dieser Klasse abgeleiteten Klassen auch
Zugriff auf dieses Feld erhalten. Protected wird demnach
dazu eingesetzt, den von dieser Klasse abgeleiteten Klassen Zugriff
auf ein Feld zu gewähren.
Der Zugriff über protected ermöglicht
anderen Klassen, sich auf die interne Implementierung der Klasse zu
verlassen, daher sollte dieser Zugriff nur gewährt werden, wenn
dies erforderlich ist. Im Beispiel kann das Mitglied billingRate
nicht umbenannt werden, da abgeleitete Klassen möglicherweise darauf
zugreifen. Häufig ist es besser, eine geschützte Eigenschaft
zu verwenden.
Die Engineer-Klasse besitzt außerdem
eine Mitgliedsfunktion, mit der basierend auf den geleisteten Arbeitsstunden
der in Rechnung zu stellende Betrag berechnet wird.
7.2 Einfache Vererbung
 
Ein CivilEngineer (Bauingenieur) gehörte
auch zur Gruppe der Techniker und Ingenieure und kann daher von der
Klasse Engineer abgeleitet werden:
using System;
class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
public string TypeName()
{
return("Engineer");
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer
{
public CivilEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
// Neue Funktion, da anders als
// Basisversion
public new float CalculateCharge(float hours)
{
if (hours < 1.0F)
hours = 1.0F; // Mindestsatz
return(hours * billingRate);
}
// Neue Funktion, da anders als
// Basisversion
public new string TypeName()
{
return("Civil Engineer");
}
}
class Test
{
public static void Main()
{
Engineer e = new Engineer("George", 15.50F);
CivilEngineer c = new CivilEngineer("Sir John", 40F);
Console.WriteLine("{0} charge = {1}",
e.TypeName(),
e.CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
c.TypeName(),
c.CalculateCharge(0.75F));
}
}
Da die Klasse CivilEngineer von der
Klasse Engineer abgeleitet wurde, erbt sie alle Datenmitglieder
der Klasse (obwohl auf das als privat deklarierte Feld name
nicht zugegriffen werden kann). Darüber hinaus erbt die CivilEngineer-Klasse
auch die Mitgliedsfunktion CalculateCharge().
Erstellungsroutinen können nicht vererbt werden,
daher muss für CivilEngineer eine neue Erstellungsroutine
geschrieben werden. Die Erstellungsroutine muss keinen besonderen Code
ausführen, daher wird unter Verwendung der Syntax base die
Erstellungsroutine für Engineer aufgerufen. Wenn der
Aufruf der Klassenerstellungsroutine base nicht erfolgt,
ruft der Compiler die base-Klassenerstellungsroutine ohne
Parameter auf.
CivilEngineer weist eine andere Berechnungsmethode
auf. Der Mindestsatz wird mit einer Stunde veranschlagt, daher muss
eine neue Version von CalculateCharge() definiert werden.
Bei Ausführung des Codebeispiels wird folgende
Ausgabe erzeugt:
Engineer Charge = 31
Civil Engineer Charge = 40
7.3 Engineer-Arrays
 
Das vorgenannte Beispiel funktioniert prima bei
einer kleinen Mitarbeiterzahl. Wenn das Unternehmen jedoch wächst,
ist die Verwendung von Engineer-Arrays einfacher.
Da CivilEngineer sich aus der Klasse
Engineer ableitet, kann ein Array vom Typ Engineer
beide Klassentypen enthalten. Dieses Beispiel weist eine andere Main()-Funktion
auf, mit der die Techniker und Ingenieure in einem Array zusammengefasst
werden:
using System;
class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
public string TypeName()
{
return("Engineer");
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer
{
public CivilEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
public new float CalculateCharge(float hours)
{
if (hours < 1.0F)
hours = 1.0F; // Mindestsatz
return(hours * billingRate);
}
public new string TypeName()
{
return("Civil Engineer");
}
}
class Test
{
public static void Main()
{
// Engineer-Array erstellen
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer("George", 15.50F);
earray[1] = new CivilEngineer("Sir John", 40F);
Console.WriteLine("{0} charge = {1}",
earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Diese Codeversion erzeugt die folgende Ausgabe:
Engineer Charge = 31
Engineer Charge = 30
Leider ist das nicht richtig.
Bei der Platzierung der verschiedenen Ingenieure
und Techniker im Array geht die Tatsache verloren, dass der zweite Engineer
tatsächlich ein CivilEngineer ist. Da es sich um ein
Engineer-Array handelt, wird beim Aufruf von CalculateCharge()
die Version für Engineer aufgerufen.
Es ist also erforderlich, den Typ von Engineer
richtig zu ermitteln. Diese Identifizierung kann über ein Feld
in der Engineer-Klasse erfolgen, mit dem der Typ gekennzeichnet
wird. Nach einer Umarbeitung der Klassen mit einem enum-Feld
zur Kennzeichnung des Engineer-Typs erhalten Sie folgendes
Beispiel:
using System;
enum EngineerTypeEnum
{
Engineer,
CivilEngineer
}
class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
type = EngineerTypeEnum.Engineer;
}
public float CalculateCharge(float hours)
{
if (type == EngineerTypeEnum.CivilEngineer)
{
CivilEngineer c = (CivilEngineer) this;
return(c.CalculateCharge(hours));
}
else if (type == EngineerTypeEnum.Engineer)
return(hours * billingRate);
return(0F);
}
public string TypeName()
{
if (type == EngineerTypeEnum.CivilEngineer)
{
CivilEngineer c = (CivilEngineer) this;
return(c.TypeName());
}
else if (type == EngineerTypeEnum.Engineer)
return("Engineer");
return("No Type Matched");
}
private string name;
protected float billingRate;
protected EngineerTypeEnum type;
}
class CivilEngineer: Engineer
{
public CivilEngineer(string name, float billingRate) :
base(name, billingRate)
{
type = EngineerTypeEnum.CivilEngineer;
}
public new float CalculateCharge(float hours)
{
if (hours < 1.0F)
hours = 1.0F; // Mindestsatz
return(hours * billingRate);
}
public new string TypeName()
{
return("Civil Engineer");
}
}
class Test
{
public static void Main()
{
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer("George", 15.50F);
earray[1] = new CivilEngineer("Sir John", 40F);
Console.WriteLine("{0} charge = {1}",
earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Anhand des type-Feldes können die Funktionen in Engineer
den tatsächlichen Objekttyp ermitteln und die geeignete Funktion
aufrufen.
Die Codeausgabe lautet wie erwartet:
Engineer Charge = 31
Civil Engineer Charge = 40
Unglücklicherweise ist die Basisklasse nun recht kompliziert geworden, denn für jede
Funktion zu einem Klassentyp ist Code vorhanden, mit dem die möglichen
Typen geprüft werden und anschließend die richtige Funktion
aufgerufen wird. Dies bedeutet eine Menge Extracode; bei 50 unterschiedlichen
Engineer-Typen wäre diese Methode nicht praktikabel.
Schlimmer noch ist die Tatsache, dass die Basisklasse
die Namen aller abgeleiteten Klassen kennen muss, um funktionieren zu
können. Wenn der Codebesitzer Unterstützungscode für
einen neuen Typ Engineer hinzufügen möchte, muss
die Basisklasse geändert werden. Ein Benutzer ohne Zugriff auf
die Basisklasse ist überhaupt nicht in der Lage, einen neuen Engineer-Typ
hinzuzufügen.
7.4 Virtuelle Funktionen
 
Zur Optimierung vorgenannter Codeabschnitte bieten
objektorientierte Sprachen die Möglichkeit, Funktionen als virtuell
(Schlüsselwort virtual) zu deklarieren. Virtuell bedeutet hierbei, dass beim Aufruf einer Mitgliedsfunktion
der Compiler den tatsächlichen Objekttyp ermitteln (nicht lediglich
den Verweistyp) und basierend auf diesem Typ die geeignete Funktion
aufrufen sollte.
Mit diesem Hintergrundwissen kann das Beispiel folgendermaßen
abgeändert werden:
using System;
class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
// Funktion jetzt virtuell
virtual public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
// Funktion jetzt virtuell
virtual public string TypeName()
{
return("Engineer");
}
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer
{
public CivilEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
// Hat Vorrang vor Funktion in Engineer
override public float CalculateCharge(float hours)
{
if (hours < 1.0F)
hours = 1.0F; // Mindestsatz
return(hours * billingRate);
}
// Hat Vorrang vor Funktion in Engineer
override public string TypeName()
{
return("Civil Engineer");
}
}
class Test
{
public static void Main()
{
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer("George", 15.50F);
earray[1] = new CivilEngineer("Sir John", 40F);
Console.WriteLine("{0} charge = {1}",
earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Die Funktionen CalculateCharge() und TypeName() wurden nun in der Basisklasse mit dem Schlüsselwort virtual deklariert, damit verfügt
die Basisklasse über ausreichende Informationen. Die Basisklasse
benötigt keine Informationen zu den abgeleiteten Typen, sie muss lediglich Kenntnis darüber haben,
dass jede abgeleitete Klasse ggf. CalculateCharge() und
TypeName() implementieren kann. In der abgeleiteten Klasse
werden die Funktionen mit dem Schlüsselwort override
deklariert, das bedeutet, dass es sich um dieselbe Funktion handelt,
die in der Basisklasse deklariert wurde. Fehlt das Schlüsselwort
override, nimmt der Compiler an, dass die Funktion nicht
in Beziehung zur Funktion der Basisklasse steht. Dies hat zur Folge,
dass das virtuelle Dispatching fehlschlägt1 .
Die Ausführung dieses Beispiels führt
zur erwarteten Ausgabe:
Engineer Charge = 31
Civil Engineer Charge = 40
Wenn der Compiler einen Aufruf von TypeName()
oder CalculateCharge() ermittelt, wechselt er zur Funktionsdefinition
und stellt fest, dass es sich um eine virtuelle Funktion handelt. Statt
Code für den direkten Funktionsaufruf zu generieren, wird Dispatchercode
erzeugt, mit dem zur Laufzeit der tatsächliche Objekttyp ermittelt
wird. Anschließend wird die mit dem tatsächlichen Typ verknüpfte
Funktion aufgerufen, nicht lediglich der Typ des Verweises. Dies ermöglicht
auch dann den Aufruf der richtigen Funktion, wenn die Klasse zur Kompilierungszeit
nicht implementiert war.
Beim virtuellen Dispatching entsteht ein leichter
Overhead, daher sollte diese Methode nicht unnötig
eingesetzt werden. Es kann jedoch passieren, dass ein JIT-Compiler (Just In Time) ermittelt, dass keine abgeleiteten
Klassen für diejenige Klasse vorlagen, für die der Funktionsaufruf erfolgte, und deshalb das virtuelle Dispatching
in einen direkten Aufruf konvertiert.
7.5 Abstrakte Klassen
 
Bei dem bisher verwendeten Ansatz besteht ein kleines
Problem. Eine neue Klasse muss die Funktion TypeName()
nicht implementieren, da sie die Implementierung von Engineer
erben kann. Daher kann eine neue Engineer-Klasse leicht
mit dem falschen Namen verknüpft werden.
Angenommen, die Klasse ChemicalEngineer
wird hinzugefügt:
using System;
class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
virtual public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
virtual public string TypeName()
{
return("Engineer");
}
private string name;
protected float billingRate;
}
class ChemicalEngineer: Engineer
{
public ChemicalEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
// Schlüsselwort override wird versehentlich vergessen
}
class Test
{
public static void Main()
{
Engineer[] earray = new Engineer[2];
earray[0] = new Engineer("George", 15.50F);
earray[1] = new ChemicalEngineer("Dr. Curie", 45.50F);
Console.WriteLine("{0} charge = {1}",
earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Die Klasse ChemicalEngineer erbt in
diesem Fall die Funktion CalculateCharge() von Engineer.
Dies kann richtig sein, es wird jedoch auch TypeName()
vererbt, was eindeutig falsch ist. Es ist also eine Methode erforderlich,
mit der ChemicalEngineer zur Implementierung von TypeName()
gezwungen ist.
Dies kann erreicht werden, indem Engineer
von einer normalen Klasse in eine abstrakte Klasse geändert wird.
In dieser abstrakten Klasse wird die Mitgliedsfunktion TypeName()
als abstrakte Funktion gekennzeichnet, d. h. alle Klassen, die
von Engineer abgeleitet werden, müssen die Funktion
TypeName() implementieren.
Eine abstrakte Klasse definiert eine Festlegung, die von den abgeleiteten Klassen eingehalten werden
muss2 . Da eine abstrakte Klasse keine erforderliche Funktionalität aufweisen muss, kann sie nicht instanziiert
werden, was im Beispiel bedeutet, dass keine Instanzen der Engineer-Klasse
erstellt werden können. Damit weiterhin zwei unterschiedliche Engineer-Typen
vorliegen, wurde die Klasse ChemicalEngineer hinzugefügt.
Abstrakte Klassen verhalten sich wie normale Klassen,
nur dass eine oder mehrere Mitgliedsfunktionen als abstract
gekennzeichnet sind.
using System;
abstract class Engineer
{
public Engineer(string name, float billingRate)
{
this.name = name;
this.billingRate = billingRate;
}
virtual public float CalculateCharge(float hours)
{
return(hours * billingRate);
}
abstract public string TypeName();
private string name;
protected float billingRate;
}
class CivilEngineer: Engineer
{
public CivilEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
override public float CalculateCharge(float hours)
{
if (hours < 1.0F)
hours = 1.0F; // Mindestsatz
return(hours * billingRate);
}
override public string TypeName()
{
return("Civil Engineer");
}
}
class ChemicalEngineer: Engineer
{
public ChemicalEngineer(string name, float billingRate) :
base(name, billingRate)
{
}
override public string TypeName()
{
return("Chemical Engineer");
}
}
class Test
{
public static void Main()
{
Engineer[] earray = new Engineer[2];
earray[0] = new CivilEngineer("Sir John", 40.0F);
earray[1] = new ChemicalEngineer("Dr. Curie", 45.0F);
Console.WriteLine("{0} charge = {1}",
earray[0].TypeName(),
earray[0].CalculateCharge(2F));
Console.WriteLine("{0} charge = {1}",
earray[1].TypeName(),
earray[1].CalculateCharge(0.75F));
}
}
Durch das Hinzufügen von abstract
vor der Engineer-Klasse wird gekennzeichnet, dass die Klasse
abstrakt ist (d. h. sie verfügt über mindestens eine
abstrakte Funktion). Des Weiteren wurde ein abstract vor
der virtuellen Funktion TypeName() eingefügt. Die Verwendung von abstract
vor der virtuellen Funktion ist wichtig; das Einfügen von abstract
vor dem Klassennamen kennzeichnet, dass es sich um eine abstrakte Klasse
handelt, da die abstrakte Funktion leicht zwischen den anderen Funktionen
untergehen könnte.
Die Implementierung von CivilEngineer
ist identisch, abgesehen davon, dass der Compiler nun sicherstellen
wird, dass TypeName() sowohl von CivilEngineer
als auch von ChemicalEngineer implementiert wird.
7.6 sealed-Schlüsselwort
 
Die Verwendung des Schlüsselwortes sealed
für Klassen verhindert den Einsatz der Klasse als Basisklasse.
Dieses Schlüsselwort wird vor allem dazu verwendet, eine ungewollte
Ableitung zu verhindern.
// Fehler
sealed class MyClass
{
MyClass() {}
}
class MyNewClass : MyClass
{
}
Diese Operation schlägt fehl, da MyNewClass
die MyClass-Klasse nicht als Basisklasse verwenden kann,
weil MyClass mit dem Schlüsselwort sealed gekennzeichnet
ist.
1
Eine Erläuterung zur Funktionsweise dieser
Methode finden Sie in Kapitel 11, Versionssteuerung mit Hilfe von
New und Override.
2
Ein ähnlicher Effekt kann durch die Verwendung
von Schnittstellen erzielt werden. Einen Vergleich zwischen diesen beiden
Methoden finden Sie in Kapitel 10, Schnittstellen.
|