6.5 Függvénypointerek, callbackek és context pointer

A C függvénypointere azt teszi lehetővé, hogy egy algoritmus vagy eseményforrás működésének egy részét a hívó határozza meg. Rendezésnél ez az összehasonlítás, bejárásnál az elemen végzett művelet, eseménykezelésnél az esemény feldolgozása. A visszahívás (callback) azonban rendszerint állapotot is igényel: számlálót, konfigurációt vagy egy feldolgozó objektumot.

C-ben ezt jellemzően két összetartozó érték képviseli: egy függvénypointer és egy void * context pointer. A helyes interfész nemcsak ezek típusát, hanem élettartamukat, tulajdonlásukat és hibaszerződésüket is meghatározza.

A deklarátorokról szóló rész alapján már olvasható a függvénypointer deklarációja. A void * tárgyalásából pedig azt kell felidézni, hogy az általános objektumpointer nem őrzi meg a referált objektum típusinformációját.

6.5.1 Függvénypointer és hívási kompatibilitás

ISO C17: a függvénypointer függvényre mutató érték. Nem objektum tárolóját jelöli, és nem alkalmazható rá pointeraritmetika.

Az alábbi deklarációrészlet egy összehasonlító függvény pointertípusát nevezi el:

/* ISO C17; deklarációrészlet. */
typedef int (*compare_fn)(const void *, const void *);

A compare_fn olyan pointertípus, amelyen keresztül két const void * argumentumot fogadó, int értéket visszaadó függvény hívható. A függvény neve pointert igénylő kifejezésben rendszerint automatikusan függvénypointerré alakul; az explicit & is használható. Ha fn megfelelő függvénypointer, akkor az fn(a, b) és a (*fn)(a, b) ugyanazt a hívást fejezi ki.

A hívás feltételei:

A kompatibilitás nem pusztán az argumentumok számát jelenti. A visszatérési típus, a paramétertípusok és a variadikus jelleg is számít. Célszerű a callbacket eleve az API-ban megadott prototípussal definiálni.

ISO C17: különböző függvénypointer-típusok között lehet explicit konverziót végezni, és az eredeti típusra visszakonvertált pointer egyenlő az eredetivel. Ez azonban nem teszi megengedetté az inkompatibilis típuson keresztüli hívást: annak viselkedése nem definiált. Egy cast nem alakítja át a függvény paraméterkezelését.

Paraméter nélküli C17 függvénynél a (void) prototípust használd. Az üres () deklaráció C17-ben nem ugyanezt mondja: nem ad paramétertípus-információt.

6.5.2 A callback és a context közös szerződése

A context pointer (context pointer, felhasználói állapotpointer) az algoritmus számára átlátszatlan adat. Az algoritmus tárolhatja vagy továbbadhatja, de csak az a callback értelmezheti, amely ismeri a mögötte álló típust.

A pointerpár fogalmilag egyetlen egység:

visszahívás=(függvény,hozzá tartozó állapot). \text{visszahívás} = (\text{függvény},\ \text{hozzá tartozó állapot}).

Ha a függvény az egyik regisztrációból, az állapot egy másikból származik, a típusellenőrzés ezt többnyire nem észleli. A callback hibás objektumtípust feltételezhet, vagy érvénytelen tárolóhoz férhet hozzá.

Két élettartam-modellt kell megkülönböztetni.

Szinkron visszahívásnál a feldolgozó függvény nem őrzi meg a pointerpárt. Minden callback befejeződik, mielőtt a feldolgozó visszatér. A hívó megfelelően élő lokális objektuma ezért használható contextként.

Tárolt regisztrációnál az eseményforrás megőrzi a pointerpárt. A contextnek minden lehetséges későbbi hívás alatt élnie kell. Nem elég addig megőrizni, amíg a regisztrációs függvény visszatér.

Az alábbi példában minden hozzáférés kölcsönzött:

Erőforrás Tulajdonos, illetve kezelő Elvárt élettartam Érvénytelenítő esemény
Bejárt tömb tárolója A main lokális tömbobjektuma A teljes bejárás alatt A tömb élettartamának vége
Bejárási context A main lokális állapotobjektuma A walk_ints visszatéréséig Az állapot élettartamának vége
Regisztrált context A main lokális állapotobjektuma Minden hívás befejezéséig és a leiratkozásig Korai élettartamvég
Eseményregisztráció A hívó által kezelt event_slot A regisztráció használata alatt A slot élettartamának vége
Callback függvény A program kódja Minden lehetséges hívás alatt Platformfüggő modulkezelésnél például túl korai modulkitöltés helyett modul-kirakás

