Pointerek a C nyelvben

Creative Commons Licenc
Ez a Mű a Creative Commons Nevezd meg! - Ne add el! - Ne változtasd! 4.0 Nemzetközi Licenc feltételeinek megfelelően felhasználható.

Tartalom

  1. Bevezetés
  2. Mik azok a mutatók?
  3. Cím szerinti paraméterátadás
  4. A tömbök és mutatók kapcsolata
  5. Mutató struktúrára
  6. Gyakori hibák

 

Bevezetés

Az alábbi cikkben igyekszem minél érthetőbben elmagyarázni mindazt, amit a C programozási nyelvben a pointerekről, azaz a muatókról tudni kell. Feltételezem, hogy az olvasó tisztában van a következő dolgokkal:

  • A primitív adattípusok a C nyelvben
  • Struktúrák
  • Standard kimenetre írás, standard bemenetről olvasás
  • Függvények, paraméterátadás

Mik azok a mutatók?

Először nézzük meg, mi is az a memória. Nem kell bonyolult dologra gondolni, képzeljünk el egy nagyon hosszú szalagot, amin több milliárd kis cella van. Minden cellába 8 bitet írhatunk, azaz 1 bájtot. Egy ilyen cella, azaz bájt elegendő, hogy ott eltároljunk egy char típusú változót. Kettő, egymás melletti bájttal egy short-ot, néggyel egy intet, és így tovább.

Minden egyes bájtnak van egy sorszáma, pontosabban címe. A legelső címe 0, majd egyesével nőnek a címek. 32 bites rendszerben ez $2^{32}$, azaz 4294967296 különböző cím, tehát 0-tól 4294967295-ig terjednek a címek. 64 bites rendszernél pedig $2^{64} = 18446744073709551616$ címünk lehet. Ez azért elég kell legyen egy ideig smiley. A változók a C-ben pedig nem mások, mint amolyan szimbólumok, amelyek a memória egy meghatározott részére hivatkoznak. Ha alacsonyabb szintű nyelvet használunk, mondjuk assembly-t, akkor ott nincsenek változók, ott már a memória címeket kell kezelnünk, ami valljuk be, elég kényelmetlen, és sok hibára ad lehetőséget. A C, és más magasabb szintű nyelvek megalkotói ezért is vezették be a változókat: Nem kell már közvetlenül a memóriával foglalkozni, elég int, double és más változókban gondolkodni. Tehát, ha például létrehozunk egy int típusú változót, akkor elég, ha majd a fordítóprogram foglalkozik azzal a problémával, hogy a kész program azt hova helyezze el a memóriába, mi csak kényelmesen leírjuk a változó nevét, ebből már a fordító tudja, hogy melyik memóriadarabkára kell gondolnia.

1
2
3
4
5
6
7
8
int main() {
    /*
     * Az a változó most 4 bájtot foglal el a
     * memóriában.
     */
    int a = 2;
    return 0;
}

Tegyük fel, hogy valamiért (erre majd később kitérek) szükségünk van arra, hogy eltároljuk egy változó címét. Erre természetesen egy másik változót kell használnunk, nade mi legyen annak a típusa? Mivel a címek 0-tól indulnak, és 1-esével nőnek, valamilyen előjel nélküli egészre lesz szükségünk. Érdemes az unsigned long-ot használni, mivel 32 bites környezetben általában ez 32 bites, 64 bites esetben pedig 64 bites. Azonban a C megalkotói ennél jobbat találtak ki. Az olyan változókat, amelyek egy memóriacímet tárolnak, nevezzük mutatóknak, azaz pointereknek. A pointer típusa attól függ, hogy milyen típusú változó címét tároljuk benne. Az alábbi példában egy int típusú változó címét tároljuk el a pa változóban:

1
2
3
4
5
int main() {
    int a = 2;
    int * pa = &a;
    return 0;
}

Itt a 3. sorban hoztuk létre a mutató változót. Mivel az a változó címét szeretnénk eltárolni, megnézzük, hogy annak mi a típusa, ez most int. Ezért a pa típusa pedig int * lesz, azaz a mutatott változó típusát leírjuk, és hozzáadunk egy *-ot. Később, ha ránéznk a pa-ra, látjuk, hogy a típusa int *, ebből tudjuk, hogy ő egy int-re hivatkozik. Az értékadás jobb oldalán azt látjuk, hogy &a. Az & ez esetben az un. címképző operátor. Ezt az operátort egy változó elé írva megkapjuk annak a változónak a címét. Pontosabban, a & <változó> kifejezés a változó címét adja vissza, a kifejezés eredményének típusa pedig <változó típus> * lesz.

