Kihagyás

Eljárások

Eljárások és függvények

Az eljárásvezérlésnek két fajtája van, az eljárásművelet és a függvényművelet, amelyből az utóbbival már megismerkedtünk.

Eljárásműveleten olyan tevékenységet értünk, amelynek alkalmazása adott argumentumokra az argumentumok értékének pontosan meghatározott megváltozását eredményezi. Minden eljárásműveletnek rögzített számú paramétere van, és minden paraméter rögzített adattípusú.

  • Bemenő mód: ha a művelet végrehajtása nem változtathatja meg az adott argumentum értékét.
  • Kimenő mód: ha a művelet eredménye nem függ az adott argumentum végrehajtás előtti értékétől, de az adott argumentum értéke a művelet hatására megváltozhat.
  • Be- és kimenő (vegyes) mód: Ha a művelet felhasználhatja az adott argumentum végrehajtás előtti értékét és az argumentum értéke a művelet hatására meg is változhat.

Az eljárásművelet specifikációja tartalmazza:

  • a művelet elnevezését,
  • a paraméterek felsorolását,
  • mindegyik paraméter adattípusát,
  • a művelet hatásának leírását.

A függvényművelet specifikációja pedig a fentieken túl tartalmazza a függvényművelet eredménytípusát is.

Mint az látható az eljárás- és függvényműveletek között igen kicsi a különbség (ez a kis különbség viszont egyes programozási nyelveken lényeges és jelentős lehet, bár a C nyelvben éppen nem az). Éppen ezért a függvényművelet argumentumai ugyanúgy lehetnek kimenő és be- és kimenő módúak is, mint az eljárásműveletek esetén, tehát a függvényművelet végrehajtása is eredményezheti az aktuális argumentumok megváltozását.

Eljárásműveletet általános alakban P(m_1 X_1 : T_1; ...; m_n X_n : T_n) formában jelölhetünk, ahol

  • P az eljárás neve,
  • m_i az i. paraméter kezelési módja,
  • X_i az i. paraméter azonosítója,
  • T_i az i. paraméter adattípusa.

Az eljárás algoritmusa egy olyan szerkezeti ábrával adható meg, melynek a feje így néz ki:

kep

Ennek az eljárásműveletnek adott A_1, ..., A_n argumentumokra történő végrehajtását eljáráshívásnak nevezzük és a P(A_1,...,A_n) módon jelöljük. Ha az i. paraméter kezelési módja kimenő vagy be- és kimenő, akkor az A_i aktuális argumentum csak változó lehet (azaz nem lehet tetszőleges kifejezés). Mivel az eljárásműveletnek nincs visszatérési értéke, magának az eljárásműveletnek nincs értéke, így az eljáráshívás utasítás, azaz nem lehet része kifejezésnek, utána ;-t kell tenni.

A függvényművelet általános (nem a C nyelvhez igazodó) jelölése annyiban tér el az eljárásművelet jelölésétől, hogy jelezzük benne a visszatérési érték típusát is: kep

A függvényművelet korábbi jelölései tehát kiegészülnek az argumentumok kezelési módjával.

Mint említettük, a C nyelvben nincs igazán nagy különbség az eljárás és a függvény között. Valójában az eljárás egy olyan függvény C-ben, aminek a visszatérési értéke void típusú. Az eljárásokban nem kötelező return utasításnak szerepelnie, de lehet, bár ekkor sem adható meg neki visszatérési érték (vagyis az utasítást rögtön le is kell zárni egy ;-vel). Mivel a C nyelvben lényegében csak függvényművelet és csak bemenő módú paraméterkezelés van, így a továbbiakban az egyszerűbb, C-hez igazodó jelölésmódot alkalmazzuk.

A legnagyobb különbség C-ben az eljárások és függvények között az eljáráshívás és függvényhívás esetén adódik. Míg az eljáráshívás utasítás, addig a függvényhívás kifejezés, vagyis részkifejezése lehet összetett kifejezéseknek.