Az utolsó eset nem az ISO C dinamikusbetöltési szolgáltatása: ha megosztott modulok kirakása is lehetséges, annak külön platformfüggő szerződés kell. Az alábbi önálló program függvényei a program futása alatt rendelkezésre állnak.

6.5.3 Kidolgozott példa: rendezés, visitor és eseménykezelő

A következő interfész három eredményt különböztet meg:

A látogató (visitor) értékként kapja az aktuális egész számot. Nem kap az elemre mutató, eltárolható pointert. A bejáró nem tartja meg sem a callbacket, sem a contextet.

Az eseménypélda egyetlen int eseményértéket továbbít. A slot már megőrzi a pointerpárt, de nem veszi át a context tulajdonlását.

Példaprofil: ISO C17, hosted Linux, x86-64 System V ABI, LP64. Választott reprodukciós eszközprofil: GCC 14.2.0 vagy Clang 19.1.7, glibc 2.40. A kód nem igényel GNU nyelvi bővítést. Az alábbiakhoz nem tartozik itt tényleges fordítási vagy futási eredmény.

/* callbacks.c */
#include <limits.h>
#include <stddef.h>
#include <stdlib.h>

enum cb_result {
    CB_OK,
    CB_STOP,
    CB_ERROR
};

typedef enum cb_result (*callback_fn)(int value, void *ctx);

struct counter {
    int threshold;
    size_t limit;
    size_t accepted;
};

static enum cb_result accept_limited(int value, void *ctx)
{
    if (ctx == NULL)
        return CB_ERROR;

    struct counter *state = ctx;

    if (value < state->threshold)
        return CB_OK;

    if (state->accepted >= state->limit)
        return CB_STOP;

    ++state->accepted;
    return CB_OK;
}

static enum cb_result walk_ints(
    const int *items, size_t count, callback_fn fn, void *ctx)
{
    if (fn == NULL || (count != 0 && items == NULL))
        return CB_ERROR;

    for (size_t i = 0; i < count; ++i) {
        enum cb_result result = fn(items[i], ctx);

        if (result == CB_STOP)
            return CB_STOP;
        if (result != CB_OK)
            return CB_ERROR;
    }

    return CB_OK;
}

static int compare_ints(const void *left, const void *right)
{
    const int *a = left;
    const int *b = right;

    return (*a > *b) - (*a < *b);
}

struct event_slot {
    callback_fn fn;
    void *ctx;
    int active;
};

static enum cb_result event_slot_set(
    struct event_slot *slot, callback_fn fn, void *ctx)
{
    if (slot == NULL || slot->active)
        return CB_ERROR;

    slot->fn = fn;
    slot->ctx = fn != NULL ? ctx : NULL;
    return CB_OK;
}

static enum cb_result event_slot_emit(struct event_slot *slot, int value)
{
    if (slot == NULL || slot->active || slot->fn == NULL)
        return CB_ERROR;

    slot->active = 1;
    enum cb_result result = slot->fn(value, slot->ctx);
    slot->active = 0;

    if (result != CB_OK && result != CB_STOP)
        return CB_ERROR;
    return result;
}

int main(void)
{
    int values[] = { INT_MAX, -3, 0, INT_MIN, 4 };
    size_t count = sizeof values / sizeof values[0];

    qsort(values, count, sizeof values[0], compare_ints);

    struct counter visitor_state = { 0, 2, 0 };
    enum cb_result result =
        walk_ints(values, count, accept_limited, &visitor_state);

    if (result != CB_STOP || visitor_state.accepted != 2)
        return EXIT_FAILURE;

    struct counter event_state = { 10, 1, 0 };
    struct event_slot slot = { 0 };

    if (event_slot_set(&slot, accept_limited, &event_state) != CB_OK)
        return EXIT_FAILURE;

    result = event_slot_emit(&slot, 12);

    if (event_slot_set(&slot, NULL, NULL) != CB_OK)
        return EXIT_FAILURE;

    if (result != CB_OK || event_state.accepted != 1)
        return EXIT_FAILURE;

    return EXIT_SUCCESS;
}

A kiválasztott profilra beállított fordítókkal a külön tanulói labor buildparancsai:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wstrict-prototypes \
    -O0 -g callbacks.c -o callbacks-gcc

clang -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wstrict-prototypes \
    -O0 -g callbacks.c -o callbacks-clang

A rendezésből következő várt sorrend INT_MIN, -3, 0, 4, INT_MAX. A visitor az első két értéket átugorja, majd a 0 és 4 elfogadásával eléri a kettes korlátot. Az INT_MAX feldolgozásakor megállást kér.

