Adventi naptár

Czirkos Zoltán · 2021.08.24.

Változó argumentumszámú függvények

A fordító számára mindig meg kell adnunk függvényeink prototípusát, mivel ismernie kell a paraméterek pontos számát és típusát. Felmerül a kérdés azonban: ha ezeket mindig meg kell adni, hogyan csinálja a printf(), hogy akárhány paramétere lehet?

Továbbmenve, különleges függvény-e a printf(), vagy olyan, mint a többi? Speciálisan kezeli a fordító, vagy ugyanúgy, mint a többit? Ez azért fontos kérdés, mert ha csak a printf() ilyen, akkor mi nem írhatunk így viselkedő függvényt. Azonban ha a printf() semmilyen szempontból nem különleges, akkor a saját függvényeink is lehetnek ilyenek.

Ha megnézzük a printf() dokumentációját, a következő prototípust látjuk:

int printf(const char *format, ...);

A függvény első paramétere egy sztring, utána pedig a prototípusban egy pontpontpont ... (ellipsis) szerepel. Ez azt jelenti, hogy ott akármennyi paraméter állhat. Ez egy C nyelvi elem, és bármely saját függvénynél is használható.

1. A változó argumentumszámú függvények hívása

A dolognak van egy rákfenéje, mégpedig az, hogy a változó argumentumszámú függvényeknél nincsen típusellenőrzés. Ez azt jelenti, hogy a fordító semmilyen ellenőrzést nem végez a paramétereket illetően, csak beteszi őket a verembe szép sorjában, és a függvénynek a feladata kitalálni valamilyen módon, hogy hány paramétert kapott, és hogy mi lehet azoknak a típusa. Ez is célja a printf() formátumsztringjének: az abban megadott %d, %s stb. kódokkal adjuk meg azt, hogy milyen paramétereket kapott, és hogyan kell értelmezni azokat:

char h[] = "hello";
char v[] = "vilag";
printf("%s %p %c %d", h, v, 'A', 'A');
hello 0x40075c A 65

A fenti függvényhívás előbb kiírja a sztringet (hello), amelyre a h pointer mutat. Utána kiírja a pointer értékét, vagyis a v memóriacímet. Mindkettő típusa pointer, csak eltérőképpen értelmezzük! A harmadik kiírt dolog egy karakter, mégpedig az A betű. A negyedik pedig egy egész szám, az A karakter kódja. Itt is egyezik a típus: mindkettő egész szám, csak másképp értelmezzük kiíráskor.

Sajnos a típusellenőrzés hiánya azt is jelenti, hogy automatikus konverziók nem lehetségesek. Ha deklarálunk egy f(double) függvényt, akkor az meghívható f(2) módon, hiszen a fordító tudja, hogy a 2-es egész számot konvertálnia kell 2.0 értékre, amely double típusú. Nincs azonban ez így a printf()-nél a változó argumentumszám miatt:

printf("%f\n", 2);   // helytelen (nálam 0.000000-t ír ki)

printf("%f\n", 2.0); // helyes, 2.000000-t ír ki

2. Változó számú és típusú argumentumok feldolgozása

Egy ilyen függvény hogyan éri el a paramétereit, ha még megnevezni sem tudja azokat?

Erre valók az stdarg.h-ban definiált makrók:

#include <stdarg.h>

/* elindítja a változó számú argumentumok feldolgozását. */
void va_start(va_list ap, utolsó);

/* lekéri a következő paramétert, amelynek típusa „típus” */
típus va_arg(va_list ap, típus);

/* befejezi a feldolgozást */
void va_end(va_list ap);

Ezeket a következőképpen kell használni:

  • Definiálni kell egy va_list típusú változót. Ez lesz a paramétere a makróknak.
  • A feldolgozás előtt meg kell hívni a va_start makrót. Ennek meg kell adni az utolsó olyan paramétert, amelynek még van neve, vagyis a ... előttit. (Ebből az következik, hogy legalább egy névvel rendelkező paraméternek lennie kell – a printf()-nél ez a formátumsztring.)
  • Az egyes paraméterek értékeit a va_arg makró adja meg, amelynek meg kell adnunk mindig a következő paraméter típusát. (A printf() ezt a formátumsztringből találja ki.)
  • Ha befejeztük, akkor meg kell hívni a va_end makrót.

