Info 2, Második és harmadik heti előadás: deklarációk, kifejezések, függvények

Utolsó módosítás: 2009. február 26.

A C nyelvben az egyszerű típusok (majdnem) mind számokat jelképeznek. Vannak összetett típusok is, amelyek más típusokból állnak elő bizonyos szabályok szerint, mint például a tömbök, de ezekkel ezen az előadáson még nem foglalkozunk részletesen.

A numerikus típusok két részre oszlanak, az egészekre és a lebegőpontos szám típusokra. Először az egészeket nézzük.

Egy egész típus korlátozott méretű egész számokat tárol: az alsó korlátjától a felső korlátjáig bármilyen egész számot tud tárolni.

A méret szerint több különböző egész típus van, mégpedig kisebbtől a nagyobbig a következők.

char
short int
int
long int
long long int

Az int a leggyakrabban használt egész. Ezt úgy választják meg, hogy a számítógép processzora az ilyen méretű számokkal még gyorsan tudjon számolni. Az int típusról a szabvány azt garantálja, hogy legalább 16 bites, így -32767 és 32767 közti számokat biztosan lehet benne tárolni. A legtöbb ma használt rendszeren az int valójában 32 bites, és -2147483648-től 2147483647-ig tud számokat tárolni.

A char a legkisebb egész típus, többek között azért alapvető, mert a fájlokat char-onként tudjuk olvasni és írni. Így aztán a bemenetről olvasó és a kimenetről író függvények gyakran char típust kezelnek. Hagyományosan a char egy karaktert jelentett, tehát a beolvasott és kiírt szövegekben egy betűnek egy char felelt meg. (Ez ma már nem mindig igaz.) Amikor szöveges bemenettel vagy kimenettel foglalkozunk, akkor a char-oknak a számszerű értékét többnyire nem használjuk, hanem csak összehasonlítjuk ismert karakterek értékeivel. Mégis, amikor például számokat olvasunk be vagy írunk ki, ki tudjuk használni, hogy az egyes számjegyeknek (0, 1, … 9) megfelelő karakterek kódjai egymás után vannak, így ilyenkor végezhetünk aritmetikai műveleteket char-on. Természetesen a char-t nem csak fájlok kapcsán használhatjuk, hanem bármilyen nagyon kis számok tárolására, mégis a fájlok a legfontosabb felhasználási területe. A char a szabvány szerint legalább 8 bites, így 0 és 127 között biztosan lehet benne számokat tárolni. Általában a char pontosan nyolc bites, és a processzor a memóriát ennél kisebb egységekben nem is tudja elérni, így kisebb egész szám típust már nem lehetne hatékonyan megvalósítani.

Ha nagyobb számokat szeretnénk tárolni, akkor van szükség a long típusra, ami legalább 32 bites, illetve a long long típusra, amely legalább 64 bites. A short típust, ami kisebb lehet azt int-nél, a char-hoz hasonlóan lehet néha használni: memóriát lehet megtakarítani vele, ha nagyon sokat kell belőle tárolni. (A short int, long int, long long int típusok nevéből az int elhagyható. A legtöbb mai rendszerben a long vagy 32 bites, vagy 64 bites, a long long pedig 64 bites. A long long-ot csak újabb rendszerek támogatják, az újabb, C99 szabványban benne van, a régebbi C89-ben nincs benne.)

Láthatjuk, hogy az egész típusok mérete nincs pontosan garantálva, csak minimumok és sorrend van megadva. Egy egész típust nem szabad úgy használni, hogy a korlátain kívüli szám kerüljön bele, például nagyobb egész típusú számot csak akkor szabad átalakítani kisebbé, ha a kisebb értékkészletébe biztosan belefér, és nem szabad aritmetikai műveletekkel (pl. összeadás, szorzás) nagyobb eredményt kapni, mint a típus mérete. Ha ez mégis előfordulna, akkor a C nyelv nem határozza meg, mi történik. A legtöbb környezet nem jelez hibát ilyen esetekben, hanem a program tovább fut egy hibás szám értékkel.

A C szabványos könyvtára kétféle segítséget ad az egész típusok korlátaihoz. Egyrészt a limits.h fejállomány konstansokat definiál, amelyek az egész típusok minimális és maximális értékeit adják meg:

CHAR_MIN   CHAR_MAX
SHRT_MIN   SHRT_MAX
INT_MIN    INT_MAX
LONG_MIN   LONG_MAX
LLONG_MIN  LLONG_MAX

