01.09.2013 Aufrufe

Ausarbeitung - TUM

Ausarbeitung - TUM

Ausarbeitung - TUM

MEHR ANZEIGEN
WENIGER ANZEIGEN

Erfolgreiche ePaper selbst erstellen

Machen Sie aus Ihren PDF Publikationen ein blätterbares Flipbook mit unserer einzigartigen Google optimierten e-Paper Software.

C# Typkonzept – Proseminar Objektorientiertes<br />

Programmieren mit .NET und C#<br />

Elias Tatros<br />

elias.tatros@cs.tum.edu<br />

Abstract: Ein C# Programm besteht aus Typen, die in eine sogenannte Assembly<br />

kompiliert wurden und entweder als ausführbare Applikation oder Bibliothek, typischer<br />

Weise mit der Dateiendung .exe bzw. .dll vorliegen. Bei der Entwicklung eines<br />

C# Programms werden also Typen (bspw. Klassen und Schnittstellen) deklariert, welche<br />

in Namensräumen organisiert werden können und jeweils eigene Member (bspw.<br />

Felder und Methoden) enthalten. Die Programmiersprache C# bietet eine Vielzahl von<br />

hierarchisch aufgebauten Typen an. In welcher Weise diese Typen organisiert sind<br />

und welche Konsequenzen es hat, sich bei der Entwicklung von Applikationen oder<br />

Bibliotheken, für einen bestimmten Typ, oder für ein bestimmtes Entwurfsmuster zu<br />

entscheiden, ist Diskussionsgegenstand dieser <strong>Ausarbeitung</strong>.<br />

1 Einleitung<br />

Im .NET Framework können Typen von anderen Typen, den sogenannten Basistypen, erben.<br />

Der abgeleitete Typ A erbt, mit einigen Einschränkungen, die Methoden, Properties<br />

und anderen Member des Basistyps B. Der Basistyp B kann ebenfalls von einem anderen<br />

Typen C abgeleitet sein. In dem Fall erbt der abgeleitete Typ A die Member von<br />

beiden Basistypen B und C. Durch dieses Prinzip wird eine Vererbungshierarchie unter<br />

den Typen aufgebaut. Alle Typen des .NET Frameworks sind letztendlich vom ultimativen<br />

Basistyp System.Object abgeleitet. Das gilt auch für die eingebauten Werttypen,<br />

wie System.Int32. Diese einheitliche Typenhierarchie wird als das Common Type<br />

System (CTS, Gemeinsames Typsystem) bezeichnet. Mehr zum Thema Vererbung lässt<br />

sich in der MSDN Library unter [Lib10d] nachlesen.<br />

1.1 Das Common Type System<br />

Im CTS und in C# gibt es zwei Arten von Typen: Referenztypen und Werttypen. Variablen<br />

von Werttypen enthalten direkt ihre Daten. Variablen von Referenztypen dagegen,<br />

beinhalten lediglich Referenzen auf ihre Daten, welche als Objekte bezeichnet werden.<br />

Es ist somit möglich, dass zwei Variablen von Referenztypen das selbe Objekt referenzieren.<br />

Wird eine Operation auf einem Objekt ausgeführt, so sind alle Referenzen dieses<br />

Objektes davon betroffen. Werden dagegen Werttypen verwendet, so hat jede Variable ih-


e eigene Kopie der Daten. Daher können Operationen auf einer Werttyp-Variable keine<br />

anderen Variablen betreffen. Die einzige Ausnahme besteht für ref und out Parametervariablen<br />

1 . Werttypen werden unterteilt in simple Typen, Enums, Structs und nullbare<br />

Typen. Referenztypen sind unterteilt in Klassen, Schnittstellen, Felder (Arrays) und Delegate.<br />

Jeder Typ im .NET Framework ist entweder ein Referenz-, oder ein Werttyp. Das<br />

CTS legt die Regeln für die Behandlung von Objekten zwischen den Sprachen des .NET<br />

Framework fest. Als einheitlich bezeichnet man das Typsystem von C# deshalb, da der<br />

Wert eines Typs immer als ein Objekt behandelt werden kann. Werte von Referenztypen<br />

können als Objekt behandelt werden, indem man die Werte einfach als Objekt ansieht<br />

(type cast zu object). Werte von Werttypen können als Objekte repräsentiert werden,<br />

indem man Box-Operationen auf sie anwendet. Box-Operationen (boxing und unboxing)<br />

ermöglichen es, Werttypen in Referenztypen umzuwandeln und umgekehrt. Weitere Informationen<br />

dazu finden sich im nächsten Kapitel und in [AH08], sowie [Lib10b].<br />

1.2 Benutzerdefinierbare Typen<br />

In C# Programmen verwendet man Typdeklarationen, um neue Typen anzulegen. In einer<br />

Typdeklaration werden der Name und die Member des neuen Typs festgelegt. Die fünf<br />

benutzerdefinierbaren Typkategorien in C# sind Klassen, Structs, Schnittstellen, Enums<br />

und Delegate.<br />

Klassen sind der Standardtyp unter den Referenztypen und machen oft den Großteil der<br />

Typen in C#-Programmen oder Bibliotheken aus. Sie definieren zwei Arten von Membern.<br />

Einerseits Datenstrukturen, die Member zum Speichern von Daten enthalten und<br />

auch als Felder bezeichnet werden und andererseits Funktionen (Methoden, Properties,<br />

...). Dadurch wird unter anderem die Kapselung der Daten erreicht. Klassen unterstützen<br />

Vererbung und Polymorphismus, jedoch kann eine Klasse nicht von gleichzeitig von mehreren<br />

Basisklassen erben. Diese Mechanismen ermöglichen es abgeleiteten Klassen die<br />

Basisklasse zu erweitern und zu spezialisieren.<br />

Structs sind der Standardtyp unter den Werttypen und ähneln Klassen, da sie ebenfalls eine<br />

Struktur aus Feldern und Funktionen darstellen. Im Gegensatz zu Klassen sind Structs jedoch<br />

Werttypen. Structs unterstützen damit keine benutzerdefinierte Vererbung. Dennoch<br />

sind alle Structs implizit von object abgeleitet.<br />

Schnittstellen definieren einen Vertrag, indem sie eine benannte Menge von Signaturen<br />

für Methoden, Propertys, Ereignisse und oder Indizierer bereitstellen. Eine Klasse, die<br />

eine Schnittstelle implementiert, muss alle in der Schnittstelle definierten Funktionen implementieren.<br />

Das Selbe gilt auch für Structs. Eine Schnittstelle kann von mehreren Basisschnittstellen<br />

erben und eine Klasse oder ein Struct darf mehrere Schnittstellen implementieren.<br />

Da Schnittstellen von Referenz- und Werttypen implementiert werden können,<br />

eignen sie sich gut als Wurzel einer polymorphischen Hierarchie von Wert- und Referenztypen.<br />

Außerdem eigenen sich Schnittstellen zur Simulation von der in C# (und der<br />

1 Bei Verwendung des ref Schlüsselworts erfolgt die Parameterübergabe als Referenz. Out Parameter verhalten<br />

sich wie ref Parameter, mit dem Unterschied, dass der Initialwert unwichtig ist.


Common Language Runtime, CLR) nicht unterstützten Mehrfachvererbung, da ein Typ<br />

mehrere Schnittstellen implementieren kann.<br />

Ein Delegat repräsentiert eine Referenz zu einer Methode mit einer bestimmten Paramterliste<br />

und einem gewissen Rückgabetyp. Delegate ermöglichen es Methoden als Paramter<br />

zu übergeben. Dazu wird zunächst ein Delegattyp deklariert, der die Signatur der durch<br />

