Die neue Programmiersprache C#
Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:
Dieser Beitrag soll dazu dienen, Ihnen einen schnellen Überblick zu verschaffen,
was C# ist und warum Sie sich damit auseinandersetzen sollten. Wenn Sie bereits
Details der Sprache kennen lernen wollen, dann lesen Sie den Artikel von Marcellus
Buchheit in dieser Ausgabe.
C# wurde unter anderem mit dem Ziel entwickelt, die Entwicklung und Wartung von
Programmen zu vereinfachen. Das Ergebnis wirkt so, als hätten sich die Entwickler
bemüht, die guten Dinge aus Visual Basic in C++ einzubauen und mit den schwierigeren
Traditionen von C und C++ zu brechen.
Und nun erwartet man, dass sich C# zur besten Sprache für die Erstellung von .NET-Anwendungen
für den kommerziellen Einsatz in Unternehmen entwickeln wird. Allerdings brauchen
Sie nicht den vorhandenen C- oder C++-Code auf C# umzustellen. Wenn Sie das Leistungsangebot
von C# für akzeptabel halten - und Sie werden es lieben - können Sie in gewisser
Weise Ihr Weltbild auf C# umstellen. Wie Sie sicher wissen, ist C++ eine überaus
leistungsfähige Sprache, aber vom Schwierigkeitsgrad her nicht gerade ein Erholungsurlaub.
Ich habe beruflich mit Visual Basic und mit C++ gearbeitet und mich nach einer gewissen
Zeit gefragt, warum ich eigentlich auch für die unwichtigste C++-Klasse immer wieder
die üblichen Destruktoren implementieren musste. C++, Du bist eine schlaue Sprache.
Und Visual C++ hat sogar IntelliSense. Warum also räumst Du nicht hinter mir auf?
Nun, wenn Sie gerne mit C und C++ arbeiten, aber gelegentlich so denken wie ich,
ist C# die Sprache für Sie.
Das wichtigste Entwurfsziel war nicht die reine Leistungsfähigkeit, sondern die
einfache Anwendung. Man tauscht etwas Rechenleistung gegen Typsicherheit und eine
automatische Speicherbereinigung (Garbage Collection) ein. C# kann zur Codestabilität
und zur Steigerung Ihrer Produktivität beitragen. Auf lange Sicht machen Sie also
die verlorene Rechenleistung mehr als wett. In Schlagworten könnte man C# folgendermaß;en
umschreiben:
- Einfache Anwendung
- Einheitliches Typsystem
- Modernes Konzept
- Objektorientierung
- Typsicherheit
- Skalierbarkeit
- Versionsunterstützung
- Kompatibilität
- Flexibilität
Schauen wir uns nun etwas genauer an, wie C# das Programmiererleben erleichtert.
Einfache Anwendung
Was stört die meisten Programmierer an C++ am stärksten? Man muss sich merken, wann
der Pfeil -> anzuwenden ist, wann ein Klassenelement mit :: adressiert wird und
wann der Punkt gefragt ist. Und der Compiler merkt meistens, wann Sie schief liegen,
nicht wahr? Er sagt Ihnen sogar noch, dass Sie einen Fehler gemacht haben. Wenn
es dafür einen Grund gibt, der über reine Spottlust hinausgeht, bin ich wohl nicht
in der Lage, ihn zu erkennen.
C# erkennt diese kleinen, aber lästigen Hindernisse des C++ Programmiererlebens
und vereinfacht den Sachverhalt. In C# wird alles durch den Punkt dargestellt. Ob
Sie nun mit Datenelementen hantieren, mit Klassen, Namensräumen, Referenzen oder
was auch immer, Sie brauchen sich nicht mehr zu merken, welcher Operator nun der
richtige ist.
So weit, so gut. Was ist das nächste lästige Ärgernis, mit dem man sich in C und
C++ herumschlägt? Nun, man muss immer exakt herausfinden, welcher Datentyp einzusetzen
ist. In C# ist ein Unicode-Zeichen kein wchar_t, sondern einfach ein char.
Eine ganze Zahl mit 64 Bits ist ein long und kein __int64. Und ein
char ist ein char ist ein char. Es gibt nicht mehr diesen Teilchen-Zoo
mit char, unsigned char, signed char und wchar_t, bei dem jedes hochwohlgeborene
Mitglied beleidigt ist, wenn man es mit anderen verwechselt. (Auf die Datentypen
komme ich später noch zurück.)
Das dritte lästige Problem, auf das man in C und C++ stöß;t, ist der Einsatz von
Integern als logische Werte, was zu den allseits beliebten Zuweisungsfehlern führt,
wenn man = und == verwechselt. C# trennt diese beiden Typen und bietet
einen separaten Logiktyp an, der dieses Problem löst. Ein bool kann true
oder false sein und lässt sich nicht in andere Typen konvertieren. Daher
lässt sich zum Beispiel ein Integer oder eine Objektreferenz nicht mehr auf true
oder false testen - er muss mit null verglichen werden. Wenn Sie in
C++ zum Beispiel gerne solche Konstruktionen schreiben wie
int i; if (i) . . .
ist für den Umstieg auf C# eine kleine Änderung fällig:
int i; if (i != 0) . . .
Ebenso programmiererfreundlich dürfte die Art und Weise sein, wie die switch-Anweisungen in C# funktionieren. In C++ kann man eine switch-Anweisung so aufbauen, dass das Programm von case zu case durchrutscht. So würden die folgenden Zeilen zum Beispiel zum Aufruf von FunctionA() und FunctionB() führen, falls i gleich eins ist:
switch (i)
{
case 1:
FunctionA();
case 2:
FunctionB();
Break;
}
Einheitliches Typsystem
C# vereinfacht das Typsystem dadurch, dass man jeden Datentyp als Objekt betrachten kann. Ob es nun um eine Klasse geht, um eine struct, ein Array oder einen Grundtypen, alle lassen sich wie Objekte benutzen. Objekte werden in Namensräumen zusammengefasst, so dass alles vom Programmcode aus zugänglich ist. Man nimmt also nicht länger Header-Dateien wie diese ins Programm auf:
#include <stdlib.h> #include <stdio.h> #include <string.h>
Statt dessen nehmen Sie einen bestimmten Namensraum ins Programm auf, um Zugriff auf die darin enthaltenen Klassen und Objekte zu erhalten:
using System;
Im .NET liegen alle Klassen in einem einzigen hierarchischen Namensraum. In C# können Sie mit der Anweisung using dafür sorgen, dass Sie für den Zugriff auf eine Klasse nicht mehr den vollständigen qualifizierten Namen angeben müssen. So enthält der Namensraum System zum Beispiel eine ganze Reihe von Klassen, darunter auch die Klasse Console. Console hat eine Methode namens WriteLine, die, wie wohl jeder schon vermutet, eine Zeile auf die Systemkonsole schreibt. Wenn sie also in einem absolut kreativen und revolutionär neuen Hallo-Welt-Programm den erforderlichen Text auf den Bildschirm bringen möchten, kann die Formulierung in C# so aussehen:
System.Console.WriteLine("Hello World!");
Derselbe Code ließ;e sich auch so schreiben:
Using System;
Console.WriteLine("Hello World!");
Und das ist fast schon alles, was Sie für ein lauffähiges Plagiat der berühmten Vorlage brauchen. Ein vollständiges C#-Programm braucht eine Klassendefinition und eine Main-Funktion. Folglich könnte ein vollständiges Hello-World-Konsolenprogramm in C# so aussehen:
using System;
class HelloWorld
{
public static int Main(String[] args)
{
Console.WriteLine("Hello, World!");
return 0;
}
}
Die erste Zeile macht den Namensraum System im Programm verfügbar. Das ist
der Basis-Namensraum von .NET. Die eigentliche Programmklasse erhält den Namen HelloWorld.
Der Code wird also in Klassen organisiert, also nicht primär in Dateien. Die Main-Methode,
die auch Argumente annehmen kann, wird innerhalb der Klasse HelloWorld definiert.
Die .NET-Klasse Console trägt die freundliche Meldung auf den Bildschirm
- und fertig ist das Programm.
Natürlich kann man auch mehr Aufwand treiben. Wie sieht es eigentlich aus, wenn
Sie das HelloWorld-Programm wiederverwenden möchten? Nun, das ist simpel. Bringen
Sie es einfach in seinem eigenen Namensraum unter! Verpacken Sie das Programm in
einem Namensraum und deklarieren Sie die Klasse als public, wenn sie auß;erhalb
des betreffenden Namensraums zugänglich sein soll. (Beachten Sie bitte, dass ich
den Namen Main hier in das passendere SayHi geändert habe.)
using System;
namespace SystemJournal
{
public class HelloWorld
{
public static int SayHi()
{
Console.WriteLine("Hello, World!");
return 0;
}
}
}
Nun können Sie diese Konstruktion zu einer DLL kompilieren und die DLL in jedes andere Programm einbinden, das Sie entwickeln. Das aufrufende Programm könnte zum Beispiel so aussehen:
using System;
using SystemJournal;
class CallingSystemJournal
{
public static void Main(string[] args)
{
HelloWorld.SayHi();
return 0;
}
}
Eine letzte Anmerkung zum Thema Klassen. Wenn in mehreren Namensräumen Klassen mit demselben Namen liegen, können Sie in C# nun für jede einen passenden Zweitnamen (alias) definieren, damit Sie die Bezüge nicht vollständig zu qualifizieren brauchen. Hier ein Beispiel. Nehmen wir an, Sie hätten eine Klasse NS1.NS2.ClassA entwickelt, die so aussieht:
namespace NS1.NS2
{
class ClassA {}
}
Nun können Sie einen zweiten Namensraum NS3 definieren, der folgendermaß;en die Klasse N3.ClassB von NS1.NS2.ClassA ableitet:
namespace NS3
{
class ClassB: NS1.NS2.ClassA {}
}
Falls Ihnen diese Konstruktion zu lang ist oder sie in der restlichen Anwendung ständig zitiert werden soll, können Sie folgendermaß;en für die Klasse NS1.NS2.ClassA den Zweitnamen A definieren:
namespace NS3
{
using A = NS1.NS2.ClassA;
class ClassB: A {}
}
Die Definition kann sich auf jede Ebene der Objekthierarchie beziehen. So können Sie zum Beispiel auch einen Zweitnamen für NS1.NS2 definieren:
namespace NS3
{
using C = NS1.NS2;
class ClassB: C.A {}
}
Modernes Konzept
Die Anforderungen der Entwickler an die Programmiersprachen werden im Lauf der Zeit
immer höher. Was einst geradezu revolutionär war, ist inzwischen - sagen wir - gealtert.
Wie der gute, alte Toyota Corolla in Nachbars Garten bieten C und C++ zwar eine
überaus zuverlässige Transportgelegenheit, aber ihnen fehlen so die letzten blinkenden
und glitzernden Spielsachen, nach denen die Leute lechzen, wenn sie richtig Gummi
geben wollen. Das dürfte wohl einer der Gründe sein, warum viele Entwickler in den
letzten Jahren zunehmend mit Java herumspielen.
C# geht in gewissem Sinne wieder zurück ans Zeichenbrett und kommt dann mit einigen
Ideen zurück, die ich in C++ schon lange vermisse. Die gute, alte Speicherbereinigung
(garbage collection) ist ein Beispiel dafür. Alles wird irgendwann vom System weggeräumt,
wenn es nicht mehr gebraucht wird. Allerdings hat solch ein Luxus natürlich seinen
Preis. Bestimmte Probleme, die sich aus Programmierfehlern ergeben (zum Beispiel
aus falschen Typkonvertierungen oder streuenden Zeigern), sind unter Umständen wesentlich
schwerer zu erkennen und können, wenn man Pech hat, im Programm wesentlich mehr
Schaden anrichten. Um dem entgegenzusteuern, bietet C# eine gewisse Typsicherheit
an, durch die sich die Stabilität der Programme erhöhen soll. Natürlich wird der
Code durch die Typsicherheit auch leichter lesbar, so dass auch die anderen aus
dem Team leichter erkennen, was Sie eigentlich treiben - nun, man kann das eine
wohl nicht ohne das andere haben, denke ich mal. (Darauf komme ich später noch zurück.)
C# hat von Haus aus umfangreichere Vorkehrungen zur Bearbeitung von Fehlern als
C++. Haben Sie sich bei der Fehlersuche schon mal so richtig tief in den Code Ihrer
Kollegen hineingewühlt? Es ist schon erstaunlich. Da sind Dutzende von ungeprüften
HRESULTs über die Zeilen verstreut und wenn ein Aufruf fehlschlägt, outet
sich das Programm mit einer genialen Fehlermeldung wie "Fehler: Es gab einen Fehler."
C# versucht, diese Situation durch die Einbindung von Konstruktionen wie throw, try..catch
und try..finally als Sprachelemente zu verbessern. Sicher, in C++ könnte
man das alles als Makro einbauen, aber nun bietet es die Programmiersprache eben
von Haus aus an.
Zu einer modernen Programmiersprache gehört, dass sie tatsächlich für irgendwelche
Problemlösungen zu gebrauchen ist. Bitte? Sie lachen? Nun, so simpel und naheliegend
diese Forderung auch zu sein scheint, so gerne wird sie ignoriert. Viele Sprachen
kennen zum Beispiel keine Währungstypen und können auch mit Zeit- und Datumsangaben
nichts anfangen. Solche Ideen sind den Sprach-Genies wohl zu sehr "Old Economy".
In Anlehnung an Sprachen wie SQL implementiert C# Datentypen wie Dezimalzahlen und
Strings und ermöglicht zudem die Erweiterung des Typsystems durch neue Grundtypen,
die genauso effizient bearbeitet werden wie die vorhandenen Typen. Auch darauf werde
ich später noch zurückkommen.
Vermutlich wird es Sie auch freuen, dass sich C# an den moderneren Methoden der
Fehlersuche orientiert. Die traditionelle Methode, ein C++-Programm zu entwanzen,
besteht darin, den Quelltext mit eingestreuten #ifdefs zu überschwemmen und
umfangreiche Codeteile zu schreiben, die nur zur Fehlersuche gebraucht werden. Letztlich
erhält man dadurch zwei Versionen desselben Programms, nämlich eine Debug-Version
und eine Lieferversion. Manche Aufrufe in der Lieferversion landen dann in irgendwelchen
Platzhaltern, die rein gar nichts zu tun haben. C# bietet Ihnen mit dem Schlüsselwort
conditional die Möglichkeit, den Programmfluss anhand von definierten Symbolen
zu steuern.
Erinnern Sie sich noch an den Namensraum SystemJournal? Eine einzige conditional-Anweisung
reicht aus, um die Funktion SayHi zu einer Funktion zu machen, die nur bei
der Fehlersuche eine Rolle spielt.
using System;
namespace SystemJournal
{
public class HelloWorld
{
[conditional("DEBUG")]
public static void SayHi()
{
Console.WriteLine("Hello, World!");
return;
}
...
}
}
Konditionale Funktionen müssen übrigens den Ergebnistyp void haben, wie in diesem Beispiel gezeigt (eigentlich sind es daher keine Funktionen mehr, sondern Prozeduren.) Um die HelloWorld-Nachricht auf den Bildschirm zu zaubern, müsste das aufrufende Programm so aussehen:
using System
using SystemJournal
#define DEBUG
class CallingSystemJournal
{
public static void Main(string[] args)
{
HelloWorld.SayHi();
return 0;
}
}
Dieser Code ist schön übersichtlich und wird nicht durch die vielen ifdefs
aufgeblasen, die doch nur zwischen den eigentlichen Codezeilen herumlungern und
intensivst darauf warten, ignoriert zu werden.
Auß;erdem wurde C# so konzipiert, dass sich die Sprache leicht parsen lässt. Es
sollte also nicht übermäß;ig schwer sein, entsprechende Werkzeuge zu bauen, mit
denen sich der Quelltext bewältigen und in lauffähigen Code umwandeln lässt.
Objektorientierung
C++ ist objektorientiert. Richtig. Ich habe selbst Leute kennen gelernt, die sich
für eine Woche oder so mit der Mehrfachvererbung herumgeschlagen haben und dann
irgendwohin verschwunden sind, um verseuchte Lagunen von den Abfällen der modernen
Zivilisation zu reinigen. Deswegen lässt C# die Mehrfachvererbung sausen und hält
sich lieber an das virtuelle Objektsystem von .NET. Kapselung, Vielgestaltigkeit
(Polymorphie) und Vererbung bleiben erhalten, die Magenkrämpfe aber nicht.
C# versenkt zudem das ganze Konzept der globalen Funktionen, Variablen und Konstanten
im Mülleimer der Zeit. Statt dessen können Sie in den Klassen statische Datenelemente
anlegen. Dadurch wird der Code übersichtlicher und weniger anfällig für Namenskonflikte.
Wo wir schon beim Thema Namenskonflikte sind - haben Sie etwa schon vergessen, wie
Sie in den Klassen Datenelemente definiert und sie später im Code umdefiniert haben?
C#-Methoden sind übrigens von Haus aus nicht virtuell. Virtuelle Funktionen verlangen
explizit nach der Angabe virtual. Es ist daher nicht so einfach, versehentlich
eine Methode zu überschreiben. Auß;erdem können Sie leichter für die korrekten Versionen
sorgen und die vtable wächst nicht so schnell. Die Datenelemente lassen sich
in C# als privat, geschützt, öffentlich oder intern definieren (private,
protected, public, internal). Sie erhalten die totale Kontrolle
über deren Kapselung.
Methoden und Operatoren lassen sich in C# überladen, allerdings mit einer Syntax,
die wesentlich einfacher zu verstehen ist, als die C++-Variante. Globale Operatorfunktionen
lassen sich aber nicht überschreiben. Die Überschreibungen gelten immer lokal. Die
Methode F im folgenden Beispiel zeigt, wie die Überschreibungen aussehen:
interface ITest
{
void F(); // F()
void F(int x); // F(int)
void F(ref int x); // F(ref int)
void F(out int x); // F(out int)
void F(int x, int y); // F(int, int)
int F(string s); // F(string)
int F(int x); // F(int)
}
Das Komponentenmodell von .NET wird durch die Implementierung von "Vertretern" (delegates)
unterstützt, dem objektorientierten Gegenstück zu Funktionszeigern.
Schnittstellen beherrschen die Mehrfachvererbung. Die Klassen können durch die explizierte
Implementierung von Funktionen private, interne Schnittstellen implementieren, ohne
dass die Verbraucher jemals davon erfahren.
Typsicherheit
Auch wenn manche Leser nicht mit mir übereinstimmen, bin ich davon überzeugt, dass
eine strengere Typsicherheit die Stabilität der Programme fördert. In C# wurde einiges
von Visual Basic übernommen, was die korrekte Code-Ausführung fördert - und damit
auch stabilere Programme. So werden zum Beispiel alle dynamisch angelegten Objekte
und Arrays mit null initialisiert. Lokale Variablen initialisiert C# zwar nicht
automatisch, aber der Compiler wird Sie darauf hinweisen, falls Sie eine lokale
Variable vor ihrer Initialisierung benutzen. Und beim Zugriff auf ein Array erfolgt
automatisch eine Bereichsüberprüfung. Im Gegensatz zu C und C++ können Sie in C#
also nicht versehentlich auf ungültige Speicherbereiche zugreifen.
In C# können Sie keine ungültige Referenz anlegen. Alle Typkonvertierungen (Casts)
müssen sicher sein und Sie können keine Konvertierungen zwischen ganzen Zahlen und
Referenztypen vornehmen. Die Speicherbereinigung in C# sorgt dafür, dass keine streunenden
Referenzen im Code herumhängen. Damit Hand in Hand geht die Überlaufsprüfung. Arithmetische
Operationen und Konvertierungen sind nicht erlaub, wenn die Zielvariable oder das
Zielobjekt überläuft. Natürlich gibt es aber gute Gründe, die dafür sprechen, eine
Variable überlaufen zu lassen. Sollte solch ein Fall vorliegen, können Sie die Überlaufsprüfung
explizit ausschalten.
Wie schon erwähnt, sind die Datentypen in C# etwas anders, als Sie es von C++ her
gewohnt sind. So ist der Typ char zum Beispiel 16 Bit breit. Auß;erdem gibt es von
Haus aus einige nützliche zusätzliche Typen wie decimal und string.
Der wohl größ;te Unterschied zwischen C++ und C# zeigt sich aber in der Art und
Weise, wie C# mit Arrays umgeht.
Arrays sind in C# "verwaltete Typen" (managed types). Das bedeutet, dass sie Referenzen
enthalten, also keine Werte, und dass sich bei Bedarf der Müllsammler um sie kümmert.
Sie können Arrays auf verschiedene Weisen deklarieren, auch als mehrdimensionale
(rechteckige) Arrays und als Arrays von Arrays. Beachten Sie bitte im folgenden
Beispiel, dass die rechteckigen Klammern hinter dem Typ stehen, also nicht hinter
dem Namen, wie in manchen anderen Sprachen.
int[ ] intArray; // ein einfaches Array
int[ , , ] intArray; // ein mehrdimensionales Array mit dem
// Rang 3 (3 Dimensionen)
int[ ][ ] intArray // ein Array von Arrays
int[ ][ , , ][ , ] intArray; // ein eindimensionales Array, das
// dreidimensionale Arrays aus zwei-
// dimensionalen Arrays enthält
Auch Arrays sind Objekte. Bei ihrer ersten Deklaration haben sie noch keine Größ;e. Daher müssen sie nach ihrer Deklaration erst einmal angelegt werden. Nehmen wir an, Sie bräuchten ein Array mit 5 Elementen. Der folgende Code liefert es:
int[] intArray = new int[5];
Falls Sie das zweimal hintereinander tun, wird das Array automatisch mit der jeweils aktuellen Größ;e angelegt. Aus den Zeilen
int[] intArray; intArray = new int[5]; intArray = new int[10];
resultiert also ein Array namens intArray, das 10 Elemente enthält. Ähnlich simpel gestaltet sich die Erzeugung eines rechteckigen Arrays:
int[] intArray = new int[3,4];
Etwas schwieriger ist dagegen die Erstellung eines Arrays aus Arrays ("jagged", also "unregelmäß;ige" Arrays). Der Gedanke drängt sich auf, new int[3][4] sei das korrekte Zauberwort, aber die Formel ist etwas aufwendiger:
int[][] intArray = new int[3][];
For (int a = 0; a < intArray.Length; a++) {
intArray[a] = new intArray[4];
}
Mit Hilfe der geschweiften Klammern können Sie ein Array in derselben Zeile initialisieren, auf der Sie es anlegen:
int[] intArray = new int[5] {1, 2, 3, 4, 5};
Das gilt auch für ein String-Array:
string[] strArray = new string[2] {"MIND", "System Journal"};
Wie erwartet, ist der klammertechnische Aufwand bei der Initialisierung eines mehrdimensionalen Arrays etwas höher:
int[,] intArray = new int[3, 2] { {1, 2}, {3, 4}, {5, 6} };
Natürlich lassen sich auch die unregelmäß;igen Arrays initialisieren:
int[][] intArray = new int[][] { new int[] {2,3,4},
new int[] {5,6,7} };
Wenn Sie den new-Operator weglassen, ergibt sich aus der Initialisierung des Arrays sogar implizit dessen Dimension:
int[] intArray = {1, 2, 3, 4, 5};
Arrays werden in C# als Objekte angesehen und daher auch wie Objekte behandelt, also nicht als adressierbare Bytefolge. Insbesondere unterliegen auch Arrays der automatischen Speicherbereinigung und müssen daher nach Gebrauch nicht explizit entsorgt werden. Arrays beruhen auf der C#-Klasse System.Array. Vom Konzept her könnten Sie Arrays also als ein Sammlungsobjekt (Collection) ansehen und mit Hilfe des Length-Attributs über jedes Arrayelement gehen. Wenn Sie intArray so definieren, wie oben gezeigt, wird der Aufruf
intArray.Length
die Länge 5 ergeben. In der Klasse System.Array gibt es auch Funktionen zum
Kopieren und Sortieren der Arrays und zur Suche nach bestimmten Elementen.
C# bietet einen foreach-Operator an, der wie sein Gegenstück in Visual Basic
funktioniert und den Gang über das Array ermöglicht. Werfen Sie einen Blick auf
die folgenden Zeilen:
int[] intArray = {2, 4, 6, 8, 10, -2, -3, -4, 8};
foreach (int i in intArray)
{
System.Console.WriteLine(i);
}
Dieser Code zeigt die Zahlen aus dem intArray auf der Systemkonsole an, wobei jede neue Zahl auf ihrer eigenen Zeile steht. Mit der GetLength-Funktion aus der Klasse System.Array könne man diesen Code auch so formulieren (die Array-Elemente werden in C# von null an gezählt):
for (int i = 0; i < intArray.GetLength(); i++)
{
System.Console.WriteLine(i);
}
Skalierbarkeit
C und C++ erfordern alle möglichen Arten von oftmals inkompatiblen Header-Dateien,
damit man auch nur den einfachsten Code kompilieren kann. C# kombiniert die Deklaration
und Definition der Typen und macht die immer umfangreicheren Header damit überflüssig.
Auß;erdem importiert und schreibt es .NET-Metadaten, so dass ein inkrementelles
Kompilieren der Anwendungen wesentlich einfacher ist.
Ist ein Projekt hinreichend gewachsen, können Sie den Code auch über mehrere kleinere
Dateien verteilen. C# macht Ihnen keine Vorschriften darüber, wo die Quelltexte
zu liegen haben oder wie die Dateien heiß;en müssen. Wenn Sie ein umfangreiches
C#-Projekt kompilieren, können Sie sich den Vorgang so vorstellen, dass der Compiler
alle Quelltextdateien aneinander hängt und dann in eine groß;e Ergebnisdatei kompiliert.
Sie brauchen sich nicht darum zu kümmern, welche Header-Dateien wohin gehören oder
welche Funktionen in welcher Quelldatei zu liegen haben. Das bedeutet auch, dass
Sie Ihre Quelltexte quasi nach Belieben verlegen, umbenennen, aufteilen oder zusammenfassen
können, ohne den Compiler ernsthaft zu behindern.
Versionsunterstützung
Die DLL-Hölle ist ein ständig lästiger werdendes Problem, und zwar nicht nur für die Entwickler, sondern auch für die Anwender. Im MSDN-Online findet sich sogar ein spezieller Dienst für die Anwender, die sich mit den verschiedenen Versionen der System-DLLs herumschlagen müssen. Nun gibt es leider nichts, was eine Programmiersprache tun kann, um den Entwickler einer Bibliothek davon abzuhalten, mit einer veröffentlichten API-Schnittstelle in seine eigene Vorstellungswelt abzudriften. Aber C# wurde schon so ausgelegt, dass die Versionsverwaltung wesentlich einfacher wird, weil die Binärkompatibilität zu den vorhandenen abgeleiteten Klassen erhalten bleibt. Wenn Sie in einer Basisklasse eine neue Funktion einführen, die es bereits in der abgeleiteten Klasse gibt, resultiert daraus kein Fehler. Allerdings muss der Entwickler der Klasse kenntlich machen, ob die Methode als Überschreibung gedacht ist oder als neue Methode, die einfach nur die entsprechende geerbte Methode verdeckt.
Kompatibilität
Auf den Windows-Plattformen haben sich im Lauf der Zeit einige verschiedene API-Formen
ausgebildet und C# kommt mit allen zurecht. Die alten Schnittstellen nach C-Art
lassen sich direkt in C# ansprechen. C# bietet den transparenten Zugriff auf die
API-Standardfunktionen von COM und OLE-Automation und unterstützt alle Datentypen
aus der .NET-Laufzeitschicht. Und wichtiger noch, C# beherrscht auch die Common
Language Subset-Spezifikation von .NET. Wenn Sie irgendwelche Objekte exportiert
haben, die von anderen Sprachen aus nicht zugänglich sind, kann der Compiler den
Code bei Bedarf entsprechend kennzeichnen. So kann eine Klasse zum Beispiel nicht
die Funktionen runJob und runjob exportieren, weil eine Programmiersprache,
die nicht zwischen Groß;- und Kleinschreibung unterscheidet, damit überfordert wäre.
Wenn Sie eine Funktion aufrufen, die von einer DLL exportiert wird, müssen Sie die
Funktion deklarieren, ihr ein sysimport-Attribut geben und bei Bedarf die
Angaben über das anwenderdefinierte Marshaling und den Ergebnistyp machen, die von
den .NET-Vorgaben abweichen.
Die folgenden Zeilen zeigen ein kleines HelloWorld-Programm, das seinen üblichen
Jubelruf in einem der üblichen Nachrichtenfenster von Windows in die Welt hinaustrompetet:
class HelloWorld
{
[sysimport(dll = "user32.dll")]
public static extern int MessageBoxA(int h, string m,
string c, int type);
public static int Main()
{
return MessageBoxA(0, "Hello World!", "Caption", 0);
}
}
Jeder .NET-Typ lässt sich auf einen passenden Typ abbilden, den .NET zur Weiterleitung des Werts über einen API-Aufruf benutzt. Der C#-Typ string wird zum Beispiel auf LPSTR abgebildet. Aber das lässt sich mit marshaling-Anweisungen ändern:
using System;
using System.Interop;
class HelloWorld
{
[dllimport("user32.dll")]
public static extern int MessageBoxW(
int h,
[marshal(UnmanagedType.LPWStr)] string m,
[marshal(UnmanagedType.LPWStr)] string c,
int type);
public static int Main()
{
return MessageBoxW(0, "Hello World!", "Caption", 0);
}
}
Sie können aber nicht nur mit DLL-Exporten arbeiten, sondern auch mit den klassischen
COM-Objekten, und zwar auf recht klassischem Wege: man legt die Objekte mit CreateInstance
an, fordert die gewünschten Schnittstellen an und ruft deren Methoden auf.
Der Import einer COM-Klassendefinition ins Programm erfolgt in zwei Schritten. Zuerst
müssen Sie eine Klasse anlegen und ihr das Attribut comimport geben, um es
mit einer bestimmten GUID zu verknüpfen. Diese Klasse, die Sie hier anlegen, kann
weder irgendwelche Basisklassen oder Schnittstellenlisten haben noch irgendwelche
Elemente.
// deklariere FilgraphManager als eine COM-Coklasse
[comimport, guid("E436EBB3-524F-11CE-9F53-0020AF0BA770")]
class FilgraphManager
{
}
Sobald die Klasse in Ihrem Programm deklariert ist, können Sie mit dem Schlüsselwort new, das in diesem Fall mit der Funktion CoCreateInstance äquivalent ist, eine neue Instanz der Klasse anlegen.
class MainClass
{
public static void Main()
{
FilgraphManager f = new FilgraphManager();
}
}
Sie können die Schnittstellen in C# indirekt anfordern, indem Sie versuchen, das Objekt per Cast in die gewünschte Schnittstelle zu konvertieren. Sollte diese Typumwandlung fehlschlagen, wird eine System.InvalidCastException ausgelöst. Klappt die Umwandlung aber, steht Ihnen ein Objekt zur Verfügung, das diese Schnittstelle repräsentiert.
FilgraphManager graphManager = new FilgraphManager();
IMediaControl mc = (IMediaControl) graphManager;
mc.Run(); // Diese Zeile funktioniert, sofern die Typumwandlung
// geklappt hat.
Flexibilität
Es stimmt schon, dass C# und .NET eine verwaltete, typsichere Umgebung bilden. Allerdings
stimmt es auch, dass manche Anwendung in der realen Welt bis zur Ebene der Assemblersprache
hinabsteigen muss, sei es aus reinen Leistungsgründen oder zur Anpassung an irgendwelche
seltsamen Schnittstellen aus anderen Programmen. Bisher habe ich nur kurz skizziert,
wie man von C# aus API-Funktionen und COM-Komponenten benutzt. In C# ist es aber
auch möglich, unsichere Klassen und Methoden zu deklarieren, die Zeiger, structs
und statische Arrays enthalten. Diese Methoden sind zwar nicht typsicher, aber sie
laufen trotzdem im verwalteten Raum, so dass es keine künstlichen Hürden zwischen
sicherem und unsicherem Code gibt, die nur per Marshaling zu überwinden wären.
Diese unsicheren Kandidaten lassen sich relativ zwanglos betreiben. Der Entwickler
kann ein Objekt entsprechend kennzeichnen, so dass die Speicherbereinigung es einfach
ignoriert, wenn sie ihre Runden dreht. Der unsichere Code wird nicht auß;erhalb
der vertrauenswürdigen Umgebung ausgeführt. Der Programmierer kann die Speicherbereinigung
sogar vollständig ausschalten, solange eine unsichere Methode läuft.