Pointerek

Összetett adattípusok, típusképzések

Azokat a típusokat, amelyek értékei tovább bonthatóak, illetve további értelmezésük lehetséges, összetett adattípusoknak nevezzük.

A C nyelv egyik legnehezebben megérthető ilyen adattípusa a pointer típus, amely annyira speciális, hogy segítségével nemcsak konkrétan adatokat, de még különböző függvényeket is tudunk hivatkozni.

A pointerekkel kapcsolatba állítható tömbök segítenek abban, hogy típus alapján összetartozó adatokat egységesen tudjunk kezelni. A tömbök egy speciális esete a karakter tömbök, amik a sztringeket jelentik. Ezekre kiemelt figyelmet szentelünk mi is, hisz ezen tömb típus az, ami talán a leggyakrabban előfordul a gyakorlatban.

Végezetül megnézzük, hogy a beépített típusképzési mechanizmusokkal hogyan tudunk olyan összetett új típusokat létrehozni, mint a szorzat-rekord, vagy egyesítési-rekord típusok.

Valamennyi esetben az adattípusokat először absztrakt szinten fogjuk bevezetni, azaz megmutatjuk, hogy az egyes adattípusokat mire szeretnénk használni, és milyen műveletekkel látjuk el őket. Megismerve az adott típusokat bemutatjuk, hogy maga a C nyelv hogyan valósítja meg ezeket, azaz definiáljuk az adott típust a virtuális adattípus szinten is. És ahol az érdekes, ott vizsgáljuk az adott típus megvalósítását a fizikai szinten is, hiszen sok mindent (például tömbök és pointerek kapcsolatát) ezen ismeret alapján fogjuk megérteni.

Pointerek

Először a pointerekkel fogunk megismerkedni, mivel ez a C nyelv egy igen hatékony eszköze, használata tömör és hatékony kódot eredményez. Látni fogjuk, hogy azon túl, hogy a pointerket széles körben tudjuk használni, bizonyos dolgok (mint például a függvények argumentum módjainak kezelése) csak és kizárólag a pointerek segítségével oldható meg, így használatukat nem fogjuk tudni mellőzni.

Első ránézésre talán a pointerek használata a legmeredekebb dolog, amit csak el lehet képzelni a C nyelvben, és sokan emiatt félnek is tőle, és nehezen tudják őket alkalmazni. A nyelv első leírását adó Kernighan & Ritchie könyv is óvatosságra int a pointerek használatával kapcsolatosan: "Azt szokták mondani, hogy a mutató, csakúgy, mint a goto utasítás, csak arra jó, hogy összezavarja és érthetetlenné tegye a programot. Ez biztos így is van, ha ész nélkül használjuk, hiszen könnyűszerrel gyárthatunk olyan mutatókat, amelyek valamilyen nem várt helyre mutatnak. Kellő önfegyelemmel azonban a mutatókat úgy is alkalmazhatjuk, hogy ezáltal programunk világos és egyszerű legyen."

Dinamikus változók és pointerek

Az eddigi tárgyalásunkban szerepelt változók statikusak abban az értelemben, hogy létezésük annak a blokknak a végrehajtásához kötött, amelyben a változó deklarálva lett. A programozónak a deklaráció helyén túl nincs befolyása a változó létesítésére és megszüntetésére. (Vigyázat, ez a fajta statikusság nincs összefüggésben a static kulcsszóval, aminek hatása, hogy a változó egy meghatározott adatterületen jön létre, és értékét megőrzi a deklaráló blokk végrehajtása után is.)

Dinamikus változónak nevezzük azt a változót, amely bármely blokk aktivizálásától (végrehajtásától) független hozhatunk létre, illetve szüntethetünk meg. Az ilyen dinamikus változók megvalósításának eszköze lesz a pointer típus. Azaz első megközelítésben a pointer típusú változó értéke egy meghatározott típusú dinamikus változó.

Ha a C megvalósítást nézzük (azaz a virtuális szintjére csöppenünk az adatkezelésnek), akkor a pointer típusú változót a * segítségével deklarálhatjuk:

típus * változónév;

