10.4 Opaque handle és stabil C könyvtári interfész

Egy könyvtári interfész akkor is szerződés, ha a fejlécben mindössze néhány függvénydeklaráció látható. Meg kell mondania, ki birtokolja az állapotot, meddig érvényesek a kölcsönzött pointerek, mi változhat hiba esetén, és milyen párhuzamos használat megengedett. Az opaque handle, vagyis rejtett reprezentációjú objektumra mutató fogantyú az állapot elrendezését elrejti, de ezeket a kötelezettségeket nem helyettesíti.

A paraméterátadásnál látott értékmásolás, a tulajdonlási szerződés és a könyvtári linkelés most egy kis kvótakönyvtárban kapcsolódik össze. A könyvtár egy számlálót kezel felső korláttal, és sikeres módosításkor opcionális callbacket hív.

10.4.1 Rejtett reprezentáció, látható kötelezettségek

ISO C17: az alábbi elődeklarálás nem teljes típust (incomplete type) vezet be:

/* C17 deklarációrészlet. */
typedef struct quota quota;

A kliens deklarálhat quota * pointert, de nem képezhet sizeof(quota) kifejezést, nem érheti el a tagokat, és nem hozhat létre ilyen típusú objektumot érték szerint. A teljes struktúradefiníció kizárólag az implementációban szerepel.

A pointer másolható, de ettől nem keletkezik új tulajdonos. Ha két pointer ugyanarra az objektumra mutat, az egyik révén végrehajtott megsemmisítés mindkettő hozzáférését érvényteleníti.

Az interfészünk szerződése:

Elem Tulajdonos és élettartam Érvénytelenítés, hibakimenet
quota * objektum Sikeres létrehozás után a kliens felel érte Pontosan egyszeri quota_destroy; minden alias érvénytelenné válik
Belső tároló A könyvtár foglalja és szabadítja fel A kliens nem használhat rajta közvetlenül free-t
Opcióstruktúra A hívóé; csak a create idejére kölcsönzött A hívás után megszűnhet
Callback és userdata Nem kerül tulajdonba; a kliens biztosítja érvényességüket a megsemmisítésig A könyvtár nem szabadítja fel a contextet
Hibaüzenet Könyvtári, csak olvasható statikus tároló Nem felszabadítható; a könyvtár betöltött élettartamáig használható

A create hibakor null pointert ír az érvényes kimeneti helyre. Ez a hely nem tartalmazhat elveszíthető, élő tulajdont. A sikertelen add változatlanul hagyja az állapotot, és nem hív callbacket.

Szálbiztonsági szerződés: külön handle-ök párhuzamosan használhatók, ha callbackjeik és közös contextadataik is ezt megengedik. Ugyanazon handle minden műveletét, a megsemmisítést is beleértve, a hívó sorosítja. Nincs belső zárolás.

A callback szinkron módon, a hívó szálán fut, már módosított állapot mellett. Nem léphet vissza ugyanazon handle API-jába, nem semmisítheti meg azt, és rendesen vissza kell térnie. C++ callbackből kivétel sem léphet át ezen a határon.

10.4.2 A minimális publikus fejléc

A következő quota.h C17-ből és C++20-ból is használható. Az állapot layoutja rejtett; az opcióstruktúra layoutja viszont szándékosan publikus, ezért kompatibilitási kötelezettség.

#ifndef QUOTA_H
#define QUOTA_H

#include <stddef.h>

#ifndef QUOTA_API
#define QUOTA_API
#endif

#ifdef __cplusplus
extern "C" {
#endif

#define QUOTA_API_VERSION 1u

#define QUOTA_OK        0
#define QUOTA_EINVAL    1
#define QUOTA_EVERSION  2
#define QUOTA_ENOMEM    3
#define QUOTA_ELIMIT    4

typedef struct quota quota;

typedef void (*quota_notify)(void *userdata, unsigned long total);

typedef struct quota_options {
    size_t struct_size;
    unsigned api_version;
    unsigned long limit;
    quota_notify notify;
    void *userdata;
} quota_options;

QUOTA_API int quota_create(const quota_options *options, quota **out);
QUOTA_API int quota_add(quota *q, unsigned long amount);
QUOTA_API void quota_destroy(quota *q);
QUOTA_API const char *quota_status_text(int status);

#ifdef __cplusplus
}
#endif

