Delegates

Ich arbeite nun zwischendurch im GATE Projekt an der GUI Bibliothek.
Und da stellt sich natürlich die Frage, wie Ereignisse vom Framework ausgelöst und verarbeitet werden sollen.

Delegates sind ein Begriff aus der dotNet Welt und dieser gefiel mir vom Aufbau her schon vor 10 Jahren, weshalb ich seither in mehreren C++ Projekten einen Delegate-ähnlichen Mechanismus eingebaut habe.

Mit GUIs wurde die ereignisgesteuerte Programmierung eingeführt.
Während in den älteren Konsolenprogrammen in der Regel ein linearer Programmfluss herrschte, wo schrittweise A, B, C usw. abgearbeitet wird, laufen in GUI-Programmen Ereignisschleifen.

In der GUI Ereignisschleife wiederholt sich folgender Ablauf:

  1. Es wird auf ein Ereignis gewartet (Timer, Mausbewegung oder Tastatureingabe)
  2. Das Ereignis wird einem grafischen Element (Button, Textfeld) zugeordnert
  3. Es wird Code ausgeführt, der mit dem Element verknüpft ist, auch Rückruffunktion oder Callback genannt.

In der Praxis sieht die Umsetzung so aus, dass wir zuerst die grafischen Element (Fenster, Buttons, Texte) erzeugen und dann die globale Ereignisschleife des Frameworks starten. Für Ereignisse, die uns interessieren, erstellen wird Callbacks und dort liegt dann oft die Programmlogik bzw. der Programmablauf.

GUIs führen quasi erzwungenermaßen auch ein objekt-orientiertes Programmierschema ein. Eine Funktion bzw. Aktion bezieht sich auf ein bestimmtes Objekt unter vielen.
Und das hält auch in die Callbacks einzug.

void button_onclick_callback(button_pointer_t* button, some_params_t* params);

Ob wir jetzt eine Callback-Funktion haben, die alle Button-Clicks behandelt und über den button Parameter erkennt, welcher geklickt wurde, oder ob es für jeden Button eine eigene Callback-Funktions gibt, wo der Parameter getrost ignoriert werden kann, bleibt mit diesem Schema dem Programmierer überlassen.

Was auch oft vorkommt sind “User-Parameter”. Man kann mit jedem Objekt einen benutzerdefinierten Wert (meist einen void* Zeiger oder eine Ganzzahl) verknüpfen, und eben dieser Wert wird bei jedem Callback nachgeschossen.
So kann der Programmierer ebenfalls wieder erkennen, auf welches Objekt sich der Aufruf bezieht (man muss eben selbst Buch führen).

void somelib_set_userparam(object_t* obj, void* user_param);

void somelib_callback(somelib_params_t* callback_params, void* user_param);

In C++ denkt man gleich in Objekten und oft ist der erste Ansatz, dass man eine Basisklasse mit virtuellen Methoden hat, wo man für jede Instanz eine Ableitung schreibt und den individuellen Ereigniscode so “überschreibt”

class Button 
{
  ...
protected:
  virtual void onClick() = 0;
}; 

class Button1 : public Button 
{
protected:
  virtual void onClick()
  {
    // handle event of Button1
  }
}

class Button2 : public Button 
{
protected:
  virtual void onClick()
  {
    // handle event of Button2
  }
}

Aber das ist total umständlich, denn für jeden Button eine ganze Klasse zu schreiben erzeugt über lange Sicht viel zu viel Tipparbeit.

Unter C++ Delegates verstehe ich daher Objekte, die einen Funktionsaufruf oder einen Objektmethodenaufruf kapseln können.
Das ist ein sehr wichtiger Aspekt, denn C++ Methoden sind keine einfachen Funktionen, und noch dazu sind unterschiedliche Methoden zueinandern nicht Pointer-kompatibel. Ein Pointer auf eine virtuelle Funktion kann anders aussehen als ein Pointer auf eine nicht-virtuelle oder statische Methode.

Delegates stellen also ein einheitliches Interface für alle möglichen Code-Aufrufe dar, mit den sie verknüpft werden.

Mein erster Ansatz vor 10 Jahren war daher ein C++ Interface (also abstrakte virtuelle Methoden) und instanziert wurden Ableitungen mit einer entsprechenden Implementierung
… das sah in etwa so aus:

template<class RET, class ARG1, class ARG2>
class IDelegate2
{
public:
  virtual RET operator()(ARG1 arg1, ARG2 arg2) = 0;
};

template<class OBJECT, class RET, class ARG1, class ARG2>
class MethodDelegate2
{
private: 
  OBJECT* obj; // stored object pointer
  RET(OBJECT::*method)(ARG1, ARG2); // stored method pointer
  
public:
  MethodDelegate2(OBJECT* objptr, RET(OBJECT::*methodptr)(ARG1, ARG2))
  : obj(objptr), method(methodptr)
  {
  }
  
  virtual RET operator()(ARG1 arg1, ARG2 arg2)
  {
    return (this->obj->*(this->method))(arg1, arg2);
  }
}

template<class RET, class ARG1, class ARG2>
class FunctionDelegate2
{
private: 
  OBJECT* obj; // stored object pointer
  RET(*func)(ARG1, ARG2); // stored function pointer
  
public:
  FunctionDelegate2(OBJECT* objptr, RET(*funcptr)(ARG1, ARG2))
  : obj(objptr), func(funcptr)
  {
  }
  
  virtual RET operator()(ARG1 arg1, ARG2 arg2)
  {
    return this->func(arg1, arg2);
  }
}
... 
MyClass myObj;
Delegate2<void, int, int>* del = new MethodDelegate2<MyClass, void, int, int>(&myObj, &MyClass::someMethod);
(*del)(123, 456);

Das funktionierte zwar, war aber immer noch umständlich zu schreiben, weil man immer sämtliche Template-Parameter beisteuern musste.

Heute setze ich auf Delegates, die einen Pointer-Puffer beinhalten und mehrere Konstruktoren für Methoden- und Funktionspointer haben. Die Konstruktoren generieren Dispatcher Funktionen für das gewünschte Ziel und speichern es im Delegate Objekt. Das ganze kann dann auch auf dem Stack liegen und ist somit viel effizienter. Der Code wäre zu lange um ihn zu posten, aber beim Release des GATE-Quellcodes kann man es sich anschauen.

Jedenfalls sieht die Bindung dann so aus:

class MyClass
{
public: 
  void foo(int a, int b);
};

MyClass myObj;
Delegate2<void, int, int> del(&myObj, &MyClass::foo);
del(123, 456);

Und damit kann ich gut leben.

Natürlich ist mir nicht entgangen, dass moderne C++ Compiler auch Funktionssignaturen in Template-Parameter kodieren können, doch nicht alle von mir unterstützten Compiler können das leisten.
Daher bleibe ich bei dem Schema, es hat sich bisher bestens bewehrt und ist bis zum C++ Standard von 2003 kompatibel (mit ein paar void-Tricks sogar bis zum 98er).


Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!