10.2 Variadikus függvények és típusbiztonság

A variadikus függvény (variadic function) változó számú argumentumot fogadhat. C-ben azonban a rugalmasságért a típusellenőrzés részleges elvesztésével fizetünk: a függvény nem kap automatikusan sem argumentumdarabszámot, sem futásidejű típusleírást. A hívónak és a hívottnak ezért külön szerződésben kell megállapodnia.

A legismertebb példa a printf: első argumentuma egyszerre szöveg és a további argumentumokat leíró séma. Megértéséhez a Függvényhívás, prototípus és paraméterátadás, valamint a Promóciók, signedness és túlcsordulás szabályaira építünk. Az alábbi nyelvi magyarázat ISO C17-re vonatkozik; a fordítói attribútumot külön bővítésként kezeljük.

10.2.1 Az ellipszis és a rejtett szerződés

A prototípus végén álló ..., azaz ellipszis jelzi a változó argumentumlistát:

/* C17 deklarációrészlet. */
int count_positive(int count, ...);

C17-ben az ellipszist legalább egy névvel rendelkező paraméternek kell megelőznie. A rögzített paraméterekre a prototípus szerinti ellenőrzés és konverzió érvényes. Az ellipszishez tartozó argumentumokhoz viszont nincs deklarált céltípus.

A fenti API szerződése lehet:

A fordító a deklarációból nem tudja ellenőrizni a második feltételt. A count csak programadat: nem köti automatikusan a nyelv a tényleges argumentumlistához.

A variadikus függvény darabszám helyett használhat formátumsztringet, lezáró jelzőértéket vagy külön típusleírókat. Mindegyik külső protokoll, nem a ... által szolgáltatott információ.

10.2.2 Default argument promotions és a printf

Az ellipszisnek átadott argumentumokra az alapértelmezett argumentumpromóciók (default argument promotions) vonatkoznak:

Eredeti típus Átadott típus
float double
_Bool, signed char, short int
char, unsigned char, unsigned short int, ha minden értékük ábrázolható benne; különben unsigned int
int, unsigned int, long, double Változatlan

A char esetén tehát az implementáció által meghatározott előjelességet és az értéktartományt is figyelembe kell venni.

Fontos, hogy az int nem változik automatikusan double-lé attól, hogy a formátumban %f szerepel. A formátumsztring nem vesz részt a nyelvi argumentumkonverzióban.

/* C17 részlet; környező függvény és <stdio.h> szükséges. */
float rate = 1.5f;
signed char delta = -2;

printf("rate=%.1f delta=%d\n", rate, delta);

A hívás lépései:

  1. A rate értéke float típusból double típusra promoválódik.
  2. A delta értéke signed char típusból int típusra promoválódik.
  3. A printf a %f alapján double, a %d alapján int argumentumot vár.
  4. Az átadott és elvárt típusok megfelelnek egymásnak.

Gyakori formátumszerződések:

Formátum Elvárt argumentum
%d int
%u unsigned int
%ld long
%zu size_t
%f double
%Lf long double
%s Karaktertömb elejére mutató pointer; ebben a fejezetben nullterminált sztringet követelünk meg
%p void *

A printf esetén a %lf ugyanúgy double argumentumot kezel, mint a %f; ezt nem szabad más formázási API-kra automatikusan általánosítani.

A %p esetén objektumpointert explicit (void *) konverzióval adj át: az ellipszis önmagában nem alakítja például az int * argumentumot void * típusúvá. A *-gal megadott mezőszélesség és pontosság külön-külön egy int argumentumot fogyaszt.

10.2.3 A va_list életciklusa

A <stdarg.h> biztosítja a változó argumentumlista bejárását. A va_list implementációfüggő bejárási állapot, nem hordozható „pointer a veremre”.

Művelet Jelentés és feltétel
va_start(ap, last) Inicializálja a bejárást; last az utolsó név szerinti paraméter
va_arg(ap, T) Kiolvassa a következő argumentumot T típussal, és továbblépteti az állapotot
va_copy(dst, src) Önálló bejárási állapotot készít src aktuális pozíciójáról
va_end(ap) Lezárja az adott inicializált állapot használatát

Minden va_start és va_copy művelethez a megfelelő va_end tartozik, ugyanabban a függvényben. Ez korai visszatérésnél is kötelező. A va_end nem szabadítja fel az argumentumként átadott erőforrásokat.

C17-ben a va_start utolsó név szerinti paramétere nem lehet register deklarációjú, tömb- vagy függvénytípusú, illetve a saját promoválódott típusával inkompatibilis típusú. Ezért például float, char vagy short helyett célszerű int-et, illetve megfelelő pointerparamétert használni.

A va_list másolására ne használj egyszerű értékadást vagy memcpy-t: egyes implementációkon tömbtípus, máshol összetettebb állapot. A hordozható művelet a va_copy.