Vegyes és kimenő módú argumentumok

A C nyelvben a függvényművelet paraméterei bemenő módúak, tehát alapvetően a függvényművelet végrehajtása az aktuális argumentumok megváltozását nem eredményezheti. A be- és kimenő, valamint kimenő módú paramétereket a pointerek segítségével magunk kell, hogy kezeljük.

Az alábbiakban egy működő megoldást mutatunk, egyelőre részletes magyarázat nélkül (majd a pointer adattípus ismertetésénél érthetővé válnak a részletek is):

  • Ha az i. paramétert kimenő (vagy vegyes) módúnak szeretnénk, akkor a függvény deklarációjában T_i X_i helyett T_i *X_i deklarációt, a függvénytörzsben pedig X_i helyett mindenhol *X_i változóhivatkozást használunk.
  • Továbbá a függvény meghívásakor az A_i argumentum helyett az &A_i argumentumot használjuk. (Talán itt már így érthető, hogy a scanf utasításnál miért is kellett az & jelet használni az egyszerű típusú változók előtt. Így lettek kimenő módú paraméterek, amik a scanf hatására értéket kaphattak.)

Blokkstruktúra a C nyelvben

A C nyelvben blokkon egy { és } zárójelpárba zárt programrészt értünk. Egy C program blokkjai mellérendeltségi és alárendeltségi viszonyban vannak. Ezt a viszonyt az ide vonatkozó szabályokkal együtt blokkstruktúrának nevezzük. A blokkstruktúra az azonosítók láthatóságát befolyásolja, de hatással lehet például a változók létezésére is.

A blokkok elhelyezkedése és sorrendje meghatározza, hogy a végrehajtás adott pontján, azaz a program egy adott utasításban mely változók használhatóak, érhetőek el. Mindezt három szabály határozza meg.

  • Sorrendiségi szabály: a program egy adott pontján csak a hivatkozás helyét megelőzően már deklarált azonosítóra hivatkozhatunk. Változó-, függvény- és típus-azonosító a megjelenése helyén deklaráltnak minősül.
  • Egyediségi szabály: egy adott blokkban egy azonosító csak egyszer deklarálható, nem számítva az alárendelt blokkokat.
  • Láthatósági szabály: Egy B1 blokkban deklarált A azonosító akkor és csak akkor látható (hivatkozható) egy B2 blokkban, ha
    • B1 megegyezik B2-vel, vagy B2 alárendeltje B1-nek és az A azonosító előbb van deklarálva, mint B2, és
    • az A azonosító nincs deklarálva egyetlen olyan C blokkban sem, amely alárendeltje B1-nek és amelynek B2 alárendeltje (beleértve azt, hogy B2 megegyezik C-vel).

Pár példán keresztül nézzük meg, mit is jelentenek ezek a szabályok!

  1. példa az egyediségi szabályt mutatja be. A piros nyíllal jelölt sorban egyszerre két változót deklarálunk ugyanazon a néven. Ez probléma, hiszen ekkor a zöld nyíllal jelölt sorokban nem tudjuk, hogy adott ponton mely változóval kell dolgozni.

kep

  1. példa a sorrendiségi szabályt mutatja, ahol a zöld nyíllal jelölt helyeken az a változót, illetve az f függvényt próbáljuk hivatkozni, de ezt nem tehetjük meg, hiszen ezek csak a használat helye után lesznek deklarálva. (Na jó, ez a példa kicsit sántít, mert sok C fordító az ANSI C szabványból adódóan bizonyos esetekben az előzetes deklaráció nélküli függvényhivatkozást is elfogadja, bár általában szól érte.)

kep 3. példa azt mutatja, hogy amikor már deklaráltuk a változókat (kék nyíl), akkor utána gond nélkül használhatjuk is őket.