ihn gekapselten Methoden festlegt. Anschließend kann der Delegattyp instanziiert werden.<br />

Dabei wird ein Delegat-Objekt angelegt, welches mit einer bestimmten Methode assoziiert<br />

wird. Delegate gleichen dem Konzept der Funktionszeiger, das zum Teil in anderen<br />

Sprachen verwendet wird. Im Gegensatz zu Zeigern sind Delegate jedoch typsicher.<br />

Enums sind spezielle Werttypen, die eine Menge von benannten Konstanten enthalten. Jedes<br />

Enum hat einen der acht einfachen, numerischen Typen als Grundlage (sbyte, short,<br />

int, long, byte, ushort, uint, ulong). Enums werden dazu benutzt kleine Mengen von konstanten<br />

Werten bereitzustellen, zum Beispiel Wochentage, oder Farben.<br />

1.3 Arrays und Nullbare Typen<br />

C# unterstützt sowohl ein- und mehrdimensionale Arrays von beliebigen Typen. Arrays<br />

konstruiert man durch das Anhängen von eckigen Klammern an den Typnamen. Zum Beispiel<br />

ist int[] ein eindimensionales Array vom Typ int. Ein zweidimensionales Array<br />

vom Typ int wird definiert durch int[,]. int[][] dagegen, stellt ein eindimensionales<br />

Array von eindimensionalen Arrays vom Typ int dar.<br />

Nullbare Werttypen müssen ebenfalls nicht deklariert werden, bevor sie benutzt werden<br />

können. Zu jedem Werttypen T gibt es implizit einen zugewiesenen Typen T?, der zusätzlich<br />

den Wert null annehmen kann. Zum Beispiel ist int? ein Typ, der als Wert entweder eine<br />

32-bit Integer Zahl annimmt, oder den Wert null hat.<br />

2 Typentwurf in C#<br />

Das Entwerfen von Typen und deren logische Organisation, möglicherweise sogar der<br />

Aufbau einer ganzen Typhierarchie, steht bei fast jedem C#-Programm und ganz besonders<br />

für wiederverwendbare Bibliotheken im Zentrum des Entwicklungsvorgangs. Es ist sehr<br />

wichtig, dass jeder Typ eine sinnvoll definierte Menge von inhaltlich zusammenhängenden<br />

Membern hat und nicht nur eine sich zufällig ergebende Ansammlung von zusammenhangloser<br />

Funktionalität ist. Die Funktionalität eines gut entworfener Typs sollte klar definiert<br />

sein und sich in einem einfachen Satz zusammenfassen lassen.<br />

Beim Entwerfen eines neuen benutzerdefinierten Typs muss der Entwickler sich für eine<br />

der fünf, in C# angebotenen Typkategorien (Klassen, Structs, Schnittstellen, Delegate<br />

und Enums), entscheiden. Es gibt oftmals mehrere Möglichkeiten ein bestimmtes Konzept<br />

durch einen dieser Typen zu realisieren. Das bedeutet, dass die Entscheidung für einen dieser<br />

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<br />

entscheiden sollte. Einige dieser Argumente und Richtlinien werden in diesem Kapitel<br />

näher beleuchtet, um so dem Leser mehr Sicherheit beim Entwerfen von Typen zu geben<br />

und ihn bei der Wahl eines optimalen Entscheidungspfades zu unterstützen.<br />

2.1 Unterschiede zwischen Wert- und Referenztypen<br />

Der erste große Unterschied zwischen Wert- und Referenztypen besteht darin, dass Referenztypen<br />

auf dem Heap Speicher allozieren, welcher vom Garbage Collector freigegeben<br />

wird, sobald keine Referenzen mehr auf das Objekt zeigen. Bei Werttypen dagegen wird<br />

der Speicher auf dem Stack oder inline alloziert. Der Speicher wird bei Werttypen wieder<br />

freigegeben, wenn der Stack abgewickelt 2 , oder der umgebende Typ aufgelöst wird. Damit<br />

ist das Allozieren und Freigeben von Speicher in der Regel für Werttypen billiger als für<br />

Referenztypen.<br />

Besonders ins Gewicht fällt diese Tatsache bei der Definition von Arrays. In Arrays von<br />

Referenztypen sind die Elemente nur Referenzen zu den Instanzen des Referenztypen auf<br />

dem Heap. In Arrays von Werttypen dagegen sind die Elemente die tatsächlichen Instanzen<br />

des Werttypen. Damit ist das Allozieren und Deallozieren von Speicher für Arrays von<br />

Werttypen deutlich billiger als für Arrays von Referenztypen.<br />

Ein weiterer Unterschied besteht in der Speichernutzung. Wird ein Werttyp zu einem Referenztyp,<br />

oder zu einer der Schnittstellen, die er implementiert, umgewandelt, so kommt<br />

es zu einem Vorgang, den man boxing nennt. Wird ein Werttyp zu einem Referenztyp umgewandelt,<br />

so wird auf dem Heap Speicher für eine Objektinstanz (auch Box genannt)<br />

alloziert. Diese Box enthält den Wert des Werttyps und Typinformationen, um auf den<br />

ursprünglichen Werttyp rückschließen zu können. Umgekehrt wird durch den Vorgang<br />

des unboxing der Referenztyp wieder in einen Werttyp umgewandelt. Dazu wird aus der<br />

Box die Typinformation entnommen und geprüft, ob sie mit dem Typ der neuen Variable<br />

übereinstimmt und anschließend der Wert aus der Box in die Variable kopiert. Das<br />

folgende Beispiel illustriert den Vorgang.<br />

using System;<br />

class BoxingExample<br />

{<br />

static void Main() {<br />

int i = 123;<br />

object o = i; // boxing<br />

int j = (int)o; // unboxing<br />

}<br />

}<br />

2 Beim Aufruf einer Methode merkt die CLR sich die aktuelle Stackposition. Im Methodenrumpf werden nun<br />

ggf. Speicherallozierungen auf dem Stack vorgenommen. Nach Beendigung der Methode nimmt die CLR alle<br />

zuvor getätigten Speicherallozierungen bis zur gemerkten Stelle wieder zurück.


Boxen sind Objekte, die auf dem Heap alloziert werden und vom Garbage Collector<br />

überprüft werden müssen. Daher kann zuviel boxing oder unboxing negative Auswirkungen<br />

auf den Heap, den Garbage Collector und letztendlich die Performanz des Programms<br />

haben. Werden dagegen gleich Referenztypen verwendet, so kommt es erst gar nicht zum<br />

Vorgang des boxing oder unboxing. Mehr zu diesem Thema lässt sich in [Lib10a] nachlesen.<br />

Der nächste Unterschied tritt auf, wenn man Referenztypen bzw. Werttypen einer Variable<br />

zuweist. Weist man einer Variable die Instanz eines Referenztyps zu, so wird lediglich eine<br />

Kopie der Referenz auf diese Instanz erstellt und der Variable zugewiesen. Handelt es sich<br />

bei der Zuweisung dagegen um einen Werttyp, so wird der gesamte Wert in die Variable<br />

kopiert. Aus diesem Grund sind Zuweisungen großer Referenztypen (> 16 bytes) billiger,<br />

als Zuweisungen großer Werttypen.<br />

Der letzte Unterschied besteht in der Art der Parameterübergabe. Referenztypen werden<br />

implizit als Kopie der Referenz übergeben und Werttypen als Kopie des Werts. Änderungen<br />

an der Instanz eines Referenztyps betreffen alle Referenzen, die auf diese Instanz zeigen.<br />

Wird die kopierte Instanz eines Werttyps geändert, so sind das Original und alle anderen<br />