Fontos különbség: a korlát elérése még nem állítja le azonnal ezt a callbacket. A következő elfogadható érték váltja ki a CB_STOP eredményt. Emiatt pontosan két elfogadható elemet tartalmazó bemenetnél a bejárás CB_OK eredménnyel is befejeződhet.

A számláló nem csordul túl: növelés csak akkor történik, ha

𝚊𝚌𝚌𝚎𝚙𝚝𝚎𝚍<𝚕𝚒𝚖𝚒𝚝. \texttt{accepted} < \texttt{limit}.

Így növelés után 𝚊𝚌𝚌𝚎𝚙𝚝𝚎𝚍≤𝚕𝚒𝚖𝚒𝚝\texttt{accepted}\leq\texttt{limit}, amely eleve ábrázolható size_t érték.

6.5.3.1 A nem ellenőrizhető előfeltételek

A nullvizsgálatok nem általános pointerérvényesség-vizsgálatok. A hívó további kötelezettségei:

A hibás típusú vagy dangling contextből nem garantáltan lesz CB_ERROR: az ilyen hozzáférés UB lehet. A callback korábbi mellékhatásait sem CB_STOP, sem CB_ERROR nem vonja vissza. Ez az API nem tranzakció.

6.5.4 A qsort comparatorának követelményei

ISO C17 könyvtári szerződés: a qsort comparatorának negatív, nulla vagy pozitív eredménye rendre kisebb, egyenértékű vagy nagyobb elemet jelent. Nem szükséges pontosan -1, 0, 1 értékeket visszaadni.

Az alábbi gyakori megoldás hibás:

/* Hibás C17 kódrészlet: signed túlcsordulás miatt UB lehet. */
/* return *a - *b; */

Például az INT_MAX - (-3) matematikai eredménye nem ábrázolható int típussal.

A helyes változat csak relációs eredményeket von ki:

c(a,b)=(a>b)−(a<b). c(a,b)=(a>b)-(a<b).

Bizonyításvázlat: a relációs operátorok int típusú 0 vagy 1 eredményt adnak. Ha a<ba<b, az eredmény 0−1=−10-1=-1; egyenlőségnél 0−0=00-0=0; ha a>ba>b, akkor 1−0=11-0=1. Mindhárom ábrázolható, az eredeti számok kivonása elmarad.

A comparatornak következetes rendezési viszonyt kell megadnia. Ne módosítsa az összehasonlított elemeket, és ne változtassa a sorrendet számláló, véletlen vagy idő alapján. A rendező mozgatja az elemeket, ezért az aktuális tárolási cím nem megfelelő kulcs egy logikai rekord azonosítására. Egyenértékű elemek eredeti sorrendjének megőrzését a qsort nem garantálja.

A szabványos C17 qsort nem kap contextet, és nincs külön comparator-hibacsatornája. A nulla nem hiba, hanem egyenértékűség. Contextes platformváltozatok léteznek, de nevüket, paramétersorrendjüket és támogatásukat külön kell ellenőrizni.

6.5.5 Reentrancia és callback alatti invalidáció

A reentrancia (reentrancy) azt jelenti, hogy egy művelet újra meghívható, miközben egy korábbi végrehajtása még nem fejeződött be. Ez egyszálú programban is előfordulhat: a callback közvetve újabb eseménykibocsátást indít.

A walk_ints nem használ közös globális állapotot. Független adatokkal és contexttel egymásba ágyazható. Ugyanannak a módosuló contextnek az újrahasználatához viszont külön alkalmazásszintű szabály szükséges. A globális változóval pótolt qsort-context ezért törékeny: egy belső rendezés felülírhatja a külső konfigurációját.

Az eseményslot más döntést hoz: az active jelzővel megtiltja ugyanazon slot visszatérő kibocsátását és callback alatti átregisztrálását. Ilyenkor CB_ERROR keletkezik.

Ez csak az API-n keresztüli módosításokat védi. A callback nem írhatja közvetlenül át a slot mezőit, nem szüntetheti meg a slot vagy context élettartamát, és nem kerülheti meg a normál visszatérést. Az active közönséges int, tehát ez egyszálú szerződés, nem szálbiztonsági mechanizmus.

Általános eseménykezelőben a leiratkozás önmagában nem feltétlenül jelenti, hogy nincs már folyamatban hívás. Contextet csak akkor szabad megszüntetni, amikor új hívás már nem indulhat, és minden korábbi befejeződött. A példabeli szinkron, egyszálú modellben az inaktív állapotban sikeres leiratkozás ezt biztosítja.