Például char típusú dinamikus változó deklarálása:

char * pc;

vagy egy unsigned short int típusú dinamikus változó deklarációja:

unsigned short int * pi;

Ezekben a példákban a * (szintaktikailag) ugyanolyan messze volt a típus azonosítójától, mint a változó nevétől. Attól függően, hogy hogyan értelmezzük a deklarációt a * kötődhet jobban vagy az egyikhez, vagy a másikhoz. Például az int * p; deklarációban egyik értelmezés szerint a *p egy int típusú (dinamikus) változó, de úgy is értelmezhető, hogy a p egy int* típusú (azaz egy int-re mutató) változó.

Ha csak a deklarációt nézzük, akkor az előbbi értelmezés a célravezető, hiszen a * alapvetően a változóhoz tartozik, ezt bizonyítja a többszörös változó deklarálás. Azaz például a int * p, q; esetben a q egy szimpla int típusú változó lesz csak, típusa nem egyezik meg p típusával. Éppen ezért érdemes a *-ot a változóhoz közelebb írni, és akkor nem vonjuk le azt a hibás következtetést, hogy mind a p, mind a q int* típusú változók.

Ha szeretnénk egy pointer típust definiálni, akkor azt a tpyedef típus *pointertípusnév utasítással tehetjük meg. Hasonló ez a változó deklarációhoz, csak változónév helyett az új típus neve szerepel.

Például:

1
2
typedef unsigned long int *ulip;
ulip p;

Azaz az ulip típus a typedef utasítás után szabályos típusként használható, segítségével egyszerűen és könnyen tudunk változókat deklarálni.

Hivatkozás, változó, érték

Az eddigiek során lényegében azonosítottuk a változóhivatkozást és a hivatkozott változót. Ezek kölcsönösen egyértelműen megfeleltethetőek voltak egymásnak. Ahhoz, hogy megértsük, hogy a dinamikus változó fogalma mit is takar valójában, meg kell különböztetnünk a változóhivatkozás, hivatkozott változó és változó értéke fogalmakat.

A változóhivatkozás szintaktikus egység, meghatározott formai szabályok szerint képzett jelsorozat egy adott programnyelven, tehát egy kódrészlet. Azaz egy p = 3; utasítás esetén maga a p jel(sorozat) a változóhivatkozás.

A változó a program futása során a program által lefoglalt memóriaterület egy része, amelyen egy adott (elemi vagy összetett) típusú érték tárolódik. Például egy int p; deklaráció esetén a memóriában lefoglalódik egy int típusú érték tárolására alkalmas hely, és ezen a területen tárolódik majd a változó értéke.

Az eddigi "statikus" (és nem feltétlenül static!) változóink esetében az int p; deklarációval a p változóhivatkozáshoz hozzárendelődött a lefoglalt memóriaterület, így például a p = 3; utasítás (a p változóhivatkozáshoz rendelt területként) ezt a memóriaterületet írta felül.

Azonban nem kell, hogy változó és változó hivatkozás mindig kölcsönösen megfeleltethetőek legyenek egymásnak. Különböző változóhivatkozások hivatkozhatnak ugyanarra a változóra, azaz a memória ugyanazon területére, illetve ugyanaz a változóhivatkozás a végrehajtás különböző időpontjaiban különböző változókra (memória területekre) hivatkozhat. Sőt az is lehet, hogy egy változóhivatkozáshoz adott időben nem tartozik tartozik hivatkozott változó. Ezt a fenti viselkedéssorozatot tudjuk majd elérni a pointerekkel, illetve dinamikus változókkal.

Megjegyzés

Bár nem vettünk belőle sokat észre, valójában ha az int p; deklaráció egy függvényben van (és az nem a main), és a függvényt különböző másik függvényekből többször hívjuk, jó eséllyel a p minden hívásnál másik memóriaterületre, így másik változóra fog hivatkozni. Ezért van az, hogy két hívás között "elfelejti" az értékét.

A static int p; deklaráció (függvényen belül) ezzel szemben azt eredményezi, hogy a változó állandó memóriaterületen jön létre, és a program ezen pontjairól akárhányszor hivatkozunk is a p változóhivatkozással, az mindig ugyanazt a változót jelenti, a függvény bármely hívásánál.