eventuell existierenden Kopien davon nicht betroffen. Das Kopieren der Instanzen<br />

von Werttypen erfolgt implizit, also ohne Einfluss des Entwicklers (zum Beispiel beim<br />

Übergeben von Argumenten, oder bei der Rückgabe eines Rückgabewerts). Aus diesem<br />

Grund können verändliche Werttypen beim Programmieren für Verwirrung sorgen. Bei<br />

der Verwendung von verändlichen Werttypen kann es zum Beispiel oft nötig sein eines<br />

der Schlüsselworte out oder ref bei der Parameterübergabe zu verwenden, um die<br />

gewünschte Veränderbarkeit zu erreichen. Es ist daher in der Regel sinnvoll, seine Werttypen<br />

als unverändlich (immutable) zu entwerfen. Als unveränderlich bezeichnet man Typen,<br />

die keine Member mit der Sichtbarkeit public besitzen und gleichzeitig die aktuelle<br />

Instanz des Typs verändern können. Ein Beispiel für einen unveränderlichen Typen ist<br />

System.String. Die Member von System.String, wie zum Beispiel die Methode<br />

ToUpper, modifizieren nicht den String selbst, sondern liefern einen neuen modifizierten<br />

String zurück. Der Originalstring bleibt unverändert.<br />

Zum Schluss sei noch erwähnt, dass alle Referenztypen pro Objekt einen Speicher-<br />

Mehraufwand von 8 Bytes, bzw. 16 Bytes unter 64-Bit, besitzen. Das kann sich auswirken,<br />

wenn eine hohe Anzahl (zum Beispiel über eine Million) von kleinen Objekten (zum<br />

Beispiel unter 16 Bytes) benötigt wird. In dem Fall wird ein großer Teil des allozierten<br />

Speichers allein für den Mehraufwand des Referenztyps benutzt. Werttypen dagegen haben<br />

keinen Speicher-Mehraufwand.<br />

2.2 Wahl zwischen Klasse und Struct<br />

Oftmals steht man als Entwickler beim Entwerfen eines Typs vor der Entscheidung, ob<br />

man den Typ als Struct (Werttyp), oder als Klasse (Referenztyp) definieren soll. In diesem<br />

Fall ist es sehr wichtig ein gutes Verständnis von den Eigenschaften von Referenzund<br />

Werttypen zu haben. Referenztypen haben einige Performanznachteile gegenüber den


Werttypen. Verwendet man einen Referenztyp, so muss für jede Instanz Speicher auf dem<br />

Heap alloziert werden. Dies ist besonders auf Multiprozessor-Systemen, die einen gemeinsamen<br />

Heap benutzen, von Bedeutung. Außerdem haben Referenztypen einen Speicher<br />

Mehraufwand von 8 bzw. 16 Bytes. Zusätzlich führt man noch bei jedem Zugriff eine<br />

Indirektion ein, da nur über eine Referenz auf die Instanz zugegriffen werden kann.<br />

Generell gilt trotzdem, dass die Mehrheit der Typen eines Programms oder einer Bibliothek<br />

Klassen sein sollten. Manchmal kann es jedoch sinnvoll sein Structs zu verwenden,<br />

insbesondere, wenn die Eigenschaften eines Werttyps gewünscht sind. Structs eignen sich<br />

gut für kleine (< 16 Byte), kurzlebige oder in andere Objekte eingebettete Typen.<br />

Der Typ sollte alle folgenden Eigenschaften erfüllen, bevor man sich für ein Struct entscheidet.<br />

Der Typ:<br />

• repräsentiert insgesamt einen einzelnen Wert (z.B. int, double),<br />

• hat eine Größe von unter 16 Bytes,<br />

• ist unveränderlich,<br />

• muss nicht oft ”geboxt”werden.<br />

Die Größe eines Structs ist wichtig, da der Typ bei jeder Parameterübergabe oder Zuweisung<br />

kopiert werden muss. Daher können große Structs (Werttypen) ein Problem darstellen.<br />

Es wird empfohlen seine Werttypen unveränderlich zu machen, daher sollte nur ein<br />

Struct verwendet werden, wenn dies auf den zu erstellenden Typ zutrifft. Vielfaches boxing<br />

und unboxing von Werttypen kostet Zeit und Speicher und kann sich, wie bereits<br />

zuvor erwähnt (s. 2.1), negativ auf den Heap, den Garbage Collector und die Performanz<br />

des Programms auswirken. Treffen eine oder mehrere der oben aufgelisteten Eigenschaften<br />

nicht zu, so sollte eine Klasse verwendet werden. Mehr zum Thema Klassen und Strucs<br />

lässt sich in [Lib10c] nachlesen.<br />

2.3 Statische Klassen<br />

Statische Klassen sind Klassen, die nur statische Member enthalten. Einzige Ausnahme<br />

sind die von System.Object geerbten Member und gegebenfalls ein privater Konstruktor.<br />

In C# sind statische Klassen automatisch versiegelt (sealed), abstrakt (abstract)<br />

und es dürfen keine Instanzvariablen deklariert, oder überschrieben werden. Structs (Werttypen)<br />

sind immer instanziierbar und können daher nicht statisch sein.<br />

Statische Klassen sind ein Kompromiss aus objektorientiertem Entwurf und Einfachheit.<br />

Aus diesem Grund sollten statische Klassen nur als Unterstützung für den objektorientierten<br />

Kern des Programms oder der Bibliothek verwendet werden. Es macht Sinn statische<br />

Klassen anzulegen, wenn eine vollständig objektorientierte Lösung übertrieben oder nicht<br />

sinnvoll wäre (z.B. für unveränderliche Funktionen, wie Math.Sin, einer Mathematik Bibliothek).<br />

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<br />

man diese nicht als Behälter für Member benutzen, für die einem gerade kein besserer<br />

Platz einfällt. Bei der Erstellung von statischen Klassen darf man keine Instanzvariablen<br />

definieren. Statische Klassen können nicht instanziiert werden und Instanzvariablen sind<br />

deswegen nicht erlaubt, da sie sowieso niemand aufrufen könnte.<br />

2.4 Abstrakte Klassen und Member<br />

Der Modifikator abstract zeigt an, dass der damit versehen Member unvollständig,<br />

oder gar nicht implementiert ist. Bei Klassen wird abstract verwendet, wenn die Klasse<br />

nur als Basisklasse für andere, abgeleitete Klassen dienen soll. Klassen, Methoden,<br />

Properties, Indizierer und Ereignisse können abstrakt sein.<br />

2.4.1 Abstrakte Klassen<br />

Abstrakte Klassen sind unvollständige Klassen, die durch eine abgeleitete Klasse erweitert<br />

werden müssen. Solange die abgeleitete Klasse selbst nicht abstrakt ist, muss sie alle<br />

abstrakten Member der abstrakten Klasse implementieren. Die abgeleitete Klasse nennt<br />

man dann konkrete Klasse. Es ist nicht zwingend erforderlich alle Member einer abstrakten<br />

Klasse als abstrakt zu markieren. Alle abstrakten Klassen haben die folgenden Eigenschaften:<br />

• Abstrakte Klassen können nicht instanziiert werden.<br />

• Abstrakte Klassen dürfen abstrakte Member und Accessoren verwenden.<br />

• Eine Abstrakte Klasse darf nicht mit dem Schlüsselwort sealed versehen werden.<br />

abstract und sealed stehen im Gegensatz zueinander. sealed verbietet die<br />

Erweiterung der Klasse und abstract erfordert die konkrete Implementierung,<br />

durch eine von der Klasse erbenden, abgeleiteten Klasse.<br />