Ezekre a programban lehet hivatkozni, ha az elején kiadjuk az

#include 
direktívát, így például hibát jelezhetünk, ha a bemenet miatt túl nagy számokkal kéne számolni. Másrészt az stdint.h fejállomány (csak a C99 szabványvan van meg) definiál adott méretű típusokat, úgy mint legalább 16 bites, legalább 32 bites stb. egész típusok, ha ezek rendelkezésre állnak, és ezekhez kapcsolódó konstansokat. (Ezek a típusok általában a följebb felsorolt szabványos egész típusok szinonímáiként vannak definiálva, de elvileg a C fordítóban lehet több beépített típus is.)

Egész típusból valójában kétszer annyi van, mint amennyiről eddig beszéltem, mivel mindegyik típusnak az előjelesen kívül van egy előjel nélküli párja is. A szabványos típusokra ezek a párok

signed char           --  unsigned char
signed short int      --  unsigned short int
signed int            --  unsigned int
signed long int       --  unsigned long int
signed long long int  --  unsigned long long int

Az előjel nélküli típus csak nemnegatív egészeket tud tárolni, így például az unsigned int tipikusan 0 és 4294967295 közti egészeket. Az előjelessel ellentétben az előjel nélküli típusokra a szabvány garantálja a túlcsordulás viselkedését: ha a típusra való átalakítással vagy aritmetikával túl nagy vagy túl kicsi számot kapnánk, akkor (a legnagyobb értéknél eggyel nagyobb kettő-hatvány szerint) maradékosztály szerinti értéket kapjuk meg.

Az előjel nélküli típusokra csak nagyon ritkán van szükség, elsősorban csak olyankor, amikor a pontos túlcsordulást pontosan akarjuk kezelni. A signed általában elhagyható, tehát a sima int az előjeles típust jelenti. Ez alól kivétel a char, ami jelenthet (a rendszertől függően) előjeles vagy előjel nélküli típust.

Megemlítem még, hogy egész szám konstanst a szokásos tízes számrendszerbeli megadáson kívül még két módon lehet megadni: a nullával kezdődő konstanst a fordító nyolcas számrendszerbelinek értelmezi, pl. 062 értéke 50; a nulla utáni x betű pedig tizenhatos számrendszerbeli számot jelent: 0x1a értéke 26.

A lebegőpontos típusok a valós számok egy korlátozott pontosságú közelítését tárolják. Egy ilyen érték a számot tudományos alakban tárolja, fix számú értékes számjeggyel, és a kitevővel. Így például ha a számokat hat értékes jegyre tárolja egy típus, akkor a műveletek

3.33333 · 100

számra kerekítenek bármilyen 3.333325 és 3.333335 közötti eredményt, és például

5.00000 · 1010

lesz az 49999950000 és 50000050000 közti eredményekből. A legtöbb számítógép azonban valójában 2 kitevőivel dolgozik a lebegőpontos típusokban, és bizonyos számú bináris számjegyet tárol. Ezen kívül természetesen a kitevő is korlátozva van egy bizonyos intervallumra.

A lebegőpontos típusok sorrendben a

float
double
long double
amelyek közül majdnem mindig a double-t érdemes használni. A double a legtöbb gépen 54 értékes bitre pontos, ami körülbelül 15 értékes tízes számrendszerbeli számjegyet jelent. A kitevő korlátja miatt a legnagyobb tároljató szám 10300 nagyságrendű, a legkisebb pedig 10-300 körül van.

A double számokkal végzett műveletek eredménye ezért nem pontos, így óvatosan kell kezelni őket. Különösen akkor veszítünk pontosságot, ha két azonos nagyságrendű számot vonunk ki.

Lebegőpontos szám konstanst meg lehet adni tizedesponttal:

3.0  0.0082  452.371
illetve tudományos jelöléssel: a
2.4801e5

jelentése 2,4801 · 105, azaz 240810; a

7.51e-4

jelentése pedig 7,51 · 10-4, azaz 0,000751.

Nézzük most meg a numerikus típusokon használható műveleti jeleket.

Ezek egy részét már ismerjük: a + összead, a - kivon, a * szoroz, az első kettőnek egyváltozós változata is van. A / egész típusokon egész osztást végez, és a törtrészt eldobja (másképpen az eredményt nulla felé kerekíti), így például 10/3 eredménye 3 lesz, 20/36. A megfelelő maradékos osztás maradékát a % adja meg, például 20%3 eredménye 2. Lebegőpontos típusokon a / nem egész osztást végez, hanem az osztás eredményét adja meg a lehető legpontosabban, ahogy az adott típussal reprezentálni lehet.

