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ó.
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
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 – aprintf()
-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. (Aprintf()
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…
Miből | Mi lesz |
---|---|
char | int |
short | int |
float | double |
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.)
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;
}
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
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;
}
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:
Ez működik a mostani fordítókkal is, de ellenjavalt.int osszeg(a, b) int a; int b; { return a+b; }
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