Pointer, mint absztrakt adattípus

Amikor a pointert, mint adattípus meg akarjuk határozni, meg kell adnunk azokat a tulajdonságokat, illetve műveleteket neki, amik lehetővé teszik a fenti viselkedés megvalósítását. Éppen ezért (szemben a statikus változóhivatkozásokkal, ahol az adott változó a definiáló blokk végrehajtásának kezdetétől a végéig él), meg kell adjuk a dinamikus változókhoz azokat a műveleteket, amikkel ezt elérjük. Dinamikus változóhivatkozáshoz tartozó változók a pointer típus műveleteivel hozhatók létre és szüntethetők meg. Absztrakt szinten azonban csak azt kell felsoroljuk, hogy konkrétan milyen műveletekre van szükség. Az alábbi táblázatban ezeket a műveleteket foglaljuk össze:

Művelet megnevezése Művelet leírása
NULL Konstans, érvénytelen pointer érték, ha egy pointerhez ezt az értéket kapcsoljuk, akkor annak jelentése, hogy a pointerhez nem tartozik dinamikus változó.
Létesít(\(\leftarrow\) X : PE) Új E típusú dinamikus változó létesítése, amely elérhetővé válik az X PE (azaz E típusra mutató) pointer által.
Értékadás(\(\leftarrow\) X : PE, \(\rightarrow\) Y: PE) Az X pointer felveszi az Y pointer értékét, azaz X és Y a művelet végrehajtása után ugyanarra a dinamikus változóra mutatnak.
Törlés(\(\leftrightarrow\) X: PE) Az X által hivatkozott dinamikus változó törlésre kerül. Az X ezen túl nem hivatkozik semmire.
Dereferencia(\(\rightarrow\) X: PE) : E Visszaad egy E típusú változó hivatkozást, amivel a dinamikus változót el tudjuk érni, amivel arra hivatkozhatunk.
Egyenlő(\(\rightarrow\) p : PE, \(\rightarrow\) q: PE): bool Összehasonlítja, hogy p és q ugyanarra a dinamikus változóra hivatkoznak-e (beleértve, hogy egyik sem hivatkozik változóra)!
NemEgyenlő(\(\rightarrow\) p : PE, \(\rightarrow\) q: PE): bool Összehasonlítja, hogy p és q más-más dinamikus változóra hivatkoznak-e (beleértve, hogy legfeljebb az egyik nem hivatkozik változóra)!

Ebben a táblázatban (és a későbbiekben minden hasonló táblázatban) a \(\leftarrow\) nyíl jelentése, hogy a művelet adott paraméterére, mint kimenő paraméter gondolunk, azaz az adott művelet hatására az adott paraméter értéket kap. A \(\rightarrow\)-val ellátott változók bemenő módú változókat jelentenek, azaz értékük a metódusban felhasználásra kerül, de nem változik meg. A \(\leftrightarrow\) pedig a be- és kimenő módú paramétereket előzi meg, azaz ezeknek a paramétereknek felhasználjuk a bejövő értékét az adott eljárásban, de meg is változtatjuk esetlegesen a végrehajtás során.

Pointer, mint virtuális adattípus

Absztrakt szinten definiáltuk, hogy adott típusnak milyen műveletekkel kell rendelkeznie. A virtuális szinten meg tudjuk mutatni, hogy az adott nyelv az adott műveletet hogyan valósítja meg, illetve ha a nyelvnek nincs konkrét megvalósítása az adott műveletre, akkor megmutathatjuk, hogy hogyan fogjuk azt megvalósítani. A pointerek esetében majdnem minden művelet megvalósítható közvetlen a nyelv elemei által.