kep 4. példától már a blokkok alá és mellérendeltségére láthatunk példákat. A 4. példában azt látjuk, hogy az 1. blokkban deklarált a változó használható az alárendelt 2. blokkban, mivel a deklarációja előbb van, ráadásul nincs olyan blokk a kettő pont között, amelyben deklarálva lenne.

kep 5. példában azt látjuk, hogy a kék nyíllal mutatott sorban deklarált b változó használható a zöld nyíllal jelölt sorban. Bár a piros nyíllal jelölt sorban is deklarálunk egy b változót, az egy alárendelt blokkban van, így az a deklaráció a zöld nyíllal jelölt sorban nem látszódik.

kep 6. példa már azt mutatja, mi a helyzet akkor, ha az alárendelt blokkokban deklarált változók elrejtik a tartalmazó blokkok deklarációit. Az 1. blokkban deklarált c változó nem látható a 4. blokkban (amit a 3. blokkon keresztül tartalmaz az 1-es blokk), mivel a 3. blokkban a kék nyíllal jelölt sorban ezt a c változót felüldefiniáljuk. Mint látszik, az sem baj, hogy ez a c változó más típussal rendelkezik, mint az elsőként deklarált c.

kep

Azon blokkok összességét, amelyből egy a azonosító látható, az a azonosító hatáskörének nevezzük. Egy azonosítót lokálisnak nevezünk egy blokkra nézve, ha az azonosító az adott blokkban van deklarálva. Azt mondjuk, hogy egy a azonosító globális egy B blokkra nézve, ha nem B-ben van deklarálva, de látható B-ben. A blokkstruktúra alapján látható, hogy a C nyelvben vannak úgynevezett lokális változók, sőt általában ezeket használjuk. Látható azonban az is, hogy a programfájlban deklarált programegységek globálisak az összes függvénydeklarációra nézve, vagyis ezek minden blokkban láthatóak a deklarálásuktól kezdve az újradeklarálásukig. Ezeket csak nagyon indokolt esetben szoktuk használni.

A gcc néha elviseli, ha egy függvényt hamarabb használunk, mint ahogyan deklarálnánk (tehát megsértjük a sorrendiségi szabályt). A hívásból ugyanis ki tudja deríteni a paraméterek számát és típusát, a visszatérési értéket viszont ilyen esetekben int-ként kezeli. Az ansi C nem engedi meg a deklarációk és utasítások keveredését, tehát már a blokk elején deklarálni kell az összes változót. A C99 szabvány ennél rugalmasabb. Ha azt akarjuk, hogy a gcc az ilyen eseteket warninggal jelezze, akkor használjuk a --pedantic kapcsolót fordításkor.

Tárolási osztályok

Eddig minden esetben, amikor egy változót deklaráltunk, annyi dolgunk volt, hogy megmondjuk, az adott változó milyen típussal rendelkezik. Ekkor a változó számára memória allokálódik. A globálisan deklarált változók számára "statikus" hely foglalódik a program teljes futási idejére. Ez azt jelenti, hogy az adott változó a program teljes futási ideje alatt ugyanazon a memóriaterületen található. A blokkokban lokálisan deklarált változóknak a veremben foglalódik hely az adott blokk végrehajtási idejére. Többször végrehajtva az adott blokkot, nem garantált, hogy a változó ugyanazt a memóraiterületet kapja meg, amit korábban. Ez a tárolási mód az alapértelmezett, automatikus tárolási mód, amelyet az auto kulcsszóval is jelezhetünk a deklaráció előtt.

Ha a deklaráció elé betesszük a static kulcsszót, akkor az adott változónak mindenképp statikus, azaz állandó helye lesz a program teljes futási ideje alatt, még akkor is, ha csak egy lokális változóról van szó. Ez a memóriahely már a program kezdetén allkoálódik és inicializálódik, a globális változókkal együtt. A változó értéke megmarad a blokk végrehajtása után is, és az újabb végrehajtás során ez a megőrzött érték újrafelhasználható, a változó deklarációja és inicializálása nem történik meg újra. Ez a kulcsszó a változó létezését befolyásolja, a láthatóságát viszont nem.

