| Szmájli | Ezt kell beírni |
|---|---|
![]() | :) :-)
|
![]() | :D :-D
|
![]() | 8-)
|
![]() | :(|)
|
![]() | <3
|
Feladat: megkeresni a beírt szövegben a szmájlikat, és utána úgy kiírni a szöveget, hogy képek szerepelnek benne helyettük.
Oldjuk meg ezt minél kevesebb memória felhasználásával!
Lehetne működőképes megoldást csinálni úgy, hogy beolvassuk az egész szöveget egy sztringbe. De ez elég rossz ötlet. Ha látunk egy hosszú szöveget, abban rá tudunk mutatni a szmájlikra, anélkül hogy az előttük vagy utánuk lévő részt ismernünk kellene.
A feladatot elképzelhetjük egy adatfolyam (stream) problémaként. Valaki diktálja nekünk a szöveget (bejövő karakterek), nekünk pedig le kell írni azt (kimenő karakterek). A diktálás közben pedig nem szeretnénk a teljes szöveget, vagy hosszú szövegrészletet a fejünkben tartani.
Karakterek olvasása: a printf, scanf %c-n kívül használhatóak:
int getchar(void); // beolvas
int putchar(int); // kiír
Ezek gyorsabbak, mint a printf és a scanf,
de egyszerre pontosan csak egy karaktert írnak és olvasnak.
Nagyon fontos: a getchar() visszatérési típusa int!
int c; // int
c = getchar();
if (c == EOF)
printf("Bemenet vége!");
else
printf("Karaktert olvastam, kódja: %d", c);
A getchar() függvénynek a visszatérési értéke a fájl vége jelet
is tudja jelezni. Ezt úgy oldották meg a C-ben, hogy az stdio.h
tartalmaz egy EOF nevű konstanst, amely ezt jelzi. Ennek a
konstansnak az értéke szándékosan kívül esik a karakter típus ábrázolási
tartományán, hiszen minden olyan szám, ami azon belül van, egy bájt, ami
szerepelhet a program bemenetén is. Emiatt a getchar() függvény
visszatérési értékét char típusban tárolni hiba!
A
getchar()függvény visszatérési típusaint. A visszatérési értékeEOF, ha vége van a bemenetnek, és nem sikerült már beolvasni egy karaktert sem (fájl vége jelnél); és a beolvasott karakter kódja, ha sikerült.
Az elvesző bitek miatt (emlékezzünk: a char
kisebb, mint az int) lesz olyan karakter, amelyet a program
összekever a fájl vége jellel. Természetesen miután meggyőződtünk róla, hogy nem
EOF, már bemásolhatjuk vagy castolhatjuk karakter típusúra. Az EOF konstans
számértékét a C fordítók maguk határozhatják meg. Ezért az is hiba, ha valaki azzal
a feltételezéssel él a forráskódban, hogy EOF = -1!
Egyébként a legtöbb karaktert kezelő függvény, pl. putchar(),
toupper(), isdigit(), … is int típusú
paraméterrel rendelkezik, de a getchar()-ral ellentétben
ez legtöbbször nem lényeges a használatuk közben.
Példa: Blian élete. Az alábbi ploglam minden 'r' betűt 'l' betűle cselél.
#include <stdio.h>
int main(void) {
int c; /* kalaktel */
while ((c = getchar()) != EOF) { /* éltékadás is! */
if (c == 'r')
putchar('l');
else if (c == 'R')
putchar('L');
else
putchar(c);
}
return 0; /* letuln zéló */
}
A (c = getchar()) != EOF kifejezés működése
a következő:
- Kiértékelődik a
getchar()kifejezés. Erre beolvasódik egy karakter vagy azEOFfájl vége jel. - Ez bemásolódik a
cváltozóba az értékadás miatt. - Az egész zárójelezett kifejezés az értékadás. Ennek értéke a másolt érték, vagyis maga a karakter.
- Ezt hasonlítjuk össze az
EOFkonstanssal. - Ha nem egyenlő vele, akkor karaktert olvastunk, és mehetünk be a ciklusba.
- Ha egyenlő, fájl vége jelet, akkor pedig kiléphetünk a ciklusból.
Fontos a zárójel. Ha az nem lenne ott, akkor az = értékadás
és != egyenlőségvizsgálat operátorok precedenciája miatt
a getchar()!=EOF összehasonlítás eredménye kerülne a c
változóba, nem a karakter! (A legkülső zárójelnek természetesen nincs köze a kifejezéshez, mert az
a while-hoz tartozik.)
Állapotgép = véges automata (finite-state machine)
Működése: az eddig kialakult állapottól és az eseménytől függ:
- az elvégzendő tevékenység,
- a következő állapot.
Az állapotgép egy olyan gép, program, amely a bemenetei hatására a belső, véges számú állapot között ugrál. Minden bemenet–állapot pároshoz egy, és csakis egy pontosan meghatározott következő állapot (állapotátmenet) tartozik.
Egy állapotgép mindig egy bizonyos állapotban van, és attól függően reagál az eseményekre. Az események hatására állapotátmenet történhet. Az állapotgépet állapotátmeneti gráffal vagy állapotátmeneti táblázattal adjuk meg.
A mostani programjainkban az állapotátmenetekhez tevékenységeket is fogunk társítani.
Példa: italautomata
Az italautomata másképp reagál a sztornó gomb és az italválasztó gomb megnyomására attól függően, hogy be lett-e dobva már a pénz, vagy még nem.
Feladat: számoljuk meg a beolvasott szövegben az ly betűket:
- Az
ly1-nek számít:lyuk, - az
lly2-nek:gally, - nagybetűkkel most ne foglalkozzunk.
A probléma nehézsége
Önmagában egyik karakternél sem egyértelmű a teendő!
l-nél: nem tudjuk, mit kell majd csinálni:alma,lyuk,gallyy-nál: a teendő attól függ, előbb mi történt:négy,lyuk
Azt azért sejtjük, hogy a végleges döntés az y karakternél
fog megszületni. Az l-nél nem lehet, hiszen a jövőbe nem látunk. Úgyhogy a második
gondolatmenet a járható út. Eltárolni a teljes szöveget viszont felesleges, hiszen elég mindig
csak egy kis részletet látni belőle.
A kidolgozatlan ötlet az előzőek alapján:
sz = 0;
while ((c = getchar()) != EOF) {
if (c == 'y') {
switch (……… ELŐZMÉNYEK ………) { // !
case ……… és l volt előtte ………: // !
sz += 1;
break;
case ……… és ll volt előbb ………: // !
sz += 2;
break;
default:
break;
}
}
}
printf("%d darab ly szerepelt.\n", sz);
Tehát szükségünk van egy változóra, ami azt reprezentálja, mik voltak az előzmények,
mi történt a múltban. Az y karaktert pedig ennek függvényében értelmezzük: nem l
volt előtte, vagy egy l volt előtte, esetleg kettő. Ezt nevezzük állapotváltozónak.
Az állapotgép működését gráffal is megadhatjuk. Ez a megadás teljesen ekvivalens a táblázattal. Minden állapotra (a gráf csúcsai) megadja, hogy az egyes eseményeknél (élekre írt karakterek) mi a teendő. A nyíl az új állapot felé mutat, illetve az elvégzendő tevékenység is a nyíl mellé írva szerepel.
A konkrét példában: alap állapotban egy l hatására még nem történik semmi, de tudjuk, hogy a
következő karakternél figyelni kell, mert az esetleg egy y lehet. Ezért felvesszük az l_volt állapotot.
Alap állapotban a másik két karaktertípus hatására semminek nem kell történnie. Az l_volt állapotban viszont
y esetén növeljük a számlálót, és visszaugrunk alap állapotba (hiszen a következő karakternél már nem lesz igaz, hogy
az ahhoz képest előző l betű volt). Az ll_volt állapotnál viszont egy harmadik l betű esetén
maradunk ugyanabban az állapotban, mert a következő karakternél igaz lesz az, hogy az előző kettő l volt. (Ha van
ilyen magyar szó egyáltalán.)
Az állapotátmeneti gráf sokszor kevésbé áttekinthető, mint a táblázat – ha abból kell kódolni, akkor nehéz lehet követni a nyilakat. Az sem látszik rajta, hogy teljesen definiált-e, szemben a táblázattal, ahol látjuk, hogy minden cellája ki van-e töltve.
Viszont tervezésre, ötletelésre kiválóan alkalmas. Az állapotgép által felismert jelsorozatokat (eseménysorozatokat) is könnyebb ezen felismerni, egyszerűen csak követni kell az átmenetekre (nyilakra, élekre) írt feliratokat.
Tervezzük meg az „ly” számláló állapotgépét! Melyik állapotban, milyen esemény hatására, mi a teendő?
Egy állapotgép működése rögzíthető egy állapotátmeneti táblán, pongyolán fogalmazva egy állapottáblán. A táblázat sorai az egyes állapotokat jelentik (amelyekbe valamely régebbi események alapján került az automata). A táblázat oszlopai pedig az eseményeket (ezek most a beérkező karakterek). Minden eseménynél, vagyis minden karakter olvasásánál az aktuális állapottól és az éppen beolvasott karaktertől függően dől el az, hogy mit kell csinálni (tevékenység) és hova kell ugrani (állapot). Gyakran ezt két külön táblázatban adják meg – lentebb az állapot- és tevékenységtábla egy táblázatba összegyúrva szerepel.
Az állapottábla nagyban segíti a tervezést, amelynek menete a
következő. Először felvesszük egy táblázat oszlopaiba a számunkra érdekes eseményeket (jelen
esetben ezek az l, az y és az összes többi karakterek). Utána az első
sorba az alapállapotot, ahonnan indul az automata. Végiggondoljuk, hogy ebben az állapotban mely
eseményre (karakterre) minek kell történnie. Ha kell, új állapotokat veszünk föl; és addig
folytatjuk, amíg van kitöltetlen sora a táblázatnak.
| l | y | egyéb | |
|---|---|---|---|
| alap | →l_volt | - | - |
| l_volt | →ll_volt | sz += 1, →alap | →alap |
| ll_volt | - | sz += 2, →alap | →alap |
| k | u | l | c | s | l | y | u | k | , | g | a | l | l | y |
Az „ly” számláló állapottáblája tehát a következőket jelenti:
- Az alap állapot: semmi, amire figyelni kellene. Ez egyben a kiindulási állapot is.
- Ha jön egy
lbetű, átmegyünk l_volt állapotba.- Ha ilyenkor jön egy
y, akkor a számlálót növelni kell +1-gyel (és → alap!) - Ha viszont még egy
l, akkor meg ll_volt állapotba. Azért, mert ha harmadikkéntyérkezik, akkor +2 kell a számlálóba. - Ha bármi más, akkor viszont vissza alap állapotba (pl. almafa, az
lutánmbetű jött).
- Ha ilyenkor jön egy
Az állapot eltárolásához a legjobb választás egy felsorolt típus,
enum. Megtehetnénk, hogy mi magunk számozzuk be az állapotokat, de akkor a
programkód követhetetlen lenne. Így viszont olvasható lesz!
typedef enum LyAllapot {
alap,
l_volt,
ll_volt
} LyAllapot;
LyAllapot all = alap;
while ((c = getchar()) != EOF) {
switch (all) {
case alap: // alap állapot
if (c == 'l')
all = l_volt;
break;
case l_volt: // már volt egy 'l'
switch (c) {
case 'l': all = ll_volt; break;
case 'y': szaml += 1; all = alap; break;
default: all = alap; break;
}
break;
case ll_volt:
Minden beérkező karakternél a tevékenység és a következő
állapot is függ a beérkező karaktertől és az állapottól. Más például a teendő egy
beérkező y karakternél akkor, ha előzőleg l betűt
láttunk. Ezért minden karakter feldolgozásánál a táblázat egy cellájából kell
kiolvasnunk a teendőket. Ez alapján a kódban egy esetszétválasztást csinálhatunk,
ami viszont triviális: a meglévő állapottábla alapján a programkód
szisztematikusan, szinte gondolkozás nélkül elkészíthető!
szmajli.c
Az eddigiek alapján a táblázat könnyen elkészíthető, most az egyszerűsítés kedvéért csak két szmájlira:
| normál | : | ) | < | 3 | |
|---|---|---|---|---|---|
| alap | →alap c | →kettőspont (semmi) | →alap c | →kisebb (semmi) | →alap c |
| kettőspont | →alap :, c | →kettőspont : | →alap | →kisebb : | →alap :, c |
| kisebb | →alap <, c | →kettőspont < | →alap <, c | →kisebb < | →alap
|
Itt is az állapotgép táblázatában minden cella tartalmaz egy következő
állapotot (fent) és egy tevékenységet (lent). A tevékenység minden esetben
valamilyen karakter vagy karakterek kiírását jelenti. c-vel jelöltük
az épp beolvasott karakter képernyőre másolását; a többi kiírásnál pedig a megadott jelet
kell majd a programnak kiírnia (pl. kisebb jel vagy kettőspont).
Mivel minden oszlopban ugyanaz az állapotátmenet, egyszerűbbnek tűnik az esemény (karakter) szerint csinálni az első esetszétválasztást:
while ((c = getchar()) != EOF) {
switch (c) {
default:
switch (all) {
case Alap: printf("%c", c); break;
case Kettospont: printf(":%c", c); break;
case Kisebb: printf("<%c", c); break;
}
all = Alap;
break;
case ':':
switch (all) {
case Alap: /* semmi */ break;
case Kettospont: putchar(':'); break;
case Kisebb: putchar('<'); break;
}
all = Kettospont;
break;
Az állapot- és tevékenységtábla kézzel történő leprogramozása (ti. a hatalmas switch-ek)
helyett lehet egy sokkal okosabb ötletünk is.
Táblázat → csináljunk 2D tömböt a kódban!
- Minden cellában egy tevékenység és egy állapot. Ez azt jelenti, hogy a táblacella egy struktúra.
- Az állapot: a következő állapot kódja.
- A tevékenységek: mi a teendő.
A tevékenység az ly számláló példájában könnyen leképezhető akár egy egész számra: mennyivel kell növelni a számlálót. Összetettebb esetben függvénymutatókat lehet ehhez használni.
Az adatszerkezet: struktúrák kétdimenziós tömbje.
typedef struct TablaCella {
Allapot kovetkezo;
int tevekenyseg;
} TablaCella;
TablaCella tabla[3][3] = { ... };
Kérdés, hogyan fogjuk ezt a tömböt indexelni.
Az állapotból és a karakterből is tömbindexet kellene csinálni:
- A sorokat az állapot szerint indexeljük:
tabla[all] - Az oszlopot a karakter szerint indexeljük:
tabla[all][kar_o] - Azon belül pedig pl.
tabla[all][kar_o].allapot
typedef enum LyAllapot { // állapot → 0...2 egész szám
alap = 0,
l_volt = 1,
ll_volt = 2,
} LyAllapot;
int karakterosztaly(char c) { // char fajtája → 0...2 egész szám
switch (c) {
case 'l': return 0;
case 'y': return 1;
default: return 2;
}
}
Az állapotnál egyszerű a feladatunk. Azt amúgy is felsorolt típussal ábrázoljuk, és az enum-ok
konverzálhatók int típusú értékké. Akár hagyhatnánk is a fordítónak, hogy 0-tól kezdődően a természetes számokat
hozzárendelje az egyes állapotokhoz – de mivel tömbindexnek fogjuk ezeket használni, érthetőbb lesz a kód, ha megadjuk ezeket.
A karakter fenti switch szerkezetében a break utasítások elhagyhatóak, hiszen a
return utáni részek úgysem hajtódnak végre a függvényből. Kicsit „sormintás”, de ha nem így lenne, akkor meg egy 256
elemű tömbre lenne szükségünk (ennyiféle bájt van), amiben szinte minden érték nulla, csak néhány másik van – úgyhogy most jó lesz
ez a megoldás is.
A táblázat
lyszaml_tabla.c
TablaCella allapotgep[3][3] = {
{{l_volt, 0}, {alap, 0}, {alap, 0}},
{{ll_volt, 0}, {alap, 1}, {alap, 0}},
{{ll_volt, 0}, {alap, 2}, {alap, 0}},
};
A táblázat egy az egyben megfelel az állapotátmeneti táblázatnak, amelyet először rajzoltunk ehhez a feladathoz.
A táblázatot használó kód
while ((c = getchar()) != EOF) {
int kar = karakterosztaly(c);
szaml += allapotgep[all][kar].tevekenyseg;
all = allapotgep[all][kar].kovetkezo;
}
Vagyis minden beolvasott karakterre csak kiolvassa a táblázatból a tevékenységet és a következő állapotot.
sorrendi hálózatok
Előnyök
- Tervezésnél: a tervezés eszköze!
- A felesleges állapotok kiszűrhetők
- Kódolásnál: mechanikusan kódolható
- Áttekinthetőbb, érthetőbb a kód, mint egy ad-hoc megoldás
Felhasználásuk
- Szűrőprogramok (fájlok feldolgozása); fordítóprogramok, nyelvi elemzők
(pl.
/* kommentek */kiszűrése) - Grafikus alkalmazások vezérlése (pl. egérkattintások, mozdulatok)
- Internetes alkalmazások kommunikációja (protokollok)
- Hardver: a processzor egy nagy állapotgép!
Hardver oldalról is fontos az állapotgép. A számítógép belseje is tele van ilyenekkel. A processzor működését is egy állapotgép vezérli: utasítás beolvasása, beolvasott utasítás dekódolása, utána további operandusok beolvasása (már a dekódolt utasítás jelentése alapján) stb. Erről a Digit tárgyban van szó.
Írjunk programot, amelyben menüből választhatjuk ki a teendőt!
1. Összeadás
2. Szorzás
3. Hatványozás
0. Kilépés
Melyik? _
printf("1. Összeadás\n"); // 1. sorminta
printf("2. Szorzás\n");
printf("3. Hatványozás\n");
printf("0. Kilépés\n");
scanf("%u", &valasztas);
if (valasztas < 4) {
switch (valasztas) {
case 1: eredm = osszead(a, b); break; // 2. sorminta
case 2: eredm = szoroz(a, b); break;
case 3: eredm = hatvanyoz(a, b); break;
}
printf("E = %d\n", eredm);
}
A sormintákkal mindig az a baj, hogy nehezen módosítható, nehezen karbantartható programkódot eredményeznek.
Itt, hogy a switch, ahol a beírt szám alapján kiválasztjuk a teendőt, ne legyen túl
áttekinthetetlen, az egyes lépéseket eleve függvénybe tettük. Ez jó is – egészen addig, amíg nem kell
módosítani a menüt. Tegyük fel, hogy a 2-es menüponthoz szeretnénk beszúrni a kivonást. A teendők:
- Beszúrunk egy
printf()-et az összeadás és a szorzás közé. - Ezek után a többit is átszámozzuk (szorzás, hatványozás).
- A
switchelőttiif-nél átírjuk a számot (amely a menüpontok számával van összefüggésben) 5-re. - Beírjuk a
switch-be az újcase 2-t. - Átszámozzuk a többi
case-t is.
Ennél biztosan van jobb megoldás is. A sorminta mindig elkerülhető. Például a menüpontok nevei betehetőek tömbbe, és akkor egy ciklussal elvégezhető a kiírás és a beszámozás. Vajon a menüpontok maguk, azaz a függvények is betehetőek a tömbbe? Ha igen, meg tudjuk szüntetni a második kódduplikációt is!
A függvényhívás menetéről már volt szó régebben. Ismétlésként foglaljuk ezt össze!
#include <stdio.h>
#include <math.h>
double negyzet(double a) {
return a * a;
}
int main(void) {
double x = negyzet(5.9);
double y = sin(6.1);
printf("%g", x + y);
return 0;
}
Minden függvényhíváskor lefoglalódik egy terület a veremben, amely az adott híváshoz tartozik, és visszatéréskor megszűnik: ez a keret (stack frame).
A függvényhívás előtt a fenti példában a következő történik:
- A hívó, azaz a
main()beteszi a verembe a paramétereket. - Helyet csinál a visszatérési értéknek is.
- Meghívja a függvényt, ami által bekerül a verembe a visszatérés címe (vagyis hogy hol kell folytatni a programot a függvényből visszatérvén).
A negyzet() függvényben a működés:
- A paramétereit a veremben találja.
- A visszatérési értéket a verembe teszi, a megfelelő memóriaterület felülírásával.
- Amikor visszatér, akkor a hívóhoz ugrik vissza, az eltárolt visszatérési cím alapján.
A függvényhívás után a hívó:
- A veremben megtalálja a visszatérési értéket. Ezt felhasználja, ha szeretné.
- Kitörli a veremből az általa betett dolgokat, hiszen azokra már nincsen szükség.
Mi lenne, ha egy másik függvényt hívnánk meg ugyanolyan kerettel?
Ha két függvénynek egyforma a fejléce, akkor egyformán kezelik a vermet, és ezért kompatibilisek egymással.
double negyzet(double a);
double sin(double alfa);
mutató pointer
int main(void) {
double (*fptr)(double); // ptr létrehozása
double x;
fptr = negyzet; // ptr beállítása
x = fptr(3); /* negyzet(3) */
fptr = sin;
x = fptr(5); /* sin(5) */
return 0;
}
Mi is történik itt? Tegyük fel, hogy van egy függvényünk, amelyik
egy darab double paramétert vár, és egy darab double
paramétert ad vissza. Ha meghívjuk ezt a függvényt, akkor a hívó felépít
hozzá egy keretet, amelyben ezeknek az értékeknek meglesz a megfelelő helye.
A hívott fogja tudni, hogy a verem tetejéhez képest hol találja a paramétereket
és hova kell tennie a visszatérési értéket. (Ennek technikai részleteiről
természetesen a fordító gondoskodik.)
Ha van egy másik függvényünk, amelynek ugyanilyen a prototípusa, akkor
a hívási keret megfelelő felépítése után meghívhatjuk azt is. Hiszen az a függvény
a veremben ugyanott fogja keresni az ugyanolyan típusú értékeket. Vagyis
ha a keretet megfelelően építjük föl, meghívható akár a negyzet(), akár
a sin() függvény. Ennek pedig semmi akadálya nincs, ha a két függvény
prototípusa megegyezik.
Így bevezethetjük a függvényre mutató pointer típust. Ez azt a memóriacímet tárolhatja, ahol a függvény található a memóriában. Ez a cím képezhető, akár elmenthető egy függvénypointer típusú változóba, és egy függvénypointer segítségével meghívható a függvény. A függvény hívása ilyenkor az alábbi módon fog történni:
- Felépítjük a hívási keretet a megfelelő módon.
- Arra a memóriacímre ugrunk a végrehajtással, amire a pointer hivatkozik.
- Miután visszatért, a veremből kivesszük a visszatérési értékét, és töröljük a keretet.
Mivel azt tudni kell, hogy mi legyen a hívási keret felépítése, ezért a
függvénypointer típusában benne kell legyen az általa hivatkozott függvény prototípusa.
Ezért néz így ki fent az fptr változó definíciója:
double (*fptr)(double);
Hogyan definiálunk egy függvényre mutató pointert?
Függvényre mutató pointer típusú változó:
és a nevet
körbevevő zárójel!
a zárójel?
VisszaTíp (*változónév)(ParamTíp, /*...*/);
double (*fptr)(double);
fptr = sin;
Egy számra mutató pointernél is a pointer típusából tudja a fordító, hogy a dereferálása esetén milyen típusú értéket vehetünk
ki a memóriából (pl. int* egy egész számra mutat, double* valósra). A függvényre mutató pointereknél
ugyanez a helyzet: a pointer típusából tudja a fordító, hogy mik a hívott függvény paraméterei és mi a visszatérési értéke. Vagyis
hogy hogyan kell számára a hívási keretet felépíteni. Tehát a függvényre mutató pointer típusú változónak a típusa két információt
kell magában hordozzon: 1) hogy egy pointerről van szó, 2) hogy milyen paraméterezésű függvényre mutat.
A jobb oldali zárójel és a benne lévő (double) mutatja, hogy egy függvényről van szó, amelynek egy
double paramétere van. A bal oldali double pedig azt, hogy ha meghívjuk a függvényt, egy valós számot
kapunk vissza. A változónév előtti * jelzi, hogy az fptr változó egy pointer, amely az előbb részletezett
fejlécű függvényre mutat.
Már csak egy a kérdés: miért van a (*fptr) zárójelben? Azért, mert a kifejezés a változódeklarációknál megszokott
logikát követi: úgy deklaráljuk a változót, ahogy használni fogjuk, és a deklaráló kifejezésben az operátorok szokásos
precedenciája érvényes. Itt a változó neve fptr. Az első zárójelpár a precedenciát adja meg: (*fptr)
miatt a * operátor az fptr-hez tartozik, nem pedig a bal szélső double-höz. Emiatt
fptr egy olyan pointer, ami... Mire is mutat? A deklaráció maradék része a double ___(double) – vagyis
egy olyan függvényre, amit double paraméterrel meghívva double-t kapunk vissza.
Amit tehát fontos megjegyezni:
- Zárójel veszi körül a változó nevét a csillaggal.
- Az argumentumoknál csak a típusokat soroljuk fel, vesszővel elválasztva.
Legegyszerűbben így tudjuk ezt észben tartani: ha egy függvénydeklarációban a név elé teszünk egy *-ot, majd azt és
a függvény nevét bezárójelezzük, függvénypointert kapunk.
Hogyan segít ebben a typedef?
A típust a typedef is megadhatja:
Emlékezzünk vissza: a typedef után írt dolog szintaxisa tökéletesen megegyezik a változódeklaráció szintaxisával. Tehát ha típusként szeretnénk definiálni egy
függvényre mutató pointert, ugyanazt kell csinálnunk, mint a változóknál, csak a sor elejére még a typedef kulcsszó is
odakerül. A fenti példában az EgyvaltozosFvPtr nevű típus lett definiálva függvénypointerként.
typedef VisszaTíp (*ÚjTípusNév)(ParamTíp, /*...*/);
typedef double (*EgyvaltozosFvPtr)(double);
EgyvaltozosFvPtr f;
f = sin;
A typedef a bonyolult típudefiníciók megalkotásában sokat segíthet,
ugyanis lehetővé teszi azt, hogy lépésenként építsük meg a típusnevet. Például
a függvényre mutató pointer típus „megépíthető”, előbb a függvény, utána pedig
a pointer típus definíciójával:
typedef double EgyvaltozosFv(double);
typedef EgyvaltozosFv *EgyvaltozosFvPtr;
Ennek az első sora a double ___(double) alakú, egyváltozós matematikai
függvények típusát definiálja EgyvaltozosFv néven. A második sor pedig egy
ilyenre mutató pointer típust definiál, EgyvaltozosFvPtr néven. Látszik, hogy
ilyenkor a zárójelre már nincsen szükség, mivel a függvény típus össze lett vonva egy szóvá.
Miért kell a zárójel a csillag köré?
Lássunk ellenpéldákat! Ha a deklarációban a név elé nem tennénk *-ot, akkor nem is változót, hanem egy
függvényt deklarálnánk. Leírva azonnal látjuk:
double f(double);
Ha pedig nem tennénk az így becsillagozott kifejezést zárójelbe, akkor is függvényt deklarálnánk. Ez esetben a *
operátor a visszatérési érték része lenne, tehát olyan függvényről lenne szó, amelyik egy pointerrel tér vissza:
double * f(double);
A függvénypointer típus különálló deklarációja sokat segíthet a szintaxis érthetőbbé tételében. Különösen akkor, ha olyan
függvényünk van, amelyik maga is függvénypointert vesz át paraméterként, vagy ad a visszatérési értékében. Erre legjobb példa a
szabványos signal() függvény, amelyik függvénypointert kap, és függvénypointert is ad vissza:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
Ezen egyből látjuk, hogy a függvény második paramétere ugyanolyan típusú, mint a visszatérési
értéke. Ellenben ugyanez typedef nélkül ilyen lenne:
void (*signal(int signum, void (*handler(int)))(int);
A menüpontok függvényei:
int osszead(int a, int b) {
return a + b;
}
int szoroz(int a, int b) {
return a * b;
}
int hatvanyoz(int a, int b) {
int eredmeny = 1;
for (int i = 0; i < b; ++i)
eredmeny *= a;
return eredmeny;
}
Vagyis minden függvény megkap két számot és elvégez rajtuk egy műveletet, aminek az eredményét a visszatérési értékben közli.
Ezekre a függvényekre egy ilyen pointer tud mutatni:
typedef int (*MatFv)(int, int);
typedef int (*MatFv)(int, int);
typedef struct {
char const *nev;
MatFv pfv;
} MenuPont;
MenuPont menupontok[] = {
{ "Összeadás", osszead },
{ "Szorzás", szoroz },
{ "Hatványozás", hatvanyoz },
{ NULL, NULL } /* végjel */
};
Mivel mutatókat tárolnak, ezért utolsó elemnek betehető egy NULL érték, ami jelzi a végét – így biztonságosan kezelhető. Ez egy végjel: amikor egy ciklus fut a tömbön, a tömb tartalma alapján tudni fogja, hol van a vége. Nem kell majd külön szerepelnie a programban a tömbméret megadásának sem. Ezt a trükköt már rengetegszer használtuk.
A menüpontok kiírása:
és a hívás
for (i = 1; menupontok[i-1].nev != NULL; ++i)
printf("%d. %s\n", i, menupontok[i-1].nev);
meret = i;
A for ciklus indexváltozójának utolsó értéke a NULL elem indexe, vagyis a tömbben lévő értékes elemek száma. Ezzel az értékkel tudjuk ellenőrizni, hogy a felhasználó által bevitt menüpont értéke érvényes-e.
A kiválasztott menüpont végrehajtása:
if (val >= 1 && val <= meret) {
eredmeny = menupontok[val-1].pfv(a, b); // fv pointer
printf("Eredmény: %d\n", eredmeny);
} else {
printf("Nincs ilyen menüpont\n");
}
A fenti kifejezés működése részletesen:
| Kifejezés | típus |
|---|---|
menupontok | struktúrák tömbje, a menüpontok leírása |
menupontok[index] | egy struktúra, menüpont leírása |
menupontok[index].fv | egy függvényre mutató pointer |
menupontok[index].fv(2.6, 3) | 2.6, 3 paraméterekkel meghívva az egyik függvény |
Ezzel a függvénypointereket a hozzájuk tartozó leírással egy tömbbe tettük. Így az egy új menüpont hozzáadása nagyon egyszerű, csak a tömböt kell módosítani. A működés automatizált, ha új menüpontunk van, csak kiegészítjük a tömböt, és minden további programrész helyesen kezeli.
Mivel a függvények értékként kezelhetőek, és paraméterként adhatóak át, egyes algoritmusok működése megfogalmazható általánosságban is. Gondoljunk a programozási tételekre a félév eleji anyagból – ezek mind generikus algoritmusok:
- Számlálás tétele: adott tulajdonságú elemek darabszáma
- Maximumkeresés tétele: legvalamilyenebb elem megkeresése
- …
Megfogalmazhatók általánosságban is, hogy a kód szintjén is generikusak legyenek.
számlálás
int szamlal(long *tomb, int n, bool (*pred)(long)) {
int db = 0;
for (int i = 0; i < n; ++i)
if (pred(tomb[i])) // adott tulajdonságú?
++db;
return db;
}
A program forráskódja is így nagyon szemléletes. Akárhány függvényt írhatunk, amelyek tulajdonságokat vizsgálnak. Ezek a predikátumok:
predikátumok
bool negativ(long x) {
return x < 0;
}
bool paros(long x) {
return x % 2 == 0;
}
A függvényeket pedig egyszerűen paraméterként átadjuk:
long tomb[10] = {………};
printf("%d negativ van.\n", szamlal(tomb, 10, negativ));
printf("%d paros van.\n", szamlal(tomb, 10, paros));
A függvény tehát paraméterként kapja a vizsgálandó számsort, továbbá egy másik függvényt, a predikátumot. Ezt a függvényt meghívja minden elemre – ez a függvény mondja meg, hogy az adott elemeket bele kell-e épp számolni, vagy nem. Hogy épp a negatív számok esetén növeljük a számlálót, vagy a páros számok esetén.
A rendezőalgoritmusokat is meg tudjuk fogalmazni generikusan. Ott ugyanez a helyzet, mint a számlálásnál vagy a keresésnél. Az algoritmus maga ugyanaz, csak az változik, hogy melyik tömbelem kisebb, melyik nagyobb – egész pontosan, hogy melyik való a tömb elejére, és melyik a végére, mert kisebbről és nagyobbról itt már nem beszélhetünk.
void rendez(double *t, int n, bool (*p)(double, double)) {
for (int i = 0; i < n-1; ++i) {
int leg = i;
for (int j = i+1; j < n; ++j)
if (p(t[j], t[leg])) // !
leg = j;
if (i != leg)
csere(&t[i], &t[leg]);
}
}
A függvénynek adott predikátum most nem egy-, hanem kétparaméterű. Ugyanis nem egy elemről kell megmondania, hogy rendelkezik-e valamilyen tulajdonsággal, hanem két elemet kell összehasonlítania; előrébb való-e az egyik a rendezett tömbben, mint a másik. Ha a kisebb elemekre mondjuk azt, hogy előrébb való, akkor növekvő sorrendet kapunk. Ha a nagyobb elemek kerülnek előre, akkor csökkenő lesz a sorrend.
predikátumok
bool kisebb(double a, double b) {
return a < b;
}
bool nagyobb(double a, double b) {
return a > b;
}
Az előző pont egyparaméterű predikátumait unáris predikátumnak nevezzük (unary predicate). Az összehasonlító, kétparaméterű predikátumokat pedig binárisnak (binary predicate).
A tevékenység egy függvény:
typedef struct AllapotPont {
Allapot kovetkezo;
void (*tevekenyseg)(void);
} AllapotPont;
Az állapotgépet kezelő program:
Esemeny esemeny;
Allapot allapot;
while ((esemeny = uj_esemenyre_var()) != -1) {
/* meghívjuk az adott tevékenység függvényét */
tabla[allapot][esemeny].tevekenyseg();
/* lépünk a következő állapotba */
allapot = tabla[allapot][esemeny].kovetkezo;
}
Hogy ne legyen túl sok állapotunk, a tevékenységeket érdemes paraméterezni. Például a tevékenységnek paramétere lehet az aktuálisan beolvasott karakter, vagy egy kiírandó szöveg is. Így egyszerűsödhet az állapotgép.
A rendezés megvalósítására a C szabványos könyvtára tartalmaz egy qsort() nevű generikus
algoritmust. Ennek az összehasonlítás kritériuma, azaz a rendezési reláció megadható. Ha sztringeket szeretnénk rendezni, könnyű a
dolgunk: az szintén szabványos strcmp() függvény lehet a paraméter:
alma barack korte szilva
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(void) {
char szavak[4][20] = {
"barack", "alma", "szilva", "korte",
};
qsort(szavak, 4, 20, strcmp); // db = 4, meret = 20
for (int i = 0; i < 4; ++i)
printf("%s ", szavak[i]);
return 0;
}
Vegyük észre, hogy itt a függvénynek két paramétert is kell adni a tömbhöz. Egyrészt tudnia kell,
hány eleme van (hány sztringet kell sorbaraknia). Ez itt db = 4. Másrészt pedig azt is látnia kell, hogy
egy elem mekkora, vagyis hogy hány karakterből álló sztringeket kell cserélgetni. Ez meg meret = 20.
Tehát „mindkét irányban” változhat a méret, az elemszám és a sztringek hossza is tetszőleges. A
qsort() paramétere ezért nem két dimenziós tömbre mutató pointer (amelybe bele kellene írni a tömb méretét is, pl.
int (*p)[20]), hanem inkább egy void *. A void *-ként átadott mutatót lehetne (void*)
szavak formában írni, kiemelve azt, hogy ott egy mutató konvertálásról van szó, de ezt nem szokás megtenni. A pointerek
konverziója void *-ra, típus nélküli pointerre a C nyelvben automatikus, éppen ezek miatt az algoritmusok miatt.
A qsort() negyedik paramétere az összehasonlító függvény, amelyet meg fogja hívni minden
elempárra, amit a rendezés közben össze kell hasonlítania.
A void * technika olyan hasznos, hogy a C könyvtár is alkalmazza:
/* fájlkezelés */
fwrite(void const *ptr, size_t meret, size_t db, FILE *fp);
fread(void *ptr, size_t meret, size_t db, FILE *fp);
/* gyorsrendezés, bináris keresés */
void qsort(void *tomb, size_t db, size_t meret,
int (*hasonlit)(void const *, void const *));
void *bsearch(void const *kulcs, void const *tomb,
size_t db, size_t meret,
int (*hasonlit)(void const *, void const *));
/* dinamikus memóriakezelés */
void *malloc(size_t meret);
free(void *ptr);
Látjuk, hogy ez az ötlet végigvonul mindenhol. Az fread() és fwrite()
megkapják a tömbök elemszámát, és az egyes elemek méretét. A gyorsrendezést és bináris keresést végző függvény szintén.
Sőt még a malloc()–free() páros is ilyen volt. A malloc() megkapja a foglalandó
terület méretét bájtban, és void*-ot ad vissza; a free() is void*-ot vár. Egyik
függvénynek sem kell tudnia, hogy tulajdonképp milyen típusú adattal dolgoznak.
A qsort() valójában nem foglalkozik vele, nem is tudja, hogy milyen típusú elemeket rendez. Az
egyes elemeket meg tudja cserélni enélkül is (nyers adatot másol, mindig annyi bájtot, amennyi egy elem mérete). Az
összehasonlítást pedig a paraméterként átvett függvényre bízza. Ennek a függvénynek a visszatérési értéke pont olyan kell legyen,
mint ahogy azt a szabványos strcmp() is használja:
strcmp()
hasonlit(a, b) =
negatív: ha a < b
nulla: ha a == b
pozitív: ha a > b
Vegyük észre, hogy ez egy kicsit más, mint a fentebb bemutatott bináris predikátum. Az igazzal tért vissza,
ha a < b. Ez viszont egy int-et ad, aminek három leheséges értéke van, és azok a kisebb, egyenlő,
nagyobb eseteket reprezentálják.
Például egy hasonlító függvény double típusú elemekre az alábbiak szerint implementálható.
Figyelni kell arra, hogy mivel nem tudja a qsort(), hogy milyen adatokkal dolgozik, ezért a hasonlító függvénynek
is void * típusú pointereket ad a két összehasonlítandó elemre. Egész pontosan void const *-ot, hiszen
az összehasonlító függvénynek értelemszerűen nem dolga, hogy módosítsa az adatot.
Ezt a void * típusú pointert át kell konvertálnunk a saját típusunkra. De mivel tudjuk, hogy
ezt a függvényt double elemeket tartalmazó tömb esetén adjuk majd a qsort()-nak, a konverzió jogos
és helyes:
int hasonlit(void const *vp1, void const *vp2) {
double const *sz1 = (double const *) vp1;
double const *sz2 = (double const *) vp2;
if (*sz1 > *sz2) return +1;
if (*sz1 < *sz2) return -1;
return 0;
}
int main(void) {
double szamok[4] = {9.7, 3.4, 8.5, 1.2};
qsort(szamok, 4, sizeof(double), hasonlit);
for (int i = 0; i < 4; ++i)
printf("%g ", szamok[i]);
return 0;
}
Így tulajdonképpen a függvény bármilyen elemekből álló tömböt rendezni tud. Az egyes elemek méretét most a
sizeof(double) adja meg. Vagy egyszerűen oda sizeof(szamok[0])-t is írhatnánk. (Egyébként az előző
példában is a 20 * sizeof(char) lett volna teljesen korrekt.)
bsearch() használata
A bináris kereséshez ugyanilyen függvény van beépítetten, a bsearch():
int t[] = {23, 119, 47, -2, 54, 86};
/* bináris keresés csak rendezett tömbökön */
qsort(t, 6, sizeof(int), hasonlit_int);
int minta = 47;
int *talalt;
talalt = (int *) bsearch(&minta, t, 6, sizeof(int), hasonlit_int);
if (talalt != NULL)
printf("Megtaláltam, itt van: %p\n", talalt);
else
printf("Nincs a tömbben.\n");
Hogy ugyanazt a hasonlító függvényt lehessen használni, mint amit a qsort()-hoz is, a bsearch() úgy
van megadva, hogy egy mintát is vár tőlünk: első paramétere egy olyan elemre mutató pointer kell legyen, amely egy mintául szolgál
a kereséshez. Innen fogja tudni, hogy néz ki az az elem, amit keresnie kell. A hasonlító függvény pedig megmondja neki, hogy a
tömbben előre, hátra kell mozogni, esetleg megtalálta az adott elemet. Ne feledjuk, bináris keresésről van szó, és ezért a tömbnek
rendezettnek kell lennie!
A visszatérési érték is ahhoz hasonló, mint amit megszoktunk: NULL pointer akkor, ha nincs meg az elem. Ha megvan,
akkor pedig a találatra mutat a pointer, amit kapunk.
Vajon lehet olyan függvényt írni, ami bármilyen tömbön működik?
double szamok[4] = {9.7, 3.4, 8.5, 1.2};
void *ptomb = (void *) szamok; // így kapja a függvény :(
A gondunk az, hogy a void * esetén a pointer aritmetika nem működhet. Amikor az adatok tömbben vannak, a pointer
típusának nem csak egy, hanem két szerepe is van. Azon felül, hogy ez ad információt arról, hogy az adott típust hogyan kell
kezelni (nyilván más egy double és egy struct Konyv), a típus ismerete a tömbelemek címének
kiszámításakor is fontos. Innen tudja a fordító azt, hogy mekkora egy tömbelem, vagyis a következő elemre lépéshez hány bájtot kell
ugrani a memóriában. Ezt az információt mindig figyelembe veszi, amikor egy tömbelemet indexelünk, vagy a címét számítjuk.
Ha a tömb elejére mutató pointert void *-gá konvertáljuk, akkor nem csak a tömb tartalma válik ismeretlenné, hanem
az is, hogy az egyes tömbelemek hol helyezkednek el. A tömb elejére mutató void * pointer nem ad semmilyen információt
a tömbelemekről. A predikátum paramétere még csak-csak lehet void * (majd a törzsében megfelelő típusú pointerré
konvertáljuk), de a tömb elemein nem tudunk lépkedni a típus ismerete nélkül. Ezt a problémát oldja meg az elemméret átadása a
qsort() és társai esetén.
Egy char* típusú mutatót használva bájtonként lépkedhetünk:
char *bajtptr = (char *) ptomb; // sizeof(char) = 1 miatt
for (int i = 0; i < 4; ++i) {
double *elem = (double *) bajtptr; // újra double
printf("%g ", *elem);
bajtptr += sizeof(double); // hány bájtot ugrunk?
}
Ahhoz, hogy a mutató aritmetika működjön, a mutatót típussal kell ellátni, hiszen ilyenkor az adott típus
méretével szorozzuk a lépések számát. Ha char * típusúvá alakítjuk a pointert, akkor bájtonként tudunk lépni, mivel
definíció szerint sizeof(char) = 1. Mivel ilyenkor a lépések bájtonként értendők, az elem címének kiszámításakor nem
azt kell megadnunk, hogy hányadik valós számra, hanem hogy hányadik bájtra ugrunk. Ezért a ciklusban a mutatónkat nem egyesével,
hanem sizeof(double) lépésekkel léptetjük: annyi bájtot kell mindig előremenni, ahány bájtból egy double
áll. Az így kapott pointert pedig bátran visszakonvertálhatjuk double *-gá.
Egyes fordítók megengedik azt, hogy void * mutatókon használjunk pointer aritmetikát,
de ez nem szabványos, úgyhogy inkább kerüljük az ilyesmit!
A probléma tehát megoldható, mégpedig úgy, ha egy generikus tömböt feldolgozó algoritmusnak nem csak a tömb
kezdőcímét (most void * formában!) és a tömb méretét adjuk át, hanem megmondjuk neki az egyes tömbelemek méretét is!
Erre nincsen trükkös megoldás, egyszerűen közölni kell ezt a számot is az algoritmussal.
Egy függvény így lépdelhet végig egy void * tömbön:
void for_each_tomb(void *t, int db, size_t meret,
void (*muvelet)(void *))
{
char *pbyte = (char *) t; // sizeof(char) = 1 miatt
for (int i = 0; i < db; ++i)
muvelet((void *) (pbyte + i * meret));
}
Tehát ebben a char * csak azért szerepel, mert tudjuk, hogy sizeof(char) = 1, és
a pointer aritmetika esetén bájtokkal dolgozunk.
A for_each_tomb() használata egy tömb minden elemének a kiírására:
void kiir_int(void *vp) {
printf("%d ", *(int *) vp);
}
int tomb[] = { 68, 12, 125, 97, 56 };
for_each_tomb(tomb, 5, sizeof(int), kiir_int);




