Ton angebend
Als Antwort auf Suns plattformunabhängiges Java hat Microsoft im letzten Jahr C# angekündigt. Die Programmiersprache ist Teil der Entwicklungsumgebung Visual Studio 7.0 und setzt auf dem .NET Framework auf. Der erste von drei Teilen eines Tutorials gibt eine Einführung in ihre Konzepte und Sprachkonstrukte.
- Michael Stal
Mittlerweile gilt es geradezu als Tradition, dass Microsoft bevorzugt auf der hauseigenen Konferenz PDC (Professional Developers Conference) technische Innovationen verkündet. Das war schon beim Component Object Model (COM) der Fall. Im Juli 2000 erfolgte der nächste Paukenschlag. Drei Jahre hat der Branchenriese an einer Software-Infrastruktur gearbeitet, die ursprünglich mit dem Codenamen ‘COM3’ als nächste Generation von COM/COM+ dienen sollte. Inzwischen verkauft Microsoft die Software mit dem einprägsamen Namen .NET nicht nur als Philosophie und Lifestyle, sondern als die ideale Entwicklungsplattform für Windows- und Webprogrammierung. Und natürlich soll auch die Java-Fraktion kräftig ins Schwitzen kommen.
Ob .NET die weltweite Marktdurchdringung von Java zu seinen eigenen Gunsten zu verändern vermag, sei dahingestellt. Lässt man die Werbetrommeln außer Acht und konzentriert sich auf die Technik selbst, offenbart sich eine für Entwickler interessante Welt. OpenSource-Projekte wie Mono und die Standardisierung wesentlicher Teile von .NET im Rahmen des Industriekonsortiums ECMA dürften dafür sorgen, dass .NET nicht nur in der Windows-Welt Verbreitung findet. Nicht zuletzt sollte sich Sun ebenfalls ein wenig daran orientieren, um die Java-Plattform weiter zu verbessern. Mit anderen Worten: Es ist an der Zeit, .NET zu lernen und zu verstehen.
.NET im Internet
Implementierung von Component Pascal auf JVM und CLR und Vergleich
Das mit diesem Artikel beginnende dreiteilige Tutorial gibt einen Einblick in die .NET-Programmierung und die Programmiersprache C# (sprich: C Sharp). Im ersten Teil kommen die Grundlagen von .NET und C# zur Sprache. Der zweite Teil konzentriert sich auf fortgeschrittene Konzepte. Als Abschluss soll im dritten Teil ein kurzer Streifzug durch das .NET-Framework erfolgen, wobei aber auch schon in den ersten beiden Teilen einige Bestandteile des Framework Thema sind. Um die Beispiele ausprobieren zu können sowie zum Lösen der Übungsaufgaben müssen das Windows Component Update, das .NET Framework und (optional) Visual Studio .NET unter Windows 2000 installiert sein.
Da es in der Septemberausgabe 2001 [6] bereits einen ausführlichen Artikel zu .NET gab, werden an dieser Stelle nur die wichtigsten Details und die gebräuchliche Terminologie vorgestellt, bevor es ‘in medias res’ - die Programmierpraxis - geht. Details zum Umgang mit der Programmierumgebung Visual Studio .NET sind ebenfalls dem genannten Artikel zu entnehmen.
Wenn Entwickler über Microsofts .NET sprechen, meinen sie in der Regel das Framework. Dessen zentraler Bestandteil ist die so genannte CLR (Common Language Runtime; siehe Abb. 1), eine Laufzeitumgebung für .NET-Anwendungen mit großer und nicht ganz unbeabsichtigter Ähnlichkeit zu Java. Die CLR basiert auf einer abstrakten Stack-Maschine, die ausschließlich Anweisungen des Zwischencodes MSIL (Microsoft Intermediate Language) verarbeitet. Listing 1 demonstriert die Umsetzung des +-Operators aus Listing 2 (Zeile 32) in MSIL.
Listing 1
Fast selbsterklärend: die Umsetzung des +-Operators aus Listing 2 (Zeile 32) in die Microsoft Intermediate Language (MSIL).
.method public hidebysig specialname static
valuetype ComplexNumbers.Point op_Addition(valuetype ComplexNumbers.Point op1,
valuetype ComplexNumbers.Point op2) cil managed
{
// Code size 40 (0x28)
.maxstack 4
.locals ([0] valuetype ComplexNumbers.Point CS$00000003$00000000)
IL_0000: ldarga.s op1
IL_0002: call instance float64 ComplexNumbers.Point::get_x()
IL_0007: ldarga.s op2
IL_0009: call instance float64 ComplexNumbers.Point::get_x()
IL_000e: add
IL_000f: ldarga.s op1
IL_0011: call instance float64 ComplexNumbers.Point::get_y()
IL_0016: ldarga.s op2
IL_0018: call instance float64 ComplexNumbers.Point::get_y()
IL_001d: add
IL_001e: newobj instance void ComplexNumbers.Point::.ctor(float64,
float64)
IL_0023: stloc.0
IL_0024: br.s IL_0026
IL_0026: ldloc.0
IL_0027: ret
} // end of method Point::op_Addition
Es ist geradezu beängstigend, wie selbsterklärend diese disassemblierten Programme sind. Im Gegensatz zu Java gibt es allerdings in .NET keine Interpretation von IL-Code. Stattdessen übersetzt das VES (Virtual Execution System) IL in den nativen Code der zugrunde liegenden Zielmaschine. Diese Übersetzung kann entweder vor oder während der Programmausführung stattfinden.
Zu den weiteren Bestandteilen der Laufzeitumgebung gehören unter anderem ein Garbage Collector, ein Klassenlader (Class Loader) und ein Codeinspektor (Verifier). Programme, deren Ausführung unter Aufsicht dieser Laufzeitumgebung erfolgt, bezeichnet die .NET-Terminologie als ‘Managed Code’. Alle anderen Programme zählen zur Kategorie ‘Unmanaged Code’. .NET kennt spezielle Brücken, um eine Interoperabilität zwischen beiden zu sichern. Mit P/Invoke (Platform Invoke) kann der Programmierer existierende DLLs anbinden. Für den wichtigen Sonderfall der COM/COM+-Interoperabilität stellt .NET ein Mapping (COM Interop) bereit. Interessantes Detail am Rande: Die wichtigste .NET-Laufzeitbibliothek ist selbst mittels COM/COM+ implementiert.
Daneben gibt es eine Differenzierung in ‘Managed Data’ und ‘Unmanaged Data’. In der Regel gehört es zu den Aufgaben des Garbage Collector, alle nicht persistenten Programmdaten zu verwalten (Managed Data). Alternativ kann der Entwickler Daten manuell allozieren und deallozieren (Unmanaged Data, auch: Unsafe Code). Zur Vermeidung von Missverständnissen sei angemerkt, dass Programmierer in ihrem Managed Code sowohl Managed Data als auch Unmanaged Data nutzen können. Das eine hängt nicht mit dem anderen zusammen.
Beim Start von ausführbaren Programmen überprüft im Normalfall ein Verifizierer, ob sich das Programm an alle Regeln bezüglich Speicherzugriffen hält. Zusätzlich gibt es die Option, die CLR unverifiziert zu nutzen. Dadurch ist die Verwendung von Zeigerarithmetik oder nativen Funktionszeigern realisierbar, sodass sich selbst Programmiersprachen wie ANSI C oder Sprachmerkmale wie Mehrfachvererbung unterstützen lassen.
Sprachunabhängiges Objektmodell
Da .NET anders als Java beliebige Programmiersprachen unterstützt, ist die Vorgabe eines gemeinsamen sprachunabhängigen Objektmodells essenziell. Microsoft hat aus den Fehlern von COM und COM+ gelernt, wo gerade der Ansatz des kleinsten gemeinsamen Nenners die meisten Interoperabilitätsprobleme impliziert. Das COM-Objektmodell unterstützt etwa die Schnittmenge der Eigenschaften, die alle Programmiersprachen anbieten. Darüber hinaus ist es zwar möglich, zum Beispiel C++-interne Datenstrukturen zu verwenden, in dem Fall ist jedoch keine Interoperabilität zu anderen Programmiersprachen mehr gegeben.
Im CTS (Common Type System; siehe Abb. 2) findet sich im Unterschied dazu ein reichhaltiger Satz von Datentypen, die sich grob in ‘Value Types’ und ‘Reference Types’ klassifizieren lassen. Erstere umfassen primitive Datentypen, Aufzählungstypen (enum) und benutzerdefinierte Werttypen (struct), wobei die Verwaltung Letzterer auf dem Stack erfolgt. Referenzierende Typen wie Felder, Klassen, Delegates (typisierte Funktionszeiger) oder Zeiger dagegen speichert das Framework auf dem Heap.
Spezielle Operatoren sorgen dafür, dass Werttypen auch per Referenz übergeben werden können. Dazu kreieren sie die zu Wertobjekten semantisch identische Referenzobjekte (Boxing) oder nehmen eine Rückkonvertierung dieser ‘geboxten’ Objekte in Wertobjekte vor (Unboxing). Ein weiteres Merkmal des CTS besteht darin, dass dort alle Datentypen gleichzeitig Objekte darstellen. Es gibt also keine Zweiklassengesellschaft von echten Objekten und sonstigen Datentypen wie in C++ oder Java.
Neben Datentypen definiert das CTS Sprachentitäten und -merkmale wie Objekte, Methoden, Schnittstellen, Ausnahmebehandlung, einfache Implementierungsvererbung, mehrfache Schnittstellenvererbung und vieles mehr. Für die Interoperabilität mit beliebigen anderen .NET-Sprachen sollte sich ein Sprachdesigner allerdings auf die Common Language Specification (CLS) beschränken, die genaue Richtlinien und Einschränkungen hinsichtlich der Implementierung vorgibt, etwa einen limitierten Satz von Datentypen. Wer sich nicht an die CLS hält, hat keine Garantie, dass seine Assemblies mit denen anderer Programmiersprachen zusammenarbeiten können.
Assemblies und Reflexion
Die Installation von .NET-Funktionen erfolgt in Gestalt von Assemblies (siehe Abb. 3). Diese stellen logische Einheiten dar, die ‘traditionellen’ DLLs und EXEs ähneln, und atomare Einheiten hinsichtlich Installation, Konfiguration, Sicherheit, Versionierung und Laufzeitmanagement bilden. Ein Assembly (Dateiendung .cs) besteht wiederum aus einem oder mehreren Modulen beziehungsweise PEs (PE = Program Executable). Visual Studio .NET unterstützt momentan in Beta 2 allerdings nur ein Modul pro Assembly. Module integrieren den ausführbaren Code für die darin bereitgestellten Typen. Jedes Assembly enthält eine Manifest-Datei, die detaillierte Informationen bereitstellt, zum Beispiel Referenzen auf importierte Assemblies, exportierte Ressourcen und Assemblies oder Sicherheitsinformationen. Das Manifest kann sich global im Assembly oder innerhalb eines Moduls befinden.
Als binäres Format unterstützt .NET das in der Microsoft-Welt übliche COFF. Beim Laden des Assembly aktiviert es einen so genannten Runtime Host, der die Kontrolle über Kompilierung und Ausführung übernimmt. Metadaten sind explizit im Assembly vorhanden (generiert).
Alle Informationen über Typen lassen sich mittels Reflexionsmechanismen dynamisch abfragen. Zu den Referenztypen jedes Moduls existieren ausführliche Metainformationen. Durch die Kombination von Manifest, Metainformationen und Reflexionsmechanismen erfordert .NET keine zentrale Registrierung von Assemblies à la COM/COM+. Anders als in Java sind Metainformationen in .NET-kompatiblen Sprachen mit Hilfe so genannter Attribute benutzerdefiniert erweiterbar.
Ein wesentliches Unterscheidungskriterium bei Assemblies ist die Klassifizierung in ‘Private’ and ‘Shared Assemblies’. Erstere liegen im lokalen Verzeichnisbaum der Anwendung und werden daher in der Regel nur von dieser Anwendung benutzt. Shared Assemblies hingegen lassen sich über ein Werkzeug in einem globalen Verzeichnis - optional auch woanders -installieren und sind demzufolge von beliebigen Anwendungen aufrufbar. Um eine Assembly in den globalen Assembly Cache ablegen zu können, muss der Entwickler dieses zuvor mit seinem privaten Schlüssel signieren, um unter anderem Namenskollisionen mit anderen Assemblies zu vermeiden. Dazu wird Shared Assemblies statt dem Namen ihrer Hauptdatei ein eindeutiger, durch den privaten Schlüssel induzierter Name (strong name) zugeordnet. Die Ausführungsumgebung (VES) lädt Assemblies zur Laufzeit in so genannte Application Domains. Diese setzen auf das grobkörnige Prozessmodell des zugrunde liegenden Betriebssystems eine feiner granulierte Abstraktion, um .NET-Komponenten gegenseitig abzuschirmen, ohne die durch Prozesse bedingten Performanzprobleme in Kauf nehmen zu müssen.
In der Regel erfolgt die Ausführung jedes .NET-Programms automatisch in einer von der Laufzeitumgebung (Laufzeit-Host) bereitgestellten Application Domain. Es ist aber ebenfalls möglich, aus einem Programm heraus neue Application Domains zu instanziieren und Assemblies dort zur Ausführung zu bringen. Assemblies beinhalten Versionsnummern. Beim Programmstart kann die VES daher genau die benötigten Assembly-Versionen laden, da sich Anwendungen die Versionen der von ihnen referenzierten Assemblies merken. Zudem lassen sich gleichzeitig in unterschiedlichen Application Domains verschiedene Versionen desselben Assemblies zur Ausführung bringen, was Microsoft als ‘Side-by-Side Execution’ bezeichnet. Insgesamt bereitet .NET somit endlich der berüchtigten ‘DLL Hell’ ein Ende (also der ungeordneten, unversionierten und damit nicht gerade stabilitätsfördernden Installation von DLLs im Dateisystem, kurz ausgedrückt: Chaos).
Wie die Java-Plattform eindrucksvoll vorgeführt hat, sind Komplexität und Mächtigkeit einer virtuellen Plattform nicht in der jeweiligen Programmiersprache begründet, sondern in Qualität und Quantität der bereitgestellten Klassenbibliothek. Das .NET Framework bietet Basisklassen für Grundfunktionen - zum Beispiel Ein-/Ausgabe, Zeichenkettenoperationen, Netzwerkkommunikation, Multithreading oder Reflexionsmechanismen. Darauf bauen wichtige Klassen zum Datenbankzugriff und zur Verarbeitung von XML auf. Die so genannte User Experience, also die Benutzerinteraktion, erfolgt über spezielle Klassen, die eine Bereitstellung von Windows-Oberflächen, dynamischen Webseiten oder den Zugriff auf Web Services realisieren. Von der Klassenbibliothek wird im dritten Tutorial-Teil noch die Rede sein.
Installation und Werkzeuge
Für die Entwicklung von C#- und .NET-Programmen gibt es momentan zwei Optionen. Zum einen bietet Visual Studio .NET eine einheitliche und umfangreiche Programmierumgebung, in der sich beliebige Programmiersprachen integrieren lassen. Zum anderen enthält Beta 2 des .NET Framework eine Reihe kommandozeilenbasierter Werkzeuge. Eine Übersicht über die wichtigsten gibt die Tabelle unten.
Die Programmiersprache C# stellt aufgrund der Programmiersprachunabhängigkeit von .NET zwar nur eine von vielen Optionen dar, um .NET-Anwendungen zu erstellen. Da C# bei Microsoft aber als die ‘Systemsprache’ für .NET gilt, dürfte sie mindestens ähnlich große Bedeutung erlangen wie bis dato Visual Basic für COM und COM+.
Hinsichtlich der Syntax kann C# seine Wurzeln in Sprachen wie C++, Visual J++, VB, Delphi und Java nicht verbergen, weshalb sich Programmierer mit Vorkenntnissen in den genannten Sprachen mit dem Erlernen von C# nicht allzu schwer tun dürften. Das vorliegende Tutorial setzt aus Umfangsgründen Vorkenntnisse in C++ oder Java voraus.
Das sattsam bekannte ‘HelloWorld’-Beispiel gehört zu den Standardübungen. Daher soll hier die erste Aufgabe sein, die folgende Textdatei HelloWorld.cs zu erstellen mit der Anweisung, csc /target:exe HelloWorld.cs zu kompilieren:
using System;
namespace MyNameSpace
{
public class MyFirstExample
{
public static void Main(string[] args)
{
Console.WriteLine("Hello {0} im Jahr {1}",
"ix", 2001);
}
}
}
Alternativ kann man die Programmierumgebung von Visual Studio .NET verwenden und dort ein C#-Konsolenprojekt erzeugen. Der Übersetzer produziert die Assembly HelloWorld.exe, die sich mit Hilfe des Disassembler (ildasm HelloWorld.exe) näher untersuchen lässt.
In .NET existiert ein Java-ähnliches Paketkonzept, das der Programmierer anders als in Java nicht auf gleichnamige Verzeichnishierarchien und Dateinamen abbilden muss. Die Definition von Paketen erfolgt durch das Schlüsselwort namespace. Um existierende Pakete einzubinden, muss man dem Compiler über Switches (/reference <Dateiliste>) mitteilen, wo sich die benötigten Assemblies befinden. Die Verwendung von Typen aus anderen Namensräumen geschieht entweder vollqualifiziert über Punktnotation wie bei System.Console.WriteLine, lässt sich aber durch using-Anweisungen abkürzen (im Beispiel verkürzt sich das deshalb zu Console.WriteLine), wobei die rechte Seite der using-Anweisung einen Namespace-Pfad repräsentiert - Namespaces können also weitere Namespaces enthalten. Die Klasse Console, mit der sich Ein- und Ausgaben auf der Konsole vornehmen lassen, entstammt dem Standardpaket System. Mit public gekennzeichnet ist die Klasse MyFirstExample, die einzig die Methode Main beinhaltet. Diese ist als statisch (static) deklariert und dient als Eintrittspunkt in das lauffähige Programm.
Im Unterschied zu Java-Paketen können .NET-Assemblies mehrere öffentliche Klassen enthalten und darunter auch mehrere Klassen mit einer statischen Main-Methode. In letzterem Fall ist dem Compiler mitzuteilen, welchen Einstiegspunkt er konkret verwenden soll. Die Kommandozeilenargumente übergibt das Laufzeitsystem der Main-Methode in Form eines Feldes von Zeichenketten. Felder sind in C# eigenständige Objekte und besitzen Eigenschaften wie eine Länge (array.Length).
Als ersten Parameter verlangt die Methode Console.WriteLine() einen String mit optionalen Formatanweisungen. Ein {i} mit i>= 0 spezifiziert den i+1. optionalen Parameter, der erste Parameter ist immer bereitzustellen. In den geschweiften Klammern können zudem Formatanweisungen auftauchen. C-Programmierer werden an dieser Stelle an den ähnlich konzipierten printf()-Befehl denken.
Von Klassen und Typen
Soweit das einfache Beispiel. Die folgenden Erläuterungen beziehen sich auf ein etwas komplizierteres C#-Programm (Listing 2). Wo notwendig oder übersichtlicher, führt das Tutorial an den entsprechenden Stellen zusätzliche Mini-Beispiele ein. Zugegebenermaßen etwas naiv implementiert Listing 2 geometrische Koordinaten (auch einen Preis für überragendes Design dürfte das Beispiel nicht gewinnen).
Listing 2
Rudimentäres Beispielprogramm zur Implementierung geometrischer Koordinaten.
1 using System;
2
3 namespace Points
4 {
5 public struct Point
6 {
7 static Point () {
8 Console.WriteLine("Class \"{0}\" instantiated", typeof(Point).Name);
9 }
10
11 public enum CoordinateKind : short { X = 1, Y = 7 };
12 private double m_x;
13 private double m_y;
14
15 public Point (double x, double y) {
16 if (x == 0 && y == 0) throw new ArgumentException("illegalî);
17 m_x = x;
18 m_y = y;
19 }
20 public double this[CoordinateKind which] {
21 get { if (which == CoordinateKind.X) return m_x; else return m_y; }
22 set { if (which == CoordinateKind.X) m_x = value; else m_y = value; }
23 }
24 public double x {
25 set { m_x = value; }
26 get { return m_x; }
27 }
28 public double y {
29 set { m_y = value; }
30 get { return m_y; }
31 }
32 public static Point operator+(Point op1, Point op2) {
33 return new Point(op1.x+op2.x,op1.y+op2.y);
34 }
35 public override string ToString() {
36 return "(" + m_x.ToString() + "," + m_y.ToString() + ")";
37 }
38 public static void swapXY(ref Point o) {
38 double tmp = o.m_x;
40 o.m_x = o.m_y;
41 o.m_y = tmp;
42 }
43
44 }
45 public class PointTest
46 {
47 public static void Main(string[] args)
48 {
49 Point p = new Point();
50 p[Point.CoordinateKind.X] = 22;
51 p[Point.CoordinateKind.Y] = 33;
52 Console.WriteLine(p.ToString());
53 Point q = new Point();
54 q.x = 20;
55 q.y = 9;
56 Console.WriteLine(q.ToString());
57 Console.WriteLine("Added result: {0}", p+q);
58 Point.swapXY(ref p);
59 Console.WriteLine(p);
60 try {
61 new Point(0,0);
62 }
63 catch (ArgumentException ae) {
64 Console.WriteLine(ae.Message);
65 Console.WriteLine(ae.StackTrace);
66 }
67 finally {
68 // will be reached in any case
69 }
70 Console.ReadLine(); // press any key to stop
71 }
72 }
73 } // end of namespace
Die Struktur Point (Zeile 5) implementiert die Abstraktion eines zweidimensionalen geometrischen Punktes, während die Klasse PointTest (Zeile 45) diesen Werttyp nutzt. Als Struktur gehört Point zur Kategorie der benutzerdefinierten Werttypen, weshalb die Speicherverwaltung seiner Instanzen auf dem Stack stattfindet. Alle Werttypen sind von der Basisklasse System.ValueType abgeleitet. Einfache Datentypen wie double oder int gehören ebenfalls zu den Werttypen. Übrigens: Die Namen für einfache Datentypen in C# wie short, int, long, float, string oder object stellen lediglich Aliases für die entsprechenden Typen im Common Type System dar (im konkreten Fall für System.Int16, System.Int32, System.Int64, System.Single, System.String, System.Object).
Ein weiteres Beispiel für einen benutzerdefinierten Werttyp ist der Aufzählungstyp CoordinateKind in Zeile 11:
public enum CoordinateKind : short {
X = 1, Y = 7 };
Optional kann der Entwickler für Aufzählungstypen einen einfachen Basistyp angeben (hier: short) und wie in C++ Elementen der Aufzählung konkrete Werte zuweisen.
Klassen, Zeiger, Felder, Zeichenketten sowie typisierte Funktionszeiger (delegate) gehören zu den Referenztypen, deren Verwaltung durch den Garbage Collector auf dem Heap erfolgt. Mit Funktionszeigern und Ereignistypen beschäftigt sich der zweite Teil des Tutorials. Hier finden sie nur wegen der Vollständigkeit Erwähnung.
Der Einfachheit halber beginnt der Streifzug durch das Typsystem hier mit den Feldern, die in C# zu den Referenztypen gehören. Wie in Java sind Felder ‘First-Class’-Objekte. Ein Beispiel für die Vereinbarung eines einfachen eindimensionales Feld lautet:
string [] names = { "Herbert", "Gerhard", "Angela"};
Regelmäßige mehrdimensionale Felder werden mit folgender Notation vereinbart:
double [,] matrix = new double [2,3];
Unregelmäßige, mehrdimensionale Felder (jagged arrays) schließlich definiert man in C# wie folgt:
double [][] jagged_matrix = new double[2][];
jagged_matrix[0] = new double[3];
// der erste Vektor hat 3 Elemente
jagged_matrix[1] = new double[5]:
// der zweite Vektor hingegen 5
Mittels Methoden wie GetLength oder Eigenschaften wie Rank lassen sich wichtige Informationen von Feldern abrufen, beispielsweise ihre Länge und Dimension.
Alle Datentypen sind von der Wurzelklasse object abgeleitet und erben deren Methoden wie ToString in Zeile 35 von Listing 2, das zur Darstellung des serialisierten Objektzustands als Zeichenkette dient. Standardmäßig liefert ToString bei benutzerdefinierten Typen einfach den qualifizierten Namen des zugehörigen Datentyps zurück, wenn der Benutzer die Methode nicht überschreibt. Daneben definiert die Wurzelklasse zahlreiche weitere Methoden wie Equals (prüft, ob zwei Objekte übereinstimmen), GetHashCode (eindeutiger Code zur Identifizierung einer Instanz) oder GetType (Typinformation für Objekt abrufen).
Möchte der Programmierer verhindern, dass sich Klassen von einer Basisklasse ableiten, markiert er sie explizit als versiegelt (sealed). Strukturen (struct) sind implizit versiegelt.
Ein Beispiel für eine Vererbungshierarchie ist die folgende, bei der ein Farbpunkt (ColorPoint) als Spezialisierung eines Punkts (Point) eingeführt wird:
class Point {
//... weitere Definitionen
double m_x;
double m_y;
public Point(double x, double y) {
m_x = x;
m_y = y;
}
}
class ColorPoint : Point {
// ... weitere Definitionen
long m_color;
public ColorPoint(double x, double y, long color)
: base(x,y) {
m_color = color;
}
}
Jede Unterklasse kann von höchstens einer Basisklasse erben (einfache Implementierungsvererbung). Die Unterklasse (ColorPoint) greift mittels des Sprachkonstrukts base auf die Basisklasse zu, zum Beispiel über base() auf deren Konstruktor. Auf eigene Elemente lässt sich in ähnlicher Weise über this zugreifen. Strukturen können im Gegensatz zu Klassen nicht von Basisklassen erben, wohl aber von beliebig vielen Schnittstellen, was im Übrigen auch für Klassen gilt:
interface IDrawable {
void draw();
}
class Rectangle : IDrawable { // incomplete
...
double top_x, top_y, bottom_x, bottom_y, angle;
public virtual void draw() { ... };
public bool IsSerializable() { ... };
}
class MyRectangle : Rectangle { // incomplete
...
public override void draw() { ... };
public new bool IsSerializable() { ... }
}
Methoden wie draw in Rectangle, die der Programmierer in seinen Unterklassen überschreiben möchte (override), sind mit dem Schlüsselwort virtual zu markieren. In der Unterklasse ist der überschriebenen Methode statt virtual das Schlüsselwort override voranzustellen. Die Methodenauflösung erfolgt dann dynamisch über eine virtuelle Tabelle. Für nicht virtuelle Methoden wie IsSerializable lässt sich durch ein vorangestelltes new die Methode der Basisklasse ebenfalls überschreiben, was die Methode MyRectangle.IsSerializable demonstriert. Hier erfolgt die Methodenauflösung allerdings im Kontrast zu virtuellen Methoden statisch durch den Übersetzer.
MyRectangle y = new MyRectangle(...);
Rectangle x = y;
x.draw(); // ruft MyRectangle.draw() auf
bool ret = x.IsSerializable();
// ruft Rectangle.IsSerializable() auf
ret = y.IsSerializable();
// ruft MyRectangle.IsSerializable auf
Die Schlüsselwörter public, protected, private, internal, protected internal, die im Wesentlichen ihren Pendants in Java und C++ entsprechen, legen die Sichtbarkeit von Typdefinitionen fest, wobei internal die Sichtbarkeit auf den Geltungsbereich eines Assembly einschränkt. Das Schlüsselwort static (Listing 2, Zeile 7) erlaubt die Bereitstellung statischer - das heißt instanzübergreifender - Definitionen.
Statische Konstruktoren wie
static Point () { Console.WriteLine(
"Class \"{0}\" instantiated",
typeof(Point).Name); }
erlauben die Initialisierung von Klassen, bevor deren Instanzen existieren, was sich ideal zur Vorbelegung statischer Klassenvariablen nutzen lässt.
Neben Konstruktoren (Listing 2, Zeile 15) kann der Programmierer Destruktoren für Klassen definieren:
class DestructorExample {
resource r;
public DestructorExample() {
r = <acquire_resource>; }
~DestructorExample() { <free resource> }
}
Gerade die Ähnlichkeit zur C++-Syntax birgt hier eine gewisse Gefahr. C#-Objekte entfernt der Garbage Collector irgendwann bei Bedarf und ruft den Destruktor auf, sofern dieser vorhanden ist (so genannte Finalization). Für das Vorhandensein gibt es allerdings keine Garantie, weshalb sich folgendes Idiom ‘eingebürgert’ hat, um die Freigaben von Ressourcen einigermaßen sicherzustellen:
class DestructorExample {
...
~DestructorExample() {
<free resource> base.Finalize(); }
public void Dispose() {
<free resource> GC.SuppressFinalize(this); }
}
Der Benutzer einer Instanz dieser Klasse sollte vor Freigabe der Instanz (beispielsweise explizit durch Belegung der entsprechenden Referenz mit null) Dispose nutzen. Dort erfolgt zunächst die Freigabe der verwendeten Ressourcen. Anschließend instruiert der Befehl GC.SuppressFinalize(this) den Garbage Collector, kein Finalize für das Objekt mehr aufzurufen. Nur wenn der Nutzer dies versäumt, gibt der Garbage Collector das Objekt (eventuell) mittels Finalize beziehungsweise Destruktoraufruf frei.
Wer Werttypen als Referenzargumente übergeben möchte, kann dies durch Boxing-Mechanismen entweder implizit oder explizit bewerkstelligen. Beispielsweise sollen in der Methode swapXY einfach die x- und y-Koordinate vertauscht werden. Für Referenztypen sind solche Aufgaben trivial, schwieriger ist es bei einem Werttyp. Elegante Lösung in C#: Durch Angabe von ref teilt der Programmierer mit, dass es sich bei dem Argument um eine Referenz handelt (Listing 2 verwendet in Zeile 38 zum Beispiel die statische Methode swapXY mit dem einzigen Argument ref Point o):
public static void swapXY(ref Point o) { ... }
An der Aufrufstelle darf ebenfalls die Angabe von ref nicht fehlen (Zeile 58), was zudem die Lesbarkeit erhöht:
Point.swapXY(ref p);
Das Laufzeitsystem wandelt die Struktur implizit in eine Referenz um und erlaubt in der entsprechenden Methode (Zeile 38) die referenzierende Manipulation des Objekts. Um eine explizite Wandlung vorzunehmen, ließe sich der Wert durch einen Cast in den Basistyp object umwandeln, etwa mittels (object)value. Bei Angabe des Schlüsselworts ref ist der Aufrufer verpflichtet, das Argument vor Übergabe zu initialisieren. Es handelt sich damit um einen in/out-Parameter. Ist anstelle von ref das Schlüsselwort out anzutreffen, handelt es sich um ein reines Resultatsargument, weshalb die Initialisierung der übergebenen Variable in diesem Fall nicht notwendig ist.
Properties: Methoden statt Felder
Neu in C# ist das Konzept der Eigenschaften (properties). Dabei handelt es sich nicht etwa um Datenfelder wie m_x in Zeile 12 von Listing 2, sondern um set/get-Methodenpaare, die dem Aufrufer nur wie Zugriffe auf reguläre Datenfelder erscheinen sollen. Ein Beispiel dafür ist in Zeile 24 die Eigenschaft x:
public double x {
set { m_x = value; }
get { return m_x; }
}
In C# ist folgender Zugriff auf obige Eigenschaft möglich:
Point q = new Point();
q.x = 20;
// set-Methode wird implizit aufgerufen
Console.WriteLine(q.x);
// get-Methode wird implizit aufgerufen
Zu beachten ist in dieser Stelle, dass der Zugriff auf q.x zwar syntaktisch wie ein Zugriff auf ein Datenfeld wirkt, aber in Wirklichkeit über die Methoden set beziehungsweise get erfolgt. Im Regelfall verbirgt sich zwar hinter einer Eigenschaft auch ein Datenfeld (m_x im Beispiel), aber das ist keineswegs zwingend. Eigenschaften werden also berechnet, Datenfelder hingegen gelesen oder geschrieben.
Ein Sonderfall stellen so genannte Indexer dar, die es ermöglichen, einen indexartigen Zugriff für benutzerdefinierte Klassen zu implementieren.
In Zeile 50 steht beispielsweise folgendes Programmfragment:
p[Point.CoordinateKind.X] = 22;
p[Point.CoordinateKind.Y] = 33;
Hier greift der Anwender mit Indexnotation auf die Instanz zu. Durch Blick auf die Implementierung in Zeile 20 zeigt sich deutlich, was dieser Code bewirkt:
public double this[CoordinateKind which] {
get { if (which == CoordinateKind.X) return m_x;
else return m_y; }
set { if (which == CoordinateKind.X) m_x = value;
else m_y = value; }
}
Ein Programmierer spezifiziert Indexer durch das Schlüsselwort this. Dabei sind die Parametertypen und der Resultatstyp frei wählbar.
In Java ist ein solches Konstrukt nicht vorhanden; in C++ lässt sich derselbe Effekt durch das Überladen des []-Operators erzielen.
Damit wäre der Punkt ‘Operator Overloading’ erreicht, ein unter Sprachpuristen bisweilen umstrittenes Thema. Subjektive Ansicht des Autors: Ein mit Bedacht realisiertes Operator Overloading ermöglicht die elegante Notation von Methodenaufrufen, etwa Matrix M = A * B statt umständlich Matrix M = A.Multiply(B). Die Sprachspezifikation von C# sieht daher das Überladen von unären (+, -, !, ~, ++, -, true, false), binären (+, -, *, /, %, &, |, ^, <<, >>, ==, !=, <, >, >=, <=) und Typkonvertierungsoperatoren vor, nicht aber das Überladen des Zuweisungsoperators = und des Indexoperators []. Ein Fallbeispiel für das Überladen des +-Operators zeigt Zeile 32 des Beispielcodes:
public static Point operator+(Point op1, Point op2) {
return new Point(op1.x+op2.x,op1.y+op2.y);
}
Anweisungen wie Console.WriteLine(‘Added result: {0}’, p+q); machen von diesem Operator Gebrauch. Operator Overloading in C# ist ein gelungener Kompromiss zwischen dem ‘Operatoren-Overkill’ in C++ und dessen gänzlichem Fehlen in Java.
Kontrollstrukturen ähnlich wie in C++ und Java
Da die Sprache bezüglich ihrer Kontrollstrukturen weitgehend C++ und Java ähnelt, hier nur die wesentlichen Unterschiede.
- Selektion mit switch: Selektionsanweisungen folgen im Wesentlichen dem auch in C++ und Java anzutreffenden Muster. Die Diskriminante kann dabei auch vom Typ string sein:
string name = address.name;Die break-Anweisungen am Ende jedes case-Blocks sind obligatorisch. Ein so genannter ‘Fallthrough’ ist nur bei leeren Anweisungen gestattet.
switch (name) {
case "Maier": Console.WriteLine(
"Nice to meet you, Hans!");
break;
case "Mueller",
case "Huber": Console.WriteLine(
"You still owe me some money!");
break;
default: Console.WriteLine(
"I don't know you");
break;
} - foreach-Schleifen und Iteration: Ein zusätzliches Sprachmittel ist die foreach-Schleife, mit der sich elegant über Container iterieren lässt:
int [] array = { 1,2,3,4,5 };
foreach (int i in array) { Console.WriteLine(i); }
Um dieses Konstrukt für eigene Klassen zur Verfügung zu stellen, muss der Entwickler diverse Schnittstellen implementieren, etwa IEnumerable und IEnumerator aus dem Namensraum System.Collections. Ein Beispiel:class MyContainer : IEnumerable, IEnumerator {
// details left out
private string [] m_values;
private int pos;
public MyContainer(int len) {
m_values = new string[len];
pos = -1;
}
public IEnumerator GetEnumerator() {
return (IEnumerator)this;
}
public void Reset() { pos = -1; }
public bool MoveNext() {
if (pos < m_values.Length-1) {
++pos;
return true;
}
else
return false;
}
public object Current {
get { if (pos != -1) return m_values[pos];
else throw new InvalidOperationException();
}
}
}
Die Anwendung dieser Container-Klasse würde nun wie folgt vonstatten gehen:foreach (string s in MyContainer)
Console.WriteLine(s);
Ausnahmebehandlung mit try und catch
Zur Ausnahmebehandlung dienen in C# try-catch-Konstruktionen. Im folgenden Beispiel überprüft die Methode invert, ob das Argument 0 beträgt, um eine Division durch 0 zu verhindern. Ist das Argument 0, löst die Methode eine selbstdefinierte Ausnahme aus (MyException). Die Fehlerbehandlung durch den Aufrufer (Methode calc) erfolgt durch eine try-catch-Anweisung:
class MyClass {
public class MyException : Exception {
public string m_msg;
public MyException (string msg) { m_msg = msg; }
}
public double invert(double i ) {
if (i == 0)
throw new MyException("0!!!");
}
public double calc(double i) {
try { double j = invert(i); }
catch (MyException me) {
Console.WriteLine(me.m_msg);}
catch { /* all other exceptions */ }
finally { /* cleanup */}
}
}
Eigene Fehlerklassen leiten sich von der Basisklasse Exception ab. Zum Auslösen eines Fehlers dient wie gewohnt die throw-Anweisung (Listing 2, Zeile 16). Spezielle Fehler behandelt der Aufrufer mit catch(Exception ec)-Blöcken, beliebige Fehler hingegen mit unparametrisierten catch-Blöcken. Der optionale finally-Block wird am Schluss durchlaufen (Listing 2, Zeilen 63 bis 69).
Anzumerken bleibt, dass sich try-catch-Blöcke beliebig ineinander verschachteln lassen und dass sich bei einem re-throw die ursprüngliche Exception in die eigene Ausnahme integrieren lässt, sodass der Aufrufer den ‘Weg’ der Exception zurückverfolgen kann.
Fazit
Dieser erste Teil des Tutorials hat die Grundelemente von .NET und C# eingeführt. C# ist Java sehr ähnlich, weist aber einige Verbesserungen auf. Beispiele hierfür sind das Überladen von Operatoren, die Einführung von Eigenschaften, Aufzählungstypen oder ein einheitliches und klares Typsystem. Mittels .NET lassen sich C#-Assemblies von beliebigen anderen .NET-Sprachen nutzen und umgekehrt. Im Kontrast zu Java legt .NET den Fokus zurzeit mehr auf Sprachunabhängigkeit als auf Plattformneutralität. Das könnte sich jedoch durch die Standardisierung von C# und elementaren Teilen des .NET Framework bei der ECMA ändern.
Wie Java tritt auch C# das Erbe von C++ an, ohne dessen inhärente Komplexität zu erben. Als Ballast wirft C# vor allem Multiparadigmenunterstützung und Aufwärtskompatibilität zu C über Bord. Das Ergebnis ist ein schlanker, rein objektorientierter Sprachkern mit einer Laufzeitumgebung, die im Gegensatz zu C++ auf Garbage Collection basiert.
Im zweiten Teil des Kurses kommen fortgeschrittenere Konzepte zur Sprache: Events und Delegates, Reflexionsmechanismen und Typinformation, Assemblies, Multithreading sowie das Thema Unmanaged Data und Code.
Michael Stal
arbeitet in der Zentralabteilung Technik der Siemens AG. Er leitet das Forschungsprojekt Distributed Object Computing und ist Chefredakteur der Zeitschrift Java Spektrum sowie Ko-Autor des Buches ‘Pattern-Oriented Software Architecture - A System of Patterns’.
Literatur
[1] Archer; Inside C#; Microsoft Press, 2001
[2] Harvey, Robinson, Templeman, Watson; C# Programming with the Public Beta; Wrox Press, 2001
[3] Robinson, Cornes, Glynn, Harvey, McQueen, Moemeka, Nagel, Skinner, Watson; Professional C#; Wrox Press, 2001
[4] Thai, Lam; .NET Framework Essentials; O’Reilly, 2001
[5] Conard, Dengler, Francis, Glynn, Harvey, Hollis, Ramachandran, Schenken, Short, Ullman; Introducing .NET; Wrox Press, 2001
[6] Holger Schwichtenberg; Runderneuerung; Microsofts neue Programmierumgebung .NET Beta 2; iX 9/2001, S. 102
iX-TRACT
- Mit der Common Language Runtime (CLR) des .NET Framework stellt Microsoft eine programmiersprachenunabhänge Entwicklungsumgebung zur Verfügung.
- Teil von .NET ist die neue Programmiersprache C#, die in vielen Aspekten Ähnlichkeit mit Java und C++ hat.
- Das sprachunabhängige Objektmodell von C# löst Microsofts bisheriges Komponentenmodell COM/COM+ ab; über ein Mapping stellt .NET Interoperabilität zwischen beiden her.
- C#-Programme lassen sich sowohl in einem beliebigen Editor erstellen und mit einem über die Kommandozeile aufgerufenen Compiler übersetzen als auch als Projekt in der Entwicklungsumgebung Visual Studio .NET bearbeiten.
| Kommandozeilenbasierte Werkzeuge | ||
| Werkzeug | Beschreibung | Beispiel |
| CSC | C#-Compiler zum Übersetzen von C#-Programmen | CSC /target:winexe /out:MyOutFileName MyWinBsp.CS |
| ILDASM | Intermediate Language Disassembler, um .NET-Programme (DLLs, EXEs) im IL-Code zu betrachten | ILDASM MyWinBsp.EXE |
| ILASM | Assembler für IL | ILASM MyAssemblyFile.IL |
| DUMPBIN | Betrachter für PE-Dateiformat | DUMBIN MyAssembly.EXE |
| AL | Der Assembly-Linker dient dem Erstellen und Modifizieren von Assemblies. | AL /linkresource: MyResources MyAssembly.EXE |
| GACUTIL | Bereitstellen von Shared Assemblies im globalen Cache | GACUTIL -i MyLibrary.DLL |
| SN | Shared Name Utility: erzeugt Schlüsselpaare zum Signieren von Shared Assemblies | SN -k MyKey.KEY |
| .NET in Kürze | |
| CLR | Common Language Runtime |
| CLS | Common Language Specification |
| COM | Component Object Model |
| CTS | Common Type System |
| MSIL | Microsoft Intermediate Language |
| PE | Program Executable |
| VES | Virtual Execution System |
Übungsaufgabe:
Implementieren Sie eine Klasse für Polygone auf Basis der Klasse Point:
- mit Operationen zum Iterieren durch die Eckpunkte des Polygons,
- Überschreiben der ToString- und Equals-Funktionen,
- mit einer Methode zum geordneten Einfügen zusätzlicher Punkte.
Eine mögliche Lösung finden Sie hier. (ka)