Ausarbeitung

Werbung
C# Typkonzept – Proseminar Objektorientiertes
Programmieren mit .NET und C#
Elias Tatros
[email protected]
Abstract: Ein C# Programm besteht aus Typen, die in eine sogenannte Assembly
kompiliert wurden und entweder als ausführbare Applikation oder Bibliothek, typischer Weise mit der Dateiendung .exe bzw. .dll vorliegen. Bei der Entwicklung eines
C# Programms werden also Typen (bspw. Klassen und Schnittstellen) deklariert, welche in Namensräumen organisiert werden können und jeweils eigene Member (bspw.
Felder und Methoden) enthalten. Die Programmiersprache C# bietet eine Vielzahl von
hierarchisch aufgebauten Typen an. In welcher Weise diese Typen organisiert sind
und welche Konsequenzen es hat, sich bei der Entwicklung von Applikationen oder
Bibliotheken, für einen bestimmten Typ, oder für ein bestimmtes Entwurfsmuster zu
entscheiden, ist Diskussionsgegenstand dieser Ausarbeitung.
1
Einleitung
Im .NET Framework können Typen von anderen Typen, den sogenannten Basistypen, erben. Der abgeleitete Typ A erbt, mit einigen Einschränkungen, die Methoden, Properties
und anderen Member des Basistyps B. Der Basistyp B kann ebenfalls von einem anderen Typen C abgeleitet sein. In dem Fall erbt der abgeleitete Typ A die Member von
beiden Basistypen B und C. Durch dieses Prinzip wird eine Vererbungshierarchie unter
den Typen aufgebaut. Alle Typen des .NET Frameworks sind letztendlich vom ultimativen Basistyp System.Object abgeleitet. Das gilt auch für die eingebauten Werttypen,
wie System.Int32. Diese einheitliche Typenhierarchie wird als das Common Type
System (CTS, Gemeinsames Typsystem) bezeichnet. Mehr zum Thema Vererbung lässt
sich in der MSDN Library unter [Lib10d] nachlesen.
1.1
Das Common Type System
Im CTS und in C# gibt es zwei Arten von Typen: Referenztypen und Werttypen. Variablen von Werttypen enthalten direkt ihre Daten. Variablen von Referenztypen dagegen,
beinhalten lediglich Referenzen auf ihre Daten, welche als Objekte bezeichnet werden.
Es ist somit möglich, dass zwei Variablen von Referenztypen das selbe Objekt referenzieren. Wird eine Operation auf einem Objekt ausgeführt, so sind alle Referenzen dieses
Objektes davon betroffen. Werden dagegen Werttypen verwendet, so hat jede Variable ih-
re eigene Kopie der Daten. Daher können Operationen auf einer Werttyp-Variable keine
anderen Variablen betreffen. Die einzige Ausnahme besteht für ref und out Parametervariablen1 . Werttypen werden unterteilt in simple Typen, Enums, Structs und nullbare
Typen. Referenztypen sind unterteilt in Klassen, Schnittstellen, Felder (Arrays) und Delegate. Jeder Typ im .NET Framework ist entweder ein Referenz-, oder ein Werttyp. Das
CTS legt die Regeln für die Behandlung von Objekten zwischen den Sprachen des .NET
Framework fest. Als einheitlich bezeichnet man das Typsystem von C# deshalb, da der
Wert eines Typs immer als ein Objekt behandelt werden kann. Werte von Referenztypen
können als Objekt behandelt werden, indem man die Werte einfach als Objekt ansieht
(type cast zu object). Werte von Werttypen können als Objekte repräsentiert werden,
indem man Box-Operationen auf sie anwendet. Box-Operationen (boxing und unboxing)
ermöglichen es, Werttypen in Referenztypen umzuwandeln und umgekehrt. Weitere Informationen dazu finden sich im nächsten Kapitel und in [AH08], sowie [Lib10b].
1.2
Benutzerdefinierbare Typen
In C# Programmen verwendet man Typdeklarationen, um neue Typen anzulegen. In einer
Typdeklaration werden der Name und die Member des neuen Typs festgelegt. Die fünf
benutzerdefinierbaren Typkategorien in C# sind Klassen, Structs, Schnittstellen, Enums
und Delegate.
Klassen sind der Standardtyp unter den Referenztypen und machen oft den Großteil der
Typen in C#-Programmen oder Bibliotheken aus. Sie definieren zwei Arten von Membern. Einerseits Datenstrukturen, die Member zum Speichern von Daten enthalten und
auch als Felder bezeichnet werden und andererseits Funktionen (Methoden, Properties,
...). Dadurch wird unter anderem die Kapselung der Daten erreicht. Klassen unterstützen
Vererbung und Polymorphismus, jedoch kann eine Klasse nicht von gleichzeitig von mehreren Basisklassen erben. Diese Mechanismen ermöglichen es abgeleiteten Klassen die
Basisklasse zu erweitern und zu spezialisieren.
Structs sind der Standardtyp unter den Werttypen und ähneln Klassen, da sie ebenfalls eine
Struktur aus Feldern und Funktionen darstellen. Im Gegensatz zu Klassen sind Structs jedoch Werttypen. Structs unterstützen damit keine benutzerdefinierte Vererbung. Dennoch
sind alle Structs implizit von object abgeleitet.
Schnittstellen definieren einen Vertrag, indem sie eine benannte Menge von Signaturen
für Methoden, Propertys, Ereignisse und oder Indizierer bereitstellen. Eine Klasse, die
eine Schnittstelle implementiert, muss alle in der Schnittstelle definierten Funktionen implementieren. Das Selbe gilt auch für Structs. Eine Schnittstelle kann von mehreren Basisschnittstellen erben und eine Klasse oder ein Struct darf mehrere Schnittstellen implementieren. Da Schnittstellen von Referenz- und Werttypen implementiert werden können,
eignen sie sich gut als Wurzel einer polymorphischen Hierarchie von Wert- und Referenztypen. Außerdem eigenen sich Schnittstellen zur Simulation von der in C# (und der
1 Bei Verwendung des ref Schlüsselworts erfolgt die Parameterübergabe als Referenz. Out Parameter verhalten
sich wie ref Parameter, mit dem Unterschied, dass der Initialwert unwichtig ist.
Common Language Runtime, CLR) nicht unterstützten Mehrfachvererbung, da ein Typ
mehrere Schnittstellen implementieren kann.
Ein Delegat repräsentiert eine Referenz zu einer Methode mit einer bestimmten Paramterliste und einem gewissen Rückgabetyp. Delegate ermöglichen es Methoden als Paramter
zu übergeben. Dazu wird zunächst ein Delegattyp deklariert, der die Signatur der durch
ihn gekapselten Methoden festlegt. Anschließend kann der Delegattyp instanziiert werden.
Dabei wird ein Delegat-Objekt angelegt, welches mit einer bestimmten Methode assoziiert wird. Delegate gleichen dem Konzept der Funktionszeiger, das zum Teil in anderen
Sprachen verwendet wird. Im Gegensatz zu Zeigern sind Delegate jedoch typsicher.
Enums sind spezielle Werttypen, die eine Menge von benannten Konstanten enthalten. Jedes Enum hat einen der acht einfachen, numerischen Typen als Grundlage (sbyte, short,
int, long, byte, ushort, uint, ulong). Enums werden dazu benutzt kleine Mengen von konstanten Werten bereitzustellen, zum Beispiel Wochentage, oder Farben.
1.3
Arrays und Nullbare Typen
C# unterstützt sowohl ein- und mehrdimensionale Arrays von beliebigen Typen. Arrays
konstruiert man durch das Anhängen von eckigen Klammern an den Typnamen. Zum Beispiel ist int[] ein eindimensionales Array vom Typ int. Ein zweidimensionales Array
vom Typ int wird definiert durch int[,]. int[][] dagegen, stellt ein eindimensionales Array von eindimensionalen Arrays vom Typ int dar.
Nullbare Werttypen müssen ebenfalls nicht deklariert werden, bevor sie benutzt werden
können. Zu jedem Werttypen T gibt es implizit einen zugewiesenen Typen T?, der zusätzlich
den Wert null annehmen kann. Zum Beispiel ist int? ein Typ, der als Wert entweder eine
32-bit Integer Zahl annimmt, oder den Wert null hat.
2
Typentwurf in C#
Das Entwerfen von Typen und deren logische Organisation, möglicherweise sogar der
Aufbau einer ganzen Typhierarchie, steht bei fast jedem C#-Programm und ganz besonders
für wiederverwendbare Bibliotheken im Zentrum des Entwicklungsvorgangs. Es ist sehr
wichtig, dass jeder Typ eine sinnvoll definierte Menge von inhaltlich zusammenhängenden
Membern hat und nicht nur eine sich zufällig ergebende Ansammlung von zusammenhangloser Funktionalität ist. Die Funktionalität eines gut entworfener Typs sollte klar definiert sein und sich in einem einfachen Satz zusammenfassen lassen.
Beim Entwerfen eines neuen benutzerdefinierten Typs muss der Entwickler sich für eine der fünf, in C# angebotenen Typkategorien (Klassen, Structs, Schnittstellen, Delegate
und Enums), entscheiden. Es gibt oftmals mehrere Möglichkeiten ein bestimmtes Konzept
durch einen dieser Typen zu realisieren. Das bedeutet, dass die Entscheidung für einen dieser Typen meist nicht sofort klar ist. Es gibt jedoch bestimmte Richtlinien, die erläutern,
wann und warum man sich, unter bestimmten Voraussetzungen, für einen gewissen Typ
entscheiden sollte. Einige dieser Argumente und Richtlinien werden in diesem Kapitel
näher beleuchtet, um so dem Leser mehr Sicherheit beim Entwerfen von Typen zu geben
und ihn bei der Wahl eines optimalen Entscheidungspfades zu unterstützen.
2.1
Unterschiede zwischen Wert- und Referenztypen
Der erste große Unterschied zwischen Wert- und Referenztypen besteht darin, dass Referenztypen auf dem Heap Speicher allozieren, welcher vom Garbage Collector freigegeben
wird, sobald keine Referenzen mehr auf das Objekt zeigen. Bei Werttypen dagegen wird
der Speicher auf dem Stack oder inline alloziert. Der Speicher wird bei Werttypen wieder
freigegeben, wenn der Stack abgewickelt2 , oder der umgebende Typ aufgelöst wird. Damit
ist das Allozieren und Freigeben von Speicher in der Regel für Werttypen billiger als für
Referenztypen.
Besonders ins Gewicht fällt diese Tatsache bei der Definition von Arrays. In Arrays von
Referenztypen sind die Elemente nur Referenzen zu den Instanzen des Referenztypen auf
dem Heap. In Arrays von Werttypen dagegen sind die Elemente die tatsächlichen Instanzen
des Werttypen. Damit ist das Allozieren und Deallozieren von Speicher für Arrays von
Werttypen deutlich billiger als für Arrays von Referenztypen.
Ein weiterer Unterschied besteht in der Speichernutzung. Wird ein Werttyp zu einem Referenztyp, oder zu einer der Schnittstellen, die er implementiert, umgewandelt, so kommt
es zu einem Vorgang, den man boxing nennt. Wird ein Werttyp zu einem Referenztyp umgewandelt, so wird auf dem Heap Speicher für eine Objektinstanz (auch Box genannt)
alloziert. Diese Box enthält den Wert des Werttyps und Typinformationen, um auf den
ursprünglichen Werttyp rückschließen zu können. Umgekehrt wird durch den Vorgang
des unboxing der Referenztyp wieder in einen Werttyp umgewandelt. Dazu wird aus der
Box die Typinformation entnommen und geprüft, ob sie mit dem Typ der neuen Variable übereinstimmt und anschließend der Wert aus der Box in die Variable kopiert. Das
folgende Beispiel illustriert den Vorgang.
using System;
class BoxingExample
{
static void Main() {
int i = 123;
object o = i;
// boxing
int j = (int)o; // unboxing
}
}
2 Beim Aufruf einer Methode merkt die CLR sich die aktuelle Stackposition. Im Methodenrumpf werden nun
ggf. Speicherallozierungen auf dem Stack vorgenommen. Nach Beendigung der Methode nimmt die CLR alle
zuvor getätigten Speicherallozierungen bis zur gemerkten Stelle wieder zurück.
Boxen sind Objekte, die auf dem Heap alloziert werden und vom Garbage Collector
überprüft werden müssen. Daher kann zuviel boxing oder unboxing negative Auswirkungen auf den Heap, den Garbage Collector und letztendlich die Performanz des Programms
haben. Werden dagegen gleich Referenztypen verwendet, so kommt es erst gar nicht zum
Vorgang des boxing oder unboxing. Mehr zu diesem Thema lässt sich in [Lib10a] nachlesen.
Der nächste Unterschied tritt auf, wenn man Referenztypen bzw. Werttypen einer Variable
zuweist. Weist man einer Variable die Instanz eines Referenztyps zu, so wird lediglich eine
Kopie der Referenz auf diese Instanz erstellt und der Variable zugewiesen. Handelt es sich
bei der Zuweisung dagegen um einen Werttyp, so wird der gesamte Wert in die Variable
kopiert. Aus diesem Grund sind Zuweisungen großer Referenztypen (> 16 bytes) billiger,
als Zuweisungen großer Werttypen.
Der letzte Unterschied besteht in der Art der Parameterübergabe. Referenztypen werden
implizit als Kopie der Referenz übergeben und Werttypen als Kopie des Werts. Änderungen
an der Instanz eines Referenztyps betreffen alle Referenzen, die auf diese Instanz zeigen.
Wird die kopierte Instanz eines Werttyps geändert, so sind das Original und alle anderen eventuell existierenden Kopien davon nicht betroffen. Das Kopieren der Instanzen
von Werttypen erfolgt implizit, also ohne Einfluss des Entwicklers (zum Beispiel beim
Übergeben von Argumenten, oder bei der Rückgabe eines Rückgabewerts). Aus diesem
Grund können verändliche Werttypen beim Programmieren für Verwirrung sorgen. Bei
der Verwendung von verändlichen Werttypen kann es zum Beispiel oft nötig sein eines der Schlüsselworte out oder ref bei der Parameterübergabe zu verwenden, um die
gewünschte Veränderbarkeit zu erreichen. Es ist daher in der Regel sinnvoll, seine Werttypen als unverändlich (immutable) zu entwerfen. Als unveränderlich bezeichnet man Typen, die keine Member mit der Sichtbarkeit public besitzen und gleichzeitig die aktuelle
Instanz des Typs verändern können. Ein Beispiel für einen unveränderlichen Typen ist
System.String. Die Member von System.String, wie zum Beispiel die Methode
ToUpper, modifizieren nicht den String selbst, sondern liefern einen neuen modifizierten
String zurück. Der Originalstring bleibt unverändert.
Zum Schluss sei noch erwähnt, dass alle Referenztypen pro Objekt einen SpeicherMehraufwand von 8 Bytes, bzw. 16 Bytes unter 64-Bit, besitzen. Das kann sich auswirken, wenn eine hohe Anzahl (zum Beispiel über eine Million) von kleinen Objekten (zum
Beispiel unter 16 Bytes) benötigt wird. In dem Fall wird ein großer Teil des allozierten
Speichers allein für den Mehraufwand des Referenztyps benutzt. Werttypen dagegen haben keinen Speicher-Mehraufwand.
2.2
Wahl zwischen Klasse und Struct
Oftmals steht man als Entwickler beim Entwerfen eines Typs vor der Entscheidung, ob
man den Typ als Struct (Werttyp), oder als Klasse (Referenztyp) definieren soll. In diesem Fall ist es sehr wichtig ein gutes Verständnis von den Eigenschaften von Referenzund Werttypen zu haben. Referenztypen haben einige Performanznachteile gegenüber den
Werttypen. Verwendet man einen Referenztyp, so muss für jede Instanz Speicher auf dem
Heap alloziert werden. Dies ist besonders auf Multiprozessor-Systemen, die einen gemeinsamen Heap benutzen, von Bedeutung. Außerdem haben Referenztypen einen Speicher
Mehraufwand von 8 bzw. 16 Bytes. Zusätzlich führt man noch bei jedem Zugriff eine
Indirektion ein, da nur über eine Referenz auf die Instanz zugegriffen werden kann.
Generell gilt trotzdem, dass die Mehrheit der Typen eines Programms oder einer Bibliothek Klassen sein sollten. Manchmal kann es jedoch sinnvoll sein Structs zu verwenden,
insbesondere, wenn die Eigenschaften eines Werttyps gewünscht sind. Structs eignen sich
gut für kleine (< 16 Byte), kurzlebige oder in andere Objekte eingebettete Typen.
Der Typ sollte alle folgenden Eigenschaften erfüllen, bevor man sich für ein Struct entscheidet. Der Typ:
• repräsentiert insgesamt einen einzelnen Wert (z.B. int, double),
• hat eine Größe von unter 16 Bytes,
• ist unveränderlich,
• muss nicht oft ”geboxt”werden.
Die Größe eines Structs ist wichtig, da der Typ bei jeder Parameterübergabe oder Zuweisung kopiert werden muss. Daher können große Structs (Werttypen) ein Problem darstellen. Es wird empfohlen seine Werttypen unveränderlich zu machen, daher sollte nur ein
Struct verwendet werden, wenn dies auf den zu erstellenden Typ zutrifft. Vielfaches boxing und unboxing von Werttypen kostet Zeit und Speicher und kann sich, wie bereits
zuvor erwähnt (s. 2.1), negativ auf den Heap, den Garbage Collector und die Performanz
des Programms auswirken. Treffen eine oder mehrere der oben aufgelisteten Eigenschaften nicht zu, so sollte eine Klasse verwendet werden. Mehr zum Thema Klassen und Strucs
lässt sich in [Lib10c] nachlesen.
2.3
Statische Klassen
Statische Klassen sind Klassen, die nur statische Member enthalten. Einzige Ausnahme
sind die von System.Object geerbten Member und gegebenfalls ein privater Konstruktor. In C# sind statische Klassen automatisch versiegelt (sealed), abstrakt (abstract)
und es dürfen keine Instanzvariablen deklariert, oder überschrieben werden. Structs (Werttypen) sind immer instanziierbar und können daher nicht statisch sein.
Statische Klassen sind ein Kompromiss aus objektorientiertem Entwurf und Einfachheit.
Aus diesem Grund sollten statische Klassen nur als Unterstützung für den objektorientierten Kern des Programms oder der Bibliothek verwendet werden. Es macht Sinn statische
Klassen anzulegen, wenn eine vollständig objektorientierte Lösung übertrieben oder nicht
sinnvoll wäre (z.B. für unveränderliche Funktionen, wie Math.Sin, einer Mathematik Bibliothek). Wie bereits in der Einführung erwähnt, sollten Klassen immer klar definierte
Aufgaben bzw. Verantwortlichkeiten haben. Das gilt auch für statische Klassen, daher sollte man diese nicht als Behälter für Member benutzen, für die einem gerade kein besserer
Platz einfällt. Bei der Erstellung von statischen Klassen darf man keine Instanzvariablen
definieren. Statische Klassen können nicht instanziiert werden und Instanzvariablen sind
deswegen nicht erlaubt, da sie sowieso niemand aufrufen könnte.
2.4
Abstrakte Klassen und Member
Der Modifikator abstract zeigt an, dass der damit versehen Member unvollständig,
oder gar nicht implementiert ist. Bei Klassen wird abstract verwendet, wenn die Klasse nur als Basisklasse für andere, abgeleitete Klassen dienen soll. Klassen, Methoden,
Properties, Indizierer und Ereignisse können abstrakt sein.
2.4.1
Abstrakte Klassen
Abstrakte Klassen sind unvollständige Klassen, die durch eine abgeleitete Klasse erweitert werden müssen. Solange die abgeleitete Klasse selbst nicht abstrakt ist, muss sie alle
abstrakten Member der abstrakten Klasse implementieren. Die abgeleitete Klasse nennt
man dann konkrete Klasse. Es ist nicht zwingend erforderlich alle Member einer abstrakten Klasse als abstrakt zu markieren. Alle abstrakten Klassen haben die folgenden Eigenschaften:
• Abstrakte Klassen können nicht instanziiert werden.
• Abstrakte Klassen dürfen abstrakte Member und Accessoren verwenden.
• Eine Abstrakte Klasse darf nicht mit dem Schlüsselwort sealed versehen werden.
abstract und sealed stehen im Gegensatz zueinander. sealed verbietet die
Erweiterung der Klasse und abstract erfordert die konkrete Implementierung,
durch eine von der Klasse erbenden, abgeleiteten Klasse.
• Eine nicht abstrakte (konkrete) Klasse, die von einer abstrakten Klasse abgeleitet ist
muss alle abstrakten Member der abstrakten Klasse implementieren.
Die Sichtbarkeit des Konstruktors für eine abstrakte Klasse sollte protected oder internal
sein. Da Abstrakte Klassen nicht instanziiert werden können, sorgt ein public Konstruktor
nur für Verwirrung. Soll die konkrete Implementierung der abstrakten Klasse auf die aktuelle Assembly beschränkt sein, so kann ein internal Konstruktor verwendet werden. Bei
der Deklaration einer abstrakten Klasse fügt C# automatisch einen protected Konstruktor
ein, solange man nicht selbst explizit einen Konstruktor anlegt.
2.4.2
Abstrakte Member
Der abstract Modifikator wird verwendet, um anzuzeigen, dass die Methode oder das
Property keine konkrete Implementierung besitzt. Die Implementierung einer abstrakten
Methode erfolgt durch Überschreiben der Methode in einer konkreten, von der abstrakten Klasse abgeleiteten Klasse. Dazu wird das Schlüsselwort override verwendet. Der
Vorgang wird im folgenden Beispiel verdeutlicht:
abstract class ShapesClass
{
// Abstrakte Methode ohne Implementierung
abstract public int Area();
}
// Konkrete Klasse Square erweitert die
// abstrakte Klasse ShapesClass
// und muss alle abstrakten Member
// implementieren (Area Methode).
class Square : ShapesClass
{
int side = 0;
// Konstruktor
public Square(int n)
{
side = n;
}
// Überschreiben der Area Methode mithilfe
// des Schlüsselworts override
public override int Area()
{
// Konkrete Implementierung der Area Methode
return side * side;
}
....
}
Abstrakte Methoden haben die folgenden Eigenschaften:
• Eine abstrakte Methode ist automatisch (implizit) eine virtuelle Methode.
• Abstrakte Methoden dürfen nur in abstrakten Klassen definiert werden.
• Abstrakte Methoden besitzen keinen ”Methoden-Körper”. Der Signatur folgen daher keine geschweiften Klammern. Zum Beispiel:
public abstract void MyMethod();
• Abstrakte Methoden dürfen nicht als statisch oder virtuell markiert werden.
Abstrakte Properties verhalten sich wie abstrakte Methoden. Unterschiede existieren nur
in der Syntax, die zur Deklaration oder zum Aufruf des Properties verwendet wird.
2.5
Schnittstellen
Schnittstellen dienen dazu einen Vertrag festzulegen, der durch eine implementierende
Klasse erfüllt werden muss, um eine bestimmte Funktionalität bereitzustellen. Im Gegensatz zu abstrakten Klassen, können Schnittstellen keinerlei Implementierung beinhalten.
Damit eignen sie sich gut, um einen Vertrag vollständig von der Implementierung zu trennen.
Die CLR (Common Language Runtime) unterstützt keine Mehrfachvererbung, das heißt
Klassen können nur von einer anderen (abstraken) Basisklasse erben. Allerdings kann eine Klasse mehrere Schnittstellen implementieren. Daher lassen sich Schnittstellen dazu
verwenden, um einen ähnlichen Effekt zur Mehrfachvererbung zu erzielen. Im unten gezeigten Fall erbt die Klasse Component von MarshalByRefObject und implementiert
gleichzeitig die IDisposable und IComponent Schnittstellen, wodurch die entsprechenden Funktionalitäten gewährleistet werden.
public class Component : MarshalByRefObject,
IDisposable, IComponent {
...
}
Schnittstellen eignen sich ebenfalls sehr gut dazu, um für einen Mix aus Wert- und Referenztypen, eine gemeinsame Schnittstelle anzubieten. Werttypen erben nur von
System.ValueType, weitere Basisklassen sind nicht erlaubt. Allerdings können Werttypen Schnittstellen implementieren. Schnittstellen sind in dem Fall also die einzige Option, um einen gemeinsamen Basistyp bereitzustellen.
// Werttypen können Schnittstellen Implementieren
public struct Boolean : IComparable {
...
}
// Referenztypen können Schnittstellen implementieren
public class String : IComparable {
...
}
// So kann ein Mix aus Referenz- und Werttypen auf einen
// gemeinsamen Basistyp, hier die Schnittstelle IComparable,
// aufbauen.
Schnittstellen ohne Member sollten nicht dazu verwendet werden, um Typen mit einer
bestimmten Eigenschaft zu markieren. Beispielsweise sollte man folgendes vermeiden:
// Vermeiden:
// Markierer-Schnittstelle ohne Member
public interface IImmutable {}
// Vermeiden:
// Klasse Key wird durch die IImmutable Schnittstelle
// markiert, um anzuzeigen, dass Key Instanzen
// unveränderlich sind.
public class Key : IImmutable {
...
}
Es ist besser, benutzerdefinierte Attribute zu verwenden, um eine bestimmte Eigenschaft
eines Typs anzuzeigen. Dadurch lassen sich dann auch Methoden bauen, die Parameter
zurückweisen, die nicht mit einem bestimmten Attribut versehen sind.
// Besser:
// Verwende Attribut, um anzuzeigen, dass
// Key Instanzen unveränderlich sind.
[Immutable]
public class Key {
...
}
// Add
// auf
public
if
Methode, die den Parameter key
das Argument ImmutableAttribute prüft
void Add (Key key, object value) {
(!key.GetType().IsDefined(
typeof(ImmutableAttribute), false)) {
throw new ArgumentException("Der Parameter
muss unveränderlich sein","key");
}
...
}
Bei der Verwendung von Attributen, sollte man sich bewusst sein, dass das Überprüfen
von Attributen deutlich teurer ist, als das Überprüfen von Typen. Handelt es sich um einen
Programmteil, der sehr kosteneffizient ablaufen muss, dann sollte man eventuell eine Ausnahme machen und doch eine Markierer-Schnittstelle anstelle des Attributs verwenden.
Eine weitere Ausnahme besteht, wenn das Überprüfen des Markers zur Kompilierzeit erfolgen muss. Da benutzerdefinierte Attribute erst zur Laufzeit überprüft werden, muss in
diesem Fall auf eine Markierer-Schnittstelle ausgewichen werden.
Beim Entwerfen und Ausliefern von Bibliotheken, darf man einer einmal ausgelieferten
Schnittstelle keine neuen Member mehr hinzufügen. Dies würde alle bstehenden Implementierungen dieser Schnittstelle unbrauchbar machen. In diesem Fall sollte man eine
neue Schnittstelle bereitstellen, oder am Besten gleich abstrakte Klassen verwenden.
2.6
Wahl zwischen Schnittstelle und abstrakter Klasse
Oftmals muss man sich bei der Erstellung einer Abstraktion zwischen einer Schnittstelle
oder einer abstrakten Klasse entscheiden. Das Argument für die Schnittstellen ist, dass
sie komplett den Vertrag von der Implementierung trennen. Abstrakte Klassen können das
jedoch in vielen Fällen genauso gut und sind flexibler, da man sie leichter anpassen kann,
ohne darauf aufbauenden Programmcode zu zerstören.
Bei der Erstellung von Bibliotheken oder APIs sind daher die Klassen den Schnittstellen vorzuziehen. Sobald die erste Version der Bibliothek/API ausgeliefert ist, stehen die
Member aller Schnittstellen für immer fest. Änderungen würden dazu führen, dass alle
Typen, die die Schnittstelle implementieren, unbrauchbar werden. Klassen dagegen sind
in diesem Fall deutlich flexibler, da man immernoch Member hinzufügen kann. Solange es
sich nicht um eine abstrakte Methode handelt, die keine Standardimplementierung besitzt,
werden erbende Klassen weiterhin funktionieren.
Die einzige Möglichkeit eine ausgelieferte Schnittstelle zu erweitern besteht darin, eine
neue Schnittstelle mit den zusätzlichen Membern bereitzustellen. Gehen wir zum Beispiel
davon aus, dass wir in Version eins unserer API die folgende Schnittstelle ausgeliefert
haben:
public interface IStream {
...
}
public class FileStream : IStream {
...
}
In Version zwei der API möchten wir nun für Operationen auf Streams Zeitüberschreitungen
(timeouts) überprüfen. Es ist also notwendig eine neue Schnittstelle bereitzustellen
(ITimeoutEnabledStream), die von der alten erbt und die neue Funktionalität bereitstellt. Nur so ist sichergestellt, dass die auf der alten Schnittstelle basierenden Implementierungen weiterhin funktionieren.
public interface ITimeoutEnabledStream : IStream {
int ReadTimeout{ get; set; }
}
public class FileStream : ITimeoutEnabledStream {
public int ReadTimeout {
get {
...
}
set {
...
}
}
}
Jetzt gibt es jedoch ein Problem mit den existierenden Klassen und Methoden, die alle
noch mit IStream arbeiten. Beispielsweise könnte es eine Klasse StreamReader geben,
die einen Stream als Konstruktorparameter nimmt und ein Property hat, dass einen Stream
zurückliefert:
public class StreamReader {
public StreamReader (IStream stream) { ... }
public IStream BaseStream { get { ... } }
}
Wie soll man jetzt sicherstellen, dass der StreamReader auch mit Streams der neuen ITimeoutEnabledStream Schnittstelle funktioniert. Es gibt hier mehrere Ansätze die funktionieren. Jedoch bringen diese neue Probleme mit sich, oder erschwerden die Benutzbarkeit
und Verständlichkeit unserer API.
• Verwenden von dynamischen Casts
ITimeoutEnabledStream stream =
myStreamReader.BaseStream as ITimeoutEnabledStream;
(if stream != null) {
stream.ReadTimeout = 100;
}
Dieser Ansatz verschlechtert die Benutzbarkeit. Den Benutzern von StreamReader
ist nicht sofort klar, dass einige Streams die neue Timeout Operation verwenden
können. Der Typcast fügt ebenfalls neue Komplexität hinzu.
• Hinzufügen eines neuen Property (TimeoutEnabledBaseStream), welches den ITimeoutEnabledStream zurückliefert, wenn dem StreamReader ein solcher vorliegt,
oder null zurückgibt, wenn der Stream kein ITimeoutEnabledStream ist.
ITimeoutEnabledStream stream =
myStreamReader.TimeoutEnabledBaseStream;
(if stream != null) {
stream.ReadTimeout = 100;
}
Auch dieser Ansatz verschlechtert die Benutzbarkeit, da Nutzern von StreamReader
an dieser Stelle absolut nicht klar ist, dass TimeoutEnabledBaseStream den Wert
null zurückliefern kann. Das könnte zu unerwarteten und verwirrenden
NullReferenceExceptions führen.
• Hinzufügen eines neuen Typs (TimeoutEnabledStreamReader), der mit der neuen
Schnittstelle zusammenarbeitet. Jeder neue Typ fügt der API weitere Komplexität
hinzu. Außerdem müsste man bei diesem Ansatz immer einen neuen Typ bereitstellen, sobald eine Schnittstelle verändert werden soll. Dies stört gegebenfalls die Typhierarchie und die klare Kapselung und Aufgabentrennung von Typen. Das größte
Problem bei diesem Ansatz ist jedoch, dass StreamReader selbst eventuell an anderen Stellen im Programm oder der API als Konstruktorparameter oder Property
verwendet wird. Um an diesen Stellen ebenfalls die neuen Streams zuzulassen ist
es notwendig auch dort wieder Änderungen vorzunehmen, um unseren neuen Typ
(TimeoutEnabledStreamReader) zu unterstützen.
Zusammenfassend kann man sagen, dass gut entworfene, abstrakte Klassen (vorzugsweise
in einer separaten Assembly) meist genauso gut den Vertrag von der Implementierung
trennen können wie Schnittstellen und zusätzlich eine höhere Flexibilität mitbringen, die
insbesondere beim Entwerfen und Erweitern von Bibliotheken oder APIs von Vorteil ist.
Ein Nachteil beim Einsatz von abstrakten Klassen anstelle von Schnittstellen ist, dass man
seinen einen und einzigen Basistyp aufgibt. Abstrakte Klassen eignen sich somit gut als
gemeinsamer Basistyp für eine Familie von Typen, während Schnittstellen gut geeignet
für zeitlich invariante Verträge sind, die für immer feststehen.
Schnittstellen sind die einzige Lösung für eine polymorphe Hierarchie von Werttypen.
Werttypen können mehrere Schnittstellen implementieren, aber nicht von anderen Typen
erben. Damit ist ein Ansatz mit abstraken Klassen unmöglich. Starke Schnittstellenkandidaten sind attributartige Typen, die auf viele Objekte anwendbar sind (”can-do”Relation).
Zum Beispiel können eine Vielzahl von Objekten formatierbar sein, daher ist
IFormattable eine sinnvolle Schnittstelle.
2.7
Structs
Structs sind der Standard-Werttyp in C#. Als Werttyp, muss auf dem Heap kein Speicher für das Struct alloziert werden. Structs sind nicht erweiterbar. Intern sind alle Structs
jedoch implizit von object abgeleitet. Structs sind insbesondere für kleine Datenstrukturen geeignet, die eingebauten Werttypen ähneln. Beispiele für gute Struct Kandidaten
sind Punkte in einem Koordinatensystem, Schlüssel-Wert Paare in einem Dictionary oder
komplexe Zahlen. Bei datenintensiven Programmen, die eine hohe Anzahl von kleinen
Datenstrukturen benötigen, kann es bei der Reservierung von Speicher einen großen Unterschied machen, ob man Structs anstelle von Klassen verwendet. Zum einen muss auf
dem Heap kein Speicher reserviert werden und zum anderen gibt es keinen Speicher Mehraufwand, der bei jedem Objekt eines Referenztyps auftreten würde.
Da Structs Werttypen sind, hat jede Struct Variable eine eigene Kopie der Daten und beein-
flusst, solange sie unveränderlich ist, nur sich selbst. Das folgende Codefragment illustriert
dies. Es wird 20 auf die Konsole geschrieben, wenn Point eine Klasse ist - im Falle eines
Structs lautet die Ausgabe dagegen 10.
Point a = new Point(10,10);
Point b = a;
a.x = 20;
Console.WriteLine(b.x);
Die einzige Möglichkeit eine Referenz auf ein Struct zu erhalten sind die ref und out
Parameter. Außerdem sollte man sich bewusst sein, dass das Kopieren eines ganzen Structs
oftmals teurer ist, als das Kopieren einer Objektreferenz. Weiterhin sollte man es (wie bei
allen Werttypen) vermeiden, Structs veränderlich zu machen. Beim Abrufen eines Property
wird z.B. immer implizit eine Kopie des Werts zurückgeliefert, damit ist das Manipulieren
des Originalwerts schwierig. Properties sollten nur get, keine set Methoden haben und alle
Initialisierungen sollten stattdessen im Konstruktor erfolgen.
Beim Entwerfen von Structs sollte man auch darauf achten, dass ein Zustand, in dem
alle Instanzvariablen auf 0, false bzw. null gesetzt sind für das Struct zulässig ist.
Wird ein Array vom Typ des Structs erstellt, so wird der Konstruktor für das Struct nicht
ausgeführt und Instanzvariablen werden mit den entsprechenden Standardwerten belegt.
Ist man sich dieser Tatsache nicht bewusst, so können Struct Instanzen leicht inkonsistente,
oder unzulässige Zustände annehmen. Structs können sehr nützlich sein, sollten aber nur
für kleine, unveränderliche Werte, die nicht oft geboxt werden müssen, verwendet werden.
2.8
Enums
Enums sind spezielle Werttypen, die sehr gut kleine abgeschlossene Mengen von Werten
darstellen können. Es gibt zwei Arten von Enums - simple Enums und Flag-Enums. Ein
einfaches Beispiel für ein simples Enum ist eine Menge von Farben:
public enum Color {
Red,
Green,
Blue,
...
}
Flag-Enums erlauben bitweise Operationen auf den Enumwerten. Ein Beispiel für ein
Flag-Enum ist eine Liste von kombinierbaren Attributen, wie folgende:
[Flags]
public enum FileAccessAttributes {
Read = 0x001,
Write = 0x002,
Execute = 0x004,
ReadWrite = Read | Write,
...
}
Es ist ebenfalls sinnvoll, seine Flag-Enums mit dem System.FlagsAttribute zu versehen
und für die Werte des Flag-Enum Potenzen von zwei zu verwenden. Dadurch können sie
einfach beliebig durch die bitweise Oder-Operation kombiniert werden. Oft verwendete
Kombination lassen sich auch leicht direkt in das Enum mit aufnehmen, wie im oben
stehenden Flag-Enum-Beispiel gezeigt. Bei der Erstellung eines Flag-Enums, sollte man
darauf achten, dass alle Kombinationen der Werte des Enums gültig sind. Ist dies nicht der
Fall, so ist es meist sinnvoller das Enum in mehrere kleinere Enums aufzuspalten.
Mengen von Werten, wurden damals oft durch Integer-Konstanten dargestellt. Durch Enums werden solche Mengen stärker typisiert. Dadurch lassen sich Fehler zur Kompilierzeit
besser feststellen und die Wertemengen sind oft besser lesbar (z.B. durch aufschlussreiche
Enumnamen) und nutzbar, z.B. durch Anzeigen aller möglichen Werte für einen Parameter oder Proprerty in IntelliSense3 . Aus diesem Gründen sind Enums statischen Konstanten
vorzuziehen. Nicht verwenden sollte man Enums, wenn die Wertemenge sich oft ändern
kann bzw. wenn Elemente entfernt werden müssen, oder wenn die Menge nur aus einem
Wert besteht. Enums sind auch eine gute Option, wenn man einen Boolean Parameter hat,
von dem man glaubt, dass er in Zukunft eventuell mehr als zwei Werte unterstützen muss.
Außerdem können Enums anstelle von Boolean Parametern die Lesbarkeit verbessern:
// Es ist nicht klar, wofür false und true stehen.
FileStream fileStream = File.Open("foo.txt", true, false);
// Mit Enums ist sofort klar, wofür die Parameter stehen.
FileStream fileStream =
File.Open("foo.txt", CasingOptions.CaseSensitive,
FileMode.Open);
3
Konzepte beim Member-Entwurf
Unter Membern versteht man Methoden, Properties, Ereignisse, Delegate, Konstruktoren
und Felder. Die Funktionalität einer Klasse wird letztendlich durch ihre Member bestimmt
und auch durch diese nach außen sichtbar. Member können verschiedene Eigenschaften
haben:
• virtuell: virtuelle Methoden können in einer Basisklasse definiert werden
3 IntelliSense
ist die Microsoft Implementierung einer Autovervollständigung innerhalb des Visual Studio.
Ähnlich arbeitende Autovervollständigungen finden sich auch in vielen anderen Programmierumgebungen.
(Schlüsselwort virtual) und mithilfe des override Schlüsselworts durch Methoden in abgeleiteten Klassen überschrieben werden.
• abstrakt: Methoden/Klassen können als abstrakt markiert werden, um eine Implementierung durch eine abgeleitete Klasse zu erzwingen.
• statisch: Methoden/Klassen können als statisch markiert werden, um die Benutzung
auch ohne vorherige Instanziierung zu ermöglichen.
Außerdem hat jeder Member eine gewisse Sichtbarkeit:
• private: Innerhalb der Klasse nutzbar.
• protected: Innerhalb der Klasse und abgeleiteten Klassen nutzbar.
• internal: Innerhalb der aktuellen Assembly nutzbar.
• internal protected: Vereinigung aus protected und internal. Also innerhalb der Assembly public und außerhalb protected.
• public: Überall nutzbar.
3.1
Überladen von Methoden
Unter Methodenüberladung versteht man das Anlegen von zwei oder mehr Methoden, innerhalb eines Typs, die den selben Namen tragen und sich lediglich in der Anzahl, oder
im Typ, der Parameter unterscheiden. Das Überladen von Methoden ist eine der wichtigsten Techniken, um Benutzbarkeit, Lesbarkeit und Produktivität zu erhöhen. Dies gilt
insbesondere beim Entwerfen einer Bibliothek. Durch Überladen ist es z.B. möglich einfachere Varianten von Konstruktoren oder Methoden bereitzustellen. Indem man nur den
Parametertyp einer Methode ändert, ist es Möglich eine bestimmte Operation für viele
verschiedene Typen anzubieten.
Es ist sinnvoll bei der Überladung von Methoden darauf zu achten, deskriptive Parameternamen zu verwenden. Somit wird in der Regel auch gleich der Standardwert für
die kürzeren Überladungen deutlich. Die erste Methode im folgenden Beispiel achtet auf
Groß- und Kleinschreibung, dies wird durch die Überladung mit dem Parameter
ignoreCase klar gemacht.
public class descriptiveParameterNameExample {
public bool searchString(string query) {...};
public bool searchString(string query,
bool ignoreCase) {...};
}
Weiterhin ist es sinnvoll, Parameternamen konsistent zu halten. Das heißt, wenn ein Parameter in einer Überladung das gleiche repräsentiert, wie in einer anderen Überladung,
dann sollte er auch in beiden den gleichen Namen tragen und wenn möglich auch an der
selben Position stehen. Enthält die Parameterliste einen params4 array Parameter oder
out Parameter, so kann man von dieser Regelung abweichen.
Es ist oftmals möglich internes Weiterleiten zur Methode mit der längsten Parameterliste
zu verwenden. Die kürzeren Überladungen rufen jeweils die nächst-längere Überladung
auf. Das hat den Vorteil, dass Standardwerte nur an einer Position gesetzt bzw. geändert
werden müssen und erlaubt eine besonders einfache Erweiterbarkeit, falls gewünscht, da
nur die längste Überladung virtuell sein braucht und dementsprechend in einer abgeleiteten Klasse einfach überschrieben werden kann, ohne dass man sich um jede einzelne Überladung kümmern muss. Dieses Pattern lässt sich auch gut in abstrakten Klassen
verwenden. Die gesamte Überprüfung von Argumenten kann in nicht-abstrakten, nichtvirtuellen Methoden erfolgen und die eigentliche Implementierung in der einen abstrakten
Methode, mit der längsten Parameterliste.
public class String {
public int IndexOf(string s) {
return IndexOf(s, 0);
}
public int IndexOf(string s, int startIndex) {
return IndexOf(s, startIndex, s.Length);
}
public virtual int IndexOf(string s, int startIndex,
int count) {
// Hier erfolgt die eigentliche
// Implementierung von IndexOf
}
}
3.2
Wahl zwischen Property und Methode
Häufig muss man sich beim Entwerfen eines Members für ein Property oder eine Methode
entscheiden. Ein Property sollte man wählen, wenn
• es sich um ein logisches Attribut des Typs handelt (z.B. Button.Color, da die Farbe
ein logisches Attribut von Button ist),
• das Verhalten der gesamten Klasse gesteuert werden soll,
4 Das
Schlüsselwort params kann für Methodenparameter verwendet werden, wenn die Anzahl der Argumente nicht bekannt ist. In jeder Methodendeklaration darf params nur einmal benutzt werden und musss ganz
am Ende der Parameterliste stehen.
• es sich um einen Accessor handelt,
• es keinen guten Grund gibt, eine Methode zu verwenden. Faustregel: Zugriff auf
einfache Daten, ohne hohen Rechenaufwand.
Bevorzugt man Properties beim Entwerfen der Member, so haben die Methoden meist eine
geringe Anzahl an Parametern und weniger Überladungen, als bei einem Methodenfokussiertem Entwurf. Properties sollten generell Daten repräsentieren und Methoden sollten
Aktionen darstellen. Eine Methode sollte man generell dann wählen, wenn
• der Rest der Klasse nichts mit den Parametern der Methode zu tun hat,
• die Operation viel langsamer als ein Feldzugriff ist. Dazu zählen alle Operationen,
bei denen Wartezeiten auftreten können (z.B. Asynchrone Operationen, Netzwerkoder Dateisystemzugriffe),
• die Operation eine Konvertierung darstellt (z.B. Object.ToString()),
• die Operation bei jedem Aufruf ein anderes Ergebnis zurückliefert, selbst bei unveränderten Parametern (z.B. Guid.NewGuid()),
• die Operation einen Array zurückliefert.
3.3
Überladen von Operatoren
Operatoren, z.B. + ,- , ++, == usw., können in Abhängigkeit von den Argumenten verschiedene Implementierungen annehmen. Da der Compiler die Typen der Argumente (Operanden) bestimmen muss, bevor der richtige Operator aufgerufen werden kann, bezeichnet
man den Vorgang als Überladen. Im folgenden Beispiel wird der +-Operator für den Typen BigInteger überladen.
public struct BigInteger {
public static BigInteger
operator+(BigInteger left,
BigInteger right) { ... };
}
Wenn x und y Instanzen von BigInteger sind, dann wird beim Aufruf von BigInteger result
= x+y; der oben definierte Operator verwendet. Beim Überladen von Operatoren muss einer der Operanden immer vom Typ sein, auf dem die Überladung definiert wird. Es ist oftmals sinnvoll Operatoren-Überladung in Structs zu verwenden, die Zahlen repräsentieren
(wie z.B. bei System.Decimal), da Benutzer der Klasse eventuell erwarten mit bestimmten Operatoren rechnen zu können. Wichtig ist, dass man Operatoren immer symmetrisch
überlädt. Das bedeutet, wenn der Gleichheitsoperator (==) überladen wird, dann sollte
auch der Ungleichheitsoperator (!=) überladen werden. Nicht überladen sollte man Operatoren, wenn nicht eindeutig klar ist, was das Resultat der Operation ist. Verwendet man
zum Beispiel den Shift-Operator (<<) zum schreiben auf einen Stream, so ist nicht klar,
was nach dieser Operation zurückgegeben wird. Eine Überladung des Shift-Operators ist
an dieser Stelle also nicht angebracht.
3.4
Erweiterbarkeit
Einer der drei großen Pfeiler der objektorientierten Programmierung ist die Vererbung. In
C# kann man Vererbung erlauben und unterstützen, wenn sie gewünscht ist und verbieten
wenn Erweiterbarkeit gefährlich, oder unerwünscht ist. Um die Erweiterung einer Klasse zu verhindern, kann man sie versiegeln. Dies geschieht, indem man das Schlüsselwort
sealed verwendet. Klassen, die man nicht versiegelt sind automatisch unversiegelt und
erweiterbar. Man kann auch individuelle Variablen oder Methoden versiegeln, wie im folgenden Beispiel gezeigt.
// Von String kann nicht geerbt werden.
public sealed class String { ... }
// Von TraceSource kann geerbt werden,
// da nicht explizit versiegelt wurde.
public class TraceSource { ... }
// Nochmaliges Überschreiben der Methode SetItem ist
// nicht erlaubt
protected sealed override void SetItem(...) { ... }
C# bietet viele Möglichkeiten an, um Erweiterung durchzuführen. Dazu zählen z.B. Subklassen, virtuelle Methoden, Rückrufe (Callbacks) und Abstraktionen. Man sollte sich jedoch bewusst sein, dass Erweiterbarkeit auch immer mit erhöhtem Aufwand in Form von
Speicher und Rechenzeit verbunden ist. So arbeiten virtuelle Methoden beispielsweise
langsamer als nicht virtuelle und als protected markierte Member sind nicht so effizient
wie private Member. Die Kosten sind oftmals auch nicht nur auf die Performanz der Applikation oder der Bibliothek beschränkt. Manchmal kann der zu häufige, oder ungeeignete Einsatz von Erweiterbarkeit auch zu Lesbarkeit- oder Verständnisproblemen führen.
Generell gilt, dass man immer die einfachste und billigste Möglichkeit der Erweiterbarkeit wählen sollte, welche die Anforderungen erfüllt. Weiterhin sollte man beachten, dass
es normalerweise möglich ist, die Erweiterbarkeit später noch einzuführen. Einmal eingeführte Erweiterbarkeit zu entfernen, ist jedoch nicht möglich, ohne darauf aufbauenden
Code unbrauchbar zu machen.
Eine sehr beliebte und einfache Methode, um Erweiterbarkeit einzuführen, ist die Definition von Subklassen. In diesen können neue Variablen, Properties, Methoden und Attribute
hinzugefügt werden, sowie zusätzliche Schnittstellen implementiert werden. In einer Subklasse können die virtuellen Methoden der Basisklasse überschrieben werden. Auf die
als protected markierten Member der Basisklasse kann zugegriffen werden. Jedoch sollte
man sich beim Entwurf der Basisklasse bewusst sein, dass die Verwendung von virtual
und protected sich negativ auf die Performanz der Applikation auswirkt. Demnach ist
die einfachste und kosteneffizienteste Form der Erweiterbarkeit eine unversiegelte Klasse
ohne virtuelle Methoden und ohne Verwendung der Sichtbarkeit protected.
Es kann jedoch sinnvoll sein, Schlüsselmethoden seiner Klasse virtuell zu machen, wenn
man Erweiterbarkeit anbieten will. Diese Methoden können von einer erbenden Subklasse
überschrieben werden und so das Verhalten dieser verändern. Damit sind virtuelle Methoden ein gutes Mittel, um eine existierende Klasse zu spezialisieren. Insgesamt liefern virtuelle Methoden auch bessere Performanz und sind günstiger im Speicherverbrauch, als ein
Ansatz über Rückrufe (Callbacks). Manchmal will man eine Art eingeschränkter Erweiterung anbieten, d.h. man möchte grundsätzlich Erweiterbarkeit anbieten, aber sicherstellen, dass bestimmte Variablen initialisiert, gewisse Methoden aufgerufen, oder Invarianten
eingehalten werden. Dazu lässt sich sehr gut eine einfache Version des Template-MethodPatterns verwenden, wie im folgenden Beispiel gezeigt:
public class Control {
// Die als public markierte Methode
// ruft die interne virtuelle Methode auf
public void SetBounds(...) {
// Invarianten, Initialisierungen,
// etc. vor Aufruf der Implementierung
...
SetBoundsCore(...);
// Invarianten, Ressourcenfreigaben,
// etc. nach Aufruf der Implementierung
...
}
protected virtual void SetBoundsCore(...) {
// Hier erfolgt die eigentliche Implementierung
// von SetBounds
}
}
Eine weitere oft genutzte Art der Erweiterbarkeit ist der Entwurf von Abstraktionen. Abstraktionen sind Typen die einen Vertrag festlegen, aber keine vollständige Implementierung von diesem bereitstellen. Ein konkreter Typ kann die Abstraktion dann erweitern
(im Falle einer abstrakten Klasse), oder implementieren (im Falle einer Schnittstelle) und
bleibt trotzdem noch kompatibel mit Operationen, die den abstrakten Typ bzw. die Schnittstelle verwenden. Beispiele von Abstraktionen aus dem .NET Framework sind Stream,
IEnumerable<T> und Object. In C# lassen sich Abstraktionen gut mit Schnittstellen
oder abstrakte Klassen erstellen, die bereits im letzten Kapitel ausführlich behandelt wurden. Es ist wichtig, dass man seinen Vertrag gut dokumentiert, sodass die Semantik der Typen die den Vertrag erfüllen sollen klar ist. Darauf sollte man speziell beim Entwerfen eines Frameworks, einer Bibliothek bzw. API achten, da Benutzer von diesen eventuell nicht
mit der Abstraktionshierarchie vertraut sind. Insbesondere, wenn die Implementierung von
einer anderen Person durchgeführt wird, können zu viele Member in einer Abstraktion für
Verwirrung sorgen und es schwierig machen die Abstraktion vertragsgemäß zu implementieren. Daher sollte man versuchen, der Abstraktion so wenig Member wie möglich
hinzuzufügen. Andererseits kann die Abstraktion auch für viele Anwendungsfälle nutzlos
werden, wenn man zu wenige Member bereitstellt. Aus diesen Gründen kann es schwierig
sein eine sinnvolle und gut benutzbare Abstraktion zu erstellen, daher sollte man vorher
einen guten Bauplan entwerfen und später eventuell gleich eine konkrete Implementierung
der Abstraktion bereitstellen. Dies macht es Benutzern der Abstraktion leichter, da sie so
ein Anwendungsbeispiel zur Implementierung sehen. Außerdem ist durch den konkreten
Typ auch gleich die Existenz der Abstraktion validiert.
4
Schlussbemerkungen
Beim Entwerfen von Typen gibt es häufig eine Reihe von Optionen zwischen denen man
sich entscheiden muss. Ausführlich behandelt wurden hier nur die Entscheidung zwischen Klassen (Referenztypen) und Structs (Werttypen) sowie Schnittstellen und abstrakten Klassen. Weiterhin bietet das .NET Framework viele Möglichkeiten, um Typen zu
erweitern, von denen in diesem Dokument einige behandelt (Subklassen, Abstraktionen,
virtuelle Methoden) und andere nur genannt wurden (z.B. Callbacks). Es gibt also oftmals
mehrere Wege, um beim Programmieren ein bestimmtes Ziel zu erreichen. Die in diesem
Dokument genannten Richtlinien und Erklärungen sollen dabei helfen, möglichst den einfachsten und effizientesten Weg zu finden. Sie sind aber keine Universallösung für jedes
konkrete Problem, Ausnahmen bestehen immer. So können zum Beispiel die Richtlinien
für die Wahl zwischen Schnittstelle und abstrakter Klasse helfen, sich für eines der beiden
zu entscheiden. Wirklich wichtig ist jedoch, dass man die Eigenschaften und Unterschiede
von diesen kennt und so auch selbst eine eigene Entscheidung treffen kann. Diese muss
nicht unbedingt mit einer Richtlinie übereinstimmen, solange es einen guten Grund dafür
gibt.
Wozu dann Richtlinien? Konsequent angewandte Richtlinien fördern Konsistenz beim
Programmieren einer Applikation oder einer Bibliothek. Dadurch, dass bestimmte Konstruktionen und Entscheidungswege an mehreren Stellen auf die gleiche Weise getroffen
wurden, lässt sich das gesamte Programm bzw. die gesamte Bibliothek besser verstehen,
benutzen und erweitern. Konsistenz wird besonders dann wichtig, wenn mehrere Personen
an einem Programm, oder eine Bibliothek arbeiten bzw. wenn diese von anderen Leuten
benutzt oder erweitert werden soll. Aus diesem Grund ist es sinnvoll sich beim Entwerfen
von Typen an zuvor festgelegte Konventionen zu halten. Natürlich gibt es nicht nur für
den Entwurf von Typen Konventionen und Richtlinien, sondern auch für das Entwerfen
von Membern, die Wortwahl bei der Benennung von Variablen, das Werfen von Exceptions und viele andere Situationen beim Programmieren mit C# und dem .NET Framework. Als weiterführende Informationsquelle ist deshalb das Buch ”Framework Design
Guidelines: Conventions, Idioms and Patterns for Reusable .NET Libraries” [KC09] besonders empfehlenswert (s. Quellen). Viele der in diesem Buch gezeigten Richtlinien und
Entwurfs-Praktiken stammen von den Entwicklern des .NET Frameworks selbst und sind
nicht nur beim Entwerfen von riesigen Bibliotheken hilfreich. Die vielzahligen Beispiele
sorgen für ein besseres Verständnis der Richtlinien und Kommentare der Entwickler liefern einen tieferen Einblick in das .NET Framework. Ein interessantes Interview mit dem
Chefentwickler von C# findet sich in [Bro10].
Literatur
[AH08]
Scott Wiltamuth Peter Golde Anders Hejlsberg, Mads Torgersen. The C# Programming
Language, Third Edition. Addison-Wesley, 2008.
[Bro10] OReilly Broadcast. An Interview with Anders Hejlsberg. http://broadcast.
oreilly.com/2009/03/an-interview-with-anders-hejls.html,
2010.
[KC09]
Brad Abrams Krzysztof Cwalina. Framework Design Guidelines: Conventions, Idioms,
and Patterns for Reusable .NET Libraries. Addison-Wesley, 2009.
[Lib10a] MSDN Library. Boxing und Unboxing in C#. http://msdn.microsoft.com/
en-us/library/yz2be5wk.aspx, 2010.
[Lib10b] MSDN Library. C# Typen und das Common Type System.
microsoft.com/en-us/library/ms173104.aspx, 2010.
[Lib10c] MSDN Library. Klassen und Structs in C#.
en-us/library/ms173109.aspx, 2010.
[Lib10d] MSDN Library. Vererbung in C#.
library/ms173149.aspx, 2010.
http://msdn.
http://msdn.microsoft.com/
http://msdn.microsoft.com/en-us/
Herunterladen