Delegates mit .NET

26.01.2004 von Thomas Wölfer
Callback-Mechanismen gehören schon seit langem zum Rüstzeug eines Programmierers. In .NET führt Microsoft dieses Konzept in die nächste Stufe und eröffnet dem Entwickler ganz neue Möglichkeiten.

Callback-Mechanismen sind ein extrem wichtiger Bestandteil im Werkzeugkasten des Programmierers, um eine gewisse Flexibilität bei der Wiederverwendung von Funktionen zu erreichen. Schon die qsort-Funktion aus der Standard-C-Bibliothek verwendet einen solchen Mechanismus: Bei qsort wird nur der eigentliche Sortieralgorithmus implementiert, die dazu benötigte Vergleichsfunktion stellt der Programmierer zur Verfügung, indem er einen Zeiger auf eine passende Vergleichsfunktion übergibt. Diese nimmt zwei Argumente, nämlich die zu vergleichenden Operanden, und liefert als Ergebnis, ob der erste Operand im Vergleich zum zweiten größer, kleiner oder gleich groß ist.

Delegates erfüllen in .NET die gleiche Aufgabe wie Zeiger auf Funktionen in C oder C++. Sie bieten jedoch eine deutlich größere Menge an Funktionalität. Warum Delegates so praktisch sind, zeigt ein einfaches Beispiel.

Angenommen Sie haben Ihr Programm mit einem Logging-Mechanismus ausgestattet, um sich die Fehlersuche zu erleichtern. Alle aufgerufenen Funktionen führen dazu Informationen über ihren Aufruf und ihre Beendigung mit, indem sie eine bestimmte Funktion aufrufen. Das könnte zum Beispiel folgendermaßen aussehen:

public class AClass {
public void DoIt() {
Console.WriteLine("DoIt() wurde aufgerufen");
// hier leistet DoIt seine Arbeit
Console.WriteLine( "DoIt() wird verlassen");
}
}

Beim Eintritt in die Funktion wird eine entsprechende Bemerkung auf die Konsole ausgegeben, beim Verlassen wird ebenfalls eine passende Meldung angezeigt. Das ist praktisch, hat aber in der Art und Weise der Implementierung auch seine Nachteile.

Mehr Flexibilität

Der wesentliche Nachteil ist dabei der, dass das Logging eine fest verdrahtete Methode verwendet: Alle Nachrichten werden grundsätzlich auf die Konsole geschrieben. Das ist jedoch nicht in allen Umgebungen sinnvoll. Verwendet man die Klasse "Aclass" beispielweise in einem Programm mit GUI, so will man die Log-Informationen vermutlich nicht auf der Konsole, sondern stattdessen in einem Fenster angezeigt bekommen. Verwendet man die Klasse hingegen in einem Service, dann wäre eine Log-Datei oder auch das EventLog von Windows die richtige Stelle, um die Meldungen aufzunehmen. Die Meldungstexte wären zwar immer dieselben - aber die Methode, um diese Texte auszugeben, unterscheidet sich dramatisch.

Für die Lösung eines solchen Problems sind Delegates wie geschaffen. Bei einem Delegate beschreibt man nur, wie eine Funktion aussieht, gibt aber nicht an, welche Funktion konkret aufgerufen werden soll. Das "Aussehen" einer Funktion wird dabei durch die Anzahl ihrer Parameter und deren Typen sowie durch den Typ des Rückgabewerts bestimmt. Man spricht auch von der "Signatur" der Funktion.

Delegates vs. Funktionen

Die Deklaration eines Delegates erfolgt genauso wie die Deklaration einer Funktion. Beschrieben wird dabei aber eben nur die Signatur der Funktion, die mit dem Delegate assoziiert wird.

Für das Log-Beispiel braucht es beispielsweise eine Funktion, die keinen Rückgabewert hat (void) und die als Parameter einen einzelnen String erhält, der geloggt werden soll. Der Delegate für eine Funktion mit einer solchen Signatur hat dann folgendes Aussehen:

public delegate void Logger( string msg);