Amennyiben a deklaráció előtt az extern (külső) kulcsszó szerepel, akkor ezzel jelezzük a fordítónak, hogy az adott változónak nem kell helyet foglalnia, a változó deklarációja egy olyan programegységben történik meg, amelyet később, a linkelés által fog az adott programkomponens elérni.

A register változót, ha lehetséges a fordító a számítógép regiszterébe helyezi (ha nem tudja, akkor a változó a memóriában marad). Ezt a tárolási módot akkor célszerű választani, amikor egy adott változó sok számításnak az eleme, ilyenkor a program futásidejét csökkentheti, ha az adott adatot nem kell mindig a regiszterbe mozgatni. Azonban mindig fordító függő, hogy az adott változó a regiszterbe kerül, vagy sem.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

void stat();

int main() {
    int i;                                               /* ciklusváltozó */
    for (i = 0; i < 5; ++i) {
        stat();
    }
    return 0;
}

void stat() {
    int ideiglenes = 1;                /* minden hívásnál inicializálódik */
    static int allando = 1;   /* a program elején egyszer inicializálódik */
    printf("ideiglenes = %d allando = %d\n", ideiglenes, allando);
    ++ideiglenes;
    ++allando;
}

Futtatva a kódot látszik, hogy az ideiglenesnevű változó minden esetben újra inicializálódik, az értéke 1 lesz, az allando változó, amely előtt ott volt a static módosító, az megőrzi a blokk végén levő értékét, és ez az érték lesz a kezdőértéke akkor, amikor a blokkot újra elkezdjük végrehajtani:

Kimenet

ideiglenes = 1 allando = 1
ideiglenes = 1 allando = 2
ideiglenes = 1 allando = 3
ideiglenes = 1 allando = 4
ideiglenes = 1 allando = 5

Rekurzió

Sokszor adódik úgy, hogy egy problémát úgy a legegyszerűbb megoldani, ha a probléma megoldását megpróbáljuk visszavezetni probléma egy egyszerűbb esetére. Az ilyenfajta megoldást rekurziónak nevezzük.

A C nyelven bármelyik függvény lehet rekurzív illetve részt vehet rekurzív függvényrendszerben (ahol a függvények nem feltétlenül közvetlenül hívják meg magukat, hanem esetleg más függvényeken keresztül).

Függvényhívás végrehajtása (i386-linux)

Amikor egy függvényt végre akarunk hajtani, akkor érdemes tisztában lenni azzal, mi is történik a végrehajtás során a memóriában. A következő kis animáció azt mutatja, mi történik akkor, amikor egy függvényhívás kifejezést kiértékelünk.

A példa első lépése, hogy a globális változóként deklarált változókat inicializálja a main függvényben. Ezek a változók a statikus, most kékkel jelölt memóriaterületen allokálódtak. Tegyük fel, hogy a felhasználó által megadott kezdőértékek rendre a 8, 8 és 4.

Amikor meghívódik az A függvény, ezen változók, mint aktuális paraméterek kerülnek felhasználásra, értékei bemásolódnak a verembe, ahol az A függvény formális paraméterei által hivatkozhatóakká válnak.

Az argumentumok elvileg tetszőleges sorrendben értékelődhetnek ki (a szabvány ezt nem definiálja), de a paraméterek jobbról balra haladva kerülnek a verembe. Mivel mind értékparaméter, az i-edik argumentum aktuális értéke átadódik az i-edik paraméternek, vagyis az aktuális argumentum értéke bemásolódik a paraméter számára foglalt memóriahelyre.

Ekkor a verembe bekerülnek azok a technikai információk is (pl. visszatérési cím), és a vezérlés átadódik a függvénynek. A függvény a végrehajtása során memóriát allokál a saját lokális változói számára a veremben. A függvény végrehajtása addig tart, amíg el nem jutunk egy return utasításig.