Ha egy meghívott segédfüggvény va_arg-gal fogyasztja az átadott listát, a hívó nem feltételezheti annak változatlan pozícióját. Megőrzéshez előzetesen külön másolatot kell készíteni.

10.2.4 Kidolgozott példa: számlálás és kétcélú naplózás

Az alábbi teljes C17 fordítási egység két szerződést mutat be. A count_positive darabszám alapján olvas, a mirror_log pedig ugyanazt a formázott üzenetet két streamre írja.

A laborhoz választott profil: Linux x86-64 System V, GCC 14.2.0, glibc 2.40. Az attribútum nélküli mag ISO C17; az attribútum GCC/Clang-bővítés. A példa itt nem fordított vagy futtatott mérési eredmény.

/* variadic_demo.c */
#include <stdarg.h>
#include <stdio.h>

#if defined(__GNUC__) || defined(__clang__)
#define PRINTF_LIKE(fmt, args) \
    __attribute__((format(printf, fmt, args)))
#else
#define PRINTF_LIKE(fmt, args)
#endif

static int count_positive(int count, ...)
{
    if (count < 0) {
        return -1;
    }

    va_list ap;
    va_start(ap, count);

    int result = 0;
    for (int i = 0; i < count; ++i) {
        int value = va_arg(ap, int);
        if (value > 0) {
            ++result;
        }
    }

    va_end(ap);
    return result;
}

static int mirror_log(FILE *first, FILE *second,
                      const char *format, ...)
    PRINTF_LIKE(3, 4);

static int mirror_log(FILE *first, FILE *second,
                      const char *format, ...)
{
    va_list ap;
    va_list copy;

    va_start(ap, format);
    va_copy(copy, ap);

    int a = vfprintf(first, format, ap);
    int b = vfprintf(second, format, copy);

    va_end(copy);
    va_end(ap);

    return (a < 0 || b < 0) ? -1 : 0;
}

int main(void)
{
    float rate = 1.5f;
    signed char delta = -2;
    int positives = count_positive(4, -3, 0, 7, 2);

    int status = mirror_log(
        stdout, stderr,
        "rate=%.1f delta=%d positive=%d\n",
        rate, delta, positives
    );

    return status < 0 ? 1 : 0;
}

Fordítás a kiválasztott GCC-vel:

gcc -std=c17 -Wall -Wextra -Wpedantic -Wformat=2 \
    -O0 -g variadic_demo.c -o variadic_demo

A számlálás várt eredménye 2. A naplózó mindkét streamre a rate=1.5 delta=-2 positive=2 sort próbálja kiírni. A két stream megfigyelhető sorrendjére ebből nem következik garancia.

A másolatot az első fogyasztás előtt készítjük. A vfprintf után az adott lista nem használható újra következő bejárásként; a megfelelő va_end azonban szükséges.

A kölcsönzési szerződés:

Erőforrás vagy tároló Tulajdonos és élettartam Érvénytelenítés, hibakimenet
A két stream A hívó/környezet kezeli; a naplózó csak kölcsönzi őket A hívás alatt nyitott, bájtos kimenetre használható streamek szükségesek
Formátumsztring A hívó biztosítja; a példában statikus élettartamú sztringliterál A hívás végéig érvényes és változatlan
Esetleges %s adatok A hívó tulajdona; a naplózó nem tárolja el a pointereket Felszabadítás vagy érvénytelen sztring szerződéssértés, nem megbízható hibakód
Két va_list állapot A mirror_log kezeli Saját va_end zárja le mindkettőt

A mirror_log -1 értéke valamelyik vfprintf hibáját jelzi. Részleges kimenet már létrejöhetett, visszagörgetés nincs. A siker sem jelent tartós tárolást vagy későbbi pufferürítési hibák kizárását.

10.2.5 Típuseltérés és diagnosztika

A va_arg(ap, T) nem típuskonverziót kér: azt állítja, hogy a következő argumentum megfelelő típusú.

/* Szándékosan hibás C17 részletek; nem futtatandók. */
printf("%f\n", 1);             /* int helyett double kellene */
printf("%ld\n", 1);            /* int helyett long kellene */
count_positive(3, 10, 20);     /* nincs harmadik olvasható érték */

Ezek nem definiált viselkedéshez, UB-hez vezetnek. Nincs garantált kimenet, és a látszólagos működés sem bizonyít helyességet. Hasonlóan hibás a va_arg(ap, float), ha a hívó float kifejezést adott át: az ténylegesen double-ként érkezett.

Az ISO C17 va_arg-szabályának vannak szűk kivételei: megfelelő signed/unsigned típuspárnál, ha az érték mindkettőben ábrázolható, illetve void * és karaktertípusra mutató pointer párosánál. Ezekből nem következik általános típusátjárhatóság, és nem írják felül a printf saját formátumszerződését.