Művelet absztrakt szinten Művelet virtuális megvalósítása
NULL NULL
Létesít(\(\leftarrow\) X : PE) X = malloc(sizeof(E));
Értékadás(\(\leftarrow\) X : PE, \(\rightarrow\) Y: PE) X = Y
Törlés(\(\leftrightarrow\) X: PE) free(X); X = NULL;
Dereferencia(\(\rightarrow\) X: PE) : E *X
Egyenlő(\(\rightarrow\) p : PE, \(\rightarrow\) q: PE): bool p == q
NemEgyenlő(\(\rightarrow\) p : PE, \(\rightarrow\) q: PE): bool p != q
Cím(\(\rightarrow\) v:E, \(\leftarrow\) p:PE) p = &v;

Igazából csak a létrehozás és megszüntetés műveletek azok, amelyeket külön, a függvénykönyvtárak megoldásaival adunk meg (éppen ezért simán elképzelhető olyan eset, hogy ezt akár más módon oldja meg a saját programunk). Viszont átlagos megoldáshoz jó lehet az, amit a standard függvénykönyvtárak biztosítanak. Ezek használatához includolni kell a stdlib.h vagy memory.h függvénykönyvtárakat. Ezekben kerül definiálásra a malloc függvény, amely a paraméterében kapott méretű memóriaterületet lefoglalja, és visszatér az adott terület címével, amivel a különböző változóhivatkozások hivatkozhatnak a létrehozott változóra. A sizeof megadja, hogy a paraméterében kapott kifejezés vagy típus mekkora helyet foglal a memóriában. Azaz összességében a malloc(sizeof(E)) hatására memória foglalódik egy E méretű változónak, amely memóriaterület hivatkozható a malloc által visszaadott PE típusú változó által. A free(p) művelet hatására a p-hez tartozó memóriaterület felszabadul, ezáltal a dinamikus változó megszűnik. A művelet végrehajtása után a p pointernek mint változónak az értéke megmarad (a free hívás nem állítja NULL-ra), de már nem tartozik hozzá érvényes változó, ezért a *p változóhivatkozás végrehajtása jobb esetben azonnali futási hibát eredményez. (Rosszabb esetben pedig olyan lappangó hibát, aminek az eredménye a program egy teljesen más pontján jelenik meg.)

Pointer típusú változó 32 bites rendszereken 4 bájt, 64 bites rendszereken 8 bájt hosszban a hozzá tartozó dinamikus változóhoz foglalt memóriamező kezdőcímét (sorszámát) tartalmazza. (Linux rendszeren minden memóriacímet egy sorszám azonosít.) A pointer értéke tehát (második megközelítésben) értelmezhető egy tetszőleges memóriacímként is, amely értelmezés egybeesik a pointer megvalósításával. Ilyen módon viszont értelmezhetjük a címképző műveletet, ami egy változó memóriabeli pozícióját, címét adja vissza. Az & művelet tehát egy változó memóriacímét adja vissza, így egy pointer értéke akár egy globális vagy lokális változó is lehet. Ha egy nem static lokális változó címével dolgozunk, akkor arra azért figyeljünk, hogy ezek a változók csak addig léteznek, míg az adott blokkból ki nem lépünk. (Egy static tárolási osztályú lokális változóra pointer segítségével akár az őt deklaráló blokkon (függvényen) kívülről is hivatkozatunk, hiszen, bár a változó azonosítója a blokkon kívül nem látható, a változó létezik.)

Példa a pointerek használatára

Nézzük meg részletesebben az alábbi kódot, amelyben egy int *p; pointer mellett létrehozunk egy dinamikus változót, amelyet a pointer által tudunk elérni, és kezelni:

kep

  1. Mielőtt belépnénk a blokkba, amely ezt az utasítás sorozatot tartalmazza, akkor természetesen még nem léteznek azok a változók, amik ehhez a blokkhoz kapcsolódnak.
  2. Belépve a blokkba, az int *p; utasítással létrehozzuk a p pointert. Azaz a memóriában lefoglalódik (allokálódik) egy olyan terület, amelyen eltárolható egy int* méretű adatot. Az ábrán ez a zölddel jelölt terület. A deklaráló utasítás erre a területre nem ír semmit, ott a bitek tetszőleges módon állhatnak (azaz ha az itt levő értéket kiolvasnánk, akkor nem definiált, hogy mit fogunk ott olvasni; vannak programozási nyelvek, amik ilyen esetben 0-zák az adott területet, azaz inicializálják, a C nyelv esetén viszont ez nem történik meg). Jelöljük egy - jellel, hogy ennek a területnek nem definiált az értéke.
  3. Számunkra a p változó definiálása a p = malloc(sizefo(int)); utasítással történik meg. A malloc(sizefo(int)) utasítás létrehoz egy dinamikus int típusú változót. Ehhez gyakorlatilag allokál egy olyan memória darabot, amin elfér egy int méretű adat. Ez lesz az ábra piros területe. A malloc eredményét értékül adva a p változónak, a p-hez tartozó memória területre bekerül egy olyan memóriacím, amelyen keresztül a "piros" területen levő dinamikus változó elérhető. Ehhez használhatjuk *p változóhivatkozást, ami nem más, mint a p változón keresztüli dereferencia művelet. Amikor a malloc lefoglalta az adott területet, ott szintén nem definiált, hogy milyen adat van, ezt megintcsak a - jellel jelöljük.
  4. Innentől kezdve azonban a *p változóhivatkozás használható, és ha akarjuk, definiálhatjuk a dinamikus változónkat. A *p = 3; utasítással a memória területre beírásra kerül a 3-as érték.
  5. Mivel azonban ez innentől egy int típusú változó, minden olyan művelet alkalmazható, amit egy int-re alkalmazni lehet. Akár növelhetjük is az értéket a *p += 6; utasítással, azaz a dinamikus változónk új értéke ezután 9 lesz.
  6. Amikor már nincs szükség a dinamikus változóra, a hozzá tartozó memória területet fel kell szabadítani. Ez nem automatikus. Ezt a programozónak kell megtennie. Erre szolgál a free metódus, aminek paraméterül kell adni egy olyan mutatót, ami az adott dinamikus változóra hivatkozik. A free hatására felszabadul a dinamikus változó memóriaterülete, innentől a program azt felhasználhatja új változók létrehozásakor.
    Ami azonban ilyenkor veszélyes lehet, hogy maga a mutató értéke nem változik, látszólag az még mindig hivatkozik egy területre, ami azonban mostmár invaliddá vált, nem lehetünk biztos a tartalmában. Elképzelhető, hogy még sokáig megőrzi az egykor ott volt dinamikus változó értékét (azaz most a 9-et), de az is lehet, hogy egy másik utasítás ezt felülírja. Ez a dolog az, ami miatt a pointerekkel nagyon körültekintőnek kell lenni. Tudni kell a programozás során, hogy a használt pointer által hivatkozott dinamikus változó valóban él még, és nem szüntette meg senki.
  7. Amikor kilépünk a blokkból, akkor a p változóhoz tartozó memória terület is felszabadul.

Bonyolítsuk annyival az előző példát, hogy nem egy, hanem kettő mutatót használjunk, amelyek aztán ugyanarra a dinamikus változóra fognak hivatkozni:

kep

  1. A blokkba lépés előtt nincs semmilyen változónk, p és q sem.
  2. Első lépésben deklaráljuk és inicializáljuk a p pointer változót az int* p = NULL; deklarációval. Ekkor p-nek mint statikus int* típusú változóknak foglalódik hely a memóriában, a kezdőértéke pedig NULL lesz. Inicializálni azért szükséges, hogy tudjuk, hogy jelen pillanatban nincs hozzá rendelve dinamikus változó. Igazából az, ha a mutató értékét NULL-ra állítjuk akkor, amikor bizonyosan tudjuk, hogy nem mutat valid memória helyre, mindig hasznos lehet, nagyon sok programhibát el lehet kerülni vele.
  3. A q változót is létrehozzuk az int* q = NULL; deklarációval. Ekkor q-nak is helyet foglalunk, és szintén a NULL kezdőértéket kapja. Az ábrán is jelöltük, hogy ezeknek a változóknak (mutatóknak) van most értéke - és rögtön kezdőérték, tehát nincs olyan pillanat, amíg az értékük definiálatlan -, csak épp nem hivatkoznak senkire.
  4. Az előbbihez hasonlóan a malloc utasítással hozzuk létre a dinamikus változónkat is, és tegyük elérhetővé a p által.
  5. Továbbra is az előző példát követve a *p változó hivatkozással, vagy más szóval p dereferenciájával, definiáljuk a dinamikus változó értékét, ami így 3 lesz.
  6. Ezután p értékét adjuk át q-nak is. Ekkor mindkét mutató ugyanarra a memória területre mutat, illetve mind a *p, mind a *q ugyanarra a dinamikus változóra hivatkozik.
  7. Ezt igazolja a *q += 6; utasítás, ami most a q változón keresztül módosítja a dinamikus változó értékét.
  8. Miután a free(q) utasításon keresztül töröljük a dinamikus változónkat, mind a p, mind a q mutató hivatkozása invaliddá válik.