Amikor a függvény végrehajtása befejeződött, akkor a függvényblokk formális paraméterei számára foglalt megórai felszabadul, a függvényhívás kifejezés felveszi a függvényben kiszámolt értéket.

A függvényből a vezérlés végrehajtása visszakerül a hívó függvényhez, amely gondoskodik a veremmutató visszaállításáról arra a pozícióra, amelyen az állt az előtt, hogy az aktuális paramétereket rátette a veremre.

kep         kep

Függvényhívás végrehajtása rekurzióval (i386-linux)

Amikor rekurzív függvényt hajtunk végre,többször egymás után, akkor ez a folyamat ismétlődik úgy, hogy a veremre pakolt értékek egyre szaporodnak. Tulajdonképpen teljesen mindegy, hogy a hívás rekurzív, vagy sem, ugyanaz történik minden egymásba ágyazott függvényhívásnál.

kep         kep

Blokkok végrehajtása

A C nyelvben nem csak függvény szinten, hanem blokk szinten lehet változókat deklarálni. Ezek tárolását a C szintén a veremben végzi. Emiatt egy blokk végrehajtásának lépései nagyon hasonlítanak a függvények végrehajtásához.

  1. Memória helyfoglalás a blokk változói számára.
  2. A blokk végrehajtása.
  3. Memória felszabadítása.

kep         kep

Függvények mellékhatása

Függvény mellékhatásán azt értjük, hogy a függvényhívás hatására nem csak a függvényérték számítódik ki (és a paraméterek változhatnak meg), hanem megváltozhat egy globális változó értéke is (vagy egyéb műveletek is végrehajtódnak, pl. kiíratás). Mellékhatás következménye, hogy az összeadás kommutativitása nem feltétlenül teljesül, ha a tagok függvényhívások. C-ben ugyanis nincs meghatározva, hogy két részkifejezés közül melyiket kell előbb kiértékelni, tehát az sem világos, hogy ha mindkettőben van függvényhívás, melyik hajtódik végre előbb.

A következő példában a main függvényben a Z változó úgy kap értéket, hogy abban egy olyan összeget kell meghatározni, amelynek tagjai az f függvény kifejezések értékei. Ugyanakkor az f függvény módosítja az A globális változó értékét, és mivel egyik tag esetében ez a változó a függvény paramétere, amely majd meghatározza közvetve a függvény visszatérési értékét, nem mindegy, hogy az f(A), vagy f(B) kifejezés értékelődik ki először. Könnyen látható, hogy amennyiben az f(A) értékelődik ki először, annak értéke 2 lesz, miközben A értéke is A-re módosul. Ekkor az f(B) kifejezés értéke 4 lesz, azaz az f(A)+f(B) kifejezés értéke 6. Ha előbb f(B) értékelődik ki, akkor f(B) értéke 3, f(A) értéke 6, azaz f(A)+f(B) értéke 9 lesz.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int A, B, Z;
int f(int x)
{
    int r;
    r = x + A;
    A = x + 1;
    return r;
}
main () {
    A = 1; B = 2;
    Z = f(A) + f(B);  /* Z==6?  Z==9? */
}

Függvények előnyei

  • Többszörös felhasználás: Hasonló részproblémák megoldására elég egy függvényt készíteni és a különböző adatokra végrehajtatni a részalgoritmust. Így a program méretét csökkenteni lehet.
  • Memória igény csökkentése: A függvények lokális változói számára csak a függvény végrehajtása idejére foglalódik memória.
  • Karbantarthatóság: Függvények használatával a program áttekinthetőbb lesz, ami jelentősen megkönnyíti a későbbi módosítását.
  • Modularitás: A tervezés során a részproblémák függvénnyel történő megoldása lehetővé teszi a figyelem lokalizálását.
  • Programhelyesség: Függvények alkalmazása megkönnyíti a bizonyítást, a program tesztelését, a hibakeresést és javítást.