Dieser Delegate kann nun im Beispiel der Klasse "Aclass" verwendet werden. Dazu wird die Signatur von DoIt verändert - die Funktion bekommt nun einen Parameter vom Typ Logger:

public class AClass {
public delegate void Logger( String msg);

public void DoIt( Logger log) {
if( log != null) {
log("DoIt() Wurde aufgerufen");
}
// hier leistet DoIt seine Arbeit
if( log != null) {
log("DoIt() wird verlassen");
}
}
}

Der an DoIt übergebene Delegate kann einfach aufgerufen werden, ganz so als würde es sich um eine normale Funktion handeln. Da ein Delegate aber auch null sein kann - in diesem Fall zeigt er nicht auf eine Funktion -, muss das vor dem Aufruf von log zunächst überprüft werden. Das hat auch einen ganz praktischen Aspekt: Will man im Programm keine Log-Informationen generieren, ruft man DoIt einfach mit null als Parameter auf.

Die interessantere Frage aber ist: Was muss man tun, um eine Instanz eines Delegates zur erhalten, mit der man DoIt dann aufrufen kann? Ganz einfach: Man muss mindestens eine Funktion schreiben, deren Signatur der des Delegates entspricht. Dann braucht es eine Delegate-Instanz, die auf diese Funktion zeigt und die dann beim Aufruf von DoIt übergeben werden kann.

Statisch oder nicht ist gleichgültig

Dabei gibt es zwei Möglichkeiten: Bei der Funktion mit der Delegate-Signatur kann es sich um eine statische Methode einer Klasse handeln oder man verwendet eine ganz normale Member-Funktion. Im Fall einer statischen Funktion sieht das Ganze dann so aus:

public class LogHelper {
public static void LogToConsole( string s) {
Console.WriteLine( s);
}

public LogHelper() {
}
}

class Worker {
public static void Main() {
AClass a = new AClass();
AClass.Logger log = new AClass.Logger( LogHelper.LogToConsole);
a.DoIt( log);

}
}

Zunächst gibt es also eine statische Methode LogToConsole in der Klasse LogHelper. Die Methode kann sich natürlich auch in einer beliebigen anderen Klasse befinden. Die Methode hat die gleiche Signatur wie der Delegate aus AClass.

Ferner gibt es eine Klasse namens Worker. Main erzeugt darin zunächst eine Instanz von AClass und dann eine neue Instanz des Logger-Delegates. Dabei wird die Funktion aus LogHelper übergeben - der Delegate zeigt somit auf diese Methode. Schließlich kann DoIt aufgerufen werden.

Unterschiede zu Funktionszeigern

Wer einen C/C++-Hintergrund hat, dem wird das sehr bekannt vorkommen: Es funktioniert wie die Verwendung von Zeigern auf Funktionen - und so ist es auch. Allerdings bieten Delegates mehr als nur Zeiger auf Funktionen.

Die Methode aus LogHelper schreibt momentan einfach auf die Konsole. Will man in eine Datei schreiben, dann muss die Information über die zu verwendende Datei natürlich auch noch irgendwo herkommen. In C würde man noch zusätzliche Informationen in einem anderen Zeiger übergeben - zum Beispiel ein Filehandle oder den Namen der Datei.

In C# kann man aber bei Delegates sowohl statische Methoden als auch ganz normale Methoden verwenden, und das bedeutet, das man auf die Informationen des umgebenden Objekts zugreifen kann. Das erweiterte Muster, in dem dann in eine Datei geschrieben wird, hat beispielsweise folgendes Aussehen:

public class LogHelper {
// methode 1
static void LogToConsole( string s) {
Console.WriteLine( s);
}

// methode 2
FileStream stream;
StreamWriter writer;

public LogHelper( string filename) {
stream = new FileStream( filename, FileMode.Create);
writer = new StreamWriter( stream);
}

public void LogToFile( string s) {
writer.WriteLine( s);
}

public void End() {
writer.Close();
stream.Close();
}
}