• Eine nicht abstrakte (konkrete) Klasse, die von einer abstrakten Klasse abgeleitet ist<br />

muss alle abstrakten Member der abstrakten Klasse implementieren.<br />

Die Sichtbarkeit des Konstruktors für eine abstrakte Klasse sollte protected oder internal<br />

sein. Da Abstrakte Klassen nicht instanziiert werden können, sorgt ein public Konstruktor<br />

nur für Verwirrung. Soll die konkrete Implementierung der abstrakten Klasse auf die aktuelle<br />

Assembly beschränkt sein, so kann ein internal Konstruktor verwendet werden. Bei<br />

der Deklaration einer abstrakten Klasse fügt C# automatisch einen protected Konstruktor<br />

ein, solange man nicht selbst explizit einen Konstruktor anlegt.<br />

2.4.2 Abstrakte Member<br />

Der abstract Modifikator wird verwendet, um anzuzeigen, dass die Methode oder das<br />

Property keine konkrete Implementierung besitzt. Die Implementierung einer abstrakten


Methode erfolgt durch Überschreiben der Methode in einer konkreten, von der abstrakten<br />

Klasse abgeleiteten Klasse. Dazu wird das Schlüsselwort override verwendet. Der<br />

Vorgang wird im folgenden Beispiel verdeutlicht:<br />

abstract class ShapesClass<br />

{<br />

// Abstrakte Methode ohne Implementierung<br />

abstract public int Area();<br />

}<br />

// Konkrete Klasse Square erweitert die<br />

// abstrakte Klasse ShapesClass<br />

// und muss alle abstrakten Member<br />

// implementieren (Area Methode).<br />

class Square : ShapesClass<br />

{<br />

int side = 0;<br />

}<br />

// Konstruktor<br />

public Square(int n)<br />

{<br />

side = n;<br />

}<br />

// Überschreiben der Area Methode mithilfe<br />

// des Schlüsselworts override<br />

public override int Area()<br />

{<br />

// Konkrete Implementierung der Area Methode<br />

return side * side;<br />

}<br />

....<br />

Abstrakte Methoden haben die folgenden Eigenschaften:<br />

• Eine abstrakte Methode ist automatisch (implizit) eine virtuelle Methode.<br />

• Abstrakte Methoden dürfen nur in abstrakten Klassen definiert werden.<br />

• Abstrakte Methoden besitzen keinen ”Methoden-Körper”. Der Signatur folgen daher<br />

keine geschweiften Klammern. Zum Beispiel:<br />

public abstract void MyMethod();<br />

• Abstrakte Methoden dürfen nicht als statisch oder virtuell markiert werden.


Abstrakte Properties verhalten sich wie abstrakte Methoden. Unterschiede existieren nur<br />

in der Syntax, die zur Deklaration oder zum Aufruf des Properties verwendet wird.<br />

2.5 Schnittstellen<br />

Schnittstellen dienen dazu einen Vertrag festzulegen, der durch eine implementierende<br />

Klasse erfüllt werden muss, um eine bestimmte Funktionalität bereitzustellen. Im Gegensatz<br />

zu abstrakten Klassen, können Schnittstellen keinerlei Implementierung beinhalten.<br />

Damit eignen sie sich gut, um einen Vertrag vollständig von der Implementierung zu trennen.<br />

Die CLR (Common Language Runtime) unterstützt keine Mehrfachvererbung, das heißt<br />

Klassen können nur von einer anderen (abstraken) Basisklasse erben. Allerdings kann eine<br />

Klasse mehrere Schnittstellen implementieren. Daher lassen sich Schnittstellen dazu<br />

verwenden, um einen ähnlichen Effekt zur Mehrfachvererbung zu erzielen. Im unten gezeigten<br />

Fall erbt die Klasse Component von MarshalByRefObject und implementiert<br />

gleichzeitig die IDisposable und IComponent Schnittstellen, wodurch die entsprechenden<br />

Funktionalitäten gewährleistet werden.<br />

public class Component : MarshalByRefObject,<br />

IDisposable, IComponent {<br />

...<br />

}<br />

Schnittstellen eignen sich ebenfalls sehr gut dazu, um für einen Mix aus Wert- und Referenztypen,<br />

eine gemeinsame Schnittstelle anzubieten. Werttypen erben nur von<br />

System.ValueType, weitere Basisklassen sind nicht erlaubt. Allerdings können Werttypen<br />

Schnittstellen implementieren. Schnittstellen sind in dem Fall also die einzige Option,<br />

um einen gemeinsamen Basistyp bereitzustellen.<br />

// Werttypen können Schnittstellen Implementieren<br />

public struct Boolean : IComparable {<br />

...<br />

}<br />

// Referenztypen können Schnittstellen implementieren<br />

public class String : IComparable {<br />

...<br />

}<br />

// So kann ein Mix aus Referenz- und Werttypen auf einen<br />

// gemeinsamen Basistyp, hier die Schnittstelle IComparable,<br />

// aufbauen.


Schnittstellen ohne Member sollten nicht dazu verwendet werden, um Typen mit einer<br />

bestimmten Eigenschaft zu markieren. Beispielsweise sollte man folgendes vermeiden:<br />

// Vermeiden:<br />

// Markierer-Schnittstelle ohne Member<br />

public interface IImmutable {}<br />

// Vermeiden:<br />

// Klasse Key wird durch die IImmutable Schnittstelle<br />

// markiert, um anzuzeigen, dass Key Instanzen<br />

// unveränderlich sind.<br />

public class Key : IImmutable {<br />

...<br />

}<br />

Es ist besser, benutzerdefinierte Attribute zu verwenden, um eine bestimmte Eigenschaft<br />

eines Typs anzuzeigen. Dadurch lassen sich dann auch Methoden bauen, die Parameter<br />

zurückweisen, die nicht mit einem bestimmten Attribut versehen sind.<br />

// Besser:<br />

// Verwende Attribut, um anzuzeigen, dass<br />

// Key Instanzen unveränderlich sind.<br />

[Immutable]<br />

public class Key {<br />

...<br />

}<br />

// Add Methode, die den Parameter key<br />

// auf das Argument ImmutableAttribute prüft<br />