A fenti program tehát 2 változót tartalmaz, és értelemszerűen mindkettőnek van helye a memóriában, azaz a pa-nak is. Tehát akkor az ő memóriacímét is lekérdezhetjük, illetve eltárolhatjuk egy harmadik változóban. A kérdés csak az, hogy ennek a változónak mi lesz a típusa? Ugyanazt a szabályt kell alkalmazni, mint mikor a pa-t deklaráltuk: Megnézzük, hogy a mutatott változó (most pa) típusa micsoda, ez most int *. Ezt a típust ellátjuk egy további *-al:

1
2
3
4
5
6
int main() {
    int a = 2;
    int * pa = &a;
    int ** ppa = &pa;
    return 0;
}

A fenti ábrán láthatjuk a megoldást, mégpedig a 4. sorban. A pa címét szintén a & operátorral kérdezzük le. Tehát most van 3 változónk: A ppa értéke a pa változó címe. A pa változó értéke pedig az a változó címe. Az a változó értéke pedig 2, amit nem értelmezünk címként.

Ha csak a pa változót használhatjuk, akkor hogyan tudjuk lekérdezni az a értékét? A pa értéke ugye az a címe, ez esetben az un. indirekció operátorra van szükségünk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>

int main() {
    int a = 2;
    int * pa = &a;
    int ** ppa = &pa;
    printf("a erteke: %d\n", *pa);
    printf("a erteke: %d\n", **ppa);
    return 0;
}

A 7. sorban a pa mutatón keresztül kérdezzük le az a értékét, mégpedig a * indirekció operátorral. Tehát a *pa kifejezés jelentése: Annak a változónak az értéke, amire a pa mutat. Hasonlóan a **ppa kifejezést is értelmezhetjük: Az átláthatóság kedvéért zárójelezzük ezt a kifejezést: *(*ppa). Tehát először a *ppa kifejezést értékeljük ki, ennek jelentése: annak a változónak az értéke, amire a ppa mutat. Ez pedig a pa változó. Erre alkalmazzuk végül a zárójelen kívüli *-ot, ennek a jelentése pedig: az a változó, amire a pa mutat, vagyis az a.

Cím szerinti paraméterátadás

Jogos a kérdés, hogy mire is jó mindez? A helyzet az, hogy a C nyelvben a mutatókat elég nehéz megkerülni, erre az első példa a cím szerinti paraméterátadás. Feltételezem, hogy az olvasó már írt egyszerűbb függvényeket C-ben, amelyeknek paramétert is adott át:

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

void foo(int b) {
	b++;
	printf("1-el novelt ertek: %d\n", b);
}

int main() {
	int a = 1;
	foo(a);
	foo(a);
	foo(a);
	printf("a erteke: %d\n", a);
	return 0;
}

Ebben a példában a foo függvény b nevű belső változója mindig a main-ben lévő a változó értékét veszi fel. Hiába változtatjuk b-t a foo-n belül, annak semmilyen hatása nem lesz az a-ra, mivel ez két különböző változó, amelyek a memória két különböző pontján találhatóak. Emiatt a program kimenete a következő lesz:

1-el novelt ertek: 2
1-el novelt ertek: 2
1-el novelt ertek: 2
a erteke: 1

Mit tehetünk, ha az a szándékunk, hogy a foo függvényen belül az eredeti, main-ben lévő a-t szeretnénk módosítani? Erre használhatjuk a mutatókat. Úgy kell módosítani a foo paraméterezését, hogy most ne egy int-et, hanem egy int*-ot kapjon, azaz egy olyan értéket, ami egy változó címe lesz:

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

void foo(int * b) {
	(*b)++;
	printf("1-el novelt ertek: %d\n", *b);
}

int main() {
	int a = 1;
	foo(&a);
	foo(&a);
	foo(&a);
	printf("a erteke: %d\n", a);
	return 0;
}