Egy példa függvény, amely kiírja a neki megadott tetszőleges számú sztringet a képernyőre:

#include <stdio.h>
#include <stdarg.h>

void sztringek(char const *elso, ...) {
    va_list ap;
    char *kovetkezo;

    if (elso != NULL)
        printf("Elso: %s\n", elso);       // ezt még közvetlenül elérjük

    va_start(ap, elso);

    kovetkezo = va_arg(ap, char*);       // első a ...-ban
    while (kovetkezo != NULL) {
        printf("Tovabbi: %s\n", kovetkezo);
        kovetkezo = va_arg(ap, char*);    // továbbiak a ...-ban
    }

    va_end(ap);
}

int main(void) {
   sztringek("hello", "vilag", NULL);
   
   return 0;
}

Figyeljük meg: valahonnan a függvénynek tudnia kell, hogy hol van vége a paraméterlistának. Ezért a paraméterek után teszünk egy strázsát: egy NULL pointert, amely a sor végét jelzi. Ezt nem szabad elfelejteni, hiszen utána memóriaszemét van a veremben, és a függvény különben nem tudná, hol van vége a sorozatnak! (Azért jó itt a NULL pointert, hiszen az is pointer, mint a sztringek – vagyis típusban kompatibilis azokkal. Ha egész számokat összegezne a függvény, akkor például 0 vagy -1 lehetne a strázsa.)

A háttérben? Fekete mágia. A va_start ráállít egy pointert az első változóra, a va_arg pedig változtatja ennek a pointernek az értékét. Az általunk megadott típusból tudja azt, hogy hány bájttal kell léptetni, és hogy milyen típusúvá kell castolni azt az egyes hívásokkor. A helyzet azonban még ennél is bonyolultabb…

3. Type promotion

MibőlMi lesz
char int
shortint
floatdouble

A változó számú argumentumok esetén ugyanis történik konverzió, mégpedig az egyes „kicsi” típusok a nekik megfelelő „nagy” típussá konvertálódnak. A konverziók jobb oldalt láthatók. Ez azt jelenti, hogy ha változó számú argumentumlistán átadunk egy karakter típusú változót, akkor azt a függvény int-té konvertálva kapja meg. Természetesen nem gond, hiszen az int ábrázolási tartománya nagyobb, vagyis adat nem veszik el. Csak figyelni kell erre a va_arg() makró használatakor. Egyébként ez a konverzió előnyös, hiszen ezért képes működni egy ilyen programrész:

char c = 'A';
printf("Karakter: %c, kod: %d", c, c);

A fordító ugyanis nem tudja azt, hogy a függvény karaktert vagy int-et vár, hiszen a típusok a ... miatt ismeretlenek. Ezért inkább a karaktert is int-té konvertálja. (Ezzel kapcsolatban a lábjegyzetet lásd lent.)

4. Saját printf

A fentiek alapján elkészíthetjük a printf() egy saját, leegyszerűsített változatát. A lenti függvény ismeri a sztring, a karakter és az egész típust. A működése egyszerű: sorban írja ki a kapott formátumsztring karaktereit, és ha egy % jelbe botlik, akkor a formátumsztring következő karakterét megvizsgálva látja az ahelyett kiírandó paraméter típusát.

#include <stdio.h>
#include <stdarg.h>

void egesz_kiir(int mi) {
    if (mi / 10 > 0)
        egesz_kiir(mi / 10);
    putchar(mi % 10 + '0');
}