Láttuk a példán keresztül, hogy van olyan eset, amikor egy dinamikus változóra több mutatóval is hivatkozunk. Ha nem figyelünk azonban az is megtörténhet, hogy mind p, mind q hivatkozását átállítjuk egy másik dinamikus változóra, még azelőtt, hogy azt törölnénk. Ha ekkor nem marad olyan mutató, amelyen keresztül a dinamikus változónkat elérhetjük, akkor azt már nem fogjuk tudni törölni. Ha egy programban gondatlanul sokszor elismételjük ezt, és sok dinamikus változó marad felszabadítás nélkül, akkor a program memóriahasználatának drasztikus növekedése ahhoz vezethet, hogy a sok memória szemét miatt a program abortál. Ezért nagyon fontos, hogy a programozó MINDEN esetben gondoskodjon a dinamikusan létrehozott változók törléséről, amennyiben azokra már nincs szükség.

Danger

A dinamikusan foglalt memória felszabadítása MINDEN esetben fontos és elengedhetetlen feladat! Lehet, hogy manapság egy 4 bájtnyi adat nem tűnik olyan nagy adatnak, ennél sokkal nagyobb területtel rendelkezik minden program. Azonban gondoljunk arra, hogy mi történik akkor, amikor ez a lényegtelennek tűnő adatfoglalás egy ciklusban kerül végrehajtásra (akár úgy, hogy a ciklus csak hívja azt a függvényt, amely az adatterületet lefoglalta). Könnyen kerülhet a program olyan helyzetbe, hogy a sok kicsi fel nem szabadított memória miatt elfogy a program memóriája, és az operációs rendszer megszakítja, felfüggeszti a végrehajtását emiatt.

A void* típus

A void típussal már találkoztunk, amikor az eljárások visszatérési értékét kellett megadni. Gyakorlatilag ez a típus egy olyan típus, aminek az értékkészlete 0 elemű. Vannak helyzetek azonban, amikor a mutatóink void* típussal rendelkeznek.

Nyilván nem azért, mert ilyenkor a mutatóhoz rendelt dinamikus változónak void a típusa, vagy pedig az adott memóriacímen void típusú adat van. A void* egy speciális, úgynevezett típustalan pointer. Az ilyen típusú pointerek "csak" memóriacímek tárolására alkalmasak, a dereferencia művelet alkalmazása rájuk értelmetlen. Viszont minden típusú pointerrel kompatibilisek értékadás és összehasonlítás tekintetében. Valójában a malloc függvény visszatérési értéke is void*, és ez kasztolódik impliciten a megfelelő típusú mutatóvá.

Függvény argumentumok módjainak kezelése

A függvényműveletnél az argumentumok kezelése érték szerint történik, amely csak bemenő módú argumentumok használatát teszi lehetővé. Ha kimenő, vagy be- és kimenő módú argumentumokra is szükségünk van, akkor ennek kezelését nekünk kell megoldani pointer segítségével.

Legyen a csere függvény feladata az argumentumok értékének megcserélése. Ha a csere függvény deklarációja:

1
2
3
4
5
6
void csere (int x, int y) {
    int m;
    m = x;
    x = y;
    y = m;
}

akkor a csere(a, b) művelet nem végzi el a cserét, hiszen az csak az a és b változók értékét kapja meg, amely értékek felmásolódnak a verembe, és ezeket cserélgeti meg a függvény a lokális változókban (ilyen szempontból a paraméter is lokális változónak minősül), amely értékek törlésre is kerülnek a veremről a függvény végrehajtása után.