6.5.5.1 Függvénypointer nem objektumpointer

ISO C17 hordozhatósági határ: az objektumpointer void *-rá, majd eredeti objektumpointer-típusára történő konverziójának garanciája nem általánosítható függvénypointerre.

Ezért a függvénycímet ne rejtsd a contextbe. A példában külön callback_fn és void * mező szerepel. Egyenlő gépi méretük egy adott ABI-n nem bizonyítja a konverzió hordozhatóságát. Ha egy platform API-ja külön garanciát ad, az platformfüggő szerződés, nem általános ISO C szabály.

6.5.6 Gyakori hibák és javításuk

Hibaminta Következmény Javítás
Inkompatibilis callback castolása A hívás UB lehet Megfelelő prototípusú adapterfüggvény
Lokális context megőrzése a visszatérés után Dangling hozzáférés Hosszabb élettartam vagy időben történő leiratkozás
Nem null pointer automatikusan érvényesnek tekintése Típus- vagy élettartamhiba Explicit hívói előfeltételek
Context felszabadítása callback közben Aktív vagy későbbi hozzáférés sérülhet Dokumentált leállítási és felszabadítási sorrend
Comparatorban kivonás Signed túlcsordulás Relációs eredmények különbsége
Hibajelzés után teljes visszagörgetés feltételezése Részlegesen módosított állapot Mellékhatások és rollback külön szerződése

6.5.7 Feladatok

6.5.7.1 Élettartam értelmezése

Egy függvény lokális struct counter objektum címét regisztrálja egy nála hosszabb életű eseményslotba, majd visszatér. Miért hibás ez akkor is, ha a regisztráció sikeres?

6.5.7.2 Callback készítése

Írj a példához illeszkedő callbacket, amely csak számlálja a negatív értékeket. Ha a számláló már SIZE_MAX, adjon CB_ERROR eredményt változtatás nélkül.

6.5.7.3 Reentrancia diagnosztikája

A slot callbackje ugyanazon slotra ismét meghívja az event_slot_emit függvényt. Mi történik? Mi változna, ha a külső callback longjmp segítségével hagyná el a hívási láncot?

6.5.8 Megoldások

6.5.8.1 Élettartam értelmezése

A regisztráció pointerértéket másol, nem contextobjektumot. A lokális objektum élettartama a függvényből visszatérve véget ér; a későbbi callback nem férhet hozzá.

Javításként a context lehet a hívó hosszabb életű objektuma, vagy a regisztrációt még az élettartam vége előtt meg kell szüntetni. Az ellenőrzési feltétel: minden lehetséges callback-hívás teljes egészében a context élettartamába essen.

6.5.8.2 Callback készítése

A következő ISO C17 részlet a példabeli típusdefiníciók után illeszthető be:

#include <stdint.h>

struct negative_count {
    size_t value;
};

static enum cb_result count_negative(int value, void *ctx)
{
    if (ctx == NULL)
        return CB_ERROR;

    struct negative_count *state = ctx;

    if (value >= 0)
        return CB_OK;

    if (state->value == SIZE_MAX)
        return CB_ERROR;

    ++state->value;
    return CB_OK;
}

A hívó tulajdonában álló contextnek élő struct negative_count objektumot kell jelölnie. Ellenőrzési esetek: nemnegatív bemenetnél nincs változás; negatív bemenetnél SIZE_MAX - 1 értékről még megengedett a növelés; SIZE_MAX esetén hiba történik, módosítás nélkül.

6.5.8.3 Reentrancia diagnosztikája

A belső kibocsátás active != 0 miatt CB_ERROR eredménnyel visszatér, újabb callback nélkül. Ha a külső callback normálisan visszatér, az eredeti kibocsátás nullázza az active mezőt.

A longjmp átugorhatná ezt a nullázást, ezért sérti a callback normál visszatérését előíró szerződést. Ellenőrzési feltétel: minden engedélyezett callback-kimenet után végrehajtódjon az aktív állapot megszüntetése.

6.5.9 Összefoglaló

A C callback interfésze függvénytípusból, contextből és viselkedési szerződésből áll. A helyességhez kompatibilis hívást, megfelelő contexttípust, elegendő élettartamot és egyértelmű hibajelzést kell biztosítani. Tárolt regisztrációnál a leiratkozás és a folyamatban lévő hívások befejezése különösen fontos. A cast nem javít típushibát, a nullvizsgálat pedig nem bizonyít érvényességet.