#endif

A fejléc csak a size_t miatt függ a <stddef.h> headertől. Nem szivárogtat ki allokátort, zárolási típust vagy Linux-specifikus struktúrát. A prefix csökkenti a névütközés esélyét.

A QUOTA_API a dinamikus szimbólumláthatóság konfigurálási pontja. Ez nem azonos a C nyelvi kapcsolással: egy külső kapcsolású függvényt az ELF-könyvtárból még el lehet rejteni. A kizárólag implementációs segédfüggvényeket továbbra is static deklarációval célszerű elkülöníteni.

Az extern "C" őr a későbbi C++ fogyasztás alapja, nem általános ABI-függetlenségi garancia. Részleteit a két nyelvű headerek tárgyalják.

10.4.3 Implementáció és allocatorhatár

A teljes quota.c ISO C17-et használ; az exportláthatóság fordítófüggő beállítása külön, a buildparancsban jelenik meg.

#include "quota.h"

#include <limits.h>
#include <stdlib.h>

struct quota {
    unsigned long limit;
    unsigned long used;
    quota_notify notify;
    void *userdata;
};

int quota_create(const quota_options *options, quota **out)
{
    if (out == NULL) {
        return QUOTA_EINVAL;
    }
    *out = NULL;

    quota_options cfg = {
        sizeof(quota_options),
        QUOTA_API_VERSION,
        ULONG_MAX,
        NULL,
        NULL
    };

    if (options != NULL) {
        if (options->struct_size < sizeof *options) {
            return QUOTA_EINVAL;
        }
        if (options->api_version != QUOTA_API_VERSION) {
            return QUOTA_EVERSION;
        }
        cfg = *options;
    }

    quota *q = malloc(sizeof *q);
    if (q == NULL) {
        return QUOTA_ENOMEM;
    }

    q->limit = cfg.limit;
    q->used = 0;
    q->notify = cfg.notify;
    q->userdata = cfg.userdata;

    *out = q;
    return QUOTA_OK;
}

int quota_add(quota *q, unsigned long amount)
{
    if (q == NULL) {
        return QUOTA_EINVAL;
    }
    if (amount > q->limit - q->used) {
        return QUOTA_ELIMIT;
    }

    q->used += amount;
    if (q->notify != NULL) {
        q->notify(q->userdata, q->used);
    }
    return QUOTA_OK;
}

void quota_destroy(quota *q)
{
    free(q);
}

const char *quota_status_text(int status)
{
    switch (status) {
    case QUOTA_OK:       return "success";
    case QUOTA_EINVAL:   return "invalid argument";
    case QUOTA_EVERSION: return "unsupported API version";
    case QUOTA_ENOMEM:   return "allocation failed";
    case QUOTA_ELIMIT:   return "quota exceeded";
    default:            return "unknown status";
    }
}

A null options alapértelmezett konfigurációt jelent: ULONG_MAX korlátot és callback nélküli működést. Az unsigned long itt nyelvi típus, nem hordozhatóan rögzített szélességű protokollmező. A null handle megsemmisítése megengedett.

A callback minden sikeres add után lefut, nulla növekménynél is. A könyvtári műveletrész konstans számú műveletet végez, allokáció nélkül; a callback költségére nincs ilyen vállalás.

10.4.3.1 Az állapotmegőrzés indoklása

Bizonyításvázlat. Legyen uu az aktuális érték, LL a korlát, aa a növekmény. Az invariáns:

0≤u≤L≤ULONG_MAX. 0 \le u \le L \le \mathrm{ULONG\_MAX}.

Ezért L−uL-u ábrázolható. Ha a>L−ua>L-u, a függvény írás nélkül visszatér. Egyébként:

a≤L−u⇒u+a≤L, a \le L-u \quad\Longrightarrow\quad u+a \le L,

tehát az összeadás sem csordul túl, és az invariáns megmarad. A közvetlen u + a > L ellenőrzés hibás lenne, mert az unsigned összeadás már az összehasonlítás előtt körbefordulhatna.

10.4.3.2 Mit nem ellenőriz a könyvtár?

A nullellenőrzés nem pointervalidálás. A nem null out írható, megfelelő típusú objektumra mutasson, és ne fedje át az opciókat. A nem null options valódi, olvasható opcióobjektumot jelöljön. Az élő handle helyett átadott felszabadított pointer szerződéssértés, nem garantáltan visszaadható hibakód.