A többi numerikus műveletet nem a C nyelvbe beépített operátorok végzik, hanem könyvtári függvények. Ezek a függvények a math.h fejlécben vannak deklarálva. Unix rendszereken ezeknek a matematikai függvények az implementációja egy külön library-ben van, amit alapértelmezésként nem épít be a programba a fordító, ezt a gcc -lm kapcsolójával lehet használni.

A következő matematikai függvényekről érdemes tudni. Ezek a függvények mind egy double argumentumot várnak, és egy double típusú választ adnak vissza. Egészrész függvények: a floor egy szám alsó egészrészét adja vissza, a ceil a felső egészrészt, a rint a legközelebbi egészt, a trunc pedig nulla felé kerekít. Exponenciális és trigonometriai függvények az exp, sin, cos, tan; ezek (részleges) inverzei a log, asin, acos, atan. Hasznos még tudni a négyzetgyököt kiszámító sqrt függvényről.

Folytassuk most az operátorok felsorolását. Hat összehasonlítás operátor van:

==  !=  <  >  <=  >=
ezek közül az első az egyenlőséget jelenti, a második a nem egyenlőséget. Ezek az operátorok int típusú eredményt adnak, mégpedig az igazat az 1, a hamisat a 0 szám jelzi. Ezeket tehát lehet egészként használni, így például mivel a 3 < 5 eredménye 1, a (3 < 5) * 20 eredménye 20 lesz, az (5 < 3) * 20 eredménye pedig 0.

Legtöbbször azonban ezeket if vagy while vagy for utasítások feltételeként használjuk. Ezek az utasítások (és még néhány más nyelvi elem) valójában bármilyen szám típusú kifejezést elfogad feltételnek, mégpedig a nullát hamisnak, bármilyen nullától különböző számot igaznak számol. Például a következő két utasítás teljesen egyenértékű:

if (0 != n % 10) 
	printf("n nem osztható 10-zel");
if (n % 10) 
	printf("n nem osztható 10-zel");

Mivel az aritmetikai és hasonló operátorok a C nyelv beépített elemei, ezért a függvényekkel ellentétben többféle típusú számokkal is használhatjuk őket. Van pontos szabály arra, hogy ezek az operátorok milyen típust adnak vissza, röviden a két bemenet típusa közül a nagyobb értékkészletűt, de legalább int-et. Egy értékből kaphatunk egy másik típusú értéket, ha (a) az értékadás operátorral egy másik típusú változónak értékül adjuk, (b) egy függvényhívásban argumentumnak adjuk át, ilyenkor a függvény deklarációjában a megfelelő paraméter típusává alakul; (c) a return utasítással egy függvény visszatérési értékévé tesszük, ilyenkor a függvény visszatérési típusává alakul; vagy (d) két különböző típusú számon végzünk aritmetikai műveletet. Nagyon ritkán azonban szükség lehet arra, hogy egy kifejezést ilyen eseteken kívül is egy másik típusúvá alakítsunk. Ezt a typecast operátorral tehetjük meg, ami úgy néz ki, hogy

(típus)kifejezés
ahol a zárójelpár kötelező. Ez (a fenti három esethez hasonlóan) kiszámolja a kifejezés értékét olyan típusúvá, amilyen normálisan lenne, aztán az eredményt átalakítja az új típusúra úgy, hogy lehetőleg megőrződjön az értéke, és egy ilyen új típusú eredményt ad vissza. Például tegyük fel, hogy az a és b int típusú változók, amiket össze akarunk szorozni, de az eredmény nagyon nagy lehet, és esetleg nem fér bele az int-be. Ha azt írnánk, hogy a * b, akkor az eredmény is int típusú lenne, ami túlcsordulást okozhatna. Ha viszont tudjuk, hogy az eredmény egy long-ba biztosan belefér, akkor használhatjuk a
a * (long int)b
kifejezést. Vegyük észre, hogy a (long int)(a * b) nem működne, mivel ez először int típussal kiszámolja a szorzást. A typecast-nak azonban csak ritkán van igazán jó alkalmazása, nagyon könnyű visszaélni vele.