A főprogramban most a foo függvényt úgy kell paraméterezni, hogy nem a értékét, hanem annak címét adjuk át, ezért használjuk a & címképző operátort (10-12. sorok). A foo formális paramétere pedig int * b, azaz egy mutató. A 4. sorban lévő kifejezés jelentése: Először kiértékeljük a *b kifejezést, azaz arra a változóra hivatkozunk, amire b mutat, ebben a példában ez most a main-ben található a. Tehát a (*b)++ jelentése: Növeljük meg azt a változót 1-el, amire a b mutat.

A scanf függvény is így működik: Ha valamilyen primitív változóba (int, double, float, stb) olvasunk be, akkor ezért kell a & címképző operátort alkalmazni. A scanf-nek szüksége van annak a változónak a memóriacímére, amelybe majd a beolvasott értéket el kell mentenie.

A tömbök és mutatók kapcsolata

Tegyük fel, hogy van egy 10 elemű int tömbünk, és szeretnénk lekérdezni a 0. elemének címét. Ezt két módon is megtehetjük:

1
2
3
4
5
6
7
int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato1 = &tomb[0];
	int * mutato2 = tomb;
	return 0;
}

A 4. sorban az a megoldás szerepel, amit az eddigi tudásunk alajpán használnánk: Hivatkozunk a tömb 0. elemére, majd annak címét lekérdezzük a címképző operátorral. Viszont az. 5. sorban lévő megoldás is pontosan ezt eredményezi. Ha egy tömb nevét önmagában leírjuk, akkor az a kifejezés a tömb 0. elemének címét adja vissza. Tehát a fenti példában a mutato1 és mutato2 változók értékei megegyeznek, mindketten a tömb 0. elemére hivatkoznak.

A mutatókat arra is használjuk, hogy egész értékekkel eltoljuk őket. A legegyszerűbb példa erre a mutató 1-el való növelése:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato = tomb;
	printf("A mutatott ertek: %d\n", *mutato);
	mutato++;
	printf("A mutatott ertek: %d\n", *mutato);
	return 0;
}

A kód kimenete a következő:

A mutatott ertek: 1
A mutatott ertek: 2

A tömb elemei most 4 bájtosak, tehát a tömb 1. eleme a 0. elem után 4 bájttal következik a memóriában. Viszont a mutatónkat 1-el növeltük. Ez első ránézésre furcsának tűnhet, de valójában az történik, hogy valójában nem 1 bájttal léptetjük a címet, hanem 4*1-el, azaz az egység mindig a mutatott változó mérete. Emiatt nem kell aggódnunk amiatt, hogy olyan mutatót hozunk létre, amivel egy tömb két szomszédos eleme között átfedést tudunk csinálni. Hasonlóan működik a -- operátor is, illetve tetszőleges egészt is hozzáadhatunk a mutatóhoz:

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

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato = tomb;
	printf("A mutatott ertek: %d\n", *mutato);
	mutato++;
	printf("A mutatott ertek: %d\n", *mutato);
	mutato += 3;
	printf("A mutatott ertek: %d\n", *mutato);
	mutato--;
	printf("A mutatott ertek: %d\n", *mutato);	
	return 0;
}

Kimenet:

A mutatott ertek: 1
A mutatott ertek: 2
A mutatott ertek: 5
A mutatott ertek: 4

Ha a mutatót kedvünkre tologathatjuk, megtehetjük-e ezt magával a tömbbel? Hiszen eddig úgy tűnik, a tomb kifejezés a fenti kódban mutatóként is működik. Nézzük az alábbi kódot:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	tomb++;
	printf("A tomb 0. eleme: %d\n", tomb[0]);
	return 0;
}

Ha ezt megpróbáljuk lefordítani, a következő hibaüzenetet kapjuk:

pelda10.c: In function ‘main’:
pelda10.c:6:6: error: lvalue required as increment operand
  tomb++;
      ^

A probléma a tomb++ kifejezés: Növelni csak változót tudunk, maga a tomb pedig nem az, csupán egy szimbólum, ami egy tömböt reprezentál. Viszont az alábbi kód működik, hiszen itt a tomb+1 kifejezésnek nincsen olyan mellékhatása, hogy megváltozik a tomb mint "változó", hanem kapunk egy memóriacímet, ami a tömb 1. elemére hivatkozik:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato = tomb + 1;
	printf("A mutatott ertek: %d\n", *mutato);
	return 0;
}

Kimenet:

A mutatott ertek: 2