void sajat_printf(const char *formatum, ...) {
    va_list ap;
    char *sztringptr;

    va_start(ap, formatum);
    for (int i = 0; formatum[i] != 0; i++) {
        if (formatum[i] == '%') { /* ha %, akkor feldolgozás */
            i++;
            switch (formatum[i]) {
            case 's':
                sztringptr = va_arg(ap, char*); // következő: egy char*
                while (*sztringptr != 0)
                    putchar(*sztringptr++);
                break;
            case 'c':
                putchar(va_arg(ap, int));     // char, de intként kapjuk!
                break;
            case 'd':
                egesz_kiir(va_arg(ap, int));  // int, egész számot írunk ki
                break;
            case '%':
                putchar('%');
                break;
            }
        }
        else
            putchar(formatum[i]); /* ha nem %, simán kiírjuk */
    }
    va_end(ap);
}

int main(void) {
    sajat_printf("[%s, %s%c %d fok van.]\n", "Hello", "Taz", '!', 15);
    return 0;
}

5. Naplózás

Saját printf()-et persze nem érdemes írnunk, hiszen a beépített is jól működik. Viszont ha például naplózni szeretnénk, hogy mikor mi történik a programunkban, akkor érdemes lehet kiírni minden sor elejére a kiírás időpontját is. Ettől eltekintve pedig jó lenne, ha ugyanúgy lehetne a naplózó függvényt használni, hiszen a printf()-et mindenki ismeri.

A naplózó függvény fejléce tehát nézzen ki így:

void sajat_log(const char* formatum, ...);

A sor elejére írja ki a pontos dátumot és időt, utána pedig jöhet a formátumsztring és a többi paraméter, amit adjunk át a printf()-nek. De hogyan adjuk át neki a változó darabszámú paramétereket, ha nevük sincs?

Ahhoz, hogy legyen neve a paramétereknek, el kell kezdenünk feldolgozni a paraméterlistát, erre való a va_start() makró. Utána viszont már nem tudjuk egészben odaadni a printf()-nek, hiszen már elkezdtük feldolgozni.

Szerencsére a printf()-nek (és az összes hozzá hasonló függvénynek) van olyan változata, ami egy, már feldolgozás alatt álló paraméterlistát, egy va_list-et kap paraméterként, ez a vprintf(). Ezzel már egyszerű a dolgunk.

#include <stdio.h>
#include <time.h>
#include <stdarg.h>

void sajat_log(const char* formatum, ...) {
    time_t most = time(NULL); /* pontos idő másodpercben */
    struct tm *most_tm = localtime(&most); /* helyi időzónában, darabokra bontva */
    char buffer[26];
    strftime(buffer, 26, "%Y-%m-%d %H:%M:%S", most_tm); /* sztringesítve */

    printf("[%s] ", buffer); // a sor elején legyen az idő

    va_list ap;
    va_start(ap, formatum);
    vprintf(formatum, ap); // utána amit a hívó kért
    va_end(ap);
}

int main() {
    sajat_log("%s\n", "Hello");
    return 0;
}
[2015-10-21 07:28:00] Hello

6. Adjuk vissza dinamikusan!

A helyzet kicsit bonyolultabb, ha nem kiírni akarjuk a paramétereket, hanem eltárolni egy sztringben. Ugyan létezik sprintf(), ami egy általunk megadott sztringbe írja a kapott paramétereket (és a párja, a vsprintf() is), de mekkora sztringet adjunk neki, ha nem tudhatjuk, mennyi lesz elég?

A jó hír, hogy (ellentétben sok más, hasonló szituációval) most le lehet mérni, hogy mekkora sztring kell, hiszen a hívás pillanatában már ott van minden információ a paraméterlistán. Létezik is rá függvény, amit erre találtak ki, az snprintf() és a vsnprintf(). A printf()-hez képest van két plusz paramétere: a sztring, amibe ír, és darabszám, ahány karaktert írhat, beleértve a lezáró 0-t. A függvény két dolgot csinál:

  • Beleírja a karaktereket sztringbe, amennyi belefér.
  • Visszaadja, hogy hány karaktert írt a sztringbe. Ha többet írna, mint szabad, akkor megszámolja, mennyi hely kellene, és azt adja vissza. (Ha ez ≥, mint a darabszám, amit kapott, abból tudhatjuk azt is, hogy a sztring vége le van vágva.) Ebbe nem számolja bele a lezáró 0-t. (Miért? Rejtély.)