class Worker {
public static void Main() {
AClass a = new AClass();
LogHelper lh = new LogHelper( "logfile.log");
AClass.Logger log = new AClass.Logger( lh.LogToFile);
a.DoIt( log);
lh.End();
}
}

Hier kapselt die LogHelper-Klasse also auch die Datei, in die geschrieben werden soll. Der Delegate zeigt dann auf die normale Member-Funktion von LogHelper und nicht länger auf die statische Methode dieser Klasse. Wird DoIt dann aufgerufen, so landet die Log-Information in der gewünschten Datei.

Hier sieht man auch einen sehr praktischen Effekt, der bei der Verwendung von Delegates auftritt: Die Implementierung von DoIt musste nicht verändert werden, obwohl die darin verwendete Log-Funktionalität sich sehr stark von der ursprünglichen unterscheidet.

Signatur ohne Typ der Klasse

Lesern mit einem C++-Hintergrund wird noch etwas anderes auffallen: Es ist gleichgültig, in welcher Klasse sich die Methode befindet - der Typ der Klasse, in der sich die Log-Funktion befindet, geht in die Signatur des Delegate nicht ein. In C++ wäre das anders: Hier würde der Typ der Klasse eingehen und der Zeiger auf die Funktion enthielte in der Signatur auch den Typ der Klasse. Damit wäre es dann zu einem späteren Zeitpunkt nicht mehr ohne weiteres möglich, die Implementierung der Log-Funktion in einer anderen Klasse unterzubringen, denn die Signatur mit dem Typ der Klasse wäre ja schon an allen Stellen verwendet worden, an denen mitprotokolliert wird. Hier ist C# also deutlich praktischer und flexibler als C++.

Delegates können aber noch weit mehr. Im Besonderen ist es so, dass ein Delegate nicht nur auf eine Funktion zeigen kann, sondern gleich auf mehrere. Diese Eigenschaft nennt man "multicast". Ein multicast-Delegate hat eine Liste von Funktionen, die allesamt aufgerufen werden, wenn der Delegate aufgerufen wird.

Um mehrere Funktionen in einem Delegate zu sammeln, nutzt man die Funktion Delegate.Combine. Als Beispiel könnte man die beiden bisherigen Log-Funktionen in einem Delegate zusammenfassen. Beim Aufruf von DoIt würde dann sowohl auf den Bildschirm als auch in die Datei protokolliert.

LogHelper lh = new LogHelper( "logfile.log");
AClass.Logger log = (AClass.Logger)Delegate.Combine(
new Delegate[] {
new AClass.Logger( LogHelper.LogToConsole),
new AClass.Logger( lh.LogToFile) });

Um danach einen Delegate aus dem multicast-Delegate wieder zu entfernen, ruft man die Methode Delegate.Remove auf. Allerdings ist schon das Zusammenfassen mit Delegate.Combine alles andere als schön lesbarer Code und darum gehen wir auf Delegate.Remove gar nicht weiter ein. Statt Delegate.Combine und Delegate.Remove verwendet man besser eine andere Methode: multicast-Delegates kommen nämlich auch mit den Operatoren += und -= klar.

Um den multicast-Delegate aus dem vorstehenden Beispiel mit += zusammenzubauen, braucht es dann nur noch den folgenden Code:

LogHelper lh = new LogHelper( "logfile.log");
AClass.Logger log = null;
log += new AClass.Logger( LogHelper.LogToConsole);
log += new AClass.Logger( lh.LogToFile);

Wenn Sie einen Delegate aufrufen und dieser seine Liste von Methoden nacheinander abarbeitet, kann es natürlich passieren, dass in einer der Methoden eine Exception ausgelöst wird. In diesem Fall bricht der Delegate ab und ruft die noch ausstehenden Funktionen nicht mehr auf. Dieses Default-Verhalten können Sie aber umgehen, indem Sie selbst den Aufruf übernehmen und mit einem Try-/Catch-Handler für jeden einzelnen Aufruf versehen. Die Liste der aufzurufenden Funktionen erhalten Sie mit der Methode GetInvocationList.

Multithreading mit Delegates