Ebből következik, hogy a tömb elemeire így is hivatkozhatunk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato = tomb;
	printf("A 3. elem: %d\n", tomb[3]);
	printf("A 3. elem: %d\n", *(tomb + 3));
	printf("A 3. elem: %d\n", mutato[3]);
	printf("A 3. elem: %d\n", *(mutato + 3));
	
	return 0;
}

A kimenet minden kiírásnál ugyanaz:

A 3. elem: 4
A 3. elem: 4
A 3. elem: 4
A 3. elem: 4

Vagyis a tömb szimbólumon ugyanúgy alkalmazhatjuk a fent bemutatott pointer aritmetikát, mint a mutatókon, feltéve, hogy nem a ++, illetve -- operátorokat alkalmazzuk. Továbbá a mutatókat használhatjuk tömb szintaktikával is. Röviden összefoglalva tehát: Ha van egy tömböm, akkor a klasszikus tomb[i] indexelős kifejezéssel teljesen azonos kifejezés a következő: *(tomb + i),  illetve tömb helyett ezt mutatókkal is eljátszhatjuk.

Nézzük az alábbi példát:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>

int main() {
	int tomb[10] = {1, 2, 3, 4, 5,
					6, 7, 8, 9, 10};
	int * mutato = tomb;
	mutato++;
	printf("A mutatott elem: %d\n", mutato[2]);
	
	return 0;
}

Itt a mutatót először beállítjuk, hogy mutasson a tömb 0. elemére (6. sor). A következő sorban 1-el eltoljuk a mutatót, tehát a tömb 1. elemére hivatkozik. A 8. sorban a mutato[2] kifejezés azzal egyenrangú, hogy *(mutato + 2), tehát még 2-vel eltoljuk, azaz összesen 3-al toltuk el a kezdeti memóriacímünket, azaz a 3-as indexű tömb elemre fogunk hivatkozni, ennélfogva a kimenet:

A mutatott elem: 4

Mutató struktúrára

A fenti példákban csupán primitív adattípusokra alkalmaztunk mutatókat, most nézzük meg mire kell figyelni, ha struktúráról, vagy unionról van szó. Nézzük az alábbi példát:

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

typedef struct MetanHajtasuAutistaHarciHorcsog {
	int zetor;
	int beszorult;
} MetanHajtasuAutistaHarciHorcsog;

int main() {
	MetanHajtasuAutistaHarciHorcsog bela;
	bela.zetor = 100;
	bela.beszorult = 1;
	MetanHajtasuAutistaHarciHorcsog * mutato = &bela;
	
	mutato->zetor = 200;
	
	return 0;
}

Ebben a kódban található egy (minden bizonnyal valószínű) struktúra, a MetanHajtasuAutistaHarciHorcsog. Ebből csináltunk a 9. sorban egy változót, bela néven, ennek a címét pedig eltároltuk a mutato nevű mutató változóban. A fentiek alapján már tudjuk, hogy a mutato típusa MetanHajtasuAutistaHarciHorcsog * lesz. Ami viszont eddig ismeretlen volt számunkra az az, hogy hogyan hivatkozunk a mutatón keresztül a struktúra elemeire. Annyi a különbség, hogy a . operátor helyett most a -> operátort kell alkalmazni.

Gyakori hibák

A mutatók használatakor legyünk rendkívül körültekintőek, ugyanis ha olyan címet tárolunk el egy pointerben, ami nem a mi programunkhoz tartozik, akkor az végzetes hibát eredményezhet. Jó, senki nem fog belehalni (feltéve, ha nem a NASA-nak, vagy egy atomerőműnek fejlesztünk), ami általában előfordulhat ilyenkor az az, hogy elszáll a programunk. Ugyanis a modern operációs rendszerek nem engedik meg a felhasználó programjainak, hogy a memória akármelyik részéhez hozzáférjenek. Az érvénytelen hivatkozást az operációs rendszer érzékeli, majd a kérdéses programot leállítja. A legjobb példa erre az un. nullpointer használata:

1
2
3
4
5
6
7
#include <stdio.h>

int main() {
	int * a = 0;
	printf("A mutatott ertek: %d\n", *a);	
	return 0;
}

Ha egy sorban több pointert szeretnénk deklarálni, akkor tegyük ki mindegyik neve elé a *-ot. Az alábbi kód ezért hibás, itt az a változó int *, míg a b csak int:

1
2
3
4
int main() {
	int * a, b;
	return 0;
}

Irodalomjegyzék

A cikkben szereplő kódok letölthetőek innen: link.