Az a és b változók értéke helyett tehát a változók címét kell átadni (ez kerül a veremre), és ezeken keresztül lehet módosítani az eredeti adatokat is. Azaz legyen a csere függvény a következő:

1
2
3
4
5
6
void csere (int *x, int *y) {
    int m;
     m = *x;
    *x = *y;
    *y = m;
}

Ekkor a csere(&a, &b) művelet már megcseréli az a és b változók értékét.

Fontos, hogy kimenő, és be- és kimenő paraméterek esetében mivel változók címét adtuk át, így amikor meghívjuk a függvényt, adott paraméter esetében nem lehet tetszőleges kifejezést beírni.

Ha nagyon nem tudunk megbarátkozni a pointerek használatával, de mindenképp szükség van be- és kimenő módú paraméterekre, akkor azt is megtehetjük, hogy a függvény elején ezeknek a paramétereknek az értékét betesszük lokális változókba, és ezekkel dolgozunk, illetve a függvény végén visszamásoljuk a megfelelő lokális változókat a paraméter által hivatkozott változóba:

1
2
3
4
5
6
7
csinalValamit(int *px, int *py) {
    int x, y;
    x = *px; y = *py;
    ...
    *px = x; *py = y;
    return;
}

Példa: másodfokú egyenlet gyökei

  • Problémafelvetés: határozzuk meg egy másodfokú egyenlet valós gyökeit!
  • Specifikáció:
    • A probléma inputja három valós szám, az \(ax^2 + bx + c\) másodfokú egyenlet \(a\), \(b\) és \(c\) együtthatói.
    • Az output két valós szám, az egyenlet \(x_1\), \(x_2\) valós gyökei, ha létezik valós gyök, különben - ha nincs valós gyök, vagy végtelen sok van -, egy szöveg, ami a valós gyök kiszámíthatatlanságát jelzi.
  • Algoritmustervezés:
    • A probléma megoldása olyan függvényművelettel adható meg, amelynek
      • három bemenő paramétere van: \(a\), \(b\), \(c\) az egyenlet együtthatói,
      • két kimenő paramétere van: \(x_1\),\(x_2\) a két valós gyök,
      • továbbá a függvény logikai értéket ad vissza, amely akkor és csak akkor lesz igaz, ha van valós gyök.
    • A közismert \(x_{1,2} = \frac{-b \pm \sqrt{b^2-4ac}}{2a}\) megoldóképletet használjuk.
  • Algoritmustervezés szerkezeti ábrával:
    kep

A megoldo függvény paramétereinél a szerkezeti ábrán már jelöltük, hogy az adott paraméterek milyen módúak. Az egyenlet együtthatói bemenő módúak, hiszen felhasználjuk ugyan a számítás során az értékeiket, de azokat nem módosítjuk. Az egyenlet megoldásai viszont kimenő módúak, azaz nem érdekes, hogy a függvény végrehajtása előtt mi az értékük, a lényeg, hogy a függvényen számítja azt ki.

Sokan esnek abba a hibába, hogy ilyenkor a függvény visszatérési értékében próbálják egyszerre visszaadni a két gyököt. Bár ez nem lehetetlen, ha ehhez létrehozunk egy új adattípust, ami képes két double értéket egyszerre eltárolni, de szimplán a gyökök felsorolása a return utasítás mögött nem lehetséges (a C függvény egyetlen - esetleg összetett - értéket tud visszaadni). Ráadásul az is lehet, hogy nincs is gyök, ezt is szeretnénk jelezni valamivel.

Éppen ezért jó megoldás, hogy a gyökök létezését jelezzük csak a visszatérési értékkel, a gyököket pedig a paramétereken keresztül állítjuk be.

A probléma alaposabb matematikai megoldása talán mindenki számára ismerős. Amennyiben az \(a\) paraméter 0, azaz legfeljebb elsőfokú egyenletünk van, egész biztosan nem lesz két különböző gyök. Az elsőfokú egyenletnek csak akkor lesz egyértelmű megoldása, ha \(b\) nem 0, de ilyenkor is csak 1 megoldás van. Ezt mi jelöljük azzal, hogy mind \(x_1\), mind \(x_2\) változó értékére beállítjuk ez a megoldást.