Nun wissen Sie schon eine ganze Menge über Delegates - aber eines der interessantesten Features kennen Sie noch nicht: Delegates lassen sich auch asynchron aufrufen. Doch was bedeutet das genau?

Wenn Sie normalerweise eine Funktion aufrufen, dann wird die Funktion komplett abgearbeitet und erst darauf wird der Code nach dem Funktionseinsprung ausgeführt. Die Instanz, innerhalb der sich dieses sequenzielle Abarbeiten abspielt, nennt man Thread. Innerhalb eines Threads werden alle Befehle immer nacheinander abgearbeitet - es gibt keine Gleichzeitigkeit. Bei Multithreading ist das anders - hier werden zwei oder mehr Threads nebeneinander ausgeführt: asynchron eben.

Wenn Sie einen Delegate asynchron aufrufen, wird automatisch ein neuer Thread angelegt und die Ausführung des Delegates in diesem separaten Thread durchgeführt. Der Aufruf kehrt sofort zurück und der folgende Befehl wird ausgeführt - also möglicherweise noch bevor der Delegate seine Arbeit beendet hat.

Mit anderen Worten: Durch den asynchronen Aufruf eines Delegate haben Sie einen einfachen Mechanismus für das Programmieren mit mehreren Threads an der Hand.

Die meisten Windows-Programme haben im Wesentlichen nur einen Thread. Dieser ist dafür zuständig, die Systemnachrichten von Windows entgegenzunehmen und dann Nachricht für Nachricht weiterzuverarbeiten. Da ein Benutzer in der Praxis gar nicht in der Lage ist, Nachrichten parallel zu erzeugen - schließlich kann er immer nur an einen Punkt gleichzeitig klicken -, ist ein einzelner Thread hier auch ausreichend.

Es gibt aber auch durchaus Fälle, in denen man bei GUI-Programmen mit mehreren Threads arbeiten sollte. Ein einfaches Beispiel für einen solchen Fall ist ein Programm, das zwischenzeitlich langwierige Berechnungen durchführt. Werden die Berechnungen im gleichen Thread durchgeführt wie der Rest des Programms, so ist bis zum Ende der Berechnung die Verarbeitung von Windows-Nachrichten blockiert.

Nun ist es aber wünschenswert, dass man mit dem Rest des Programms ganz normal weiterarbeiten kann, während der aufwendige Vorgang läuft. So soll vielleicht ein Fortschrittsmelder angezeigt werden oder aber man will bereits Teilergebnisse der Berechnung anzeigen.

Probleme ohne Threads

Um aber einen Fortschrittsmelder anzuzeigen und upzudaten, müssen Windows-Messages versendet werden. Die können aber von niemandem verarbeitet werden, bis die Berechnung nicht durchgeführt ist. Man kann also den Fortschrittsmelder erst dann anzeigen, wenn man keinen mehr braucht, weil die Berechnung fertig ist.

Für ein solches Problem benötigt man also einen zusätzlichen Thread. Der langwierige Vorgang wird in diesem Thread betrieben, der normale Thread läuft einfach weiter und verarbeitet die Windows-Nachrichten.

Dabei sind noch ein paar Dinge zu beachten, doch bevor diese Details geklärt werden, hier erst einmal ein einfaches Beispiel. Die langwierige Arbeit soll von einer Klasse namens Worker erledigt werden. Die Beispielaufgabe ist dabei einfach die Berechnung von Wurzeln mit Math.Sqrt:

public class Worker {
public Worker() {
}
public void Work( int stop) {
for( int i=0; i < stop; i++) {
double d = Math.Sqrt(i);
}
}
}

Um nun die Methode Work asynchron aufrufen zu können, erfordert dies zunächst einen Delegate mit der gleichen Signatur:

public delegate void WorkerAsyncDelegate( int stop);

Jetzt kann man eine Instanz des Workers und des Delegates erzeugen.

Worker w = new Worker();
WorkerAsyncDelegate d = new WorkerAsyncDelegate( w.Work);

Schließlich kommt es dann zum asynchronen Aufruf des Delegate mit Hilfe der Methode BeginInvoke:

d.BeginInvoke( 10000, null, null);

BeginInvoke erhält mehrere Parameter. Am wichtigsten sind dabei zunächst die ersten Parameter, denn dabei handelt es sich um die, die dann letztlich an Worker.Work übergeben werden.

Mehr muss nicht getan werden, um die Arbeit in einem separaten Thread laufen zu lassen. Zwei Dinge sind jedoch noch wünschenswert: ein Fortschrittsmelder und eine Möglichkeit zum Abbruch.

ProgressBar bestücken leicht gemacht

Der Fortschrittsmelder als solches ist kein großes Problem, denn dafür gibt es ja ein fertiges Control. Es stellt sich allerdings die Frage, wie dieses Control denn mit den passenden Daten ausgestattet wird, damit es den Fortschritt auch anzeigen kann.

Das ist aber weniger dramatisch, als man meinen sollte. Schließlich gibt es ja die Worker-Klasse, deren langwierige Operation innerhalb von Worker.Work durchgeführt wird. Nichts hält einen davon ab, beim Erzeugen der Worker-Instanz eine Referenz auf das ProgressBar Control zu übergeben. Der Konstruktor der Worker-Klasse merkt sich diese Referenz und Worker.Work kann dann die Daten des Fortschrittsmelders entsprechend aktualisieren:

public class Worker {
private ProgressBar pb;

public Worker( ProgressBar pb) {
this.pb = pb;
}

public void Work( int stop) {
this.pb.Minimum=0;
this.pb.Maximum = stop;
for( int i=0; i < stop; i++) {
this.pb.Value = i;
double d = Math.Sqrt( i);
}
}
}

Beim Start von Worker.Work ist nun natürlich die Referenz auf das ProgressBar-Control zu übergeben. Bei dieser Gelegenheit kann man auch gleich die Schaltfläche disablen, über die Worker.Work angestoßen wird, um zu verhindern, dass der langwierige Prozess mehrfach hintereinander gestartet wird.

Dabei stellt sich natürlich die Frage, wie man den Button wieder aktiviert. Direkt nach dem Aufruf von BeginInvoke ginge das zwar - allerdings ist es zu früh, denn BeginInvoke kehrt ja direkt zurück, obwohl die Berechnung noch läuft. Man benötigt also eine Methode, mit der man mitbekommt, wann der Worker-Thread seine Arbeit beendet hat.

Asynchrones Ende: Callbacks

Um festzustellen, wann der Worker-Thread mit seiner Arbeit fertig ist, gibt es verschiedene Wege. Der einfachste ist dabei der Einsatz eines Callbacks, in der .NET-Umgebung also einfach ein weiterer Delegate, der auf eine Methode zeigt. Diese Methode wird beim Beenden des Threads aufgerufen und erhält dabei einen Parameter vom Typ IAsyncResult. Dabei handelt es sich um ein Interface, über das Informationen über den Thread erfragt werden können. Die .NET-Dokumentation weist zudem ausdrücklich darauf hin, dass man am Ende eines asynchronen Delegate-Aufrufs die Methode EndInvoke aufrufen muss. Darum ist dieser Callback nicht nur dafür geeignet, die Schaltfläche wieder zu aktivieren, sondern kann auch dazu verwendet werden, um EndInvoke wie gefordert aufzurufen:

private void Callback( IAsyncResult ar) {
WorkerAsyncDelegate wad = (WorkerAsyncDelegate)ar.AsyncState;
wad.EndInvoke( ar);
this.button1.Enabled = true;
this.progressBar1.Value = 0;
}

Um den Callback aufrufen zu können, muss er natürlich übergeben werden. Das erledigt unser Programm beim Aufruf von BeginInvoke:

private void button1_Click(object sender, System.EventArgs e) {
this.button1.Enabled = false;
Worker w = new Worker( this.progressBar1 );
WorkerAsyncDelegate d = new WorkerAsyncDelegate( w.Work);
d.BeginInvoke( 100000, new AsyncCallback( Callback), d);
}

Dieser Aufruf findet im Event Button1_Click statt.