A fel nem használt argumentum önmagában nem UB: a printf("%d\n", 1, 2) a második értéket figyelmen kívül hagyja. A túl kevés argumentum viszont veszélyes, mert a formátum nem létező adat kiolvasását kérheti.

A példabeli format(printf, 3, 4) attribútum jelentése: a harmadik paraméter formátumsztring, az ellenőrzendő argumentumok a negyediktől kezdődnek. Ez nem módosítja az átadást, csak diagnosztikai információt ad a fordítónak. Nem ISO C17-képesség, és nem általános futásidejű védelem.

Különösen fontos, hogy külső szöveg ne váljon formátummá. A printf(input) helyett printf("%s", input) szükséges, feltéve, hogy input érvényes, nullterminált sztringre mutat. A sztring tárolójának a teljes hívás alatt élnie kell.

10.2.6 Típusos alternatívák

Homogén adatokhoz rendszerint jobb a pointer–darabszám interfész:

/* C17 deklarációrészlet; <stddef.h> szükséges. */
size_t count_positive_array(const int *values, size_t count);

Itt a fordító ellenőrzi az elemtípust, bár a tömbhatárt továbbra is a szerződés rögzíti. A függvény kölcsönzi a tömböt, nem tárolja el és nem szabadítja fel. count > 0 esetén legalább count olvasható int szükséges; count == 0 esetén megengedhető a null pointer.

Eltérő típusú adatokhoz névvel ellátott struktúra vagy típusjelölős union-elemek tömbje használható. Ezek hosszabb deklarációért cserébe ellenőrizhetőbb interfészt adnak. Variadikus API-t akkor érdemes választani, ha a rugalmasság valóban indokolja a rejtett szerződés költségét.

10.2.7 Feladatok

  1. Magyarázó: Milyen típusként érkezik a példában rate és delta? Miért nem javítaná ki a %f formátum egy közvetlenül átadott 1 argumentum típusát?

  2. Kódolási: Valósítsd meg a count_positive_array függvényt. Üres bemenetre fogadjon el null pointert, és ne dereferálja azt.

  3. Diagnosztikai: Miért hibás a mirror_log, ha a második vfprintf is ap-t kap? Mi történik, ha a va_copy csak az első vfprintf után kerülne? Melyik korábbi hibát várhatjuk formátumellenőrzéstől, és melyiket nem?

10.2.8 Megoldások

10.2.8.1 Promóció és formátum

A rate double, a delta int típusként érkezik. A formátum feldolgozása a hívott függvényben történik; az argumentumkonverzió már a hívás részeként megtörtént. Az 1 ezért int marad.

Ellenőrzési feltétel: a javítás például printf("%f\n", 1.0) legyen; ne pusztán a warning elnyomása.

10.2.8.2 Típusos számláló

Önálló C17 fordítási egység:

/* positive_array.c */
#include <stddef.h>

size_t count_positive_array(const int *values, size_t count)
{
    size_t result = 0;

    for (size_t i = 0; i < count; ++i) {
        if (values[i] > 0) {
            ++result;
        }
    }

    return result;
}
gcc -std=c17 -Wall -Wextra -Wpedantic \
    -c positive_array.c -o positive_array.o

Bizonyításvázlat: minden iteráció előtt result az első i elem pozitív értékeinek száma, ezért 0≤result≤i≤count0 \leq result \leq i \leq count. Az eredmény nem csordul túl. Üres bemenetnél a ciklustörzs nem fut.

Ellenőrzési feltétel: (NULL, 0) eredménye 0; a {-3, 0, 7, 2} tömbé 2. A művelet O(count)O(count) idejű, folytonos hozzáférésű, nem allokál és nem érvénytelenít tárolót.

10.2.8.3 Fogyasztott lista és ellenőrzési korlát

Az első vfprintf elfogyasztja a bejárási állapotot; ugyanazzal nem indítható újra a lista. Utólagos másolása sem állítja vissza a kezdetet. A javítás az előzetes va_copy, majd mindkét állapot lezárása.

Ellenőrzési feltétel: minden inicializált lista minden visszatérési úton kapjon va_end hívást. A literális formátumhoz adott hibás típusra a fordító figyelmeztethet; a count_positive(3, 10, 20) saját darabszámszerződését a formátumellenőrzés nem ismeri.

10.2.9 Összefoglaló

A variadikus C API típus- és darabszámszerződését a programozó biztosítja. A promóciókat az argumentum eredeti típusa, nem a formátum vezérli. A va_list szabályos inicializálást, va_copy alapú másolást és lezárást igényel. A fordítói formátumellenőrzés hasznos védőháló, de ahol lehet, a típusos tömb- vagy struktúrainterfész erősebb alapot ad.