Tehát nincs más dolgunk, mint

  • meghívni az vsnprintf()-et 0 mérettel, hogy lemérjuk, mennyi memóriát kell foglalnunk,
  • malloc(),
  • megint meghívni a vsnprintf()-et, hogy beleírjuk a karaktereket.

A rossz hír, hogy az első vsnprintf() hívás "elhasználja" a paraméterlistát, hiszen a va_arg() makró átállít belül egy pointert. Ezért a második vsnprintf()-et nem tudjuk ugyanazzal a va_list-tel meghívni. A va_start() makrót pedig nem hívhatjuk kétszer, mert csak egyszer kezdhetjük el a paraméterek feldolgozását.

Ezért a három eddig bemutatott makrón kívül van még egy negyedik, a va_copy():

/* Átmásolja az összes, src-ben hátralévő paramétert dest-be.
 * Végül dest-re is kell va_end()-et hívni. */
void va_copy(va_list dest, va_list src);

Tehát még mielőtt a vsnprintf() elkezdené kiszedni a paramétereket, kell róluk készítenünk egy másolatot, amit a második vsnprintf() kap meg.

#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>

char* printf_string(const char* formatum, ...) {
    va_list ap1; /* ezt kapja az első vsnprintf */
    va_start(ap1, formatum);

    va_list ap2; /* ezt kapja a második vsnprintf */
    va_copy(ap2, ap1);

    int meret = vsnprintf(NULL, 0, formatum, ap1) + 1; /* lezáró 0 */
    char* str = (char*) malloc(meret * sizeof(char));

    vsnprintf(str, meret, formatum, ap2); /* ap2-be ír */

    va_end(ap1);
    va_end(ap2);
    return str;
}

int main() {
    char* str = printf_string("%s %d!\n", "Hello", 5)
    printf("%s", str);
    free(str);
    return 0;
}

7. fv(), fv(void), fv(...)

A fentiek miatt, és tradicionális okokból is C-ben a fv() és az fv(void) függvény fejléc teljesen mást jelent:

fv(void)
Egy olyan függvény, amelynek nincs paramétere, és nem is kaphat.
fv(int x, ...)
Változó paraméterszámú függvény. A ... előtt legalább egy neves paraméter kell legyen, hogy a feldolgozást a fent bemutatott módon el lehessen végezni.
fv()
Egy olyan függvény deklarációja, amely bármennyi és bármilyen típusú paramétert kaphat. Mivel a paraméterek nincsenek megnevezve, ezért nem lehet elérni őket. Ennek nagy szerepe a C szabványosítás előtti változatában volt, amikor a függvényeket így kellett definiálni:
int osszeg(a, b)
int a;
int b;
{
    return a+b;
}
Ez működik a mostani fordítókkal is, de ellenjavalt.

Lábjegyzet

Két megjegyzés a „fordító nem tudja azt”-tal kapcsolatban.

Az egyik, hogy a GCC a printf() stílusú függvényeknél összehasonlítja a formátumsztringet és a kapott paramétereket. Ha ezek nem stimmelnek, akkor figyelmeztetést küld:

printf("Hello, %s", 5);
proba.c:50:4: warning: format ‘%s’ expects type ‘char *’, but argument 2 has type ‘int’

Ez igen hasznos dolog. Sajnos nem minden fordító tesz így. Viszont ez csak figyelmeztetés; a C nyelvtani szabályai szerint a fenti sor helyesnek számít. (Szintaktikailag helyes, szemantikailag helytelen.)

A másik, hogy ugyancsak a GCC figyelmeztetést küld akkor is, ha leírjuk a va_arg(ap, char) sort: mivel a karakter típusú argumentum int-té konvertálódik, ezért ez biztosan csak hibás lehet.

proba.c:33:32: warning: ‘char’ is promoted to ‘int’ when passed through ‘...’
proba.c:33:32: note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
proba.c:33:32: note: if this code is reached, the program will abort