Amennyiben valódi másodfokú egyenletünk van, azaz \(a\) nem 0, akkor meg kell nézni a gyök alatti kifejezés (más szóval diszkrimináns) értékét. Ha ez negatív, akkor egyáltalán nincs valós gyök, egyébként pedig a megoldó képlettel ki lehet számítani azt.

A szerkezeti ábrán azonban feltűnhet, hogy itt mi egy kicsit többet fogunk csinálni. Mivel szó volt arról, hogy a valós számok számábrázolása lehet pontatlan, főleg a kisebb abszolút értékű esetekben, így a nagyobb megoldás segítségével pontosítjuk a kisebb megoldás értékét.

Nézzük meg a kódot is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* Másodfokú egyenlet valós gyökeinek meghatározása a 
 *   megoldo függvénnyel.
 * 1998. március 31. Dévényi Károly, devenyi@inf.u-szeged.hu
 * 2013. augusztus 29. Gergely Tamás, gertom@inf.u-szeged.hu
 */

#include <stdio.h>
#include <math.h>
#include <stdbool.h>

bool megoldo(double a, double b, double c, double *x1, double *x2) {
    bool valos = true;                                  /* van-e megoldás */

    if (a == 0.0) {
        if (b == 0.0) {                           /* az egyenlet elfajuló */
            valos = false;
        } else {
            *x1 = *x2 = -(c / b);
        }
    } else {
        double d;                                      /* a diszkrimináns */
        d = b * b - 4.0 * a * c;
        if (d < 0.0) {                               /* nincs valós gyöke */
            valos = false;
        } else {
            *x1 = (-b + sqrt(d)) / (2.0 * a);
            *x2 = (-b - sqrt(d)) / (2.0 * a);
            if (fabs(*x1) > fabs(*x2)) {  /* gyökök pontosabb kiszámolása */
                *x2 = c / (*x1 * a);
            } else if (*x2 != 0) {
                *x1 = c / (*x2 * a);
            }
        }
    }

    return valos;
}

int main() {
    double a, b, c, x, y;                         /* a főprogram változói */

    printf("Kérem az első egyenlet együtthatóit!\n");
    scanf("%lg%lg%lg%*[^\n]", &a, &b, &c); getchar();
    if (megoldo(a, b, c, &x, &y)) {
        printf("Az egyenlet gyökei: %20.10f és %20.10f\n", x, y);
    } else {
        printf("Az egyenletnek nincs valós megoldása!\n");
    }
    printf("Kérem a második egyenlet együtthatóit!\n");
    scanf("%lg%lg%lg%*[^\n]", &a, &b, &c); getchar();
    if (megoldo(a, b, c, &x, &y)) {
        printf("Az egyenlet gyökei: %20.10f és %20.10f\n", x, y);
    } else {
        printf("Az egyenletnek nincs valós megoldása!\n");
    }
    return 0;
}

A fentiek alapján \(x_1\) és \(x_2\) paraméterek esetében kezelnünk kell azt, hogy ezek kimenő módú paraméterek. Ezért ezen változóknak nem az értékét, hanem a címét adjuk át a megoldo függvénynek. Valamennyi esetben ezért amikor a megoldo függvényen belül hivatkozunk ezekre a változókra, előttük ott szerepel a *. Fontos, hogy a main függvényben, illetve ahol meg akarjuk hívni ezt a függvényt, definiálni kell azokat a változókat, amelyekben majd az eredményt várjuk, és ezen változóknak a memória címét kell átadjuk. Ezt tesszük meg a & cím operátorral.

Mivel a megoldo függvényben használjuk a pontosításhoz az fabs nevű függvényt, így include-olni kell a math.h-t. Amiatt pedig, hogy szeretnénk használni a \(C^{99}\) szabvány által elérhető bool típust, include-olni kell az stdbool.h-t.


Utolsó frissítés: 2020-10-19 15:43:57