A hívó–hívott allokátorhatár (caller/callee allocator boundary) szabálya egyszerű: aki foglal, az biztosítja a hozzá tartozó felszabadító műveletet. A kliens ezért akkor sem hívhat free(q)-t, ha az aktuális implementáció történetesen malloc-ot használ. Később pool, más runtime vagy kiegészítő belső erőforrás is megjelenhet.

A hibaüzenet nem handle-ből kölcsönzött adat: a handle megsemmisítése nem érvényteleníti. A szöveg tartalmára nem építünk programlogikát; erre a numerikus státusz szolgál.

10.4.4 Méretmező, verziózás és bővíthetőség

A struct_size C bájtban, vagyis a sizeof egységében értendő. Nem biztonsági ellenőrzés: a könyvtár nem tudja megállapítani, hogy a hívó igazat állított-e a rendelkezésre álló objektum méretéről.

A jelenlegi implementáció az első verzió teljes struktúráját megköveteli, a nagyobb méretet elfogadja, de az ismeretlen folytatást nem olvassa. Az api_version az inkompatibilis szerződésváltást jelöli.

Egy későbbi opcionális mező hozzáadásakor három szabály szükséges:

  1. A korábbi mezők típusa, sorrendje, offsetje és jelentése változatlan marad.
  2. Az új könyvtár továbbra is elfogadja a régi méretet; nem követeli automatikusan az új sizeof értéket.
  3. A hiányzó új mezőhöz dokumentált alapérték tartozik.

Ha egy mező offsetje oo, mérete ww, a közölt struktúraméret pedig SS, akkor az olvashatóság méretfeltétele:

S≥oésS−o≥w. S \ge o \quad\text{és}\quad S-o \ge w.

A kivonásos alak elkerüli az ellenőrzésben az o+wo+w túlcsordulását. Az ABI-tervben azt is biztosítani kell, hogy új mező ne kerüljön a régi struktúra záró paddingjébe: különben a régi méret alapján jelen levőnek látszhatna. Ezt minden támogatott target ABI-ra külön kell kezelni.

Az ismeretlen mezők figyelmen kívül hagyása csak opcionális viselkedésnél elfogadható. Egy kötelező biztonsági beállítást nem szabad csendben elhagyni: ahhoz képességlekérdezés, új függvény vagy elutasítható verzióváltás kell.

A forráskompatibilitás újrafordíthatóságot, a bináris kompatibilitás meglévő gépi kód használhatóságát, a viselkedési kompatibilitás pedig a korábbi vállalások megőrzését jelenti. Az opaque handle csak a belső layoutot választja le ezekről; az exportált függvények és a publikus opcióstruktúra továbbra is ABI-felület.

10.4.5 C++ fogyasztó és fordítási profil

A következő teljes client.cpp C++20-as program. A probe objektum tulajdonosa a main; a könyvtár csak a címét őrzi, és a handle megszűnése előtt minden callback befejeződik.

#include "quota.h"

#include <cstdio>

struct probe {
    unsigned count;
    unsigned long last;
};

extern "C" void record_total(void *userdata, unsigned long total)
{
    auto *p = static_cast<probe *>(userdata);
    ++p->count;
    p->last = total;
}

int main()
{
    probe events{};
    quota_options options{};
    options.struct_size = sizeof options;
    options.api_version = QUOTA_API_VERSION;
    options.limit = 10;
    options.notify = record_total;
    options.userdata = &events;

    quota *q = nullptr;
    int rc = quota_create(&options, &q);
    if (rc != QUOTA_OK) {
        std::puts(quota_status_text(rc));
        return 1;
    }

    int first = quota_add(q, 9);
    int second = quota_add(q, 2);

    quota_destroy(q);
    q = nullptr;

    std::puts(quota_status_text(second));
    return first == QUOTA_OK && second == QUOTA_ELIMIT
        && events.count == 1 && events.last == 9 ? 0 : 1;
}

Kijelölt, itt nem ellenőrzött laborprofil: Linux x86-64 System V, LP64; GCC/G++ 14.2.0, glibc 2.40, libstdc++ 14.2.0, GNU binutils 2.43. A parancsok ezekre beállított eszközneveket feltételeznek. A példát itt nem fordítottuk és nem futtattuk.