public void Add (Key key, object value) {<br />

if (!key.GetType().IsDefined(<br />

typeof(ImmutableAttribute), false)) {<br />

throw new ArgumentException("Der Parameter<br />

muss unveränderlich sein","key");<br />

}<br />

...<br />

}<br />

Bei der Verwendung von Attributen, sollte man sich bewusst sein, dass das Überprüfen<br />

von Attributen deutlich teurer ist, als das Überprüfen von Typen. Handelt es sich um einen<br />

Programmteil, der sehr kosteneffizient ablaufen muss, dann sollte man eventuell eine Ausnahme<br />

machen und doch eine Markierer-Schnittstelle anstelle des Attributs verwenden.<br />

Eine weitere Ausnahme besteht, wenn das Überprüfen des Markers zur Kompilierzeit erfolgen<br />

muss. Da benutzerdefinierte Attribute erst zur Laufzeit überprüft werden, muss in<br />

diesem Fall auf eine Markierer-Schnittstelle ausgewichen werden.


Beim Entwerfen und Ausliefern von Bibliotheken, darf man einer einmal ausgelieferten<br />

Schnittstelle keine neuen Member mehr hinzufügen. Dies würde alle bstehenden Implementierungen<br />

dieser Schnittstelle unbrauchbar machen. In diesem Fall sollte man eine<br />

neue Schnittstelle bereitstellen, oder am Besten gleich abstrakte Klassen verwenden.<br />

2.6 Wahl zwischen Schnittstelle und abstrakter Klasse<br />

Oftmals muss man sich bei der Erstellung einer Abstraktion zwischen einer Schnittstelle<br />

oder einer abstrakten Klasse entscheiden. Das Argument für die Schnittstellen ist, dass<br />

sie komplett den Vertrag von der Implementierung trennen. Abstrakte Klassen können das<br />

jedoch in vielen Fällen genauso gut und sind flexibler, da man sie leichter anpassen kann,<br />

ohne darauf aufbauenden Programmcode zu zerstören.<br />

Bei der Erstellung von Bibliotheken oder APIs sind daher die Klassen den Schnittstellen<br />

vorzuziehen. Sobald die erste Version der Bibliothek/API ausgeliefert ist, stehen die<br />

Member aller Schnittstellen für immer fest. Änderungen würden dazu führen, dass alle<br />

Typen, die die Schnittstelle implementieren, unbrauchbar werden. Klassen dagegen sind<br />

in diesem Fall deutlich flexibler, da man immernoch Member hinzufügen kann. Solange es<br />

sich nicht um eine abstrakte Methode handelt, die keine Standardimplementierung besitzt,<br />

werden erbende Klassen weiterhin funktionieren.<br />

Die einzige Möglichkeit eine ausgelieferte Schnittstelle zu erweitern besteht darin, eine<br />

neue Schnittstelle mit den zusätzlichen Membern bereitzustellen. Gehen wir zum Beispiel<br />

davon aus, dass wir in Version eins unserer API die folgende Schnittstelle ausgeliefert<br />

haben:<br />

public interface IStream {<br />

...<br />

}<br />

public class FileStream : IStream {<br />

...<br />

}<br />

In Version zwei der API möchten wir nun für Operationen auf Streams Zeitüberschreitungen<br />

(timeouts) überprüfen. Es ist also notwendig eine neue Schnittstelle bereitzustellen<br />

(ITimeoutEnabledStream), die von der alten erbt und die neue Funktionalität bereitstellt.<br />

Nur so ist sichergestellt, dass die auf der alten Schnittstelle basierenden Implementierungen<br />

weiterhin funktionieren.<br />

public interface ITimeoutEnabledStream : IStream {<br />

int ReadTimeout{ get; set; }<br />

}<br />

public class FileStream : ITimeoutEnabledStream {


}<br />

public int ReadTimeout {<br />

get {<br />

...<br />

}<br />

set {<br />

...<br />

}<br />

}<br />

Jetzt gibt es jedoch ein Problem mit den existierenden Klassen und Methoden, die alle<br />

noch mit IStream arbeiten. Beispielsweise könnte es eine Klasse StreamReader geben,<br />

die einen Stream als Konstruktorparameter nimmt und ein Property hat, dass einen Stream<br />

zurückliefert:<br />

public class StreamReader {<br />

public StreamReader (IStream stream) { ... }<br />

public IStream BaseStream { get { ... } }<br />

}<br />

Wie soll man jetzt sicherstellen, dass der StreamReader auch mit Streams der neuen ITimeoutEnabledStream<br />

Schnittstelle funktioniert. Es gibt hier mehrere Ansätze die funktionieren.<br />

Jedoch bringen diese neue Probleme mit sich, oder erschwerden die Benutzbarkeit<br />

und Verständlichkeit unserer API.<br />

• Verwenden von dynamischen Casts<br />

ITimeoutEnabledStream stream =<br />

myStreamReader.BaseStream as ITimeoutEnabledStream;<br />

(if stream != null) {<br />

stream.ReadTimeout = 100;<br />

}<br />

Dieser Ansatz verschlechtert die Benutzbarkeit. Den Benutzern von StreamReader<br />

ist nicht sofort klar, dass einige Streams die neue Timeout Operation verwenden<br />

können. Der Typcast fügt ebenfalls neue Komplexität hinzu.<br />

• Hinzufügen eines neuen Property (TimeoutEnabledBaseStream), welches den ITimeoutEnabledStream<br />

zurückliefert, wenn dem StreamReader ein solcher vorliegt,<br />

oder null zurückgibt, wenn der Stream kein ITimeoutEnabledStream ist.<br />

ITimeoutEnabledStream stream =<br />

myStreamReader.TimeoutEnabledBaseStream;<br />

(if stream != null) {<br />

stream.ReadTimeout = 100;<br />

}


Auch dieser Ansatz verschlechtert die Benutzbarkeit, da Nutzern von StreamReader<br />

an dieser Stelle absolut nicht klar ist, dass TimeoutEnabledBaseStream den Wert<br />

null zurückliefern kann. Das könnte zu unerwarteten und verwirrenden<br />

NullReferenceExceptions führen.<br />

• Hinzufügen eines neuen Typs (TimeoutEnabledStreamReader), der mit der neuen<br />

Schnittstelle zusammenarbeitet. Jeder neue Typ fügt der API weitere Komplexität<br />

hinzu. Außerdem müsste man bei diesem Ansatz immer einen neuen Typ bereitstellen,<br />

sobald eine Schnittstelle verändert werden soll. Dies stört gegebenfalls die Typhierarchie<br />

und die klare Kapselung und Aufgabentrennung von Typen. Das größte<br />

Problem bei diesem Ansatz ist jedoch, dass StreamReader selbst eventuell an anderen<br />

Stellen im Programm oder der API als Konstruktorparameter oder Property<br />

verwendet wird. Um an diesen Stellen ebenfalls die neuen Streams zuzulassen ist<br />

es notwendig auch dort wieder Änderungen vorzunehmen, um unseren neuen Typ<br />

(TimeoutEnabledStreamReader) zu unterstützen.<br />

Zusammenfassend kann man sagen, dass gut entworfene, abstrakte Klassen (vorzugsweise<br />

in einer separaten Assembly) meist genauso gut den Vertrag von der Implementierung<br />

trennen können wie Schnittstellen und zusätzlich eine höhere Flexibilität mitbringen, die<br />

insbesondere beim Entwerfen und Erweitern von Bibliotheken oder APIs von Vorteil ist.<br />

Ein Nachteil beim Einsatz von abstrakten Klassen anstelle von Schnittstellen ist, dass man<br />

seinen einen und einzigen Basistyp aufgibt. Abstrakte Klassen eignen sich somit gut als<br />

gemeinsamer Basistyp für eine Familie von Typen, während Schnittstellen gut geeignet<br />

für zeitlich invariante Verträge sind, die für immer feststehen.<br />

Schnittstellen sind die einzige Lösung für eine polymorphe Hierarchie von Werttypen.<br />

Werttypen können mehrere Schnittstellen implementieren, aber nicht von anderen Typen<br />

erben. Damit ist ein Ansatz mit abstraken Klassen unmöglich. Starke Schnittstellenkandidaten<br />

sind attributartige Typen, die auf viele Objekte anwendbar sind (”can-do”Relation).<br />

Zum Beispiel können eine Vielzahl von Objekten formatierbar sein, daher ist<br />

IFormattable eine sinnvolle Schnittstelle.<br />

2.7 Structs<br />

Structs sind der Standard-Werttyp in C#. Als Werttyp, muss auf dem Heap kein Speicher<br />

für das Struct alloziert werden. Structs sind nicht erweiterbar. Intern sind alle Structs<br />

jedoch implizit von object abgeleitet. Structs sind insbesondere für kleine Datenstrukturen<br />

geeignet, die eingebauten Werttypen ähneln. Beispiele für gute Struct Kandidaten<br />

sind Punkte in einem Koordinatensystem, Schlüssel-Wert Paare in einem Dictionary oder<br />

komplexe Zahlen. Bei datenintensiven Programmen, die eine hohe Anzahl von kleinen<br />

Datenstrukturen benötigen, kann es bei der Reservierung von Speicher einen großen Unterschied<br />

machen, ob man Structs anstelle von Klassen verwendet. Zum einen muss auf<br />

dem Heap kein Speicher reserviert werden und zum anderen gibt es keinen Speicher Mehraufwand,<br />

der bei jedem Objekt eines Referenztyps auftreten würde.<br />

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<br />

dies. Es wird 20 auf die Konsole geschrieben, wenn Point eine Klasse ist - im Falle eines<br />

Structs lautet die Ausgabe dagegen 10.<br />

Point a = new Point(10,10);<br />

Point b = a;<br />

a.x = 20;<br />

Console.WriteLine(b.x);<br />

Die einzige Möglichkeit eine Referenz auf ein Struct zu erhalten sind die ref und out<br />

Parameter. Außerdem sollte man sich bewusst sein, dass das Kopieren eines ganzen Structs<br />

oftmals teurer ist, als das Kopieren einer Objektreferenz. Weiterhin sollte man es (wie bei<br />

allen Werttypen) vermeiden, Structs veränderlich zu machen. Beim Abrufen eines Property<br />

wird z.B. immer implizit eine Kopie des Werts zurückgeliefert, damit ist das Manipulieren<br />

des Originalwerts schwierig. Properties sollten nur get, keine set Methoden haben und alle<br />

Initialisierungen sollten stattdessen im Konstruktor erfolgen.<br />

Beim Entwerfen von Structs sollte man auch darauf achten, dass ein Zustand, in dem<br />

alle Instanzvariablen auf 0, false bzw. null gesetzt sind für das Struct zulässig ist.<br />

Wird ein Array vom Typ des Structs erstellt, so wird der Konstruktor für das Struct nicht<br />

ausgeführt und Instanzvariablen werden mit den entsprechenden Standardwerten belegt.<br />

Ist man sich dieser Tatsache nicht bewusst, so können Struct Instanzen leicht inkonsistente,<br />

oder unzulässige Zustände annehmen. Structs können sehr nützlich sein, sollten aber nur<br />

für kleine, unveränderliche Werte, die nicht oft geboxt werden müssen, verwendet werden.<br />

2.8 Enums<br />

Enums sind spezielle Werttypen, die sehr gut kleine abgeschlossene Mengen von Werten<br />

darstellen können. Es gibt zwei Arten von Enums - simple Enums und Flag-Enums. Ein<br />

einfaches Beispiel für ein simples Enum ist eine Menge von Farben:<br />

public enum Color {<br />

Red,<br />

Green,<br />

Blue,<br />

...<br />

}<br />

Flag-Enums erlauben bitweise Operationen auf den Enumwerten. Ein Beispiel für ein<br />

Flag-Enum ist eine Liste von kombinierbaren Attributen, wie folgende:<br />

[Flags]<br />

public enum FileAccessAttributes {<br />

Read = 0x001,


}<br />

Write = 0x002,<br />

Execute = 0x004,<br />

ReadWrite = Read | Write,<br />

...<br />

Es ist ebenfalls sinnvoll, seine Flag-Enums mit dem System.FlagsAttribute zu versehen<br />

und für die Werte des Flag-Enum Potenzen von zwei zu verwenden. Dadurch können sie<br />

einfach beliebig durch die bitweise Oder-Operation kombiniert werden. Oft verwendete<br />

Kombination lassen sich auch leicht direkt in das Enum mit aufnehmen, wie im oben<br />

stehenden Flag-Enum-Beispiel gezeigt. Bei der Erstellung eines Flag-Enums, sollte man<br />

darauf achten, dass alle Kombinationen der Werte des Enums gültig sind. Ist dies nicht der<br />

Fall, so ist es meist sinnvoller das Enum in mehrere kleinere Enums aufzuspalten.<br />

Mengen von Werten, wurden damals oft durch Integer-Konstanten dargestellt. Durch Enums<br />

werden solche Mengen stärker typisiert. Dadurch lassen sich Fehler zur Kompilierzeit<br />

besser feststellen und die Wertemengen sind oft besser lesbar (z.B. durch aufschlussreiche<br />

Enumnamen) und nutzbar, z.B. durch Anzeigen aller möglichen Werte für einen Parameter<br />

oder Proprerty in IntelliSense 3 . Aus diesem Gründen sind Enums statischen Konstanten<br />

vorzuziehen. Nicht verwenden sollte man Enums, wenn die Wertemenge sich oft ändern<br />

kann bzw. wenn Elemente entfernt werden müssen, oder wenn die Menge nur aus einem<br />

Wert besteht. Enums sind auch eine gute Option, wenn man einen Boolean Parameter hat,<br />

von dem man glaubt, dass er in Zukunft eventuell mehr als zwei Werte unterstützen muss.<br />

Außerdem können Enums anstelle von Boolean Parametern die Lesbarkeit verbessern:<br />

// Es ist nicht klar, wofür false und true stehen.<br />

FileStream fileStream = File.Open("foo.txt", true, false);<br />

// Mit Enums ist sofort klar, wofür die Parameter stehen.<br />

FileStream fileStream =<br />

File.Open("foo.txt", CasingOptions.CaseSensitive,<br />

FileMode.Open);<br />

3 Konzepte beim Member-Entwurf<br />

Unter Membern versteht man Methoden, Properties, Ereignisse, Delegate, Konstruktoren<br />

und Felder. Die Funktionalität einer Klasse wird letztendlich durch ihre Member bestimmt<br />

und auch durch diese nach außen sichtbar. Member können verschiedene Eigenschaften<br />

haben:<br />

• virtuell: virtuelle Methoden können in einer Basisklasse definiert werden<br />

3 IntelliSense ist die Microsoft Implementierung einer Autovervollständigung innerhalb des Visual Studio.<br />

Ähnlich arbeitende Autovervollständigungen finden sich auch in vielen anderen Programmierumgebungen.


(Schlüsselwort virtual) und mithilfe des override Schlüsselworts durch Methoden<br />

in abgeleiteten Klassen überschrieben werden.<br />

• abstrakt: Methoden/Klassen können als abstrakt markiert werden, um eine Implementierung<br />

durch eine abgeleitete Klasse zu erzwingen.<br />

• statisch: Methoden/Klassen können als statisch markiert werden, um die Benutzung<br />

auch ohne vorherige Instanziierung zu ermöglichen.<br />

Außerdem hat jeder Member eine gewisse Sichtbarkeit:<br />

• private: Innerhalb der Klasse nutzbar.<br />

• protected: Innerhalb der Klasse und abgeleiteten Klassen nutzbar.<br />

• internal: Innerhalb der aktuellen Assembly nutzbar.<br />

• internal protected: Vereinigung aus protected und internal. Also innerhalb der Assembly<br />

public und außerhalb protected.<br />

• public: Überall nutzbar.<br />

3.1 Überladen von Methoden<br />

Unter Methodenüberladung versteht man das Anlegen von zwei oder mehr Methoden, innerhalb<br />

eines Typs, die den selben Namen tragen und sich lediglich in der Anzahl, oder<br />

im Typ, der Parameter unterscheiden. Das Überladen von Methoden ist eine der wichtigsten<br />

Techniken, um Benutzbarkeit, Lesbarkeit und Produktivität zu erhöhen. Dies gilt<br />

insbesondere beim Entwerfen einer Bibliothek. Durch Überladen ist es z.B. möglich einfachere<br />

Varianten von Konstruktoren oder Methoden bereitzustellen. Indem man nur den<br />

Parametertyp einer Methode ändert, ist es Möglich eine bestimmte Operation für viele<br />

verschiedene Typen anzubieten.<br />

Es ist sinnvoll bei der Überladung von Methoden darauf zu achten, deskriptive Parameternamen<br />

zu verwenden. Somit wird in der Regel auch gleich der Standardwert für<br />

die kürzeren Überladungen deutlich. Die erste Methode im folgenden Beispiel achtet auf<br />

Groß- und Kleinschreibung, dies wird durch die Überladung mit dem Parameter<br />

ignoreCase klar gemacht.<br />

public class descriptiveParameterNameExample {<br />

public bool searchString(string query) {...};<br />

public bool searchString(string query,<br />

bool ignoreCase) {...};<br />

}


Weiterhin ist es sinnvoll, Parameternamen konsistent zu halten. Das heißt, wenn ein Parameter<br />

in einer Überladung das gleiche repräsentiert, wie in einer anderen Überladung,<br />

dann sollte er auch in beiden den gleichen Namen tragen und wenn möglich auch an der<br />

selben Position stehen. Enthält die Parameterliste einen params 4 array Parameter oder<br />

out Parameter, so kann man von dieser Regelung abweichen.<br />

Es ist oftmals möglich internes Weiterleiten zur Methode mit der längsten Parameterliste<br />

zu verwenden. Die kürzeren Überladungen rufen jeweils die nächst-längere Überladung<br />

auf. Das hat den Vorteil, dass Standardwerte nur an einer Position gesetzt bzw. geändert<br />

werden müssen und erlaubt eine besonders einfache Erweiterbarkeit, falls gewünscht, da<br />

nur die längste Überladung virtuell sein braucht und dementsprechend in einer abgeleiteten<br />

Klasse einfach überschrieben werden kann, ohne dass man sich um jede einzelne<br />

Überladung kümmern muss. Dieses Pattern lässt sich auch gut in abstrakten Klassen<br />

verwenden. Die gesamte Überprüfung von Argumenten kann in nicht-abstrakten, nichtvirtuellen<br />

Methoden erfolgen und die eigentliche Implementierung in der einen abstrakten<br />

Methode, mit der längsten Parameterliste.<br />

public class String {<br />

public int IndexOf(string s) {<br />

return IndexOf(s, 0);<br />

}<br />

}<br />

public int IndexOf(string s, int startIndex) {<br />

return IndexOf(s, startIndex, s.Length);<br />

}<br />

public virtual int IndexOf(string s, int startIndex,<br />

int count) {<br />

// Hier erfolgt die eigentliche<br />

// Implementierung von IndexOf<br />

}<br />

3.2 Wahl zwischen Property und Methode<br />

Häufig muss man sich beim Entwerfen eines Members für ein Property oder eine Methode<br />

entscheiden. Ein Property sollte man wählen, wenn<br />

• es sich um ein logisches Attribut des Typs handelt (z.B. Button.Color, da die Farbe<br />

ein logisches Attribut von Button ist),<br />

• das Verhalten der gesamten Klasse gesteuert werden soll,<br />

4 Das Schlüsselwort params kann für Methodenparameter verwendet werden, wenn die Anzahl der Argumente<br />

nicht bekannt ist. In jeder Methodendeklaration darf params nur einmal benutzt werden und musss ganz<br />

am Ende der Parameterliste stehen.


• es sich um einen Accessor handelt,<br />

• es keinen guten Grund gibt, eine Methode zu verwenden. Faustregel: Zugriff auf<br />

einfache Daten, ohne hohen Rechenaufwand.<br />

Bevorzugt man Properties beim Entwerfen der Member, so haben die Methoden meist eine<br />

geringe Anzahl an Parametern und weniger Überladungen, als bei einem Methodenfokussiertem<br />

Entwurf. Properties sollten generell Daten repräsentieren und Methoden sollten<br />

Aktionen darstellen. Eine Methode sollte man generell dann wählen, wenn<br />

• der Rest der Klasse nichts mit den Parametern der Methode zu tun hat,<br />

• die Operation viel langsamer als ein Feldzugriff ist. Dazu zählen alle Operationen,<br />

bei denen Wartezeiten auftreten können (z.B. Asynchrone Operationen, Netzwerkoder<br />

Dateisystemzugriffe),<br />

• die Operation eine Konvertierung darstellt (z.B. Object.ToString()),<br />

• die Operation bei jedem Aufruf ein anderes Ergebnis zurückliefert, selbst bei unveränderten<br />

Parametern (z.B. Guid.NewGuid()),<br />

• die Operation einen Array zurückliefert.<br />

3.3 Überladen von Operatoren<br />

Operatoren, z.B. + ,- , ++, == usw., können in Abhängigkeit von den Argumenten verschiedene<br />

Implementierungen annehmen. Da der Compiler die Typen der Argumente (Operanden)<br />

bestimmen muss, bevor der richtige Operator aufgerufen werden kann, bezeichnet<br />

man den Vorgang als Überladen. Im folgenden Beispiel wird der +-Operator für den Typen<br />

BigInteger überladen.<br />

public struct BigInteger {<br />

public static BigInteger<br />

operator+(BigInteger left,<br />

BigInteger right) { ... };<br />

}<br />

Wenn x und y Instanzen von BigInteger sind, dann wird beim Aufruf von BigInteger result<br />

= x+y; der oben definierte Operator verwendet. Beim Überladen von Operatoren muss einer<br />

der Operanden immer vom Typ sein, auf dem die Überladung definiert wird. Es ist oftmals<br />

sinnvoll Operatoren-Überladung in Structs zu verwenden, die Zahlen repräsentieren<br />

(wie z.B. bei System.Decimal), da Benutzer der Klasse eventuell erwarten mit bestimmten<br />

Operatoren rechnen zu können. Wichtig ist, dass man Operatoren immer symmetrisch<br />

überlädt. Das bedeutet, wenn der Gleichheitsoperator (==) überladen wird, dann sollte<br />

auch der Ungleichheitsoperator (!=) überladen werden. Nicht überladen sollte man Operatoren,<br />

wenn nicht eindeutig klar ist, was das Resultat der Operation ist. Verwendet man


zum Beispiel den Shift-Operator (


man sich beim Entwurf der Basisklasse bewusst sein, dass die Verwendung von virtual<br />

und protected sich negativ auf die Performanz der Applikation auswirkt. Demnach ist<br />

die einfachste und kosteneffizienteste Form der Erweiterbarkeit eine unversiegelte Klasse<br />

ohne virtuelle Methoden und ohne Verwendung der Sichtbarkeit protected.<br />

Es kann jedoch sinnvoll sein, Schlüsselmethoden seiner Klasse virtuell zu machen, wenn<br />

man Erweiterbarkeit anbieten will. Diese Methoden können von einer erbenden Subklasse<br />

überschrieben werden und so das Verhalten dieser verändern. Damit sind virtuelle Methoden<br />

ein gutes Mittel, um eine existierende Klasse zu spezialisieren. Insgesamt liefern virtuelle<br />

Methoden auch bessere Performanz und sind günstiger im Speicherverbrauch, als ein<br />

Ansatz über Rückrufe (Callbacks). Manchmal will man eine Art eingeschränkter Erweiterung<br />

anbieten, d.h. man möchte grundsätzlich Erweiterbarkeit anbieten, aber sicherstellen,<br />

dass bestimmte Variablen initialisiert, gewisse Methoden aufgerufen, oder Invarianten<br />

eingehalten werden. Dazu lässt sich sehr gut eine einfache Version des Template-Method-<br />

Patterns verwenden, wie im folgenden Beispiel gezeigt:<br />

public class Control {<br />

// Die als public markierte Methode<br />

// ruft die interne virtuelle Methode auf<br />

public void SetBounds(...) {<br />

// Invarianten, Initialisierungen,<br />

// etc. vor Aufruf der Implementierung<br />

...<br />

SetBoundsCore(...);<br />

// Invarianten, Ressourcenfreigaben,<br />

// etc. nach Aufruf der Implementierung<br />

...<br />

}<br />

}<br />

protected virtual void SetBoundsCore(...) {<br />

// Hier erfolgt die eigentliche Implementierung<br />

// von SetBounds<br />

}<br />

Eine weitere oft genutzte Art der Erweiterbarkeit ist der Entwurf von Abstraktionen. Abstraktionen<br />

sind Typen die einen Vertrag festlegen, aber keine vollständige Implementierung<br />

von diesem bereitstellen. Ein konkreter Typ kann die Abstraktion dann erweitern<br />

(im Falle einer abstrakten Klasse), oder implementieren (im Falle einer Schnittstelle) und<br />

bleibt trotzdem noch kompatibel mit Operationen, die den abstrakten Typ bzw. die Schnittstelle<br />

verwenden. Beispiele von Abstraktionen aus dem .NET Framework sind Stream,<br />

IEnumerable und Object. In C# lassen sich Abstraktionen gut mit Schnittstellen<br />

oder abstrakte Klassen erstellen, die bereits im letzten Kapitel ausführlich behandelt wurden.<br />

Es ist wichtig, dass man seinen Vertrag gut dokumentiert, sodass die Semantik der Typen<br />

die den Vertrag erfüllen sollen klar ist. Darauf sollte man speziell beim Entwerfen eines<br />

Frameworks, einer Bibliothek bzw. API achten, da Benutzer von diesen eventuell nicht


mit der Abstraktionshierarchie vertraut sind. Insbesondere, wenn die Implementierung von<br />

einer anderen Person durchgeführt wird, können zu viele Member in einer Abstraktion für<br />

Verwirrung sorgen und es schwierig machen die Abstraktion vertragsgemäß zu implementieren.<br />

Daher sollte man versuchen, der Abstraktion so wenig Member wie möglich<br />

hinzuzufügen. Andererseits kann die Abstraktion auch für viele Anwendungsfälle nutzlos<br />

werden, wenn man zu wenige Member bereitstellt. Aus diesen Gründen kann es schwierig<br />

sein eine sinnvolle und gut benutzbare Abstraktion zu erstellen, daher sollte man vorher<br />

einen guten Bauplan entwerfen und später eventuell gleich eine konkrete Implementierung<br />

der Abstraktion bereitstellen. Dies macht es Benutzern der Abstraktion leichter, da sie so<br />

ein Anwendungsbeispiel zur Implementierung sehen. Außerdem ist durch den konkreten<br />

Typ auch gleich die Existenz der Abstraktion validiert.<br />

4 Schlussbemerkungen<br />

Beim Entwerfen von Typen gibt es häufig eine Reihe von Optionen zwischen denen man<br />

sich entscheiden muss. Ausführlich behandelt wurden hier nur die Entscheidung zwischen<br />

Klassen (Referenztypen) und Structs (Werttypen) sowie Schnittstellen und abstrakten<br />

Klassen. Weiterhin bietet das .NET Framework viele Möglichkeiten, um Typen zu<br />

erweitern, von denen in diesem Dokument einige behandelt (Subklassen, Abstraktionen,<br />

virtuelle Methoden) und andere nur genannt wurden (z.B. Callbacks). Es gibt also oftmals<br />

mehrere Wege, um beim Programmieren ein bestimmtes Ziel zu erreichen. Die in diesem<br />

Dokument genannten Richtlinien und Erklärungen sollen dabei helfen, möglichst den einfachsten<br />

und effizientesten Weg zu finden. Sie sind aber keine Universallösung für jedes<br />

konkrete Problem, Ausnahmen bestehen immer. So können zum Beispiel die Richtlinien<br />

für die Wahl zwischen Schnittstelle und abstrakter Klasse helfen, sich für eines der beiden<br />

zu entscheiden. Wirklich wichtig ist jedoch, dass man die Eigenschaften und Unterschiede<br />

von diesen kennt und so auch selbst eine eigene Entscheidung treffen kann. Diese muss<br />

nicht unbedingt mit einer Richtlinie übereinstimmen, solange es einen guten Grund dafür<br />

gibt.<br />

Wozu dann Richtlinien? Konsequent angewandte Richtlinien fördern Konsistenz beim<br />

Programmieren einer Applikation oder einer Bibliothek. Dadurch, dass bestimmte Konstruktionen<br />

und Entscheidungswege an mehreren Stellen auf die gleiche Weise getroffen<br />

wurden, lässt sich das gesamte Programm bzw. die gesamte Bibliothek besser verstehen,<br />

benutzen und erweitern. Konsistenz wird besonders dann wichtig, wenn mehrere Personen<br />

an einem Programm, oder eine Bibliothek arbeiten bzw. wenn diese von anderen Leuten<br />

benutzt oder erweitert werden soll. Aus diesem Grund ist es sinnvoll sich beim Entwerfen<br />

von Typen an zuvor festgelegte Konventionen zu halten. Natürlich gibt es nicht nur für<br />

den Entwurf von Typen Konventionen und Richtlinien, sondern auch für das Entwerfen<br />

von Membern, die Wortwahl bei der Benennung von Variablen, das Werfen von Exceptions<br />

und viele andere Situationen beim Programmieren mit C# und dem .NET Framework.<br />

Als weiterführende Informationsquelle ist deshalb das Buch ”Framework Design<br />

Guidelines: Conventions, Idioms and Patterns for Reusable .NET Libraries” [KC09] besonders<br />

empfehlenswert (s. Quellen). Viele der in diesem Buch gezeigten Richtlinien und


Entwurfs-Praktiken stammen von den Entwicklern des .NET Frameworks selbst und sind<br />

nicht nur beim Entwerfen von riesigen Bibliotheken hilfreich. Die vielzahligen Beispiele<br />

sorgen für ein besseres Verständnis der Richtlinien und Kommentare der Entwickler liefern<br />

einen tieferen Einblick in das .NET Framework. Ein interessantes Interview mit dem<br />

Chefentwickler von C# findet sich in [Bro10].<br />

Literatur<br />

[AH08] Scott Wiltamuth Peter Golde Anders Hejlsberg, Mads Torgersen. The C# Programming<br />

Language, Third Edition. Addison-Wesley, 2008.<br />

[Bro10] OReilly Broadcast. An Interview with Anders Hejlsberg. http://broadcast.<br />

oreilly.com/2009/03/an-interview-with-anders-hejls.html,<br />

2010.<br />

[KC09] Brad Abrams Krzysztof Cwalina. Framework Design Guidelines: Conventions, Idioms,<br />

and Patterns for Reusable .NET Libraries. Addison-Wesley, 2009.<br />

[Lib10a] MSDN Library. Boxing und Unboxing in C#. http://msdn.microsoft.com/<br />

en-us/library/yz2be5wk.aspx, 2010.<br />

[Lib10b] MSDN Library. C# Typen und das Common Type System. http://msdn.<br />

microsoft.com/en-us/library/ms173104.aspx, 2010.<br />

[Lib10c] MSDN Library. Klassen und Structs in C#. http://msdn.microsoft.com/<br />

en-us/library/ms173109.aspx, 2010.<br />

[Lib10d] MSDN Library. Vererbung in C#. http://msdn.microsoft.com/en-us/<br />

library/ms173149.aspx, 2010.

Hurra! Ihre Datei wurde hochgeladen und ist bereit für die Veröffentlichung.

Erfolgreich gespeichert!

Leider ist etwas schief gelaufen!