A logikai operátorok bonyolultabb feltételek építésében segítenek, mégpedig a && az és művelet, aminek az értéke igaz, ha mindkét bemenete igaz; a || pedig a vagy művelet, ami igazat ad, ha legalább az egyik bemenete igaz. A logikai operátorok is mindig int-et adnak, 1-gyel jelzik az igazat, 0-val a hamisat; a két bemenetük pedig bármilyen típusú szám lehet, és csak a 0 számít hamisnak. Az && és a || operátor mindig először a bal oldali kifejezést értékeli ki, a jobb oldalit pedig csak akkor, ha a bal oldali nem dönti el egyértelműen az eredményt. Az && jobb oldala tehát akkor értékelődik ki, ha a bal oldal igaz; az || jobb oldala pedig akkor, ha a bal oldal hamis.

Például legyen egy tömbünk és egy változónk a következőképpen deklarálva:

int a[8];
int k;

Most ha az a-nak az első 3-nál nagyobb elemét szeretnénk megkeresni, akkor írhatjuk azt, hogy

k = 0;
while (k < 8 && a[k] <= 3)
	k++;

Ha az a-nak mind a nyolc eleme kisebb nullánál, akkor a ciklus úgy áll meg, hogy k értéke 8 lesz, és az utolsó iterációban már csak a k < 8 kifejezés értékelődik ki, az a[k] <= 3 nem, és ez tiszta szerencse, mert ez utóbbi a tömbön kívül próbálna olvasni, ami szabálytalan lenne.

Néha hasznos a harmadik logikai művelet is, az egyváltozós !, ami a tőle jobbra álló kifejezés logikai tagadását adja. Ez magas precedenciájú, vagyis erősen köt. Például az

!(6 == x || 7 == x)
kifejezés akkor igaz, ha x sem hattal, sem héttel nem egyenlő. Ez egyenértékű a következővel.
6 != x && 7 != x

Találkoztunk már az értékadás (=) operátorral, amely a bal oldalán lévő kifejezés által megadott helyre eltárolja a jobb oldali értéket. A bal oldalon állhat változó, mint pl. n = 3, vagy tömbelem, mint pl. a[i] = -1, meg még más kifejezések is, amiket csak később fogunk megismerni. Az értékadást lehet egy kifejezés belsejében is használni, ilyenkor az értékadás részkifejezés értéke az lesz, ami a jobb oldalon áll. Szabad így például olyan kifejezést írni, hogy

k = (n = 5);
ami mindkét változó értékét ugyanarra állítja be (a zárójel elhagyható). Olyat is szabad írni, hogy például
if ((n = 3 * k) < 20)
ami egyben mindenképpen beállítja az n változó értékét, másrészt megvizsgálja, hogy ez az érték kisebb-e 20-nál, és valamilyen utasítást végrehajt, ha igen.

Tipikusabb példa a következő.

int
main(void) {
	int c;
	while (EOF != (c = getchar()))
		putchar(c);
	return 0;
}

Ez a program a standard bemenet tartalmát lemásolja a standard kimenetre. A getchar könyvtári függvény beolvas egy karaktert a standard bemenetről, ha ez sikerül, akkor visszaadja a karaktert, ha nem, akkor az EOF konstans által jelölt számot adja vissza. A visszatérési értéket el is tároljuk a c változóba, hogy később kiírhassuk a kimenetre a putchar függvénnyel, de össze is hasonlítjuk EOF-fal, hogy a ciklust le tudjuk állítani a bemenet végén. (Megjegyzés: a getchar függvény int típusú számot ad vissza, mivel ez a típus tárolni tudja a char összes lehetséges értékét – bármelyik előfordulhat egy fájlban – és egy ezektől megkülönböztethető EOF értéket is. Ha a függvény char-t adna vissza, akkor valamelyik bájtot nem lehetne a fájl végétől megkülönböztetni.)

Kis kitérő. A C nyelvben többnyire nincs meghatározva, hogy egy kifejezés milyen sorrendben értékelődik ki, így például egy olyan kifejezésben, mint f() + g(), nem egyértelmű, hogy a két függvényhívás milyen sorrendben hajtódik végre. Ha mindkét függvénynek van mellékhatása, pl. kiír valamit a képernyőre, vagy megváltoztat vagy olvas valamilyen közös változót, akkor a sorrend lényeges lehet. Az mindig teljesül, hogy a függvényhívásban az argumentumként álló kifejezések előbb értékelődnek ki, mint hogy a függvény meghívódjon, de az nem egyértelmű, hogy több argumentum milyen sorrendben számítódik ki például az f(g(), h()) hívásban.

Ezek miatt az sem lenne egyértelmű, hogy egy olyan kifejezésben, mint pl.

