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/