[A] Function Pointers und Functors



  • Nur ein kleiner Braindump, weil ich grad' nix anderes zu tun habe. Nicht am letzten Absatz aufhängen, der ist so noch extrem besch...eiden 😉

    --->8------>8------>8------>8------>8------>8------>8---

    Besonders wenn man mit Frameworks für grafische Oberflächen arbeitet, kommt man manchmal in die Verlegenheit, daß man über gewisse Dinge benachrichtigt werden möchte, beispielsweise darüber, daß der Benutzer einen Button angeklickt hat. Die übliche Methode dafür ist es, eine sogenannte Callback-Funktion zu verweinden, die aufgerufen wird, sobald das Gewünschte geschieht. Man erhält also eine Rückfrage vom Framework, was nun getan werden soll, wenn ein bestimmter Status eingetreten ist.
    In C kann man dazu einfach einen Pointer auf eine Funktion nehmen, die die richtige Signatur hat:

    typedef void (*callback_t)();
    
    void callee()
    {
        printf("Hello, world");
    }
    
    void caller(callback_t callback_function)
    {
        callback_function();
    }
    
    int main(int argc, char *argv[])
    {
        caller(callee);
    }
    

    Dieses Beispiel definiert auch gleich die üblichen Bezeichner, die ich auch in diesem Artikel verwenden werde:
    Ein Caller ist derjenige, der eine Callback Function (oder ein Callback) dazu benötigt, um seine Arbeit zu erledigen. Der Callee ist dabei derjenige, der aufgerufen wird.
    Nun, obiges Beispiel ist nicht sonderlich sinnvoll, denn der Caller kennt den Callee, könnte ihn also auch direkt aufrufen, wozu also so einen Umstand mit 'nem Callback? Das Callback ermöglicht es dem Programmierer, die Definition des Callees auszutauschen.
    Ich könnte jetzt weiter auf obigem Beispiel rumreiten und eine Funktion callee2() definieren, die als Ersatz für callee() eingesetzt werden könnte, aber etwas ganz anders tut. Stattdessen nehme ich aber ein etwas praxisnäheres Beispiel, nämlich die Funktion qsort() aus der C Standard Library. Sie implementiert den Sortieralgorithmus Quicksort, aber die Definition und Implementierung ist so generisch, daß man im Prinzip alles mit ihr beliebig sortieren kann, nicht nur Zahlen in aufsteigender Reihenfolge. Die Deklaration von qsort() sieht so aus:

    void qsort(void *buf, size_t num, size_t size, int (*compare)(const void *, const void *));
    

    Der Parameter buf muss lediglich einen Block von num zusammenhängenden, Datenblöcken der Größe size enthalten, die sortiert werden sollen. Um nun qsort() zu sagen, wie man zwei Blöcke vergleicht, wird als letzter Parameter eine Callback Function verwendet. Der Callee kann nun selbst die übergebenen Daten verarbeiten und muss, per Definition:
    0 zurückgeben, wenn die beiden Daten in der Sortierreihenfolge gleich sind,
    eine negative Zahl, wenn das erste Datum in der Sortierreihenfolge vor dem zweiten Datum kommt und
    eine positive Zahl, wenn das erste Datum in der Sortierreihenfolge hinter dem zweiten Datum kommt.
    Wer einfach nur ein Array von Zahlen sortieren will, wird dieses Vorgehen als reichlich umständlich empfinden, aber es bietet einen wichtigen Vorteil: Flexibilität und damit Code-Reusability.
    Angenommen wir entwickeln eine Personenverwaltung (ja, genau, das alte Lehrbuchbeispiel, eignet sich prima zum Totdiskutieren von allem möglichen). Dazu haben wir folgenden Struct und ein passendes Array, in dem die Personendaten gespeichert sind:

    typedef struct person_t {
        char *FirstName;
        char *LastName;
        int Age;
    } Person;
    
    Person PersonList[42];
    

    Wollten wir jetzt in unserem Programm dem Anwender die Möglichkeit geben, die Personen nach Vorname, Nachname und Alter getrennt zu sortieren, müssten wir ohne Callbacks dreimal einen gleichen Sortieralgorithmus runterschreiben, der sich nur in einer Zeile unterscheidet. Wollten wir später ein weiteres Attribut hinzufügen, würde eine weitere Implementierung des gleich Algorithmus hinzukommen.
    Callbacks erlauben es uns aber, eine einzige Implementierung des Algorithmus zu verwenden, und lediglich den Vergleich selbst für jedes Element zu definieren:

    #include <stdlib.h>
    
    typedef struct person_t {
        char *FirstName;
        char *LastName;
        int Age;
    } Person;
    
    Person PersonList[42];
    
    int sort_by_firstname(const void *a, const void *b)
    {
        return strcmp(((Person *)a)->FirstName, ((Person *)b)->FirstName);
    }
    
    int sort_by_lastname(const void *a, const void *b)
    {
        return strcmp(((Person *)a)->LastName, ((Person *)b)->LastName);
    }
    
    int sort_by_age(const void *a, const void *b)
    {
        return ((Person *)a)->Age - ((Person *)b)->Age;
    }
    
    main(int argc, char *argv[])
    {
        qsort(PersonList, sizeof(PersonList) / sizeof(Person), sizeof(Person), sort_by_age);
    }
    

    Kommt nun eine neue Sortiereigenschaft hinzu (oder soll bei gleichen Vornamen auch der Nachname zur Sortierung herangezogen werden), benötigen wir nur eine neue Vergleichsfunktion (und natürlich eine weitere Option für den Anwender, diese neue Sortierung auch auszuwählen). Wir können unseren qsort() also zur Sortierung von so ziemlich jeder Datenstruktur benutzen, ohne daß wir qsort() ändern müssten oder wir gar wissen müssten, wie qsort() überhaupt funktioniert.
    Auf die gleiche Art und Weise kann man auch ein Callback verwenden, um bei einem bestimmten Ereignis bestimmte Dinge zu erledigen. C-Programmierer, die statt der Win32 API lieber Gtk+ für ihre grafischen Oberflächen verwenden, sagen dem Toolkit beispielsweise mit:

    g_signal_connect(G_OBJECT(button), "clicked", G_CALLBACK(callback), null);
    

    daß beim Klick auf den Button button die Funktion callback() aufgerufen werden soll, die wir selbst definieren können.
    Klassische Funktionspointer als Callback haben aber diverse Nachteile. So muss der Callee immer exakt die Signatur haben, die vom Caller vorgegeben wird. Beim obigen Sortierbeispiel fällt schnell auf, daß wir immer noch sehr viel ähnlichen Code haben, den man eigentlich gerne in eine einzige Vergleichsfunktion stecken würde, der wir per Parameter sagen, welches Feld des Structs er vergleichen soll. Außerdem ist aus C++ heraus qsort() nahezu unbrauchbar, weil ein Funktionspointer nicht zu einem Methodenpointer kompatibel ist, wir also niemals eine Methode eines Objektes als Callback heranziehen können (das mag beim Sortieren noch tolerierbar sein, aber für ein Callback aus der grafischen Oberfläche heraus ist es kaum noch machbar, schönen Code zu schreiben).

    Die Lösung dieses Problems ist der Functor, der quasi "the object oriented way of callback" ist. Ein Functor ist per Definition nichts anderes, als ein Objekt, das man als Funktion aufrufen kann. In C++ bedeutet das, daß wir lediglich den operator() für eine beliebige Klasse implementieren müssen, um aus ihr einen Functor zu machen.
    Doch bevor wir ans Eingemachte gehen und einen sauberen Functor implementieren, schauen wir uns zunächst Functor-Templates an, die die STL bereits mitbringt: std::binder1st<> und std::binder2nd<>. Beide sind Adapter, um Functors mit zwei Parametern zu einem Functor mit lediglich einem Parameter zu binden. Das mag sich nun auf den ersten Blick ziemlich unbrauchbar anhören, denn wenn wir schon einen Functor schreiben müssen, können wir ihn ja auch gleich selbst mit der richtigen Signatur implementieren. Aber die STL wäre ja nicht die STL, wenn sie nicht auch dafür etwas anbietet, nämlich mem_fun1_t<>(), das ebenfalls ein Adapter ist, diesmal um aus einem gewöhnliche Methodenpointer einen waschechten binären Functor (d.h. mit zwei Parametern) zu machen. Alle diese Templates haben wiederum Helper-Funktionen: bind1st<>(), bind2nd<>() und mem_fun1<>().


Anmelden zum Antworten