n + (n = 10)
az első n a régi, vagy az új értéket adja. Éppen ezért a C nyelvnek van is egy olyan szabálya, ami kifejezetten megtiltja az ehhez hasonló kifejezéseket, ez valami olyasmit mond, hogy egy kifejezésben nem lehet értéket is adni valaminek, és lekérdezni ugyanannak az értékét. A fordító bizonyos esetekben erre a szabályra figyelmeztet, de nem tudhatja mindig pontosan ellenőrizni, ezért erre a programozónak magának kell figyelni. A szabály alól azonban van egy nagyon fontos kivétel: az értékadás jobb oldalán szereplő részkifejezésben lekérdezhetjük ugyanannak a balértéknek az értékét, amit az értékadás megváltoztat. Így aztán a következő kifejezések szabályosak.
k = k - 2;
c = (c * (c - 1))/2 + 1;

A ++ és a -- rövidítő operátorokkal már találkoztunk, de még nem tudunk mindent róluk. Ezekből két változat van, elöltöltős, mint például ++x és --x, illetve hátultöltős mint például x++ illetve x--. Mindkét operátor csak balértékre alkalmazható, vagyis olyen kifejezésre, ami egy értékadás bal oldalán szerepelhetne, és a ++ mindig növeli, a -- mindig csökkenti ennek az értékét eggyel. Az elöltöltős változat az új, növelt vagy csökkentett értéket adja vissza, a hátultöltős viszont a régi értéket. Így például ez a program:

#include 
int
main(void) {
	int k;
	int l;
	int m;
	int n;
	k = l = m = n = 8;
	printf("k %d, l %d, m %d, n %d\n", k, l, m, n);
	printf("k %d, l %d, m %d, n %d\n", k++, ++l, m--, --n);
	printf("k %d, l %d, m %d, n %d\n", k, l, m, n);
	return 0;
}
azt írja ki, hogy
k 8, l 8, m 8, n 8
k 8, l 9, m 8, n 7
k 9, l 9, m 7, n 7

Gyakran használjuk ezeket az operátorokat tömbökkel kapcsolatban. Egy a tömbben a kitöltött elemek számát tároljuk a size egész változóban, ezt eleve beállítjuk nullára a size = 0; utasítással. Amikor egy új elemet be akarunk rakni a tömbbe, akkor ezt az

a[size++] = valami;
utasítással tesszük. (Persze meg kell győződnünk róla, hogy nem megyünk túl a tömb határán.) Ha a fenti utasítást mondjuk ötször hajtottuk végre, akkor a size értéke 5 lesz, a tömbben pedig az a[0], a[1], a[2], a[3], a[4] elemek vannak kitöltve.

Az ilyen rövidítéseket persze nem muszáj használni új programok írásánál, de mivel a meglévő C programokat gyakran tényleg egyszerűvé teszi, a programok olvasásához szükséges ismerni őket. (A rövidítéseket is túlzásba lehet vinni persze, amikor is a programok túl bonyolultak lesznek.)

Van még egy másik sorozat rövidítő operátor is, ezek.

+=  -=  *=  /=  %=

Ezek kétargumentumúak, a bal oldalukon balértéknek kell állnia, mint az egyenlőségnél. Az

a += b
kifejezés egyenértékű az
a = a + b
kifejezéssel (persze lehet, hogy az utóbbiba még zárójelek kellenek, ha a b bonyolult kifejezés). Így az n += 5 kifejezés az n változó értékét megnöveli öttel. Az értékadáshoz hasonlóan ez a kifejezés is használható másik kifejezés belsejében, és az új értéket adja vissza.

A fentiekben majdnem minden operátort felsoroltunk. Van még hat numerikus operátor, amit nem említettünk meg (a bit műveleteket végző operátorok), és ehhez tartozó öt rövidítő értékadás operátor. Van továbbá még két rövidítő operátor, a feltételes, és a vessző operátorok. Elég példát láttunk már a közönséges zárójelre, ami a kifejezések csoportosítására szolgál, és a függvényhívásra szolgáló zárójelekre – a zárójeleknek tehát többféle használata van, amelyeket a fordító a környezetük alapján különböztet meg, úgy, mint az egy és kétoperandusú mínusz jelet. Láttuk már a tömbök indexelésére szolgáló szögletes zárójelpárt is, ezzel, és négy másik operátorral majd akkor fogunk bővebben foglalkozni, amikor a tömbökről, struktúrákról, és mutatókról tanulunk, valamint akkor megismerjük az eddig látott néhány operátor új felhasználását is.