GCC-kiterjesztés, Linux/ELF: a rejtett alapláthatóság mellett a QUOTA_API exportálja a publikus függvényeket.

gcc -std=c17 -Wall -Wextra -Wpedantic -fPIC -fvisibility=hidden \
  '-DQUOTA_API=__attribute__((visibility("default")))' \
  -c quota.c -o quota.o

gcc -shared quota.o -o libquota.so

g++ -std=c++20 -Wall -Wextra -Wpedantic \
  client.cpp -L. -lquota -Wl,-rpath,'$ORIGIN' -o quota-client

A C implementációt C fordítóval készítjük el; a C++ fordító csak a klienst dolgozza fel. A várt működés egy sikeres növelés, egy állapotváltozás nélküli elutasítás és egyetlen callback. Ez elvárt eredmény, nem mérési jegyzőkönyv.

10.4.6 Gyakori hibák

A quota_destroy(q) nem nullázza a hívó változóját: a pointerparaméter is érték szerint adódik át. A későbbi q = NULL vagy q = nullptr hasznos helyi védelem, de más aliasokat nem javít meg.

A userdata megőrzése nem élettartam-hosszabbítás. Ha a kliens egy már visszatért függvény lokális objektumának címét adta meg, a későbbi callback érvénytelen hozzáférést végezhet.

Ugyancsak hibás az „opaque, tehát szálbiztos” következtetés. A reprezentáció elrejtése hozzáférési korlát, nem szinkronizáció.

10.4.7 Feladatok

  1. Magyarázó: miért szűnhet meg az opcióstruktúra közvetlenül a sikeres létrehozás után, miközben a userdata által jelölt objektum nem? Mi történik a handle másolatával megsemmisítéskor?

  2. Kódolási: egészítsd ki a C++ klienst úgy, hogy az elutasított növelés után még egy egységet hozzáadjon. Ellenőrizd, hogy a végső callbackérték tíz, a hívásszám kettő.

  3. Diagnosztikai: egy új könyvtármező hozzáadása után a fejlesztő változatlanul hagyja a struct_size < sizeof *options ellenőrzést. Miért hibás ez régi kliensekkel? Miért nem elegendő pusztán eltávolítani az ellenőrzést?

10.4.8 Megoldások

10.4.8.1 Az élettartamok szétválasztása

A create lemásolja az opciók értékeit, ezért az opcióobjektumot később nem olvassa. A userdata pointerének másolása viszont nem másolja le a mögöttes objektumot. Annak a callbackek teljes ideje alatt élnie kell.

Megsemmisítéskor minden handle-alias érvénytelenné válik. Az ellenőrzési feltétel: ne maradjon később használható hozzáférési út a megszűnt objektumhoz.

10.4.8.2 Állapotmegőrzési próba

A kliensben az első két add után, a megsemmisítés és az eredeti visszatérés helyére kerüljön az alábbi C++20-részlet:

int third = quota_add(q, 1);
bool ok = first == QUOTA_OK && second == QUOTA_ELIMIT
    && third == QUOTA_OK
    && events.count == 2 && events.last == 10;

quota_destroy(q);
q = nullptr;
return ok ? 0 : 1;

A korábbi buildparancs használható. A vizsgált feltétel nem pusztán a hibakód: az elutasított művelet nem fogyaszthat kvótát és nem generálhat callbacket.

10.4.8.3 Visszafelé kompatibilis méretkezelés

Az új sizeof nagyobb lehet a régi kliens által átadott méretnél, így az ellenőrzés kompatibilis hívást utasítana el. Eltávolítása viszont a hiányzó mező olvasásához vezethet.

A javítás a régi minimális méret megőrzése, mezőnkénti méretellenőrzés és alapértékadás. Ellenőrzési feltétel, hogy az új könyvtár régi klienssel is működjön, és semmilyen hiányzó mezőt ne olvasson.

10.4.9 Összefoglaló

A stabil C interfész rejtett állapotból és látható szerződésből áll. A create/destroy pár rögzíti az ownership- és allokátorhatárt; a callback, a context és a hibaadat külön élettartam-szabályt kap. A méretmező támogatja a bővítést, de csak megőrzött ABI-prefixszel, alapértékekkel és tudatos verziópolitikával. Az extern "C" előkészíti a C++ fogyasztást; a helyességet továbbra is az explicit szerződés biztosítja.