Ein Tipp am Rande: XP-Stile verwenden

Noch ein kleiner Tipp zu Windows.Forms-Programmen. Wenn Sie Windows-Client-Anwendungen unter Visual Studio .NET programmiert haben, werden Sie vermutlich schon wissen, wie man diese Programme im XP-typischen Look anzeigen lässt. Dazu müssen Sie lediglich eine Manifest-Datei anlegen, die sich im selben Verzeichnis befindet wie das ausführbare Programm.

Bei VisualStudio 2003 geht das deutlich einfacher. Sie benötigen nur noch einen Aufruf der Methode Application.EnableVisualStyles, bevor das erste Objekt aus der Bibliothek Windows.Forms erzeugt wird.

[STAThread]
static void Main() {
Application.EnableVisualStyles();
Application.Run(new Form1());
}

Auf eines müssen Sie jedoch weiterhin achten: Auch EnableVisualStyles kümmert sich nicht darum, dass die Style-Bits der einzelnen Controls richtig gesetzt sind. Um beispielsweise Schaltflächen im XP-Stil zu erhalten, müssen Sie den Stil des Controls von Standard auf System umstellen: Dann zeigt EnableVisualStyles auch eine Auswirkung.

Events und Delegates

Abschließend noch ein paar Worte zu Events, denn Events sind mit Delegates sehr eng verwandt. Um genau zu sein: Events setzen auf Delegates auf, versorgen den Programmierer jedoch dabei mit einem zusätzlichen Sicherheitsnetz, das für die spezielle Verwendung von Delegates bei der Ereignisbehandlung notwendig ist.

Angenommen Sie hätten eine Schaltfläche und wollten einen Klick auf diese Schaltfläche verarbeiten. In diesem Fall wäre die Behandlung ganz einfach: Der Button bräuchte nur einen Delegate - beispielsweise mit dem Namen Click - und im Code würden Sie dann eine Funktion mit diesem Delegate verbinden. Klickt der Benutzer dann im Programm auf die Schaltfläche, wird diese Funktion aufgerufen. Um also das Click-Ereignis zu behandeln, bräuchte man nur eine einzelne Zeile Code:

Button.Click = new Button.ClickHandler( OnButtonClicked);

Diese Lösung hat aber einen Haken: Wenn man den Delegate wie abgebildet verwendet, würden bereits zuvor zugewiesene Handler verloren gehen. Der Fehler liegt darin, dass im Code nur "=" und nicht "+=" verwendet wurde. Da Button.Click aber public sein muss, damit der Delegate auch von außen erreichbar ist, wäre dieses einer der häufigsten Fehler in .NET-Programmen.

Events: Einfach nur ein Schutz

Genau die beschriebene Fehlerquelle unterbindet .NET mit Events. Bei einem Event wird zunächst ein Delegate definiert, der die Signatur für das Event definiert. In Windows.Forms gehören dazu immer zwei Parameter, nämlich das auslösende Objekt und eine Instanz einer von EventArgs abgeleiteten Klasse mit Informationen über das eigentliche Ereignis.

public class MyButton {
public delegate void ClickHandler(object sender, EventArgs e);
public event ClickHandler Click;

protected void OnClick() {
if (Click != null)
Click(this, null);
}
}

Durch die Deklaration des Click-Events erledigen Sie gleichzeitig zwei Aufgaben: Zum einen wird eine Delegate-Variable namens Click vereinbart, die innerhalb der Klasse dazu dient, die zum Delegate gehörenden Funktionen aufzurufen - im Beispiel erfolgt das in OnClick. Zum anderen wird dadurch ein von außerhalb sichtbarer Event deklariert, dem dann Delegates zugewiesen werden. Allerdings mit der Einschränkung, dass - anders als bei reinen Delegates - Zuweisungen bei Events nur mit "+=" und "-=" möglich sind. Eine Zuweisung mittels "=" wird vom Compiler beanstandet. Als Resultat hat man deutlich weniger Fehlerquellen bei der Behandlung von Ereignissen mit Events. (mha)