
Ein Überblick über die .NET-Infrastruktur und elementare Konzepte von C# standen im Vordergrund des ersten Tutorial-Teils. Der zweite widmet sich den fortgeschritteneren Konzepten wie Meldungen über Events und Delegates, Multithreading, Zugriff auf und Bereitstellung von Metainformation, Umgang mit Assemblies, Cross-Language-Interoperabilität sowie dem Thema Unmanaged Data und Unmanaged Code.
Wer komplexe Anwendungen erstellt, stößt mindestens einmal auf folgendes Problem: Eine Komponente X möchte von einer Komponente Y Rückmeldung erhalten, wenn dort eine Änderung geschieht. Das einfachste und intuitivste Beispiel hierfür sind Windows-Programme, bei der ein benutzerdefinierter Event-Handler auf Ereignisse wie das Drücken von Buttons reagiert. Für gewöhnlich lässt sich zu diesem Zweck das Observer-Pattern [[#literatur 1]] instanziieren, was aber mitunter zu einer Inflation von Klassen führt. Deshalb enthält .NET genau zu diesem Zweck eigene Programmkonstrukte: Delegates und Events. Ein Beispiel demonstriert das.
Listing 1a implementiert dazu einen Aktienticker (StockTicker, Listing 1a, Zeile 47) und Listing 1b steuert dessen Clientanwendung bei. Der Ticker verwaltet Aktieninformationen des Typs Share (1a, Zeile 9) in einer Hash-Tabelle (1a, Zeile 48) und verwendet als Primärschlüssel die Kurzbezeichnung der Aktie als Zeichenkette, also etwa MSFT für Microsoft. Der Client (Testdriver, 1b, Zeile 6) möchte Rückmeldung erhalten, sobald sich Aktienwerte ändern. Rückmeldung bedeutet: Der Aktienticker soll in diesem Fall automatisch eine benutzerdefinierte Methode des Clients aufrufen, konkret die Methode OnChange (1b, Zeile 21). In dieser Methode soll der Aktienticker als Argumente erstens sich selbst als Ereignisquelle und zweitens eine spezielle Datenstruktur (ChangeEventArgs) zur genauen Beschreibung des Ereignisses übergeben.
Um typisierte Funktionszeiger von einem Ereignis-Konsumenten an einen -Produzenten zu übermitteln, stellt C# das Sprachmittel der Delegates zur Verfügung. Im Beispiel:
public delegate void ChangeEvent( object src, ChangeEventArgs e);
Hier handelt es sich um Funktionszeiger auf beliebige Methoden, die kein Resultat zurückliefern und einen object- sowie einen ChangeEventArgs-Parameter besitzen. Einen solchen Zeiger erzeugt Listing 1b in Zeile 17:
new ChangeEvent(OnChange);
Verwenden lässt sich dieses Delegate zum Beispiel wie folgt:
ChangeEvent ce = new ChangeEvent(OnChange); ce(obj, arg); // Impliziter Aufruf von OnChange()
Der +-Operator kann Delegates ohne Resultat zu Multicast-Delegates kombinieren.
ChangeEvent ce1 = new ChangeEvent(OnChange1); ChangeEvent ce2 = new ChangeEvent(OnChange2); (ce1+ce2)(obj, arg); // Aufruf von OnChange1 UND OnChange2
Tatsächlich handelt es sich bei Delegates intern um Klassen, sodass sie sich auf die gleiche Weise wie Klassen definieren und nutzen lassen. Für die Definition der Schnittstelle des Ereignisproduzenten stellt C# in Form von Ereignissen (event) ein weiteres Sprachmerkmal zur Verfügung. Das Beispiel deklariert in der Klasse StockTicker (1a, Zeile 49):
public event ChangeEvent OnChangeEvent;
Den OnChange-Event nutzt der Client dazu, seine Delegates anzumelden, etwa in Zeile 17 von Listing 1b:
m_StockTicker.OnChangeEvent += new ChangeEvent( OnChange);
Ein Abmelden wäre dementsprechend durch den -=-Operator durchzuführen. Tritt ein entsprechendes Ereignis in StockTicker ein, erfolgt also die Änderung eines Aktienwerts in der Methode ChangeValue (1a, Zeile 67), ruft der Aktienticker automatisch alle Ereigniskonsumenten mittels
OnChangeEvent(this, args)
auf, sofern sich überhaupt welche angemeldet haben (OnChangeEvent != null). Dieser Aufruf bewirkt, dass alle für dieses Ereignis registrierten Clients über ihre Rückrufmethoden eine entsprechende Nachricht erhalten. Aus Umfangsgründen kann das Tutorial auf diesen Punkt nicht näher eingehen. Zumindest sei aber angemerkt, dass sich Delegates ebenfalls für asynchrone Aufrufe nutzen lassen.
Zur effizienten Nutzung von Multiprozessorsystemen, aber auch von Einprozessormaschinen ist die Verwendung von Threads essenziell. Leider sind Thread-Pakete betriebssystemabhängig. Daher ist es begrüßenswert, wenn Mechanismen und Bibliotheken existieren, die diese Unterschiede vor dem Entwickler verbergen. Gerade bei Java hat sich die Bereitstellung eigener Sprachmerkmale und Pakete bewährt. Kein Wunder, dass auch .NET Unterstützung für Multithreading integriert.
Ein weiteres Beispiel demonstriert die Nutzung von Threads unter C# (Listing 2). Die Unterstützung von Threads verbirgt sich im Paket System.Threading. Im vorliegenden Fall kreiert das Hauptprogramm zwei Worker-Threads mit den Namen Worker1 und Worker2 und übergibt ihnen eine Referenz auf eine globale Datenstruktur des Typs GlobalData (Zeile 6), die lediglich eine ganzzahlige Zahl enthält. Die Erzeugung neuer Threads ist relativ einfach (Zeile 42):
Thread t1 = new Thread(new ThreadStart(w1.loop));
Der Anwender instanziiert die Klasse Thread und übergibt dem Konstruktor ein Delegate des Typs ThreadStart, wobei das Delegate eine ergebnis- und parameterlose Methode repräsentiert. Der eigentliche Start eines Thread erfolgt durch:
t1.Start();
Im Beispiel werden beide Threads durch Instanzen des Typs Worker (Zeile 14) implementiert, wobei die Thread-Eintrittsmethode, also die Methode, die der Thread durchläuft, sich in der Methode loop (Zeile 22) befindet.
class Worker {
...
public void loop() {
// do work
}
}
Der erste Thread überschreibt die globale Variable sequenziell mit 10, 11, 12 und so weiter und pausiert jeweils mittels Anweisung Thread.Sleep(100) für 100 ms (Zeile 31). Parallel dazu schreibt der zweite Thread nacheinander 20, 21, ... und pausiert ebenfalls für 100 ms nach jedem Schleifendurchlauf. Das Hauptprogramm wartet mit Thread.Join (Zeile 49) auf das Ende beider Worker-Threads und terminiert anschließend selbst. Durch Variation der Thread-Prioritäten (Zeile 44: Thread.Priority) und Wartezeiten lässt sich ein wenig der Ablauf steuern und das Verhalten des Scheduler studieren.
In der nebenläufigen Verarbeitung ist sicherzustellen, dass keine Race-Conditions entstehen und der Zugriff auf globale Daten synchronisiert und sequenziell erfolgt. Für diesen Zweck implementiert .NET eigene Synchronisationsmechanismen, deren Basis die Klasse System.Threading.Monitor darstellt. Mittels des Konstrukts lock(object){} lassen sich beispielsweise Mutexe definieren, die jeweils nur ein Thread zu einem bestimmten Zeitpunkt durchlaufen kann. Beispiel ist Zeile 10:
lock(this) { return m_Value; }
Sie nutzt die globale Datenstruktur selbst als Sperre. Wie in Java birgt die sorglose Nutzung von Threads Fallstricke, auf die die Literatur ausführlich eingeht [[#literatur 2]].
Mit Hilfe von Metainformationen lassen sich nicht nur Informationen dynamisch über Programme abfragen, sondern auch nutzen. Ein großer Teil der Mächtigkeit von Java resultiert aus dem dort verfügbaren Reflexions-paket. Dem wollte Microsoft nicht nachstehen und hat .NET einen ähnlichen Mechanismus beschert. Wieder ein praktisches Beispiel zur Erläuterung. Listing 3a zeigt den Quellcode für eine Bibliothekskomponente (Assembly) Component.DLL. Das dazugehörige lauffähige Clientprogramm (Listing 3b) lädt das Bibliotheks-Assembly zur Laufzeit in seinen Adressraum (also in die zugehörige Application Domain):
Assembly a = Assembly.LoadFrom("Component.dll");
und greift über a.GetTypes()[0] auf den dort definierten Klassentyp MyComponent zu. Mit Hilfe einer Aktivierungskomponente (Activator, Listing 3b, Zeile 11) lässt sich diese Klasse instanziieren:
object o = Activator.CreateInstance(t);
Anschließend liest das Listing die Typinformation zur Methode algorithm ein und ruft die Methode generisch mit dem Argument 21.0 auf.
MethodInfo mi = t.GetMethod("algorithm");
double d = (double) mi.Invoke(o, new object[]{21.0});
Das Ergebnis lautet 42 - wie sollte es anders sein. Mit Hilfe derartiger Reflexionsmechanismen ist nicht nur die Bereitstellung ausgefeilter Werkzeuge wie Typbrowser möglich, sondern zum Beispiel auch das dynamische Laden und Austauschen von Funktionen. Der Phantasie sind hier fast keine Grenzen gesetzt. Ein Warnhinweis soll an dieser Stelle aber nicht fehlen. Reflexionsmechanismen sollte man mit Bedacht nutzen und nur dann, wenn sie wirklich notwendig sind. Reflexive Programmierung ist nicht nur fehlerträchtig, sondern macht das daraus resultierende Programm schlecht les- und wartbar.
Neben dieser festverdrahteten Metainformation erlaubt .NET sogar die zusätzliche Bereitstellung benutzerdefinierter Metainformation durch so genannte Attribute. Ein Beispiel:
[AuthorIs("Michael")]
class MyClass {
...
}
Das fiktive Attribut AuthorIs zur Angabe des jeweiligen Codeautors lässt sich ebenfalls dynamisch abfragen. Die benutzerdefinierte Integration neuer Attribute erfolgt über Attributklassen:
[AttributeUsage(AttributeTargets.All)]
public class AuthorIsAttribute : Attribute {
private string m_Name;
public AuthorIsAttribute(string name) {
m_Name = name; }
}
Die Attributklasse ist selbst rekursiv mit einem Attribut deklariert. Dies spezifiziert, dass AuthorIs für beliebige Elemente anwendbar sein soll (AttributeTargets.All). Möglich wären auch andere Einstellungen wie AttributeTargets.Class, um die Anwendung des Attributs auf Klassen zu beschränken.
Im Allgemeinen laufen .NET-Programme unter vollständiger Kontrolle des Garbage Collector. Dieser entfernt nicht mehr benötigte Objekte vom Heap und verschiebt die anderen gegebenenfalls, um die Speicherausnutzung kompakter zu gestalten. Der Zugriff auf Objekte erfolgt über Variablen, die sich auf Stack- oder Heap-Objekte beziehen. Direkt über Adressen zuzugreifen, ist nicht gestattet. Wer mit diesen Einschränkungen nicht leben will, kann unsicheren Code verwenden:
class TestUnsafeData {
class AClass {
public int i;
public override string ToString()
{ return i.ToString(); }
}
static unsafe void Main(string[] args) {
int x = 7;
int *y = &x;
*y = 12;
AClass a = new AClass();
fixed (int *z = &a.i) { *z = 42; }
Console.WriteLine(x);
Console.WriteLine(a);
// Ergebnis: 12 42
Console.ReadLine();
}
}
Die als unsafe spezifizierte Methode erlaubt in ihrem Rumpf Zeiger und Adressarithmetik im Stil von C++. Ein Problem ergibt sich bei Zeigern auf Heap-Objekte, die der Garbage Collector zur Laufzeit verschieben kann. Um solche Zeiger trotzdem einsetzen zu können, existiert das Sprachkonstrukt fixed. Im Beispiel zeigt die Zeigervariable z, die nur innerhalb des fixed-Blocks gültig ist, auf ein Datenfeld einer Klasseninstanz. Innerhalb des fixed-Blocks ist garantiert, dass der Garbage Collector die Instanz a im Speicher fixiert lässt und nicht verschiebt.
Zugriffe auf nativen Code, der nicht der Kontrolle der CLR (Common Language Runtime) unterliegt, also auf Methoden in konventionellen DLLs, unterstützt C# mittels des so genannten P/Invoke-Mechanismus (Platform Invoke):
class PInvokeTest {
[DllImport("user32.dll")]
static extern int MessageBoxA(int hWnd, string m, string c, int t);
static void Main(string[] args) {
MessageBoxA(0, "Hello DLL", "My Window", 0);
}
}
Im obigen Code greift die Main-Methode auf die als extern definierte DLL-Methode MessageBoxA zu. Für den wichtigen Sonderfall COM/COM+ bietet .NET entsprechende Werkzeuge, mit denen sich COM+-Komponenten über so genannte RCWs (Runtime Callable Wrapper) zu .NET-Komponenten wrappen oder sich umgekehrt .NET-Komponenten über CCWs (COM Callable Wrappers) als COM-Komponenten nutzen lassen. Wichtig sind diese Wrapper bei Funktionen, die es in .NET nicht gibt, zum Beispiel beim Nutzen von Transaktionsmonitoren wie MTS/COM+. Ausführliche Details zu dieser Thematik enthält der Band Professional C# [[#literatur 3]].
Im ersten Teil des Tutorials war bereits von Assemblies die Rede. Dort wurden sowohl die Struktur als auch die Klassifizierung in Shared und Private Assemblies erläutert. Letztere sind im Verzeichnis der Applikation oder in einem der Subverzeichnisse anzutreffen. Die Laufzeitumgebung versucht dementsprechend, Assemblies erst im lokalen Verzeichnis aufzuspüren und sucht danach in Unterverzeichnissen weiter - das so genannte Probing. Da jede Anwendung ihre privaten Assemblies mitbringt, ist das Problem von Namenskollisionen irrelevant.
Global verfügbare Assemblies befinden sich hingegen in der Regel in einem zentralem Systemverzeichnis, dem Global Assembly Cache. Dessen Inhalt lässt sich mit gacutil abfragen (gacutil - l). Um eigene Shared Assemblies zu erzeugen, ist zunächst das Generieren eines Schlüsselpaars mit dem Tool sn erforderlich: sn -k mykey.snk. Dem Übersetzer werden Ort des Schlüsselpaares über spezielle Assembly-Attribute mitgeteilt. In Visual Studio .NET ist zu diesem Zweck zu jedem Projekt eine eigene Datei AssemblyInfo.cs vorhanden. Bei Nutzung des Kommandozeilencompilers ist der Namensraum System.Reflection per Include-Anweisung einzubinden und am Anfang des Codes folgendes zu spezifizieren:
[assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyKeyFile( @"<rel. oder abs. pfad>\mykey.snk")]
Das erste Attribut spezifiziert die Version mit 1.0 (die dritte Ziffer definiert den Build und die vierte die Release). Das Attribut AssemblyKeyFile gibt die Position des Assembly im Dateisystem an.
(Bemerkung am Rande: Das Zeichen @ am Anfang des String ermöglicht die Verwendung von Sonderzeichen wie \ in Zeichenketten. Statt \\pfad\\file lässt sich kürzer @\pfad\file schreiben.)
Der C#-Übersetzer extrahiert beim Build-Prozess den privaten Schlüssel, ermittelt einen Hash-Wert über das Assembly und schreibt sowohl den Hash als auch den öffentlichen Schlüssel ins Assembly. Dadurch lassen sich zum einen Manipulationen verhindern und zum anderen erhält das Assembly dadurch einen eindeutigen Namen (strong name). In Client-Assemblies, die dieses Shared Assembly nutzen, integriert der Compiler übrigens aus Platzgründen einen über den öffentlichen Schlüssel errechneten Hash-Wert, nicht den Schlüssel selbst. Mit dem Werkzeug ildasm lassen sich die die Inhalte von Assemblies untersuchen (siehe Abbildung 1).
Wenn das Assembly signiert und fertig übersetzt vorliegt, muss der Programmierer es nur noch mit gacutil -i <assembly> in den Global Assembly Cache installieren. Auch das Entfernen installierter Shared Assemblies ist möglich.
Am Ende dieses zweiten Tutorial-Teils soll der Blick auf eine Eigenschaft gerichtet werden, mit der Microsoft besonders für sein neues Framework wirbt - die Programmierprachenunabhängigkeit. Dank Übersetzung in IL (Intermediate Language) und Kontrolle durch das VES (Virtual Execution System) ist ein Sprach-Mischmasch kein Problem mehr. Sogar COBOL- und Eiffel-Compiler existieren mittlerweile für .NET. Endlose Religionskriege in Teeküchen und Diskussionsforen über die beste Programmiersprache neigen sich somit ihrem Ende zu - möchte man meinen. Don Box würde an dieser Stelle .NET is Love verlautbaren. Aber zurück zu den Fakten.
Wie üblich soll die Veranschaulichung pragmatisch erfolgen. Eine Managed-C++-Bibliothek definiert eine einfache Klasse, deren einzige Methode eine C#-Unterklasse überschreibt. Genutzt wird das Ganze schließlich aus VB (Listing 4). Die Ausgabe des VB-Hauptprogramms lautet dementsprechend wenig überraschend Hallo__CSHARP__.
Kurzes Resümee: C# erweist sich hier als ideale Programmiersprache, VB.NET unterscheidet sich nur noch syntaktisch von C#. C++ with Managed Extensions bietet zwar die größte Flexibilität, beinhaltet aber auch die meisten Fallstricke.
Ob Projektleiter allerdings damit glücklich würden, wenn jedes Teammitglied seine Lieblingssprache verwendet, sei an dieser Stelle dahingestellt.
Der zweite Teil des Kurses hat detaillierte Blicke auf fortgeschrittene Eigenschaften von C# und .NET geworfen. Typisierte Ereignisse und Funktionszeiger erlauben zum Beispiel die elegante Umsetzung des Observer-Patterns für Ereignismeldungen. Eine effiziente Nutzung von Parallelität bietet sich durch Threads und Synchronisationsmechanismen an. Durch die Verfügbarkeit von Typinformation sind die Entwicklung ausgefeilter Werkzeuge sowie die Nutzung dynamischer Programmiertechniken wie - auf Neudeutsch - On-Demand-Activation oder Late-Binding möglich. Mittels spezieller Sprachkonstrukte wie unsafe und fixed lassen sich Zeiger- und Zeigerarithmetik à la C++ nutzen. Interoperabilität zwischen .NET-Sprachen regelt die Common Language Runtime, Interoperabilität mit nativem Nicht-IL-Code ist durch Platform Invoke zu bewerkstelligen, wobei für COM-Interoperabilität zusätzliche Werkzeuge zur Erzeugung von Wrapper-Klassen existieren. Assemblies lassen sich durch Schlüssel signieren und global zur Verfügung stellen.
Aus Platzgründen kann das Tutorial weitere Details wie Sicherheitsfunktionen, Konfigurationsaspekte oder Nutzung von Compilerdirektiven nicht ansprechen. Dazu findet sich in der Literatur eine detaillierte Abhandlung [[#literatur 3]].
Der dritte und letzte Teil des Kurses legt den Fokus auf die .NET-Anwendungsprogrammierung mit den entsprechenden Framework-Bibliotheken.
Michael Stal
ist Senior Principal Engineer bei der Corporate Technology der Siemens AG und leitet das Kompetenzfeld Middleware & Application Integration. Er ist Chefredakteur der Zeitschrift Java Spektrum sowie Koautor der Buchreihe Pattern-Oriented Software Architecture.
Literatur
[1] E. Gamma, R. Helm, R. Johnson, J. Vlissides; Design Patterns - Elements of Reusable Object-Oriented Software; Addison-Wesley, Reading 1995
[2] D. Schmidt, M. Stal, H. Rohnert, F. Buschmann; Pattern-Oriented Software Architecture; Volume 2; Patterns for Concurrent and Networked Objects; Wiley & Sons, Chichester 2000
[3] Robinson, Cornes, Glynn, Harvey, McQueen, Moemeka, Nagel, Skinner, Watson; Professional C#; Wrox Press, Birmingham 2001
[4] M. Stal; Ton angebend; C#- und .NET-Tutorial, Teil 1; iX 12/2001, S. 122 ff.
| iX-TRACT |
|
Dieser Text ist der Zeitschriften-Ausgabe 01/2002 von iX entnommen.
iOS, Android, Windows Phone 7 und HTML5 - das neue Sonderheft von heise Developer führt Einsteiger und Profis in die Programmierung mobiler Geräte ein.