C/C++ gyorstalpaló
Mivel nagyon sokan a C/C++-os dolgokkal keresik fel az oldalam, ezért ahelyett, hogy 10-20 cikkre szétbontanám a dolgot, megpróbálok írni egy tömör gyorstalpalót, amely alapján bárki elkezdhet foglalkozni C és C++ nyelvvel. Nyilvánvalóan a teljesség igénye nélkül, nehogy összezavarjam a kezdő programozót (bár ez nehéz lesz, mert minden mindennel összefügg).
Ez a cikk első sorban magáról a programozási nyelvről szól, és nem arról, hogy hogyan kell használni, illetve hogy hogyan kell telepíteni a fordítót, hogy hogyan kell debugolni, természetesen ad tippeket és egy kis referenciát a leggyakrabban használt dolgokról. Ezt úgy is sok gyakorlás és tanulás után sajátíthatja el csak az ember. A cikk célja az, hogy az olvasó megértse a C és C++ nyelveket, és annak nyelvi elemeit és szerkezetét.
Tartalomjegyzék
- A programokról általában
- Az adattípusok
- Literálok
- Kifejezések
-
Adattípusok közötti konverzió
- Előjeles és előjel nélküli számok közötti konverzió
- Egész számok nagyobb méretű változatra való konvertálása
- Egész számok kisebb méretű változatra való konvertálása
- Enum típusok konvertálása
- A bool típusra való konvertálás
- Konvertálás lebegőpontos és egész szám között
- Kikényszerített típus konverzió
- Változók
- Saját típusnevek létrehozása
- Mutatók
- Tömbök
- Mutató aritmetika
- Többszörös mutató és tömbtípusok
- Tömbök és struktúrák kezdőértékadása
- Konstans adatok
- Volatile adatok
- Alprogramok
- Utasítások
- Deklarációk és definíciók
- Fordítás és linkelés
-
Az előfeldolgozó
- 1. lépés: A trigraph-ok átalakítása a nekik megfelelő karakterre
- 2. lépés: Sorvégi sorfolytató \ jelek kezelése
- 3. lépés: egymás melletti stringek összefűzése
- 4. lépés: előfeldolgozó direktívák lekezelése
- Fejléc fájlok
- Tetszőleges számú paraméterű függvények használata
- Egyéb C nyelvi dolgok.
- A memóriakezelés problémái
- Referenciák
- Saját operátorok írása
- Több függvény azonos névvel
- C stílusú függvények használata
- Névterek
- Generikus függvények
- Alapértelmezett argumentumok
-
Objektumorientált programozás a C++-ban
- Metódusok
- Konstans metódusok
- Saját operátor metódusok írása
- Konstruktorok, destruktorok és az = operátor
- A hármas szabály
- Hozzáférés szabályozása
- Futásközbeli memóriafoglalás és felszabadítás C++-ban
- Öröklés
- Polimorfizmus
- Ideiglenes objektumok
- Statikus metódusok, mezők és belső típusok
- Osztálysablonok
- Tag inicializációs lista
- Metódusmutatók
- C++ stílusú típuskonverzió
- Kivételkezelés
- Egyéb C++ dolgok
- És most hogyan tovább?
A programokról általában
A számítógépes programok feladata tömören a következő: adatokat dolgoznak fel, és ennek eredményeként újabb adatokat állítanak elő. A nagyobb programok alprogramokból állnak, és ezekből az alprogramokból nagyon sok lehet.
A következő szekciókban elég részletesen leírok mindent, ami a nyelvről tudni kell.
Az első sikerélmény
De, hogy rögtön az elején legyen egy kis sikerélmény, íme egy nagyon egyszerű számkitalálós játék forráskódja:
#include <stdio.h> #include <stdlib.h> /** * Ez egy komment blokk, ide bármit írhatsz. * * Akár több soros kommentet is. */ // Ez egy sor végéig tartó komment int main(void) { int szam; int tippelesekSzama = 0; int tipp; printf("Találd ki az 1 és 1000 között a számot amire gondoltam!\n"); srand(time(0)); szam = rand() % 1000 + 1; do { // Addig kérdezgetjük a játékost, amíg el nem találja. printf("Tipped: "); scanf("%d", &tipp); if (tipp < szam) { printf("Nagyobb számra gondoltam!\n"); } else if (tipp > szam) { printf("Kisebb számra gondoltam!\n"); } else { printf("Eltaláltad!\n"); } tippelesekSzama++; } while(tipp != szam); // Értékelés printf("\nTippjeid száma: %d\n", tippelesekSzama); if (tippelesekSzama == 1) { printf("MÁZLISTA VAGY!\n"); } else if (tippelesekSzama <= 5) { printf("Jó szerencséd volt!\n"); } else if (tippelesekSzama <= 10) { printf("Egész jól tudod, hogy hogyan kell az ilyet játszani. :)\n"); } else if (tippelesekSzama <= 50) { printf("Ezt még gyakorolnod kell...\n"); } else if (tippelesekSzama <= 200) { printf("Türelmes vagy...\n"); } else { printf("Csak nem elkezdted egyesével végigpróbálni?! :D\n"); } }
Máris érdemes beszerezni egy C fordító programot vagy fejlesztői környezetet (javaslom a Code::Blocks-ot), és ezt elmenteni egy .c kiterjesztésű fájlba, lefordítani, majd elindítani. A program konzolablakban fut, és amint véget ér azonnal kilép, ha nem akarod, hogy azonnal eltűnjön a konzolablak, érdemes parancssorból indítani a programot.
Grat, te most már csináltál is egy programot. :) Most ha ezt végigolvasod, akkor tudni fogod rögtön, hogy mit is csinál a fenti program.
Még egy kis infó: C és C++ nyelv két különböző nyelv, de egymásra épül, a C volt korábban, a C++ később. De ettől függetlenül mind a két nyelv fejlődik egymástól függetlenül. A C forráskódok kiterjesztése .c, a C++ forráskódoké .cpp, de lehet találkozni .cc kiterjesztéssel is néha.
Tippek, ha nem működik valami
A számítógép azokat az utasításokat fogja végrehajtani, amit te beírtál, és nem azt, amire gondoltál.
Ha egy program nem azt csinálja, amire számítasz, akkor a feladat az, hogy kiderítsük, hogy miért csinálja azt a program, amit csinál, és miért nem azt, amit akarunk.
Ennek az egyik legjobb módja, hogy használjuk a printf
függvényt
(erről C standard könyvtár cikkben lehet olvasni) és kiíratunk mindent, amit csak tudunk, és csak akkor kezdjünk el belejavítani a kódba,
amikor már 100% biztosak vagyunk abban, hogy mi a rossz.
Az adattípusok
Először is beszéljünk arról, hogy a C-ben írt programok milyen típusú adatokkal dolgoznak.
Egész számok
C-ben a legalapvetőbb adattípus az egész szám a következő fajtái vannak:
- char: 1 byte-os egész szám, mely -128-tól 127-ig tud tárolni számokat.
- short: 2 byte-os egész szám, amely -32768-tól 32767-ig tárolni számokat.
- int: 4 byte-os egész szám, amely -2-31 -tól 2-31 -1 -ig tud számokat tárolni. Kb. -2 milliárdtól 2 milliárdig.
- long: Szintén 4 byte-os egész szám, akár csak az int. De 64 bites platformra való fordítás esetén általában 8 byte-os. Az értelmezési tartomány, mikor 8 byte-os: -2-63 -tól 2-63 -1.
A negatív számok kettes komplemens módban vannak ábrázolva a memóriában. Ezeknek a típusok van előjel nélküli változata is, ilyenkor az unsigned kulcsszó írjuk eléjük:
- unsigned char: 0 - 255 között.
- unsigned short: 0 - 65535 között.
- unsigned int: 0 - 232-1 között.
- unsigned long: 32 biten ugyanakkora, mint az int, 64 biten: 0 - 264 - 1 között van.
Néha találkozhatunk olyannal, hogy az unsigned szó önmagában áll, ekkor az unsigned int-et jelenti.
Az unsigned ellentéte a signed, de azt nem kel kiírni a típus neve elé, mert az az alapértelmezés.
Felsorolási típusok
Ezek olyan adattípusok, melynek az értékkészlete előre meghatározott, példán érthető meg legjobban, hogy miről van szó:
enum Evszak { TAVASZ, NYAR, OSZ, TEL }
A szintaxis a következő: enum kulcsszó, ez követi típus neve, majd kapcsos zárójelek között felsoroljuk a lehetséges értékeket.
Ez egy olyan adattípus, amely a fent említett négy évszak nevét veheti fel.
Miután a fordítóprogrammal megismertettük ez az adattípust, onnantól kezdve elég csak annyit írni, hogy enum Evszak
és tudni fogja, hogy miről van szó.
C++ nyelv esetén az enum
szót se szükséges elé írni.
Az enum típusok tulajdonképpen egész számok. Az első elemük 0-nak, a második 1-nek, stb. felel meg. De ebbe beleszólhatunk, ha megmondjuk, hogy melyik elem melyik számot jelentse:
enum Evszak { TAVASZ, NYAR, OSZ = 10, TEL }
Ez azt jelenti, hogy a TAVASZ
ugyanúgy 0, NYAR
ugyanúgy 1, az OSZ
viszont már 10, a TEL
pedig 11 lesz. Tehát ez a beállítás a számlálót is beállítja.
Mindennek az egész számos dolognak akkor van jelentősége, hogy ha egész számmá konvertáljuk az enum
típust, mivel nagyon közeli rokonok.
Logikai típus
Ez csak C++-ban elérhető, de C-ben is elő lehet állítani az előző enum-os megadással, ha kell. A típus neve bool, és lehetséges értékei a true és a false, azaz igaz és hamis. A típus mérete általában 1 byte, de ez a fordítóprogramtól függ, hogy mennyi.
Valós számok
A következő valós szám típusok vannak:
- float: 4 byte-os valós szám típus.
- double: 8 byte-os valós szám típus.
Mind a két típus mínusz végtelentől, plusz végtelenig tud ábrázolni tört számokat. A két adattípus között a legfontosabb különbség a pontosság, a double pontosabb.
A float pontossága kb. 7 értékes számjegy, a double pontossága pedig 15. Ez lényegében azt jelenti, hogy 7 egymás melletti nullától különböző számjegyet tartalmazó számot tud ábrázolni, tehát tudja ábrázolni a 12345670000-t és a 0,000001234567-et is, de a 12345670001-et már nem, és a 0,010001234567-et sem. Ezeket a legnagyobb nagyságrend alapján 12345670000-nak, illetve 0,01000123-nak fogja értelmezni. Hasonlóan a double esetén csak ott több értékes számjegy.
Mivel csak az értékes számjegyeket tudja ábrázolni, ezért számításkor számítási hibák keletkeznek, melyek összeadódnak, ahogy egyre több számítás van, a végeredmény elég pontatlanná válhat.
Bővebb leírás, hogy ez az ábrázolás, hogyan működik: Lebegőpontos számábrázolás
Struktúra típus
Ezt szokták rekordtípusnak is nevezni. Egy struktúrában több különböző típusú, logikailag összetartozó adat lehet. Például egy képpontot leíró adattípus így néz ki:
struct Keppont { int x; int y; float piros; float zold; float kek; }
A szintaxis a következő: struct kulcsszó, ezt követi a típus neve, majd kapcsos zárójelben pontosvesszővel elválasztva felsoroljuk a logikailag összetartozó adatokat, a fent látható formában: először az adattípus neve, majd az adat neve (ezzel fogunk a programunkban hivatkozni majd rá.)
Ez egy képpont típus, mely tárolja a képpont x és y koordinátáját, valamint a színét, piros, zöld és kék komponensek formájában. Teljesen haszontalan struktúra, de tanpéldának jó.
A megadás után a kódban elég csak annyit írni, hogy struct Keppont
, C++ esetén még a struct
kulcsszó is elhagyható.
Ezen típus mérete legalább az őt alkotó adattípusok méretének az összege, de esetenként lehet nagyobb is.
Unió típus
Hasonlít a struktúrára, azzal a különbséggel, hogy az unió mezői azonos memóriaterületen osztoznak. Így egyszerre csak egyetlen egy mezőjében lehet értelmes adat. Megadása ugyanúgy történik,
mint a struct
-nak, csak
union
-t kell írni:
union Unio { int egyik; float masik; double harmadik; }
Ezt akkor használják, amikor valamilyen típusfüggő adatot tárolunk, egyik típus esetén az egyik adat van beállítva, egy másik esetén pedig egy másik.
Mutató típus
Ezt gyakrabban pointer típusnak nevezik. A memóriában minden egyes byte-nak van címe. Ez tulajdonképpen az adott byte sorszáma a memóriában. Ez a sorszámozás nullától kezdődik, és tart, ameddig a memória tart.
A mutató típus nem magát az adatot tárolja, hanem egy sorszámot, a szóban forgó adat első byte-jának a sorszámát. Amikor azt mondjuk, hogy egy mutató mutat egy adott adatra, az azt jelenti, hogy az értéke a szóban forgó adat első byte-jának a sorszáma.
A mutató típus mérete 32 bites program esetén 4 byte, 64 bites esetén 8 byte.
A mutató típust máshogy adják meg, mint az eddig említett adattípusokat, majd később lesz erről újra szó.
Tömb típus
Számítógépet általában nagy mennyiségű adat feldolgozására használjuk, ezért gyakori, hogy sok azonos típusú adattal kell dolgozni, ha ezeket egymás mellé rendezzük a memóriában, egy adat tömböt kapunk.
A tömb típusokat a mutatótípushoz hasonlóan máshogy kell megadni. Ezért majd később lesz róla szó.
Mérete, értelemszerűen az őt alkotó adatok típusának a mérete, szorozva az elemek számával.
A „semmilyen” típus
Ennek az adattípusnak a neve void. Csak abban az esetben használható, hogy ha logikailag értelmezhető ez, különben a fordító fordítási hibát fog mondani.
Literálok
Előző részben az adattípusokkal foglalkoztunk, most foglalkozzunk magával az adatokkal. Azokkal az adatokkal, amelyeket közvetlenül a forráskódunkba írhatunk.
Egész számok
Az egész számok a legalapvetőbb literálok, többféle számrendszerben is megadhatók:
- Tízes számrendszerben:
123, 67, 789
. - Nyolcas számrendszerben (a nullával kezdődő számokat úgy értelmezi, mintha nyolcas számrendszerben lennének):
0177, 0345
- Tizenhatos számrendszerben (
0x
kezdettel):0x2345FDA
Ezen egész literálok alapértelmezett adattípusa int. Ha unsigned int-ként szeretnénk, akkor azt megtehetjük, ha egy U-t írunk a szám után, pl. 567U
. A long típushoz L-t kell utána írnunk, pl. 876L
. Az unsigned long típushoz pedig UL-t (vagy LU-t), pl. 763UL
. Kis és nagybetű nem számít itt.
Valós számok
A tört számok a következőképpen adhatók meg:
- Tizedestörtként:
0.456
,3.1415
- Normál alakban:
6.2e+23
. Ezt úgy kell értelmezni mint 6,2×1023.
A valósszám-literálok alapértelmezett típusa double. Ha float típusú literált akarunk azt a szám végére tett F betűvel jelezhetjük: 5.78F
.
Karakter literálok
A karaktereket aposztrófok között adhatjuk meg: 'A'
.
A karakterliterálok char típusúak, és beírt karakter ASCII kódját jelenti.
Ha nem nyomtatható (vezérlő) karaktert akarunk írni, azt a \ karakter segítségével tehetjük meg (ez szokták nevezni escape-elésnek ):
\\
: A \ karakter írásához meg kell kettőzni azt.\a
: a 7-es ASCII kódú BEL karakter. Régi terminálokon ezen karakter kiírásakor a gép hangszórója sípolt egyet (manapság már nem jellemző).\b
: 8-as ASCII kódú backspace karakter. Szövegszerkesztőben a kurzor előtti karaktert törli. A terminálokon, egyszerűen vissza lépteti a kurzort eggyel (törlés nélkül).\t
: 9-es kódú tabulátor karakter. Terminálokon a kurzort a következő 8-cal osztható pozícióra teszi.\n
: 10-es kódú új sor karakter. A terminálokon megfelel az enternek, azaz a kurzort a következő sor elejére teszi. Ez a leggyakrabban használt vezérlő karakter\v
: 11-es kódú függőleges tabulátor. Történelmi okai vannak, hogy ez még benne van a C/C++ és sok modern nyelvben. Szinte soha nem használt.\f
: 12-es kódú lapdobó karakter. Szintén történelmi okokból létezik, nyomtatóra küldve lapot dobott a nyomtató, terminálokban pedig általában törli a képernyő tartalmát.\r
: 13-as kódú kocsivissza karakter. Ez terminálokon a kurzort az azonos sor elejére teszi vissza. Nem használják sűrűn. Windows-ban gyakran párban jár a \n-nel.\0
: 0-ás kódú NUL karakter. A C-ben ennek a karakternek speciális szerepe van.\'
: Aposztróf írásához. (karakter literálokban)\"
: Macskaköröm írásához. (szöveges literálokban)\?
: Kérdőjel írásához. Ez szintén történelmi dolog, korlátozott karakterkészletű számítógépeken a C nyelv sok karaktere nem volt elérhető, ezért azokat speciális (kérdőjeleket tartalmazó karaktersorozatokkal kellett helyettesíteni).\xHH
: A HH helyére ír 16-os számrendszerbeli számnak megfelelő kódú karakter, pl. \xFF.\OOO
: Az OOO helyére írt 8-as számrendszerbeli számnak megfelelő kódú karakter pl. \061. (A betű).
Szöveges literálok
Gyakrabban szokták stringnek nevezni. Megadásuk: macskakörmök közé írjuk a szöveget: "Egy hosszabb szöveg!".
Természetesen itt is lehet az előbb említett módon a \ jellel escape-elni a karaktereket, ha szükséges.
A stringben található karakterek kódjai szépen sorban növekvő sorszámú byte-okon vannak a memóriában, az utolsó karakter után pedig egy 0-ás értékű byte van, jelezve a string végét.
A literál adattípusa egy mutató, mely erre a string-re mutat. (A mutató típusa egy const char *
, de erről majd később írunk.)
Mutató literál
Egyféle mutató literál létezik a NULL, ez olyan mutatóérték, amely nem mutat semmilyen adatra. Technikailag egy olyan mutató, amely a 0. sorszámú byte-ra mutat a memóriában. Ez a hely a memóriában a senki földje, oda semmilyen adatot nem lehet tenni és nem is lehet ott semmit se elérni. A NULL érték helyett használható a 0 számjegy is, mikor erre a literálra hivatkozunk.
Ha egy olyan mutatóra van szükségünk, amely mutat valami értelmes adatra, azt máshogy kell csinálni, erről lesz később szó.
Kifejezések
A számítógép számokkal dolgozik, így minden egyes dolog, matematikai kifejezések kiszámolásával történik. Ebben a részben szépen sorra vesszük a C nyelv operátorait.
Alapvető aritmetikai műveletek
- Összeadás: pl.
234 + 345
(= 579) - Kivonás: pl.
345 - 123
(= 222) - Szorzás: pl.
765 * 123
(= 94095) - Osztás: pl.
234 / 123
(= 1). A C/C++ nyelv egész számok esetén egész osztást végez: nem foglalkozik a tört résszel, hogy ha tört számot szeretnénk kapni eredményül, akkor legalább az egyik számnak lebegőpontosnak kell lennie:234.0 / 123
(= 1.902439024) - Mínusz: pl.
-(3 - 2)
(= -1). A mínusz operátor egyetlen operandusának az ellentettjét veszi. - Plusz: pl.
+(3 - 2)
(= 1). Ez az operátor lényegében nem csinál semmit. - Osztási maradékképzés: pl:
234 % 123
(= 111). A maradékképzés csak egész számok esetén működik, lebegőpontos számok esetén fordítási hibát kapunk.
Logikai műveletek
C-ben és C++-ban minden egész szám, amely nem 0, a logikai igaz értéknek felel meg. A 0 értékek pedig hamisnak. Ha bool típusról van szó, akkor ott nyilván a true az igaz, a false pedig a hamis.
- Logikai ÉS: pl.
1 && 1
(= true). Értéke true, hogy ha mind a két operandusa igaz értékű, különben false. Kiértékeléskor, ha a baloldali operandus értéke hamisnak bizonyul, akkor a jobb oldalit már ki se fogja számolni (értéke mindenhogy hamis lesz). Ezt nevezik „short cut”-nak. - Logikai VAGY: pl.
0 || 1
(true). Értéke true, hogy ha legalább az egyik operandusa igaz. Kiértékeléskor, ha a baloldali operandus értéke igaznak bizonyul, akkor a jobb oldalit már ki se fogja számolni (értéke mindenhogy igaz lesz). - Logikai TAGADÁS: pl.
!true
(false). Hamis operandus esetén true-t, igaz operandus esetén false-t ad vissza.
Bitszintű logikai műveletek
Ezek a műveletek csak egész számokra vannak értelmezve, lebegőpontos operandus esetén fordítási hibát kapunk.
- Bitszintű VAGY:
12 | 5
(= 1100 | 0101 = 1101 = 13). Ha a két operandus két azonos pozíción lévő bitje közül legalább az egyik 1-es, akkor az eredményben azon a pozíción is 1-es bit lesz; ha mindkettő 0, akkor az eredményben is nulla lesz. - Bitszintű ÉS:
12 & 5
(= 1100 & 0101 = 0100 = 4). Ha a két operandus két azonos pozíción lévő bitje közül mindkettő 1-es, akkor az eredményben azon a pozíción is 1-es bit lesz; különben 0. - Bitszintű KIZÁRÓ VAGY:
12 ^ 5
(= 1100 ^ 0101 = 1001 = 9). Ha a két operandus két azonos pozíción lévő közül pontosan az egyik 1-es, akkor az eredményben azon a pozíción is 1-es bit lesz; különben 0. - Bitszintű TAGADÁS (negáció):
~12
(=~00000000 00000000 00000000 00001100
=11111111 11111111 11111111 11110011
= -13). Ez az egyetlen egy operandusán az összes bitet az ellenkezőjére fordítja. Mivel a 12 egy int típusú adat, ezért a negációja is int lesz, és mivel kettes komplemensben ábrázoljuk az előjeles egész számokat, ezért az értéke így -13 lesz.
Ezek a műveletek akkor hasznosak, amikor biteket kell állítgatunk egy számon belül. Legyen m egy szám, amelyben az állítandó bitek 1-re vannak állítva, a többi nulla. Legyen s egy tetszőleges egész szám, ekkor a következőket tehetjük:
- Bitek beállítása 1-re: Ha s értékét
s | m
-re állítjuk be, akkor az s-ben beállítottuk az összes bitet 1-re, ahol az m-ben is 1 volt. - Bitek beállítása 0-ra: Ha s értékét
s & ~m
-re állítjuk be, akkor az s-ben beállítottuk az összes bitet 0-ra, ahol az m-ben 1 volt. - Bitek tesztelése: Ha
s & m
értéke megegyezik m-mel, akkor az s-ben az összes bit 1, ahol az m-ben is 1.
Hogy ez hogy jön ki, azt gyakorlásként az olvasóra bízom. :)
Bit tologató műveletek
Ezek is csak egész számokra működnek, akárcsak az előzők.
- Balra tolás: pl.
2 << 3
( = 10 << 3 = 10000 = 16). A baloldali operandus bitjeit a jobboldali operandus értékének megfelelő számú helyi értékkel balra tolja. A baloldalról kicsúszó bitek elvesznek, jobb oldalról pedig nullákat tolunk be.Ez a művelet lényegében megegyezik 2 adott hatványával való szorzással, de sokkal gyorsabb annál. - Jobbra tolás: pl.
16 >> 3
( = 10000 >> 3 = 10 = 2). A baloldali operandus bitjeit a jobboldali operandus értékének megfelelő számú helyi értékkel jobbra tolja. A jobboldalról kicsúszó bitek elvesznek. Hogy bal oldalról mi csúszik be, az függ attól, hogy előjeles vagy előjel nélküli számokra alkalmazzuk-e a műveletet. Előjel nélküli szám esetén egy 0 csúszik be, előjeles szám esetén pedig a legmagasabb helyi értékű bit értékével megegyező bit. Ez az előjeles számok kezelése miatt működik így, mivel a jobbra tolás lényegében ekvivalens 2 adott hatványával való osztással, és az eredményt pedig mindig lefelé kerekíti. Példák:5 >> 2
(=1), illetve-5 >> 2
(=-2).
Ez a művelet akkor hasznos, amikor egy nagyobb méretű számba több kisebb méretű adatot akarunk bele rakni, az egyik gyakori minta az, amikor egy int méretű számba két short méretű számot tesznek be egymás mellé, valahogy így: (a << 16) + b
.
Fontos! Soha ne próbáljunk meg negatív értékkel tologatni, illetve nagyobb értékkel, mint a baloldali operandus bitjeinek a száma, mert az, hogy ekkor mi történik, nincs definiálva, így az eredmény processzoronként eltérő lehet, és nem feltétlenül az fog történni, amire számítunk.
Összehasonlító műveletek
Ezek a következők: >
, >=
, <
, <=
, !=
, ==
. Különösebb magyarázatra nem szorulnak, ami szokatlan lehet, hogy a ==
az egyenlőség, míg a !=
a nem egyenlőség (más nyelvek =
-t és <>
-t használnak).
Sőt C-ben a =
az értékadás operátora, melyről később lesz szó, és ezt könnyű elszúrni, és nehéz megtalálni.
A művelet eredményének a típusa C++ esetén bool, és az értéke true
vagy false
. C nyelv esetén int és az értéke 1 vagy 0.
A kettő teljesen kompatibilis egymással, így ebből nem lehet gond, ahogy a következő részben meglátjuk, hogy miért is.
Példák:
3 > 4
(false)3 < 4
(true)-3 < 1
(true)-3 < 1u
(false, majd később le lesz írva, hogy miért)
Feltételes operátor
Ez egy három operandusú operátor. Példa: 1 + 1 == 2 ? "Tényleg kettő" : "Világrendje felborult..."
. Ha a ? előtti első operandus értéke igaz, akkor a kifejezés értéke a kettősponttól baloldali kifejezés, különben a jobboldali.
Méret operátor
Ennek az operátornak a segítségével megtudhatjuk egy adattípus vagy egy kifejezés eredményének a méretét byte-ban. Ez nagyon hasznos, mert nem nekünk kell kiszámolni a méretet.
Az értéke size_t
típusú, mely egy előjeles nélküli egész szám.
Példák:
sizeof(int)
: 4sizeof(double)
: 8sizeof(1)
: 4 (mert int típusú az egész literál)sizeof(3.14)
: 8 (mert double típusúak a lebegőpontos literálok)sizeof(5.0f)
: 4 (mert jeleztük, hogy float típust akarunk)
Műveleti sorrend (precedencia)
Matematikából megszokhattuk, hogy bizonyos műveleteket előbb kell elvégezni, mint másokat, ez alkalmazható a C/C++ nyelvre is. Eddig ismertetett műveletekre a műveleti sorrend a következő:
- Egyoperandusú műveletek:
+ - ! ~
. A - a mínusz, a + plusz. - Multiplikatív műveletek:
* / %
- Additív műveletek:
+ -
. A - a kivonás. - Tologatás:
<< >>
- Relációk:
< <= > >=
- Egyenlőség:
== !=
&
^
|
&&
||
? :
(feltételes operátor)
Természetesen, ha nem vagyunk biztosak a dolgunkban, akkor it is használhatunk zárójeleket.
Operátorok eredményének az adattípusa
Az aritmetikai operátoroknak a számoláshoz két azonos típusú adatra van szükségük, az eredmény is ezzel a típussal megegyező típusú lesz. Gyakori, hogy a két adattípus nem egyezik meg, ekkor azonos típusra kell őket hozni. A következő szabályok közül az első alkalmazhatót alkalmazva:
- Ha az egyik double típusú, akkor másik is double típusúra lesz alakítva.
- Ha az egyik float típusú, akkor a másik is float típusúra lesz alakítva.
- Ha az egyik unsigned long típusú, akkor másik is unsigned long típusúra lesz alakítva.
- Ha az egyik long típusú, a másik unsigned int, és a long típus 64 bites, akkor a másik long-ra, különben mindkettő az unsigned long-ra lesz alakítva.
- Ha az egyik long típusú, a másik is long típusúra lesz alakítva.
- Ha az egyik valamilyen unsigned típusú, mindkettő unsigned int-re lesz alakítva.
- Végül, ha semelyik szabály sem alkalmazható, mindkettő int-re lesz alakítva.
Az eredmény típusa a közös típus lesz.
Kivétel ez alól a közös típusra hozós szabály alól a bittologató műveletek, mivel ott a jobboldali operandus csak a tolás mértékét adja meg, így ott annak a típusa lényegtelen, az eredmény típusa a baloldali operandus típusa, vagy unsigned int vagy int, ha annak mérete kisebb volt int-nél.
Relációs és egyenlőség műveleteknél az operandusok azonos típusra lesznek hozva, de nyilvánvalóan logikai értéket állít elő. A szabályokból látható,
hogy az unsigned típusra való konvertálás előbbre való, ezért egy korábbi példában, ahol -3
-at hasonlítottunk össze 1u
-val, a -3
unsignedre lett konvertálva, így belőle jó nagy, 4 milliárd feletti, szám lett.
Kivétel még a logikai operátorok, mivel azok logikai értéket állítanak elő.
Hogy ezek a számkonverziók hogyan mennek, azt a következő részben írom meg.
Túlcsordulás
Ha a számolás eredménye nem fér el az adott adattípus értelmezési tartományában, akkor az túlcsordulást okoz. A túlcsordulást felfoghatjuk úgy, mint egy ötdigites kilométerórát, amely 99999 után 00000-ra fordul át. Csak éppen kettes számrendszerben vagyunk. Előjel nélküli számoknál valóban ilyen átfordulás van. Például unsigned short típus esetén 65535 után 0 következik. Előjeles számoknál pedig 127 után -128.
Ha túlcsordulás történik a számolás eredménye helytelen lesz, ezért erre oda kell figyelni.
Adattípusok közötti konverzió
Ebben a részben leírom, hogy hogyan történik az eddig ismert típusok közötti konverzió.
Előjeles és előjel nélküli számok közötti konverzió
Semmi se történik, csak másként értelmezzük ugyanazt a byte sorozatot. Ez a következőt jelenti a gyakorlatban:
- A pozitív számok az előjeles változat értelmezési tartományán maradnak ugyanazok.
- A negatív számokhoz pedig hozzáadunk 28×méret -et, amikor előjel nélkülivé alakítjuk, illetve kivonjuk belőle ezt, amikor előjelessé alakítjuk. Például char típus esetén a -5 az unsigned char -rá való konvertálás után 251 lesz, mert a mérete 1 bájt, és az ahhoz tartozó szám 256. A short típus esetén -5-ből 65531 lesz, amikor előjel nélkülivé alakítjuk.
Egész számok nagyobb méretű változatra való konvertálása
Amikor nagyobb méretű változatra konvertálunk egy egész számot (pl.: char-t int-re), akkor a szám értéke megmarad.
Ez bit szinten azt jelenti, a szám a nagyobb adatterület alsó bitjein lesz elhelyezve. A felső bitek pedig vagy 0-kra vagy 1-re lesznek beállítva. Ha a szám unsigned típusú, akkor a felső bitek mindig 0-k lesznek, ha előjeles, és a szám negatív, akkor 1-esek. Ez biztosítja azt, hogy negatív szám esetén megmarad a szám értéke.
Egész számok kisebb méretű változatra való konvertálása
Amikor kisebb méretű változatra konvertálunk, akkor elkerülhetetlen lesz az, hogy adatot veszítsünk, és a szám értéke megváltozzon.
A bitek szintjén ez azt jelenti, hogy az alsó helyi értékekről a megfelelő számú bit le lesz vágva, és a levágott bitek elvesznek.
Példaként nézzük meg, amikor a 257-es értékű short típusú adatot char-rá konvertáljuk:
00000001 00000001 (257) => 00000001 (1).
Mivel a felső bájt elveszett, ezért csak az alsó maradt meg, ami 1.
Enum típusok konvertálása
Az enum típus ekvivalens az egész számokkal. Az értékei tulajdonképpen megfelelnek az egész számoknak, tehát a fenti példát használva a TAVASZ
értéke 0
,
a NYAR
értéke 1
, az OSZ
értéke 2
, a TEL
értéke pedig 3
.
Minden művelet, amely egész számokra alkalmazható, alkalmazható enum típusra is. Az enum típusok így szabadon konvertálhatók egész számmá, és vissza is.
A bool típusra való konvertálás
Egészszám adattípusokat lehet bool-ra konvertálni.
Ha bool
típusra konvertálunk, akkor az eredmény false
lesz, hogy ha a konvertálandó érték 0, különben true
.
Konvertálás lebegőpontos és egész szám között
Ilyenkor az egész szám bitjeiből egy lebegőpontos számot kell alkotni, vagy fordítva. Ez egy viszonylag lassú művelet.
Kikényszerített típus konverzió
Kifejezések kiértékelésekor ahhoz, hogy a két operandus típusa megegyezzen, konvertálni kell. Néha explicit módon is jelezni kell, hogy konvertálni szeretnénk.
Ezt úgy tehetjük meg, hogy a kifejezés elé zárójelben odaírjuk a típus nevét, amelyre konvertálni akarjuk. Pl. (unsigned long)1
.
Ekkor az az 1-es szám unsigned long típusra lett konvertálva, mert azt mondtuk.
Számok közötti típuskényszerítéskor ténylegesen egy új adattá alakul a dolog. Mutatók közötti típuskényszerítéskor egyszerűen a mutató értelmezése változik meg (pl. eddig
úgy kezeltük mintha egy int
-re mutatna, majd egy (long*)
típuskényszerítés után már long
típus adatra fog mutatni.
Hasonlóan kikényszerített típuskényszerítéssel lehet egész számok és mutatók között konvertálni.
Változók
Volt szó adattípusokról, az adatokról magukról, és már számolni is tudunk velük. 1, 2, 4 és 8 byte-os adatokkal számoltunk, és jó tudni, hogy a processzor egyszerre csak ekkora adatokkal tud bánni a többit a memóriában kell tárolni. És most már jött el az ideje, hogy megnézzük, hogy ezeket hogyan is kell ezeket a dolgokat memóriában tárolni.
Változók megadása (deklarációja)
Az adat típusának megfelelő területet a programunk számára változók megadásával oldhatjuk meg. A változót úgy adjuk meg, hogy egy adattípus neve után odaírjuk a változó nevét, és pontosvesszővel zárjuk az utasítást. Például:
int x;
És ezzel létrejött egy x nevű változónk, amely 4 byte-ot (sizeof(int)
) foglal a memóriában, amit ezután használhatunk is.
Természetesen van arra is lehetőségünk, hogy több azonos típusú változót adjunk meg egyetlen egy sorban, vesszővel elválasztva, de még arra is, hogy egyet sem (csak a típus megadás áll ott magában utána egy pontosvesszővel), ezt struct-oknál és enum-oknál gyakran alkalmazzák, azárt, hogy fordítót tájékoztassák az adattípusról, de még nem csinálnak változót ott azon a helyen.
A változó neve (és minden más, aminek nevet adunk) egy ún. azonosító. Melyekre a következő szabályok vonatkoznak:
- Az angol kis és nagybetűiből, valamint az _ karakterből állnak.
- Az első karakter kivételével tartalmazhat számjegyeket is.
- Mikor hivatkozunk egy azonosítóra, a kis és nagy betű számít, tehát a
X
és azx
két különböző azonosító.
Változókat megadhatunk függvényen belül és függvényen kívül. Hogy mi a függvény a C nyelvben, arról majd később.
Változók használata
A változóval alapvetően két dolgot csinálhatunk: adatot tárolunk benne, illetve használjuk a benne lévő adatot.
Adatok eltárolása
Ahogy azt a kifejezések résznél már láttuk. Dolgokat kiszámolni matematikai kifejezéssekkel lehet. Adatok változókban való tárolására úgyszintén vannak operátorok. Méghozzá erre való az =
operátor.
Példa:
x = 13
Ez a kifejezés azt csinálja, hogy az =
jobb oldalán álló kifejezés értékét eltárolja a baloldalon lévő változóban, és ha szükséges, típuskonverziót is végez, hogy a változó típusával megegyező legyen az adat, amit beleírunk.
Mint minden kifejezésnek, ennek is van értéke méghozzá a jobboldalon lévő kifejezés értéke, a típusa a változó típusa lesz. Az értékadó operátornak mindig a jobb oldala értékelődik ki először.
Adatok elérése a változóban
A változóban lévő adatot úgy érjük el, hogy a változó nevét beleírjuk egy kifejezésbe:
x + 12
Ha előbb eltároltuk az x-be a 13-at, akkor ennek a kifejezésnek az értéke 25 lesz. A kifejezés a változóval jelzett tag típusa a benne tárolt adat típusa lesz, értelemszerűen.
Struktúrák használata
Az adattípusoknál felsoroltuk már a struktúrákat, amely több különböző adatot foglalt össze. Most leírom, hogy ezeket hogyan kell használni. Kezdetben adjunk meg két változót, amely egy pontot reprezentál a 3 dimenziós térben x, y és z koordinátákkal:
struct Pont { double x; double y; double z; } p1; struct Pont p2;
A struktúrák mezőit a . (pont) operátorral érhetjük el. Tehát a p1.x
, p1.y
, p1.z
a p1
változóban található x, y és z mezők. A
p2.x
stb. meg a p2 változóban található mezők lesznek, és ezek ugyanúgy használhatók, mint bármely korábban említett változó. Tehát értéket tehetünk bele: p1.x = -1.5
.
Amit még megtehetünk az az, hogy két azonos struktúra típusú változó között értékadást végzünk. Tehát a p1 = p2
egy szabályos értékadás. A p1
változó összes mezője felveszi majd a
p2
változó összes azonos nevű mezőjének értékét.
Változók kezdőértéke
Amikor megadunk egy változót, annak nincs semmilyen előre definiált kezdő értéke. Gyakorlatilag bármilyen érték lehet benne, amely éppen ott van a memóriában abban a pillanatban. Tehát, mindenképpen fog kelleni egy kezdőértékadás majd. A C nyelv lehetőséget biztosít arra, hogy a megadáskor azonnal egy értékadással értéket tegyünk bele, így nem kell később majd külön azt megtenni:
int x = 13;
Értékmódosító operátorok
Nagyon gyakori, hogy egy változóban tárolt értéket a korábbi értéke alapján megváltoztassuk, erre a nyelv számos operátort kínál.
Növelő és csökkentő operátorok
A növelő operátor a ++
a csökkentő operátor a --
.
Tehát ha az írjuk, hogy x++
akkor ezzel az x értékét növeltük eggyel, ha az x--
akkor pedig csökkentettük eggyel.
Mint minden kifejezésnek, a növelő és csökkenő operátoroknak is van értékük. Ha a ++
-t vagy --
-t a változó neve után
írjuk az értéke változó értéke lesz a megváltoztatás előtt. Szóval ha x
értéke 13, akkor x++
14-re állítja azt, és a kifejezés értéke 13 lesz.
Ez nevezik post-incrementnek ill. post-decrementnek, mivel a kiértékelés után növeli az értékét.
De van olyan lehetőségünk is, hogy elé írjuk: tehát ++x
, illetve --x
. Ekkor a kifejezés értéke a megváltoztatott érték lesz, tehát 14 növeléskor illetve 12 csökkenéskor.
Ez nevezik pre-incrementnek ill. pre-decrementnek. Ennek csak akkor van lényege, hogy ha a növelés csökkentés után szükségünk van a kifejezés értékére.
Különben teljesen mindegy, hogy melyiket használjuk.
Ezek az operátorok csak egész és enum típusú adatokra működnek, double és float-ra nem.
Hozzáadó, hozzászorzó stb. operátorok
Van lehetőségünk arra, hogy egy váltózó értékéhez hozzáadjuk, hozzászorozzunk, osszunk belőle, stb. Ha azt írjuk, hogy: x *= 2
, akkor a változó eddigi értékét megszorozzuk kettővel, és
visszaírjuk a változóba. Tehát ha x-ben 13 volt, akkor most 26 lesz. Ez nagyjából ugyanaz, mintha ezt írtuk volna: x = x * 2
, csak rövidebben.
Természetesen ez nem csak szorzásra működik, hanem sok más operátorra is. Nyilván értelemszerű, hogy melyik ezek mit fognak csinálni:
+= -= *= /= %= |= ^= &= <<= >>=
Az operátorral alkotott kifejezés értéke természetesen ugyanaz, mint a = operátor esetén: a változóval azonos típusú, és a változóban az értékadás után tárolt érték.
A változóknak nagyon fontos szerepük van a nyelvben. Így a következő témák nagy része direkt vagy indirekt módon róluk szól majd.
Saját típusnevek létrehozása
Saját típusnevet a typedef
-fel adhatunk meg. A megadást egy typedef
kulcsszóval kezdjük utána a megadás teljesen úgy folytatódik, mintha változót adnánk meg, csak éppen amit csinálunk az nem változó lesz,
hanem adattípusnév. Például:
typedef struct Pont { double x; double y; double z; } Pont;
Innentől kezdve van egy Pont
nevű adattípusunk, amely a fenti struktúrát fogja jelenteni. És úgy használjuk mint bármely korábban említett típust, tehát így adhatunk meg változót:
Pont p1;
Ennek a típusa meg fog egyezni a fent említett p1 változóval.
Mutatók
A C/C++ kulcsfontosságú eleme a mutató. Rögtön nézzük is meg, hogy hogyan készítünk egy mutató típusú változót:
int *mutato;
A mutatót hasonlóképpen adjuk meg, mint a változót csak azzal a különbséggel, hogy elé kell írnunk egy csillagot. Ettől csillagtól a változónk mutató típusú adatot tárol. A típus, amit elé írunk arról nyújt információt, hogy milyen típusú adatra fog mutatni a benne tárolt memóriacím. Hogy ez miért lényeges azt hamarosan megtudhatjuk.
Mutató típusú adatok előállítása
C-ben lényegében a következőképpen nyerhetünk mutatótípusú adatot:
- NULL mutató
- Szöveges literál
- Egy másik változó által tárolt adat címe.
Az első kettőt már ismerjük. Most a harmadikról lesz szó. Ehhez szintén egy új operátort kell megismernünk. A változó elé tett & jel lekéri a benne tárolt adat címét:
&x
Ha x
egy int típusú változó volt, akkor &x
egy int típusú adatra mutató lesz. Nyilvánvaló, hogy ez az operátor csak változókra és változóként viselkedő dolgokra használható.
Hogy ne kelljen mindig leírnom, hogy a „benne tárolt adat helye a memóriában”, ezért később csak annyit írok, hogy a „változó címe”.
Ezt a mutatóértéket értékadással bele is írhatjuk a korábban említett mutato
nevű változóba:
mutato = &x;
Az lvalue és rvalue fogalma
Azok a dolgok, amelyek az = operátor bal oldalán állhatnak lvalue-knak (left value, azaz baloldali érték) nevezzük.
Az lvalue-k értéket tárolhatnak. Példéul lvalue típusú adat egy változó.
Az lvalue mindig valahol a memóriában van tárolva, így alkalmazható rá a &
mutatócsináló operátor.
Azok a dolgok, amelyek nem állhatnak = operátor bal oldalán, az rvalue-knak (right value, azaz jobboldali érték) nevezzük. Példul ilyenek a literálok.
Adat elérése mutatón keresztül
Ha a mutató típusú adat elé *-t írunk, akkor hivatkozhatunk a mutatott adatra magára:
*mutato
Ez a csillagos kifejezés úgy viselkedik, mint egy változó, azaz egy lvalue:
*mutato = 13;
Vagy kifejezésbe tesszük, hogy kiolvassunk adatokat onnan:
12 + *mutato
Itt van szerepe annak, hogy adott, hogy milyen típusú adatra mutat a mutatónk, az adja meg, hogy milyen típusú adatként értelmezze a gép a címen lévő byte-okat.
A korábban említett pont operátor eredménye valamint az = operátor és a társaié is egy lvalue.
Az összeomlások 99,99%-a...
Amikor egy program hibával leáll, az szinte mindig azért van, mert olyan memóriaterületet akarunk elérni egy mutató segítségével, amelyhez nem férünk hozzá. Vagy egy csak olvasható területre akarunk írni adatokat. Ezt még a tapasztalt programozók is rendszeresen elrontják.
Természetesen nem csak programösszeomlás lehet a következménye a rossz helyre való írásnak. Hanem véletlenül felülírhatjuk a saját programunk adatát, eléggé nehezen megmagyarázható viselkedést okozva.
Mutató típusok nevére való hivatkozás
Mint láttuk a mutató típusú változókat szokatlanul kell megadni, de mi a helyzet, amikor magára az adattípusra akarunk hivatkozni?
Mondjuk egy sizeof
operátor keretében, vagy kikényszerített adatkonvertálás esetén. Ez esetben úgy adjuk
meg a típust, mintha változót deklaráltunk volna, de magát a változó nevét kihagyjuk, tehát: sizeof(int *)
működik, és értéke 4 vagy 8, attól függően, hogy 32 biten vagy 64 biten vagyunk.
Struktúra típusra mutató típusok
Egy struktúrára mutató típusú változót is úgy készítünk, mint bármilyen más mutatót, használjuk a fenti Pont
típust:
Pont *pontMutato;
Ezen mutató típusú változón keresztül a struktúra elemeit a következőképpen érhetjük el:
(*pontMutato).x
Tehát a csillagoperátorral elérjük a mutatott adatot, majd a pont operátorral elérjük a mezőt. A zárójel azért kell, mert a pont operátor erősebb, mint a csillag. Mivel nagyon gyakran kell struktúrákra mutató adatokkal dolgozni, ezért C/C++ nyelv biztosít egy operátort, amely erre a célra használható:
pontMutato->x
Ez a ->
operátor teljesen ugyanúgy működik mint a .
operátor azzal a különbséggel,
hogy ez nem struktúra típusú változókat, hanem struktúra típusra mutató adattípust vár a baloldalán. Ekvivalens az előzővel.
Típus nélküli mutató
Amikor ismeretlen/lényegtelen az, hogy egy mutató pontosan milyen típusú adatra mutat, akkor használatos a void
típus:
void *ismeretlenAdatraMutat;
voidos mutatóra nem alkalmazható a * operátor, ha mégis adatot akarunk elérni rajta keresztül, akkor típuskényszerítést kell alkalmaznunk.
Handle
Szaknyelven handle-nak nevezik azt a mutatót, amely nem azért van, hogy a * operátort alkalmazzuk rajta, hanem hogy ezt a mutatót átadjuk függvényeknek (róluk később), és majd majd azok fognak a mutatott adattal dolgozni csak. Ezt gyakran typedef-ek használatával oldják meg, valahogy így:
typedef Tipus *HandleTipus;
Ilyenkor a HandleTipus
egy mutatótípus lesz, melyen nem látszik automatikusan, hogy egy pointer (mivel a változók, melyeket abban a típusban deklarálunk, nem csillagosak).
Ez a minta nagyon gyakori, tulajdonképpen ez az alapja az objektum-orientált programozásnak.
Tömbök
Ha nem csak 1db adatra van szükségünk, hanem sok azonos típusú adatra, akkor tömböket használunk. A tömb elemei egymás mellett vannak szépen sorban a memóriában. Példaként adjunk meg egy 30 elemű int tömböt:
int tomb[30];
A megadás hasonló, mint egy változó esetén, csak a változó neve után szögletes zárójelbe beírjuk, hogy hány db elemű lesz a tömb. Ez az egy változó 30 db int típusú adatot fog tartalmazni, tehát 30-szor nagyobb helyet foglal a memóriában, mint
egy int típusú adat, azaz 120 byte-ot. Természetesen ezt a gyakorlatban nem kell számolgatnunk, mert a sizeof(tomb)
meg fogja nekünk ezt mondani.
Tömbök elemeinek az elérése
A tömbök elemeit a [] operátor segítségével érjük el. A szögletes zárójelek közé beírjuk, hogy hányadik elemre van szükségünk. Az indexelés 0-tól kezdődik. Tehát ha el akarjuk érni az első elemet, azt írjuk, hogy:
tomb[0]
A [] operátor eredménye egy lvalue, esetünkben egy int típusú. szóval ezt egy a tomb[0]
-t úgy használhatjuk mint bármely más változót.
A harminc elemű tömbünk utolsó eleme a 29-es indexű. De vigyázzunk, mert a C/C++ nyelv semmilyen ellenőrzést nem biztosít, hogy az index a megfelelő tartományban van-e. Így teljesen szabályos index a -123 és a 6269 is. Csak ezzel valószínűleg olyan memóriaterületet érnénk el, amelyhez semmi közünk, és a programunk elszáll, vagy rosszabb eset, amikor a programunk valamely más adatát írjuk így felül, szóval vigyázzunk.
A tömb típusú változók elemeit csak a [] operátor segítségével módosíthatjuk. A tomb
változónak közvetlenül nem lehet értéket adni.
A C/C++ nyelvben a tömb típusú változók nem állhatnak az értékadás bal oldalán.
Tömbök típusok nevére való hivatkozás
Amikor a tömb típusneve kell, a mutatókhoz hasonlóan itt is ugyanúgy hivatkozhatunk a tömb típusnevére,
mint a mutatóknál: kihagyjuk a változót. Például: sizeof(int [30])
Egy fontos dolog, amit a tömbökről tudni kell, hogy implicit mutatóvá konvertálhatók, a tömb elemtípusával megegyező típusúvá.
Tehát a fenti tomb
változónk int *
-re konvertálható ennek a mutatónak az értéke &tomb
lesz. Ez azt jelenti, hogy
kifejezésekben a tomb
és a &tomb
lényegében megegyezik.
Hogy ez miért fontos, azt a következő nagy részben tudjuk meg.
A jobbról félig nyílt tartományok
A C és C++ nyelvekben nagyon gyakori dologról lesz most szó: ha a tartományokkal foglalkozunk, legyen az egy memóriablokk vagy egy számtartomány, vagy hasonló, a következőképpen adjuk őket meg: az elejét az első elemmel adjuk meg, a végét pedig az utolsó utáni elemmel.
Például ha meg akarjuk adni a következő számtartományt: 1, 2, 3, 4, 5. Akkor a gyakorlatban 1-től 6-ig terjedő tartományt mondunk. Az 1 benne van, a 6 nincs. Természetesen nem vagyunk számokra korlátozva, a gyakorlatban sokkal gyakoribb, amikor mutatókkal csináljuk ezt: van két mutatónk, az egyik a tartomány elejére, a másik a végére mutat. Az elejére mutató a tartomány első elemére mutat, a végére mutató pedig az utolsó elem után eggyel.
Ez egy félig nyílt tartomány. A mindennapi értelmezésben általában zárt tartományokat mondunk: tehát 1-től 5-ig. Viszont a félig nyílt tartománynak technikai számos előnye van a zárttal szemben:
- A tartomány hossza egyszerűen meghatározható, ha kivonjuk egymásból az alsó és felső határokat (vagy mutatókat). Zártnál mindig hozzá kellene adni egyet.
- Mutatók esetén egyszerűen hozzáadhatunk a tartomány végéhez, hiszen a végmutató arra a helyre mutat, ahová az új elemet kell tenni, ezután egyszerűen eggyel előrébb toljuk a mutatót.
-
Mutatók esetén a tartomány bejárásakor könnyen jelezhető, hogy végére értünk a tartománynak,
mivel az végigjáráshoz használt mutató pont a végmutatóval egyezik meg, mikor végigértünk (ld.
while
ciklus).
Általánosságban, amikor egy tartomány elejéről beszélünk, akkor az az első elemre vonatkozik. Ha egy tartomány végéről beszélünk, akkor az az utolsó elem után van.
Mutató aritmetika
Ebben a részben a mutatókkal végezhető számításokat írom le. Megtudjuk, hogy miért van szoros kapcsolat a tömbök és a mutatók között ebben a nyelvben.
Mutatókkal való hozzáadás és kivonás
Példaként vegyük a mutato
változónkat korábbról. Ez egy mutató egy int
típusú adatra, és azt is tudjuk, hogy az int
adattípus 4 byte-os. A C/C++ nyelvben megtehetjük egy mutatóhoz egész
számokat adunk hozzá vagy vonunk ki belőle, pl:
mutato + 3
Ez a művelet egyébként kommutatív az összeadásra nézve:
3 + mutato
Kivonásra nyilván nem, tehát csak ez a helyes, fordítva nem lehet:
mutato - 3
A számítás eredménye úgyszintén egy mutató lesz, méghozzá egy olyan mutató, amely 3 int-tel előrébb lesz, mint a mutatott adat, mivel az int mérete 4 ezért ez a mutató 12 bájttal magasabb címre fog mutatni.
A kivonós példánál meg 12 bájttal visszább fog mutatni. Azért az int méretéről van szó, mert a mutato
mutató int típusú adatra mutat.
Ha mondjuk char
típusú adatra mutatóról lenne szó, akkor a +3 csak 3 byte-ot jelentene, mert a char
csak 1 byte-os.
Használható a ++
, --
, +=
és a -=
operátor is, ha a mutato
változó értékét módosítani akarjuk.
Ez az egész arra jó, amikor tömböket kezelünk, és itt jön be a tömbök és a mutató közötti kapcsolat. Írtuk az előző részben, hogy a tömb elemei szépen sorban vannak a memóriában,
és azt is, hogy tömb típusú adatok implicit konvertálhatók mutatóvá, mely mutató a tömbre fog mutatni. Azt is tudjuk, hogy mutatók a mutatott a adat elejére mutatnak, tehát ha a tömb elejére mutatunk, akkor annak az első elemére mutatunk.
Tehát a tomb
változó, kifejezésben használva egy mutató, mely a tömb első elemére mutat.
Így például a következő kifejezés eredményeként kapott mutató a tömb 16. elemére (15. indexű) elemére fog mutatni:
tomb + 15
A [] operátor pointereken
Előbb volt szó a pointer aritmetikáról. Azzal, hogy számokat adunk hozzá egy mutatóhoz, a mutatott adat szomszédságában lévő azonos típusú adatokat érhetjük el. A [] operátor ezt könnyíti meg. Az eddigiek ismeretében állíthatjuk, hogy ez:
*(mutato + 3)
Megegyezik ezzel:
mutato[3]
Ez pedig:
mutato + 3
Ezzel:
&mutato[3]
Ez a pointerek és tömbök közötti kapcsolat sokszor megjelenik, amikor C nyelven programozunk (C++-ban már kicsit rejtettebb).
Konverzió mutatók és egész számok között
Explicit típuskényszerítéssel egész számmá alakíthatjuk a mutató értékét:
(int)mutato
A szám értéke a mutatott bájt sorszáma lesz.
Természetesen van mód a visszafelé konvertálásra is:
(void*)123
Ekkor egy olyan mutatót kapunk, amely a 123. byte-ra mutat a memóriában. Természetesen azon a helyen elérni nemigen tudunk adatot, mert a programunk azonnal elszáll.
Az egész számmá konvertálást akkor lehet jól használni, ha két mutató különbségét akarjuk megkapni.
Többszörös mutató és tömbtípusok
A tömb is adattípus, a mutató is adattípus, tehát lehet mutató típusú adatra mutató típust létrehozni, illetve lehet tömbökből álló tömböt, és mutatókból álló tömböt is készíteni. Ebben a részben megtudjuk, hogy ezeket a dolgokat hogyan is lehet.
Mutatótípusú adatra mutató
Ha mutató típusú adatra akarunk mutatni, akkor egyszerűen két csillagot írunk:
int **ketszeres;
Ha az előző kétszeres mutatónkra szeretnénk egy mutatót, akkor már 3 csillagot:
int ***haromszoros;
De ez már gyakorlatban nagyon ritka. Ennél többszörös mutatóval még soha se találkoztam élesben.
Többdimenziós tömbök
Ha pl. egy sakk táblát akarunk tárolni, akkor azt célszerű úgy, hogy van 8 sorunk, és 8 oszlopunk. Ez felfogható úgy, hogy van egy 8 elemű tömbünk, mely 8 elemű tömböket tartalmaz. Ez így adhatjuk meg:
int sakkTabla[8][8];
A sakktábla mezőnek az eléréséhez is két indexet adunk meg, pl. sakkTabla[0][0]
a bal felső sarokhoz. Az első index kiválasztja a sort, ami szintén a tömb, és azon belül a második indexszel választhatjuk ki a tényleges cellát.
A változó értelmezéskor a baloldali indexnek van elsőbbsége. Tehát ha egy 7 elemű tömböt akarunk, mely 9 elemű tömböket tartalmaz, akkor az így adhatjuk meg:
int hetszerKilences[7][9];
Mutatókból álló tömb
Például egy 10db mutatót tartalmazó tömböt így adunk meg:
int *mutatoTomb[10];
Egy ilyen kidíszített változó értelmezésekor a tömbindex erősebb, mint a mutató csillaga, tehát ezt így kell olvasni: 10 elemű tömb, mely mutatókat tartalmaz, melyek int típusra mutatóknak. Ha azt szeretnék, hogy fordítva legyen, zárójeleket kell alkalmaznunk.
Mutató egy tömb típusú adatra.
A következő deklaráció egy mutatót deklarál, mely egy 10 elemű tömbre mutat:
int (*mutatoTombre)[10];
A zárójel itt azért kell, mert a mutatót olvassuk először, és ugye a tömbindexnek elsőbbsége van. Tehát ezt a változót úgy olvassuk ki, hogy: mutató, mely 10 elemű tömbre mutat, mely int típusú adatokat tartalmaz. Az ilyen mutatók a gyakorlatban meglehetősen ritkák.
Tömb, mely tömbre mutató adatokat tartalmaz
Lássuk a következő szörnyet:
int (*szorny[20])[10];
A zárójelezés itt a kiolvasás miatt lényeges, ugyanis ennek a változónak a típusát a következőképpen kell kiolvasni: 20 elemű tömb, mely mutatókat tartalmaz, melyek 10 elemű tömbökre mutatnak, mely tömbök int típusú adatot tartalmaznak. Ilyennel gyakorlatban lényegében soha sem találkozunk.
Természetesen, ha a változó típusnevére lenne szükségünk, mondjuk a sizeof operátorhoz, akkor ugyanúgy tesszük, mint eddig: változó neve nélkül, tehát az előbbi változó típusneve: int (*[20])[10]
Megadás tükrözi a használatot
Ez a zárójeles megadás elsőre elég intuícióellenesnek tűnik, de ha egy fontos dolgot megtanulunk velük kapcsolatban, akkor rögtön könnyebb lesz: a megadás tükrözi a használatot. Nézzük meg, hogy a fent említett változókat hogyan kellene használni, hogy elérjük egy mutatott elemi adatot:
**ketszeres
: az belső csillaggal elérjük a mutató által mutatott mutató adatot, a második csillaggal pedig a tényleges adatot.***haromszoros
: itt is hasonlóan a legbelső csillaggal elérjük a kétszeres mutatót, a második csillaggal mutató, a külsővel pedig a tényleges adatot.*mutatoTomb[5]
: itt először elérjük a mutató tömb 5-ös elemét, majd a csillag segítségével a mutatott adatot, tudjuk, hogy a tömb index operátor erősebb mint a csillag.(*mutatoTombre)[6]
: itt a csillaggal először elérjük a mutatott tömböt, majd annak a tömbnek a 6-os indexű elemét.(*szorny[7])[8]
: itt elérjük a tömb 7-es elemét, csillaggal a mutatott tömböt, végül annak a 8-as elemén lévő adatot.
Változókkal, mutatókkal, tömbökkel kapcsolatos műveletek precedenciája
Itt művelet elsőbbség szempontjából két szintet különböztetünk meg:
++ -- [] . ->
: A ++ és a -- a változó után lévő post-incrementre és post-decrementre vonatkozik. A müveleteket ezen a szinten balról jobbra végezzük.++ -- * & sizeof
: Ezen az eggyel gyengébb szinten a ++ és a -- a változó elé írt operátor tehát a pre-decrement és a pre-increment. Ha megnézzük ezeket az operátorokat a változó elé írjuk. Ezen a szinten vannak egyébként az egyoperandusú matematikai műveletek is: + - ~ !, melyeket a kifejezés elé szoktunk írni.
Tömbök és struktúrák kezdőértékadása
Korábban írtuk, hogy amikor egy változót deklarálunk, rögtön értéket is adhatunk neki. Nem kivétel ezalól a struktúra és a tömb sem.
Tömbök kezdőértékadása
Tömbnek úgy adhatunk kezdőértéket, hogy kapcsos zárójelben felsoroljuk az értékeket:
double pont[3] = {0, 1, 2};
Ha a szükségesnél kevesebb értéket adunk meg, akkor csak az első elemek kapnak kezdőértéket, a többi elem bájtjai ki lesznek nullázva.
double pont[3] = {10, 11}; // 3. nulla lesz.
Így a következő egyszerű trükkel teljesen nullázható bármilyen tömb:
double pont[3] = {0}; // Mind nulla lesz.
Ha több értéket adunk meg, mint amekkora a tömb mérete, az hiba.
A fordító képes arra, hogy az inicializáláskor megadott elemszám alapján meghatározza a tömb méretét, a következő tömb automatikusan 10 elemű lesz:
int automatikElemSzam[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Karaktertömb esetén lehetőségünk van a tömböt string-gel inicializálni, ez leginkább az előbb említett automatikus méretezéssel együtt alkalmazható:
char automatikString[] = "Automatikusan kiszámolja a fordító, hogy mennyi byte fog kelleni!";
Ez feltölti a tömböt a string karaktereinek a kódjával. A fordító fogja tudni, hogy az 'á' és au 'ó' betű mennyi bájt mondjuk UTF-8-ban és a string végén lévő lezáró 0-ás bájtról se feledkezik el.
Többdimenziós tömb inicializálásakor az inicializáló értékek úgyszintén tömbök lesznek:
int multiDimenzio[4][4] = { {1, 2, 3, 4}, {1, 2, 3, 4}, {1, 2, 3, 4}, {1, 2, 3, 4} };
Automatikus méretezésnél mindig csak a legbelső indexet lehet elhagyni:
int multiDimenzioAutomatik[][4] = { {1, 2, 3, 4}, {1, 2, 3, 4}, };
Különben hibát kapnánk.
Struktúrák incializálása
Használjuk a korábban említett Keppont
struktúránkat. Az inicializálás hasonló a tömbökéhez, csak itt a struktúra mezőit inicializáljuk rendre:
Keppont keppont = {213, 56, 0.0, 0.3, 0.0};
Itt is igaz, hogy ha kevesebbet adunk meg a kelleténél, a maradék nullázva lesz. Ezzel csak annyi a gond, hogy ha a struktúrában az elemek sorrendje megváltozik, akkor utána kell állítani az összes helyet, ahol inicializálás van. Erre csak egy megoldás van: ne variáljuk a mezők sorrendjén.
Konstans adatok
Nem minden adat módosítható. A C és C++ nyelvet használják beágyazott eszközökön, ahol az adatok egy része ROM-ba van égetve, így nem módosítható. De a programunkban is lehetnek olyan részek, amelyek védettek az írással szemben, ha ilyen helyre akarunk írni, akkor a programunk általában hibával elszáll.
Konstans típusú adatok
Hasonlóan történik, mint a változó létrehozás, csak a típus neve elé írunk egy const
szót. Ekkor érdemes inicializálni is a változót, mert többször nem adhatunk értéket neki ezután:
const double PI = 3.1415926536;
Innentől kezdve a konstans nem módosítható, tehát nem állhat értékadás baloldalán.
Konstansokat tartalmazó tömbök
Hasonlóan működik ez tömbök esetén is:
const int konstansTomb[3] = {1, 2, 3};
Ekkor a tömb elemei az inicializáció után már nem módosítható.
Mutatók konstans adatokra
Eddig elég érthető volt, hogy a konstans adatok nem módosíthatók, mutatóknál kicsit más a helyzet. Ha ezt írjuk:
const char *szoveg = "Hello world!";
Az azt jelenti, hogy a mutatott szöveg nem módosítható, és nem azt, hogy a mutató változó értéke ne lenne módosítható. Ugyanis a const
szócska az adattípusra vonatkozik, és nem változóra rakott dekorációra.
Mutató típusú konstansok
Tehát tudunk konstans adatokat megadni. A mutató is egy adat, ezért tudunk mutató típusú konstans létezhet, ezt úgy adjuk meg, hogy a mutató csillaga után írunk egy const
szót:
int * const konstansMutato = &egyIntTipusuValtozo;
Ezzel a konstansMutato
változóban tárolt mutató már nem módosítható, viszont a mutatott érték igen. Az ilyen típusok kiolvasásakor a *-okat jobbról balra kell olvasni,
és amely csillag után const
szó áll az a konstans mutató.
Tehát példaként induljuk ki egy olyan változóból, melynek típusa: mutató, mely egy mutató típusú adatra mutat, és az meg egy konstans int-re mutat:
const int **az_adat_a_konstans;
A következő példa egy olyan változót ad meg, melynek típusa: mutató, mely konstans mutatóra mutat, és az egy int adatra mutat:
int * const *a_mutatott_mutato_a_konstans;
Az utolsó páldában említett változó típusa: konstans mutató, mely mutató típusra adatra mutat, és az int-re mutat.
int ** const a_mutato_a_konstans;
Itt is igaz, amit korábban mondtuk, hogy a használat adja meg, hogy hogyan kell deklarálni az adott dolgot, itt a const
kulcsszó jelzi, hogy melyik csillag használata után kapunk nem módosítható adatot.
Típuskonverzió és konstansok
Ez mutatótípusoknál érdekes, ugyanis az int *
típusú adat automatikusan konvertálható const int *
-ra.
Ez csupán annyit jelent, hogy amit a módosítható adatra mutatón keresztűl módosíthattunk, a konstans adatra mutatón keresztül nem módosíthatjuk.
Fordított konverzió csak típuskényszerítéssel lehetséges. Ugyanis, nem biztos, hogy jó dolog egy csak olvashatónak címkézett adatterületre csak azért is beleírni.
Volatile adatok
Ugyanazok a szintaktikai szabályok vonatkoznak rá, mint a const
-ra, csak itt volatile
szót írunk.
Ha egy változót volatile
-nak adunk meg, akkor a fordító minden változóval kapcsolatos optimalizációt kikapcsol.
Ez akkor hasznos, amikor egy változó értéke valami külső hatástól változhat meg (pl.: hardveres dologtól, vagy másik száltól). Példa, deklarációra:
volatile int x;
Tegyük fel, hogy van egy logikai változónk, melynek a kezdőértéke hamisra van állítva. Ezután nem sokkal van egy feltételes ugró utasítás,
amely ennek a változónak az értéke (igazsága) alapján ugrik. Mivel a fordító feltételezi, hogy ez a változó magától nem változik meg,
és az az ugró utasítás soha nem fog ugrani (mert a változó hamis), ezért kitörli a programból az az utasítást, gondolván, hogy felesleges.
De ha a változó volatile
, akkor nem meri majd megtenni ezt, mert a változó módosulhat akárhol a programban.
Alprogramok
Eddig az írományunkban csak az adatokról és azok elérési módjáról írtunk ilyen sokat. Most szép lassan haladunk afelé, hogy a számítógép csináljon is valamit adatokkal, de még nem jutottunk el odáig. A programozás oszd meg és uralkodj elven működik, azaz a megoldandó sok lépésből álló feladatot felosztjuk részfeladatokra, melyeket tovább osztunk egészen addig, amíg a legelemibb lépésig el nem jutunk.
Alprogramok megadása
Tehát akkor nézzük meg a következő példán, hogy hogyan is néz ki egy alprogram:
unsigned int legnagyobbKozosOszto(unsigned int a, unsigned int b) { utasítások... }
Ez az alprogram kiszámolja két szám legnagyobb közös osztóját. Az alprogram neve legnagyobbKozosOszto
, és láthatjuk, hogy ahhoz, hogy működjön szüksége van két előjel nélküli egész számra, melyet
a
-nak és b
-nek hívnak, és az utána lévő zárójelbe írtunk.
Ezeknek a változóknak az értékét az alprogram felhasználója fogja majd megadni (szaknyelven formális paraméterek vagy egyszerűen paraméterek).
A függvény neve előtt lévő típusnév pedig annak az adatnak a típusa, amelyet ez alprogram előállít.
A C/C++-ban az alprogramok úgy működnek, mint egy függvény: a paraméterei alapján kiszámolják az értéküket, ezért inkább szokták függvény szót használni alprogram helyett.
A kapcsos zárójelek között azok utasítások vannak, melyek a számolást el fogják végezni, de mivel még ezeket az utasításokat még nem ismerjük, ezért nem akarom összezavarni az olvasót velük most.
C-ben, ha a függvényünknek egy paramétere sincs, akkor a zárójelbe egy void
szót kell írni, ezzel jelezzük,
hogy a függvényünknek egy paramétere sincs. Ha egyszerűen üresen hagyjuk a zárójeleket, az is szabályos szintaxis,
viszont akkor azzal azt mondjuk a fordítónak, hogy a függvényünknek bármilyen típusú paraméterek, bármilyen számban átadhatók.
C++-ban a kettő ugyanazt jelenti: paraméter nélküli függvény.
Alprogramok használata
A függvényeket úgy használhatjuk, hogy beleírjuk egy kifejezésbe őket:
int lnko = legnagyobbKozosOszto(360, 35);
A függvény neve után zárójelben beírjuk az értékeket a működéséhez szükséges változóba, ezeket nevezik szaknyelven aktuális paramétereknek vagy argumentumoknak.
Amikor a számítógép ennek az értékadó utasításnak a jobb oldalát kiértékeli el fogja indítani az alprogramunkat, mely kiszámolja a legnagyobb közös osztót, mely 5 lesz, és beírja az lnko
változóba.
Természetesen nem csak konstans értéket írhatunk a zárójelekbe, hanem kifejezéseket is, ezek ki lesznek értékelve a függvény hívása előtt, hogy milyen sorrendben, az nincs definiálva, tetszőleges sorrendben történhet a kiértékelés.
Tulajdonképpen ilyen egyszerű az alprogramok használata. A következő részekben pár hasznos tudnivalót még írok a függvényekről.
A függvényhívás is egy operátornak tekinthető (az ún. () operátor), melynek az erőssége (precedenciája) azonos a [] operátoréval.
Függvény prototípusok
Amikor a függvény megadásakor kapcsos zárójelben leírjuk a lépéseket is, az a függvény definíciója. Azonban van arra is lehetőségünk, hogy a függvény első sorát egy pontosvesszővel rögtön lezárjuk. Ekkor egy függvény prototípust hoztunk létre, ha a fordítóprogram ilyet lát, akkor az csak azt jelzi számára, hogy létezik ilyen függvény, de még nem ismeri a végrehajtásának a lépéseit:
unsigned int legnagyobbKozosOszto(unsigned int a, unsigned int b);
Nyilván a fordítóprogram csak olyan dolgokkal tud dolgozni, amit a programban felülről lefelé való olvasása közben már látott, az ilyen sorokkal jelezzük számára, hogy az adott függvény létezik. De erről majd lesz később külön szó.
Prototípusban a paraméterek nevei elhagyhatók, tehát ez is szintaktikailag helyes:
unsigned int legnagyobbKozosOszto(unsigned int, unsigned int);
De a könnyebb megértés kedvéért érdemes kiírni őket.
Függvények visszatérési értékéről
A függvények bármilyen típusú adattal vissza tudnak térni. Ha mutatóval térnének vissza, akkor nyilván a függvény neve elé is ugyanúgy oda kell csillagozni. De van pár extra dolog:
void
visszatérési típus
A függvény visszatérési értékének a típusa lehet void
. Ez akkor használatos, amikor a függvényünk csak lépéseket hajt végre, de nem állít elő konkrét értéket.
Nyilván ez esetben a visszatérési értéke se használható semmire. Az ilyen függvények hívására olyan kifejezéseket írunk, melyben csak a függvényhívás van és semmi más.
A függvények nem tudnak tömb típusú adattal visszatérni
Korábban leírtuk, hogy a tömb típusú változóknak közvetlenül nem lehet értéket adni, ezért egy tömb típusú visszatérési értéknek se lenne sok értelme, mivel nem lehetne változóba írni.
A függvények paramétereiről
A függvények paraméterei olyan változók, melyek csak a függvény futásakor élnek csak, utána az értékük elveszik. Ha paraméterként olyan dolgot szeretnénk átadni, amelynek a hívó kódrészre is hatása van, egy mutatót kell átadni.
Mutatók átadása
Ha egy alprogramnak egy mutatót adunk át, akkor az az alprogram arra a területre adatot írhat nekünk (a * operátort alkalmazza rajta). Például az előző legnagyobb közös osztós függvényünket átírhatjuk ilyen formába:
void legnagyobbKozosOszto2(unsigned int a, unsigned int b, unsigned int *eredmeny);
Ekkor a függvény hívása így néz ki kifejezésben:
legnagyobbKozosOszto2(360, 35, &eredmenyValtozo)
Azonban gyakori az olyan is, amikor egy nagy méretű adatról van szó, így nem lenne szerencsés az egész adatblokkot átmozgatni a függvény munkaterületére, elég csak egy mutatót átadni, viszont ilyenkor a függvényünk potenciálisan írhat arra területre, ezért hogy ezt elkerüljük, olyan mutatóként kell átadnunk, mely konstans adatra mutat, így a függvény számára nem lesz engedélyezve a mutatott adat módosítása:
void valamitCsinal(const void *nagyMeretuAdatraMutat);
Tömbök átadása
A paraméterek átadása tulajdonképpen értékadással történik, és tudjuk, hogy a tömbök között a közvetlen értékadás nem működik. Így ha függvénynek át akarjuk adni, ismételten mutatókat kell használnunk. Ez tömbnél egyszerűen megy, mert automatikusan konvertálható mutatóvá. Tehát át kell adni egy mutatót, valamint az elemszámot is:
void tombotFeldolgozo(int *tomb, int elemszam);
Ugye mutatókon is alkalmazható a [] operátor, így a függvény használhatja azt, az elemszam
paraméter pedig azért kell, hogy a függvény tudhassa, hogy hány elemű a tömb, és ne indexeljen a tömbből kifelé.
Tetszőleges számú paraméterű függvény
Írhatunk olyan alprogramot is, amely tetszőleges számú paramétert elfogadhat, a standard programkönyvtárban lévő printf
függvény egy jó példa erre:
int printf(const char *format, ...);
A ...
jelzi, hogy tetszőleges számú paramétert elfogad a függvény, hogy ezt hogyan kell kezelni az alprogramon belül, majd arról sokkal később lesz szó.
A main
nevű függvény
A programunk induláskor elindítja main
nevű függvényt, és addig fut, amíg az be nem fejezi a futását.
Függvénymutatók
Az alprogram egy bizonyos hosszúságú végrehajtható programkód. Ez is memóriában van, ezért van rá mód, hogy egy mutatót ráállítsunk. Ezt szokták röviden függvénymutatónak is nevezni.
Megadásuk
Függvénymutató típusú változókat például a következőképpen adhatunk meg:
unsigned int (*tetszolegesLnkoFuggvenyMutato)(unsigned int a, unsigned int b);
A zárójel miatt ezt így kell olvasni: mutató, mely egy olyan függvényre mutat, amely unsigned int-tel tér vissza, és két unsigned int típusú paramétert kér.
Itt a paraméterek nevének (az a
és b
) csak informatív szerepe van, akár el is hagyhatók.
A megértést segíti, hogy ha tudjuk, hogy a deklaráció tükrözi a használatot. Hogyan is használnánk egy ilyen változót? Először csillaggal elérjük a mutatott adatot, majd a () operátorral függvényhívást végzünk rajta. Mivel a () operátor erősebb, mint a * operátor, ezért zárójelezést kell alkalmazni.
Mivel eléggé terjedelmes minden adandó helyen megadni paraméterestől, mindenestül a függvénymutató-típust, ezért általában typedef
-elni szokták az ilyeneket.
Értékadás függvénymutatónak
Az előbb deklarált függvénymutatónknak a következőképpen adhatunk értéket:
tetszolegesLnkoFuggveny = legnagyobbKozosOszto
A legnagyobbKozosOszto
nevű függvényünk pontosan olyan paraméterezésű és visszatérési értékű, mint a változóban említett mutató, ezért az értékadás megtehető.
Továbbá azt is érdemes megfigyelni, hogy nincs szükség &-re, mert a függvény neve önmagában is a saját kódjának a címét jelenti (implicit konverzió van, mint a tömbnél), de ha kitesszük sem hiba.
Függvényhívás függvénymutatón keresztül
A () operátort minden további nélkül alkalmazhatjuk a függvénymutatónkon, miután beállítottuk egy függvényre:
int eredmeny = tetszolegesLnkoFuggvenyMutato(360, 35);
Vegyük észre, hogy a * operátorra tulajdonképpen itt nincs szükségünk, mert a függvénymutató implicit konvertálható visszafelé a mutatott függvénnyé is. De zárójelben kitesszük elé a *-ot mint a deklarációnál, az sem hiba.
Függvénymutatók haszna
Tegyük fel, hogy van egy alprogramunk, mely egy olyan műveletet végez, amely sokáig tart, lehet ez egy komolyabb számolás, de lehet akár fájlművelet is. Ekkor a számítógép benne van a nagy munkában, és majd 2 óra múlva kiírja, hogy készen van...
Ilyen esetekre jó lenne, hogy ha néha visszajelzést adna a hívó alprogramnak, hogy éppen hol tart, ez azonban nagyon sokféle lehet, és erősen környezetfüggő. Mit csináljon? Írjon ki a terminálba valamit? Előrébb lökjön egy folyamatjelző csíkot egy ablakban? Küldjön e-mailt egy adminisztrátornak?
Ezt döntse el az alprogramot hívó fél: adja át a címét annak az alprogramnak, amely ezeket elvégzi, és majd az a hosszú művelet időnként elindítja, és megtörténik, amit akarunk.
Azt a dolgot, amikor a hívott függvény egy, a hívó által átadott, függvényt hívogat szaknyelven callback-nek, azaz visszahívásnak nevezik. Az átadott függvényt pedig callback függvénynek.
Egy tipikus callback-elő függvény megadása így néz ki:
int csinalj_valamit_sokaig_egy_fajllal( const char *fajlnev, void (*callback)(void *, int), void *felhasznalo_adata );
A függvény mellett felhasználó még átadhat mutatót is. Ez általában a hívó környezetből valami fontos adat, például egy folyamatjelző csík kezelő mutatója, vagy akármi.
Ez a csinalj_valamit_sokaig_egy_fajllal
majd ilyen függvényhívásokat végez majd a saját kódjában:
callback(felhasznalo_adata, valamilyen_státuszkód_vagy_akármi)
Azaz átadja neki a mutatót, amit csinalj_valamit_sokaig_egy_fajllal
függvény hívója átadott, és még valami státuszkódot, amely a függvényt értesíti, hogy pl. hány százaléknál jár a folyamat.
Ez a, függvénymutató + egyéb hívói adat átadása egy alprogramnak, és az az alprogram elindítja az átadott függvényt azzal az adattal, dolog nagyon gyakori, olyannyira, hogy szaknyelven van szó rá: closure, azaz a függvénymutató és a környezetének egységbezárása.
Utasítások
Ebben a részben megtudjuk, hogy milyen utasításokat írhatunk a kapcsos zárójelek közé. Az utasítások a megadás sorrendjében fognak végrehajtódni.
Változó deklaráció
Már korábban ismertettük, hogy hogyan lehet változót megadni, és azt is mondtuk, hogy lehet függvényen kívül is meg belül is megadni őket. Ha függvényen belül (a kapcsos zárójelei között) adjuk meg, akkor lokális változót hoztunk létre, mely csak a függvényen belül érthető el, a függvényből való kilépés után felszabadul a memória, és a benne tárolt adat elveszik. Ha függvényen kívül adunk meg egy változót, akkor az a teljes programban elérhetővé válik, tehát globális változó lesz, és az ott lefoglalt terület a program teljes futása alatt elérhető.
Kifejezés utasítás
Ez az utasítás a legalapvetőbb utasítás, ez egy kifejezés és pontosvesszővel lezárva. A program egy ilyen utasításkor azt fogja csinálni, hogy kiértékeli a kifejezést, a végeredményét pedig eldobja. Példa:
int main(void) { int x; 2 + 2; // Kiszámolja, hogy 4, aztán el is felejti. x = 2 + 2; /* Kiszámolja, hogy 4, aztán beírja az x-be. Az értékadó kifejezés értéke az x változó (azaz egy lvalue) melyet most eldobunk. */ }
Nyilvánvaló, hogy a kifejezés utasításokba olyan kifejezéseket írunk, melynek van valami hatása valamilyen változóra, vagy a programunkra nézve. Kiszámoltatni valamit a géppel csak azért, hogy utána ne használjuk értelmetlen dolog (2. utasítás az a 2+2).
A kifejezés utasítás kifejezése lehet üres, ekkor nem fog semmi se történni. Ekkor csak egy magában álló pontosvessző jelzi, hogy ott utasítás van; nincs előtte kifejezés. Ciklusoknál és feltételes utasításoknál gyakori kezdőhiba szokott lenni az utasítás zárójele után rakott pontosvessző mely ilyen üres utasítást hajt végre.
Return utasítás
Ez egy return
kulcsszó utáni kifejezésből áll. Ez beállítja a függvény visszatérési értékét, és kilép a függvényből, például nézzük a következő függvényt, mely egyszerűen összead két számot:
int osszead(int a, int b) { return a + b; }
Nyilván ilyen függvény írásának nem sok értelme van, de tanpéldának jó.
Feltételes utasítás
Általános formája
if (kifejezés) utasítás_ha_igaz vagy if (kifejezés) utasítás_ha_igaz else utasítás_ha_nem_igaz
Ez azt csinálja, hogy kiértékeli a kifejezést, ha az értéke igaz (azaz nem nulla), akkor végrehajtja az utasítás_ha_igaz utasítást, ha nem igaz, akkor az utasítás_ha_hamis utasítást (ha ez az else kulcsszóval együtt kimaradt, akkor semmit se csinál).
Példa:
const char *kisebb_e_mint_10(int x) { if (x < 10) return "Igen"; else return "Nem"; }
Ez a függvény visszatér az "Igen" illetve a "Nem" szöveggel, attól függően, hogy a paraméterében átadott szám kisebb-e, mint 10.
Blokk utasítás
Az előző if
utasítás és még sok másik csak egyetlen egy utasítást tud végrehajtani, ha azt szeretnénk, hogy többet is tudjon, blokkot kell alkalmazni, a blokk csak annyit jelent, hogy kapcsos zárójelek közé írjuk az utasításokat.
A blokkba tetszőleges számú utasítást tehetünk, az előző példa átírva úgy, hogy blokkot használjon így néz ki:
const char *kisebb_e_mint_10(int x) { if (x < 10) { return "Igen"; } else { return "Nem"; } }
Ha a blokkban változót deklarálunk, akkor az a változó csak a blokkban lesz elérhető.
Sokfelé ágazó utasítás
Ezt egy példán érthetjük meg:
const char *szovegge(int x) { const char *eredmeny; switch (x) { case 0: eredmeny = "nulla"; break; case 1: eredmeny = "egy"; break; case 2: eredmeny = "kettő"; break; case 3: eredmeny = "három"; break; case 4: eredmeny = "négy"; break; case 5: eredmeny = "öt"; break; case 6: eredmeny = "hat"; break; case 7: eredmeny = "hét"; break; case 8: eredmeny = "nyolc"; break; case 9: eredmeny = "kilenc"; break; default: eredmeny = "egyik sem"; } return eredmeny; }
Ez a függvény a 10 alatti számokat alakítja szöveggé.
A switch
utasítás azt csinálja, hogy a tetején lévő kifejezést kiértékeli (esetünkben ez csak egy változó értéke), és az eredmény értékétől függően beugrik az utána lévő blokkba valahol,
ha az érték egyik case
címkével sem egyezik meg, akkor a default
címkénél ugrik be.
Ez a default
címke kihagyható, ha elmarad, akkor egyszerűen átugorja az egész blokkot, és a következő utasításnál folytatja a programot.
Azok a break
utasítások azért kellenek, hogy kiugorjuk a switch
blokkjából, ha lekezeltük az esetet. Ha ezt nem tennénk meg, akkor a program szépen tovább futtatná a programot a blokkban,
és a többi case
címkénél lévő utasítást is lefuttatná. Ezt ugye el akarjuk kerülni.
Fontos tudni azt is, hogy a switch
blokkban nem deklarálhatunk változót közvetlenül (csak akkor, hogy ha külön blokk utasítást csinálunk magunknak ott bent).
A switch utasítás csak egész vagy enum típusú kifejezéssel működik.
Elöltesztelő ciklus
Általános formája a következő:
while (kifejezés) utasítás
Lássunk rögtön egy példát:
int osszegez(const int *tomb, int elemszam) { int i = 0; int osszeg = 0; while (i < elemszam) { osszeg += tomb[i]; i++; } return osszeg; }
Az új utasítás itt a while
. Ez azt csinálja, hogy kiértékeli az zárójelében lévő kifejezést, ha igaz (azaz nem nulla), akkor végrehajtja az utána lévő egyetlen utasítást (ez amely most az esetünkben egy blokk),
ezután újra kiértékeli a zárójelben a kifejezést, és újra végrehajtja a blokkot, és ezt addig ismételgeti, amíg az a kifejezés hamis nem lesz. Ha hamis lesz, átugorja a blokkot és folytatja a következő utasításon.
Mint látható ez a függvény az i
változót használja, hogy szépen sorban végigmenjen a tömb elemein, és egyenként hozzáadja azt az osszeg
változóhoz.
és ez a while
ciklus akkor fog kilépni, amikor i
eléri az elemszam
értékét. Mivel a tömb utolsó eleme elemszam - 1
, tehát végigértünk.
Azokat az utasításokat, amelyet a ciklus ismételget szaknyelven ciklusmagnak nevezik.
Hátultesztelő ciklus
Általános formája a következő:
do utasítás while (kifejezés);
Lássuk ezt is használat közben egy példán:
void veletlen_pont_egy_gombben(double sugar, double *koordinatak) { double x, y, z; do { x = (rand() % 10000 - 10000) / 10000.0 * sugar; y = (rand() % 10000 - 10000) / 10000.0 * sugar; z = (rand() % 10000 - 10000) / 10000.0 * sugar; } while (x*x + y*y + z*z > sugar*sugar); koordinatak[0] = x; koordinatak[1] = y; koordinatak[2] = z; }
Ez a függvény egy adott sugarú gömbön belül visszaad egy véletlenszerű pontot, és beírja az átadott tömbbe (feltételezi, hogy legalább 3 elemet tud tárolni).
A rand()
függvény egy olyan függvény, amely egy tetszőleges véletlen egész számot ad vissza (egyenletes eloszlásút), róla később lesz szó.
A do
ciklus azt csinálja, hogy először végrehajtja az utasítást, majd ellenőrzi a feltételt, ha az igaz, akkor újra végrehajtja a blokkot és így tovább. A különbség az elöltesztelős while
ciklushoz képest az,
hogy a ciklus utasítása legalább egyszer mindenképp végre fog hajtódni (while
ciklusnál előfordulhat olyan, hogy egyszer sem).
Amint láthatjuk, hogy ez a do
ciklus először vesz egy véletlen koordinátát egy 2*sugar
méretű kockában, melynek közepe az origó,
majd ellenőrzi, hogy ez a pont a kockába írt gömbben van-e. Ha nincs, akkor kér újat, és ezt addig ismételgeti, amíg nem talál egy olyan pontot, amely jó.
Mivel a gömb a kocka elég nagy részét kitölti, ezért 1-2 próbálkozás elég egy megfelelő ponthoz.
for
ciklus
Általános formája a következő:
for (init_kifejezés; feltétel_kifejezés; léptető_kifejezés) utasítás;
Írjuk át a korábbi tömbösszegzőnket úgy, hogy használja ezt a ciklust:
int osszegez(const int *tomb, int elemszam) { int i; int osszeg = 0; for (i = 0; i < elemszam; i++) { osszeg += tomb[i]; } return osszeg; }
A ciklusokat leggyakrabban arra használjuk, hogy tömböket vagy egyéb adatszerkezeteket járjunk be.
A for
ciklus működése a következő:
- Először kiértékeli zárójelében lévő első kifejezést, az inicializáló kifejezést, ezt általában arra használják, hogy ciklusban használt számláló változót alaphelyzetbe állítsák.
- Ezután kiértékeli a feltétel kifejezést (A második kifejezés), mely ha igaz, akkor végrehajtja az utasítást, melyet az áttekinthetőség kedvéért blokkba írtunk, ha a feltétel hamis, akkor kilép a ciklusból, és folytatja a programot a rákövetkező utasításon.
- Miután végrehajtotta az utasítást, akkor kiértékeli a fejlécében lévő harmadik kifejezést, ezt arra szokták használni, hogy a számláló változó értékét növeljük.
- Ezután a 2. lépéstől kezdve újra ellenőrzi a feltételt, utasításokat hajt végre, majd növeli a változót, addig, míg egyszer ki nem lép.
Helyben deklarált számláló változók
C++ -ban lehetőség van arra, hogy a ciklusban használt számlálót a ciklusban deklaráljuk, mivel általában másra úgy sem használjuk:
int osszegez(const int *tomb, int elemszam) { int osszeg = 0; for (int i = 0; i < elemszam; i++) { osszeg += tomb[i]; } return osszeg; }
Ekkor ez az i
változó csak a cikluson belül lesz elérhető, azon kívül nem. Ez csak C++, sima C-ben nem működik (csak C99 szabványtól felfelé).
Üresen hagyott cikluskifejezések
A for
ciklus 3 kifejezése lehet üres, ez a következőket jelentheti:
- Üresen hagyott inicializáló kifejezés
- Ekkor nyilván nem lesz semmilyen számláló inicializálás
- Üresen hagyott feltétel
- Úgy lesz kezelve, mintha mindig igaz lenne, azaz a ciklus végtelenségig futni fog (amíg más módon ki nem lépünk belőle).
- Üresen hagyott léptető kifejezés
- Ekkor nem lesz semmilyen léptetés.
Ha végtelenségig futó ciklust akarunk, akkor az általában olyan for
ciklussal tesszük, melynek mind a három kifejezése üres.
Az ilyen ciklusoknál általában valahol a középén van a feltétel, mely alapján kilépünk.
Ciklusvezérlő utasítások
Van két utasítás, melyet gyakran jól jön, amikor ciklusokat használunk:
A continue
utasítás
A continue
utasítás egy continue
kulcsszó, majd pontosvessző.
Ez az utasítás a ciklus ismétlődő utasítását végrehajtottnak tekinti. Így újra jön a feltételek ellenőrzése, vagy for
ciklus esetén a léptetés, majd feltétel-ellenőrzés.
Írjuk át az előző tömbösszegző függvényünket, hogy kihagyja a negatív számokat:
int osszegez(const int *tomb, int elemszam) { int osszeg = 0; for (int i = 0; i < elemszam; i++) { if (tomb[i] < 0) continue; osszeg += tomb[i]; } return osszeg; }
Ugyanaz, mint előbb, csak ha a tömb eleme kisebb, mint nulla, akkor ki lesz hagyva. A continue utasítás befejezettnek tekinti a ciklusmagot, így az osszeg
változó frissítésére nem kerül sor.
A break
utasítás
A break
utasítás formája hasonló a conitnue
-hoz: csak egy break
és pontosvessző. A break
utasítás megszakítja a teljes ciklust,
és a program futása a ciklus után következő utasítással folytatódik.
Példaként írjuk át az összegzőnket úgy, hogy ha 0 értéket lát, akkor szakítsa meg az összegzést:
int osszegez(const int *tomb, int elemszam) { int osszeg = 0; for (int i = 0; i < elemszam; i++) { if (tomb[i] == 0) break; if (tomb[i] < 0) continue; osszeg += tomb[i]; } return osszeg; }
Látható, hogy ott a break
utasítás, tehát ki fog lépni majd a ciklusból.
A goto
utasítás
Ez az utasítás feltétel nélküli ugrásra alkalmas egy címkézett utasításra egy utasítást úgy lehet címkézni, hogy elé írunk egy azonosítót majd egy kettőspontot.
Mivel a goto
-val való összevissza ugrálás érthetetlenné teheti a kódot, ezért csak akkor érdemes alkalmazni, amikor más lehetőségünk abszolút nincs,
vagy olyan dolgot kellene csinálnunk, amely nem tükrözi egyértelműen a szándékunkat. Egy pár példát mutatok, hogy mikor érdemes ezt használni.
Erőforrások felszabadítása C-ben
Ezt csak C nyelvben szükséges alkalmazni, C++-ban vannak erre jobb módszerek is (RAII), de erről majd később.
void akarmi(void) { erőforrások_lefoglalása csinálunk_valamit if (ki_kell_lépnünk) goto kilepes; csinálunk_valamit if (ki_kell_lépnünk) goto kilepes; csinálunk_valamit if (ki_kell_lépnünk) goto kilepes; csinálunk_valamit if (ki_kell_lépnünk) goto kilepes; csinálunk_valamit if (ki_kell_lépnünk) goto kilepes; csinálunk_valamit kilepes: erőforrások_felszabadítása }
A lefoglalt erőforrás lehet memória, megnyitott fájl, vagy bármi hasonló.
Egyszerű return
utasítás nem jó, mert akkor az erőforrás lefoglalt marad, egy függvény végére való goto
megoldja ezt a problémát.
Kiugrás több ciklusból
Hivatalosan az egyetlen dolog, amire a C++-ban is használható a goto
az, hogy több ciklusból ugorjunk ki vele egyszerre, a break
utasítás csak egyetlen egy ciklusból képes kiugorni:
for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { csinálunk_valamit if (ki_kell_ugrani_az_egészből) goto kiugras; csinálunk_valamit } } kiugras: következő_utasítás
Ciklusok ugrópontnak való használatának elkerülése
Mivel a goto
-t sokan mindenáron kerülendő dolognak tartják, ezért ilyen dologgal nem egyszer találkoztam már éles kódban:
do { csinál_valamit if (előre_kéne ugrani) break; csinál_valamit if (előre_kéne ugrani) break; csinál_valamit if (előre_kéne ugrani) break; csinál_valamit } while (0);
Nyilvánvaló, hogy az a ciklus nagyon nem ciklus, csak ugrópontnak van használva. Ilyen esetben a goto
jobban kifejezi, hogy mit akar a programozó:
csinál_valamit if (előre_kéne ugrani) goto ide; csinál_valamit if (előre_kéne ugrani) goto ide; csinál_valamit if (előre_kéne ugrani) goto ide; csinál_valamit ide: következő_utasítás
Mindezek ellenére vannak programozók és állatok (Velociraptor © xkcd), akiknél a goto
látványa feltétlen agressziót vált ki,
így ha ilyenek ütés távolságán belül vagy, kerüld a goto
használatát. :)
És ezzel lekezeltük az összes utasítást és vezérlőszerkezetet a C nyelvben. Pár újabb dolog még lehet a C++ nyelvben.
Deklarációk és definíciók
A C/C++ nyelvben ez két fontos fogalom (bár itt a leírásban, gyakran nem említjük külön, hogy mikor deklarálunk, és mikor definiálunk).
- Deklaráció
- Ez egy olyan dolog, amikor a fordítónak megmondjuk, hogy egy adott dolog létezik, de nem definiáljuk még. A deklarációk fontos jellemzője, hogy tetszőleges számban ismételhetők (amíg ugyanazt a dolgot deklaráljuk újra).
- Definíció
- Amikor definiálunk valamit, akkor kifejtjük a fordító számára, hogy miről van szó. Egy dolog definíciójából csak egyetlen egy lehet az egész programban.
Változók deklarációja és definíciója
Globális változó deklarációjára példa:
extern int globalis;
Ez jelzi, hogy a változó potenciálisan valahol a forrásfájlon kívül van definiálva.
Globális változó definíciójára példa:
int globalis;
A változó használatához a deklaráció is elég, viszont valahol a programunkban definiálnunk is kell azt a változót (különben linkelési hibát kapunk, de erről később).
Globális változók használata eléggé ellenjavallt, főleg, hogy ha többszálú programról van szó (és egyszerre két szál piszkálja...)
Struktúrák és uniók deklarációja és definíciója
Deklaráció:
struct Akarmi; union AkarmiUnio;
Definíció:
struct Akarmi { mezők }; union AkarmiUnio { mezők };
A deklaráció is elég, hogy egy mutató típusú változót akarunk létrehozni a struktúra típusra. Ha struktúra típusú változót akarunk megadni, akkor ahhoz már a fordítónak ismernie kell a típus pontos méretét, ahhoz pedig a definíció fog kelleni (különben befejezetlen típusra fog panaszkodni).
A typedef
A typedef
C-ben definíciónak minősül, tehát egy dolgot csak egyszer lehet megadni, C++-ban már deklaráció, tehát ugyanaz a typedef
többször is ismételhető.
Függvények deklarációja és definíciója
Deklarációra példa:
int osszead(int a, int b);
Definícióra példa:
int osszead(int a, int b) { return a + b; }
Tehát a prototípus a deklaráció (ismétlődhet), amikor pedig a kapcsosok közé leírjuk a függvény lépéseit az a definíció (csak egyszer lehet).
A függvény használatához elég a deklaráció.
Fordítás és linkelés
Ebben a részben leírom, amit a fordításról és a linkelésről (felhasználó szintjén) tudni kell.
Fordítás
Ha programunk szintaktikailag helyes, akkor a fordító egy ún. tárgykódot állít elő. Ez a tárgykód tartalmazza a lefordított függvények gépi kódját, valamint a publikus szimbólumok tábláját. Viszont ekkor még nem ismert, hogy tényleges függvényhíváskor honnét is kell indítani a kódot.
A publikus szimbólumok táblájában van az összes definiált függvény, valamint definiálatlanként az összes deklarált, hivatkozott, de nem definiált függvény.
A függvények mellett ez előbbiek vonatkoznak a globális változókra is, csak azokat ritkábban használják így külön nem említem őket.
Linkelés
A linker állítja elő a tényleges programot. Ehhez azonban az összes függvénynek, amely a programunkban elindulhat, meg kell találni a definícióját.
Ezeket a definíciókat a következő helyekről szedheti:
- Tárgykódokból
- Ezek azok a fájlok, amelyeket a fordítóprogram generál .c és .cpp fájlokból. A fájlok kiterjesztése általában .o vagy .obj. Linuxon a GCC ELF formátumú tárgykódot állít elő, Windowson az MSVC COFF formátumút.
- Statikus függvénykönyvtár
- Ezek tulajdonképpen összezippelt tárgykód fájlokból állnak. Általában .a vagy .lib kiterjesztésű fájlok. Az .a kiterjesztésűt a GCC csinálja, a .lib-et pedig az MSVC eszközei Windowson.
- Dinamikus függvénykönyvtár
- Ezek olyan függvénytárak, melyek a program futása elején vagy közben töltődnek be. Ezen fájlok kiterjesztése Windowson .dll, Linuxon .so. Nyilvánvaló, hogy ha a programunk ilyet használ, akkor a függvény futtatható gépi kódja nem kerül bele a programunkba, csak a hivatkozás, hogy melyik DLL-ben vagy SO-ban van, és mi a neve.
Ahhoz, hogy a programunk linkelhető legyen minden egyes általunk nem definiált függvénynek meg kell lennie a definíciójának valahol, és az ezeket tartalmazó fájlokat be kell adni a linker-nek. Ha egy hivatkozott függvényhez nincs meg a definíció, akkor hibát kapunk (undefined reference hiba). Ha pedig egy függvényhez több definíciót is talál, akkor is hibát kapunk (redefinition hiba). Ha mindent rendben talál, akkor előállítja nekünk a programot.
A folyamat jelentősége programozás szempontjából
A programok általában több forrásfájlból állnak, minden egyes függvénynek pontosan egyetlen egy definíciójának kell léteznie az egész programban, ezt a definíciót megírhatjuk mi a saját programunkban, de gyakran előfordul, hogy egy más által megírt, idők próbáját kiállt függvényt kell használnunk, ilyenkor elég a függvény prototípusát deklarálni, a linkelő pedig majd megkeresi nekünk a definíciót, ha megadjuk neki azt a fájlt, amiben keresse.
Ha C-ben programozunk, akkor ügyelnünk kell arra, hogy ha a függvény definíciója máshol van, akkor deklarációnak paraméterezésben, és visszatérési értékben stimmelnie kell. Ugyanis ennek a dolognak a helyességét nem igazán lehet ellenőrizni, mivel a statikus könyvtárakba, és dinamikusokba csak a függvény neve szerepel.
Függvények és globális változók elrejtése a tárgykódban
Függvények elrejtése
Ha azt szeretnénk, hogy egy függvény ne kerüljön be a definiált függvények listájába, akkor a deklarációja, illetve a definíciója elé kell írni a static
kulcsszót.
static int osszead(int x, int y) { return x + y; }
Ezt a függvényt lényegében csak az a forráskód használhatja, amelyikben megadták, más forrásfájlból nem lehet őket elérni. Valamint tetszőleges számú azonos nevű statikus függvény lehet különböző forrásfájlokban, a linker nem panaszkodik érte.
Globális változók elrejtése
Ő eléjük is beírható a static
kulcsszó:
static int globalisAkarmi;
Inline függvények
Ezek a függvények szintaktikailag annyiban különböznek az előző függvényektől, hogy inline
szót írunk static helyett:
inline int osszead(int x, int y) { return x + y; }
Ezek a függvények se lesznek a definiáltak listájában, az előzőhöz hasonlóan, viszont javaslatot adunk a fordítónak, hogy a függvényhívás helyére beillessze a függvény kódját, azaz inline-olja. Ez komoly sebességnövekedést okozhat, ha a függvény kódja rövid, és sokszor hívódik. Természetesen a fordító nagyjából figyelmen kívül hagyja ezt, és dönthet úgy, hogy mégse szúrja be a hívás helyére, illetve a nem inline-ként megadott függvényeket is inline-olhat, hogy ha olyan kedve van.
Statikus lokális változók
A static
szót alkalmazhatjuk függvények lokális változóinál is.
Az ilyenekről azt kell tudni, hogy akkor inicializálódnak, amikor először az őket tartalmazó blokkra kerül a vezérlés.
Ha már inicializálva vannak, akkor nem inicializálódnak újra. Így például csinálhatunk egy számlálófüggvényt:
int szamlalo(void) { static int ctr = 0; return ctr++; }
Minden egyes hívásakor eggyel nagyobb számot ad vissza, mint az előző volt. Első híváskor nullára inicializálja a változót. Utána növeli azt, a változó a program végéig a memóriában marad.
Az előfeldolgozó
Mielőtt a fordítás elkezdődik, a fájlon előfeldolgozás fog történni. Ezek lépései a következők:
1. lépés: A trigraph-ok átalakítása a nekik megfelelő karakterre
Ez egy kevesek által ismert dolog, én is kb. az utolsó dolgok között tanultam meg. Általában az ember véletlenül akad rá a jelenlétükre. A következő átalakítások történnek a feldolgozáskor:
Trigraph | Megfelelője |
---|---|
??= | # |
??/ | \ |
??' | ^ |
??( | [ |
??) | ] |
??! | | |
??< | { |
??> | } |
??- | ~ |
Ezek létezésének történelmi okai vannak: voltak olyan hiányos karakterkészletű számítógépek, ahol hiányoztak ezek a karakterek, így meg kellett oldani, hogy azokra is lehessen C nyelven fordítani.
Manapság már nem használják ezeket, általában akkor jövünk rá ezekre a dolgokra,
amikor debug-oláskor idegesek vagyunk, és olyan dolgokat íratunk ki, hogy "WTF??!"
, és erre azt fogjuk látni, hogy "WTF|"
.
Hasonlóan megemlítendő, hogy léteznek digraph-ok is:
Digraph | Megfelelője |
---|---|
<: | [ |
:> | ] |
<% | { |
%> | } |
%: | # |
%:%: | ## |
Ez azonban nem lesznek kicserélve, egyszerűen mint az említett határolók alternatív változataként használhatók.
2. lépés: Sorvégi sorfolytató \ jelek kezelése
Hogy ha egy sor végére \ jelet teszünk, akkor az olyan, mintha az a sorvégjel nem is lenne ott. Így a következő string egyszerűen azt jelenti, hogy "ABC"
:
"A\ B\ C"
Ez tipikusan az olyan dolgok elválasztására használatos, amelyeknek csak egyetlen egy sorban lehetnek. Fontos, hogy a \ után ne üssünk szóközt vagy semmi hasonlót.
3. lépés: egymás melletti stringek összefűzése
Két egymást követő string literál össze lesz fűzve, tehát a következő string ugyanúgy "ABC"
-t jelent majd:
"A" "B" "C"
4. lépés: előfeldolgozó direktívák lekezelése
Ez az utolsó lépés, és ez lesz a nagyobb falat, mert ezekből sok van. A preprocesszor direktívák a #-tel kezdődő sorok, nyilván ha több sorba akarjuk törni, akkor használjunk a sor végén \ jelet.
#include direktíva
Ez az egyik leggyakrabban használt preprocesszor dolog, lényegében mindegyik programban van legalább egy ilyen. Ez arra használatos, hogy egy másik forráskódfájlt szúrjunk be a kódunkba, általában fejléceket (erről kicsit később). Azaz ennek a direktívának a sora le lesz cserélve az adott fájl tartalmával a fordítás közben. A fájl elérési útvonalában mindig a / a könyvtárelválasztó, hogy ne kelljen mindig átírni, ha más operációs rendszeren fordítjuk a dolgokat, mert ugye ez az elválasztó op. rendszerenként más és más lehet.
Beszúrás az alapértelmezett helyről
Ha az alapértelmezett helyről szúrunk be (általában a standard függvénykönyvtár fejléceit), akkor hegyes zárójelek közé tesszük a fájlnevet:
#include <stdio.h>
Ekkor a fordítók csak az alapértelmezett útvonalakon keresik a fájlt.
Beszúrás a forráskód mellől
Ha saját fejlécet írtunk, akkor macskakörmök közé tesszük a nevet:
#include "foo.h"
Ekkor a fordító először a forráskód könyvtárához viszonyítva keresi a beszúrandó fájlt, ezután próbálkozik az alapértelmezett útvonalakon.
A C és a C++ standard se definiálja kőbe vésve, hogy a fordítónak hol kell keresnie a fájlt, szóval ettől, amit leírtam lehetnek eltérések. Bár a fordítók nagyrésze úgy viselkedik, ahogy leírtam.
#define direktíva
Konstans értékek definiálása
Ezzel preprocesszor szimbólumokat illetve makrókat deklarálhatunk, a legegyszerűbb eset, amikor konstansokat deklarálunk vele:
#define VALAMI 16
Innentől kezdve a VALAMI szócska a kódunkban mindenhol 16-ra lesz cserélve.
Általában a forráskódunkban minden egyes számnak (0 és néhány nagyon speciális eset kivételével) van valami jelentése: pl. maximális elemszám, valami bitmaszk, tömbméret stb.
Ha csak a számot írjuk a kódba, akkor a kódot olvasó embernek nem fog rögtön leesni, hogy az a szám mit is jelent.
Ha #define
-nal a fenti módon nevet adunk neki, és a kódban csak a nevet használjuk, akkor sokkal érthetőbb lesz a kódunk, sőt,
ha az értéke bármi okból megváltozna, akkor csak egy helyen kell átírni a számot.
Az, hogy a makrónevek csupa nagybetűsek az egy bevett konvenció, mindenhol így használják. Természetesen, lehet őket kis betűvel is csinálni.
Makrók definiálása
A makró egy olyan dolog, melynek paraméterei is vannak, például:
#define MAX(a, b) (a > b ? a : b)
Ilyenkor minden MAX(akármi, akármi)
le lesz cserélve, arra a feltételes operátoros kifejezésre.
Fontos tudni, hogy először a makró fejtődik ki, és csak aztán a tartalma, ha azok is tartalmaznak makrókat.
A makró nem függvény, a függvényeknél ez a dolog, ugye, fordítva volt: ott a paraméterek először kiszámolódtak, aztán indult el a függvény.
FONTOS: A paraméteres makrók kifejtésekor is szövegcsere történik a kódban, ezért használatukkor mindig gondolni kell arra, hogy mire is fognak lecserélődni a kódban, túlzásba vitt használatuk erősen áttekinthetetlenné teheti a kódot, arról nem is beszélve, hogy rejtélyes hibákat okozhat fordításkor, és nehezen észrevehető hibákat futáskor. Ezért csak erősen indokolt esetben használjuk őket. Ha szokatlan dolgot látunk más kódjában (szintaktikailag nem odaillő dolgot), akkor az jó eséllyel valami makró lesz.
A string-gé alakító # makró operátor
Ha valami elé makró megadáskor oda tesszük a # jelet, akkor az a makró kifejtésekor string-gé lesz alakítva, vegyük pl. a következő egyszerű string-gé alakítós makrót:
#define STRINGIFY(x) #x
Ha azt írjuk, hogy STRINGIFY(1234)
, akkor abból a kódban a "1234"
szöveg lesz. Itt érdemes újra megemlíteni, amit előbb mondtam,
például mi lesz eredménye ennek?
STRINGIFIY(MAX(3,1))
Mivel mindig a külső makró fejtődik ki először, ezért az ezt csinálja belőle: "MAX(3,1)"
, egy stringet, és a belső makró sose kerül kifejtésre.
A ## összefűző operátor
Szintén makrókban használatos, arra, hogy két dolgot egy értelmes tokenné varázsoljunk:
#define ADD_PREFIX(prefix, x) prefix ## x
Ha azt írjuk, hogy ADD_PREFIX(foo, bar)
, akkor az előállítja nekünk a foobar
azonosítót.
#undef direktíva
Segítségével egy korábban definiált preprocesszor szimbólumot szüntethetünk meg:
#undef VALAMI
Ezen sor után már nincs többé VALAMI szimbólum.
__LINE__, __FILE__, __DATE__, __TIME__, __cplusplus direktívák
Ezek speciális szimbólumok, melyeket minden fordító ismer, jelentésük:
- __LINE__
- Az aktuális sor száma.
- __FILE__
- Az aktuális fájl neve
- __DATE__
- A fordítás dátuma.
- __TIME__
- A fordítás időpontja
- __cplusplus
- Ez a szimbólum definiálva van, hogy ha C++ fordítót használunk, különben nincs.
#if, #ifdef, #ifndef, #else, #elif és #endif direktívák
Ezek segítségével feltételhez köthetjük egyes kódrészek fordítását.
Tipikusnak mondható példa:
#if defined(__LINUX__) || defined(__UNIX__) // Linuxos kód ide #elif defined(__WINDOWS__) // Windowsos kód ide #elif defined(__MACINTOSH__) // Macra szánt kód ide. #else #error "Állítsd be a platformot!" #endif
Nyilvánvaló, hogy #define
-oljuk az adott platformokra szimbólumokat,
és akkor a defined
makrófüggvény igaz értéket ad rájuk, minden más olyan, mint a hagyományos if-nél, csak ez itt fordítási időben fut le, és kiválasztja, hogy melyik kódot kell fordítani.
Az #error direktívában a következő részben van szó.
Az #ifdef AKARMI
egyenlő egy #if defined(AKARMI) -vel
.
Az #ifndef AKARMI
egyenlő egy #if !defined(AKARMI) -vel
.
#warning és #error direktíva
A #warning
direktíva segítségével fordítási figyelmeztetéseket írhatunk, az #error
direktíva segítségével pedig fordítási hibát kelthetünk, saját szöveggel.
#pragma direktíva
Segítségével fordítóprogram-specifikus opciókat lehet beállítgatni, az adott fordítótól függ, hogy mit lehet beállítani.
Fejléc fájlok
Ezek is forráskódot tartalmazó fájlok. Kiterjesztésük .h C esetén, de C++ esetén szokták .hpp kiterjesztéssel is használni, a gyári C++ fejléceknek meg általában nincs kiterjesztésük.
A tényleges forráskóddal ellentétben, ezeket a forráskód részeket arra tervezték, hogy más forráskódba szúrják be őket az #include
direktíva segítségével.
Egy tipikus fejléc valahogy így néz ki:
#ifndef VALAMI_EGYEDI_NEV #define VALAMI_EGYEDI_NEV Függvénydeklarációk, typedefek, struktúradefiníciók #endif
Látható, hogy a teljes fejléc egy #ifndef
direktívától függ, ez azért van,
hogy ne deklaráljunk mindent újra, hogy ha esetleg többször is beszúródik a fejléc.
Ha készítünk egy hasznos függvénygyűjteményt, akkor az összes függvény és típus deklarációját tegyük a fejlécbe, így amikor a függvénytárat egy programban felhasználjuk, akkor elég csak a fejlécet beszúrni, és nem kell nekünk újra deklarálni mindent, amikor kell. A függvénygyűjteményünk implementációja pedig egy másik forrásfájlban vagy függvénykönyvtárban van.
Egymásra hivatkozó fejlécek esete
Mikor valamilyen függvényre vagy adattípusra van szükségünk, akkor az első dolog az lesz, hogy beszúrjuk azt a fejlécet, amely azt tartalmazza. Előfordulhat, hogy így két fejléc egymásra hivatkozik, mint például a következő egyszerű példában:
a.h:
#ifndef A_H #define A_H #include "b.h" typedef struct A { B *b; } A; #endif
b.h:
#ifndef B_H #define B_H #include "a.h" typedef struct B { A *a; } B; #endif
main.c:
#include "a.h" int main(void) { }
Ha a main.c-t megpróbáljuk lefordítani, a fordító a b.h-ban azt fogja mondani, hogy márpedig az A típus típus nem létezik, pedig beszúrtuk a fejléc-ét.
Ez azért történik, mert az a.h beszúrja a b.h-t, és a b.h-t beszúrja az a.h-t, viszont az a.h egyszer már be van szúrva, ezért a benne lévő #ifndef
nem engedi, hogy újra beszúródjon,
így effektíve olyan, mintha a b.h-ban be se szúrnánk a típust, a előfeldolgozás után a kódunk lényegében így néz majd ki:
typedef struct B { A *a; } B; typedef struct A { B *b; } A; int main(void) { }
A megoldás a következő: mielőtt más fejléceket szúrunk be, deklaráljunk minden saját struktúrát, és rájuk a typedef-et előtte a fejléceinkben, valahogy így:
a.h:
#ifndef A_H #define A_H struct A; typedef struct A A; #include "b.h" struct A { B *b; }; #endif
b.h:
#ifndef B_H #define B_H struct B; typedef struct B B; #include "a.h" struct B { A *a; }; #endif
Így előfeldolgozás után olyan kódot kapunk, amelyre a fordítóprogramnak nem lehet panasza:
struct A; typedef struct A A; struct B; typedef struct B B; struct B { A *a; }; struct A { B *b; }; int main(void) { }
Tetszőleges számú paraméterű függvények használata
Korábban említettük, ha a formális paraméter listánk végére ...
-ot írunk, akkor függvényünk tetszőleges számú paramétert kaphat.
Viszont ezeket a paramétereket nem tudjuk elérni a függvényünkben, mivel nincs nevük.
Paraméterek átadása
Amellett, hogy a nevüket nem tudjuk a paramétereknek, még a típusukat sem.
Annyit kell tudni, hogy minden int
-nél kisebb méretű egész adattípus legalább int
méretűként lesz átadva,
illetve a double
-nál kisebb méretű lebegőpontos számok (tehát a float
), pedig double
-ra. Szóval hiába csak egy char
típusú adat a paraméter,
az mindenképp int
-ként lesz átadva (tehát a felső bitjei ki lesznek nullázva). Illetve hiába adunk át float
-ot, ha double
-ra lesz alakítva.
Argumentumok elérése
Az argumentumokat közvetlenül nem érhetjük el, de van rá standard mód: be kell szúrni az stdarg.h-t a forráskódunkba, és akkor elérjük a következő makrókat és adattípusokat:
- va_list
- Egy adattípus, melyen keresztül az extra paraméterek elérhetőek.
- va_start(lista, utolsó_paraméter)
- Utolsó argumentumon való meghívásával inicializálhatjuk a va_list-et, így elérhetjük a paramétereket az utolsó után.
- érték = va_arg(lista, típus)
- A soron következő paraméter elérése, meg kell adni a listát, illetve a várt adat típusát.
- va_end(lista)
- Ezt akkor írjuk, amikor végeztünk a paraméterekkel.
A következő példában egy olyan függvényt írok, mely adott számú int
típusú egész számokat adogat össze. Az n paraméterben megadjuk az összeadandók számát, és ezt követik az összeadandók maguk:
int osszeadSokat(int n, ...) { va_list list; int i; int sum = 0; va_start(list, n); for (i = 0; i < n; i++) { sum += va_arg(list, int); } va_end(list); return sum; }
Ez jól szemlélteti ezen makrók használatát.
A függvény hívása valahogy így megy:
osszeadSokat(10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Egyéb C nyelvi dolgok.
Ebben a részben megemlítek pár ritkán használt dolgot, amit még én se nagyon használok.
A #line preprocesszor direktíva
Amikor a fordítóprogram figyelmeztetéseket vagy hibákat ír ki, akkor kiírja, hogy mely sorban volt, és melyik fájlban. Ezzel a direktívával felülbírálhatjuk ezt. Például:
#line 1234 "nincs_is_ilyen_fajl.c"
Ez a sor úgy viselkedik, mint a nincs_is_ilyen_fajl.c fájl 1234. sora, és ezután a 1235., 1236., stb sor következik, függetlenül attól, hogy ténylegesen hányadik sorban volt ez, és milyen nevű fájlban. Véleményem szerint más programozók szívatásán kívül másra nemigen jó.
Bitmezők a struktúrákban
Ha valaki nagyon spórolni akar a tárterülettel, akkor még azt is megadhatja egy structban, hogy hány bitet kíván egy mezőben felhasználni. Ekkor a fordító ezeket egyesíti egy nagy 32 vagy 64 bites mezővel, és bitműveletek segítségével érhető el a dolog. Példa:
struct DatumIdo { unsigned int ev : 12; // kisebb, mint 4096 unsigned int honap: 4; // kisebb, mint 16 unsigned int nap: 5; // kisebb, mint 32 unsigned int ora: 5; // kisebb, mint 32 unsigned int perc: 6; // kisebb, mint 64 }
Ez pont 32 bit, tehát a fordító összeforraszthatja ezeket 1db 32 bites int-té. Ez mind szép és jó. Viszont az, hogy a fordító ezeket hogyan rakja össze, az teljesen fordítófüggő, így ha a struktúra tartalmát fájlba írjuk, vagy hálózaton küldjük át, nem biztos, hogy a program egy másik fordítóval fordított verziója, azonos módon értelmezi azt.
Anonim uniók
Anonim uniók segítségével lehet változókat és struktúra mezőket azonos memóriaterületre tenni:
union { float egyik; int masik; double harmadik; }; Illetve: struct Akarmi { union { float valos; int egesz; }; int normalMezo; };
Az első példában változók ugyanúgy elérhetők, mint bármely más változó, csak egyszerre csak egynek lehet értelmes értéke. Az Akarmi struktúrának úgy szintén közvetlenül elérhető a normalMezo
,
valos
és egesz
mezője is, mintha közvetlenül a struktúrához tartozna, csak éppen valos
és az egesz
azonos memóriaterületen osztozik,
így csak az egyiknek lehet értelmes értéke.
Én nem használok anonim uniókat, egyszerűen azért, mert nevet adok nekik. Másrészt csak C++-ban érhető el.
A long double
típus
A long double
típust sok fordító ismeri. Ez még a double
-nél is pontosabb, hogy a mérete mekkora az fordító függő,
de a 80 bites, 10 byte-os szám elég gyakori. De előfordul 128 bites (quadruple) lebegőpontos implementáció is. Lebegőpontos literálban a L szuffixummal jelölhetjük, hogy az adott dolog long double típusú:
8.999999999999999999L
A long long
típus
A long long
típust a C99 szabvány definiálja mint legalább 64 bites egész számot.
Literálokban az egész szám után LL-t vagy ULL-t kell tenni. De mivel nem mindegyik fordító támogatja a C99-et,
és megvan mindegyiknek a maga módja az ábrázoláshoz, valamint a printf
függvényben sincs standard formátummegadása még neki,
ezért nem szoktam használni.
A wchar_t
típus
A C-ben egy typedef, míg a C++-ban a nyelv része ez az adattípus.
Míg a char
csak egybyte-os karaktereket tud tárolni, addig a wchar_t
több-byte-os karaktereket is, hasznos lehet pl. az Unicodehoz.
Még van lehetőség arra is, hogy a kódunkba hosszú karakter literálokat, illetve hosszú karakter string literálokat adjuk meg:
- Hosszú karakter literál példa
L'瞄'
- Hosszú karakter string példa
L"瞄馄ᄴ馉ሴ„”衳蝹饴"
Tehát a literál elé kell írni egy L betűt. Ezek unicode karakterekből állnak, nyilvánvaló, hogy mindnek 255 feletti a kódja, így több byte-ot foglal. A wchar_t
szép és jó,
viszont a mérete erősen fordítóprogram függő, így nem kompatibilis, amikor különböző rendszerek beszélgetnek egymással ezt a típust használva (pl. egy program Windowson és Linuxon készült verziója).
A memóriakezelés problémái
Mielőtt áttérnénk a C++ specifikus részekre, ennek is szánok egy komplett fejezetet. Ugyanis, amikor a programunk hibával leáll, az szinte biztos, hogy azért van, mert valamit rosszul csinálunk a memóriában. Ha a programunk egyre több és több memóriát eszik, az azért van, mert valamit rosszul csinálunk a memóriában... Ha cyberbűnözők a programunkon biztonsági rést találnak, és ezáltal futtatnak le tetszőleges kódot (telepítve ezzel vírusokat és sok más csúnyaságot), az azért van, mert valamit rosszul csinálunk a memóriában. Ebben a részben azzal foglalkozunk, melyek ezek a problémák.
Memóriaszivárgás
Ilyen akkor történik, amikor egy futás közben lefoglalt memóriának a címét elveszítjük. Ekkor a programunk egyre több és több memóriát fog lefoglalni miközben fut. Ez főleg sokáig futó programoknál (például szerverek) okozhat nagy gondokat, azaz elfogy a memória, a winchestert elkezdi pörgetni, először nagyon lelassul, majd végleg összeomlik... Ez sokféleképpen történhet, két lehetőséget mutatok.
Mutató felülírása
Ezt egy egyszerű példán mutatom be:
void *valami; valami = malloc(1000); valami = NULL;
Először lefoglalunk 1000 byte-ot, majd és az elejének a címét beletesszük a valami mutatótípusú változóba.
Majd NULL-ra állítjuk a mutató.
Ezzel az 1000 bájt címét el is veszítettük. A cím ismerete nélkül nem tudjuk a free
függvényt se hívni, hogy felszabadítsuk.
Az az 1000 bájt véglegesen elveszett.
Így akárhányszor mutatónak adunk értéket gondolkozzunk el azon, hogy vajon tárolhat-e valami olyan blokkot, amelynek a címe így elveszhet. Az esetek nagy többségében nem, de az a kevés eset az, ami problémát okoz.
Mutató elveszítése
Ez talán a leggyakoribb ok: elveszítjük a mutató típusú változót, amely a blokknak a címét tárolja. Ez történhet egy blokk végén:
//... { int *munkaTerulet = malloc(1000); //... // Itt a munkaTerulet változó elveszi a mutatóval együtt. A terület viszont lefoglalt marad. } //...
De ugyanúgy megtörténhet a free
hívásakor is, ha olyan dolgot szabadítunk fel, ahol elveszhet a cím.
Én azt javaslom, hogy bármikor malloc
-ot írunk, azonnal írunk be a kódba a hozzá tartozó free
-t is.
A free
írásakor, pedig gondoljunk bele, hogy nem-e olyan dolgot szabadítunk fel, amely tartalmaz olyan mutatót,
amely egy futás közben foglalt blokkra mutat. (Szabadítsuk fel azokat is, ha kell).
A próblémát saját allokátor írásával oldhatjuk meg, amely nyomon követi a lefoglalt blokkat. Egy egyszerű példa:
#include <stdio.h> #include <stdlib.h> int szamlalo = 0; void *lefoglal(size_t meret) { void *terulet = malloc(meret); if (terulet) szamlalo++; return terulet; } void felszabadit(void *terulet) { if (terulet) szamlalo--; free(terulet); } void vanELek(void) { if (szamlalo > 0) { fprintf(stderr, "%d db blokkot elfelejtettél felszabadítani. \n", szamlalo); } else if (szamlalo < 0) { fprintf(stderr, "Valamelyik memóriablokkot többször szabdítottad fel.\n"); } } int main(void) { void *x; atexit(vanELek); x = lefoglal(10); }
Ha ezeket a lefoglaló függvényeket használjuk, akkor kilépéskor szólni tud, hogy ha elfelejtettünk deallokálni valamit valahol. Ezt lehet fokozni még azzal, hogy úgy írjuk meg, hogy nyomon kövessen minden allokációt, jegyezze a blokk méretét, az allokálás helyét, stb. De ezt a gyakorlatot az olvasóra bízom.
Természetesen vannak programok, mint például Linux alatt a valgrind, illetve némelyik fordító képes a fent említett dolgot a malloc-ra is alkalmazni.
Lógó mutatók
Ez az a jelenség, amikor a mutató olyan helyre mutat, amit már felszabadítottunk. Az ilyen mutató használata kezdetben nem is feltűnő, de amint a későbbi memóriafoglalások elkezdik felülírni, egyszer csak azt tapasztaljuk, hogy a változóink értéke valami rejtélyes módon megváltozik, és ettől előbb-utóbb elszáll a program. Ennek a jelenségnek is számos oka lehet.
Függvényből kijutni akaró lokális változók
Ilyenkor (véletlenül) egy függvény lokális változójának a címe kijut a függvényből. Mivel a lokális változók felszabadulnak függvény végeztével, ezért azt ottani memóriaterület elérése problémás lesz, mivel a program veremterületét vágjuk tönkre így. Példa:
void *akarmi(void) { int lokalis; return &lokalis; }
A függvény által visszaadott mutatóra írni garantált verem korrupció, szinte biztos kifagyás. Természetesen máshogy is kijuthat a függvényből a lokális változó mutatója: például paraméterben kapott struktúrába írjuk, vagy kimenő paraméterként. Ez a probléma kezdőhiba: soha ne add ki a függvényből a lokális változóid címét.
Free után maradó logó mutatók
Végeztünk a memóriablokkal, tehát felszabadítjuk azt, még lehet, hogy elővigyázatosságból be is állítjuk a mutató
változó értékét NULL-ra utána. Azonban lehetnek mutatók, amelyek továbbra is a felszabadított területre mutatnak, és ha azokat használjuk
akkor probléma lesz. Tehát akárhányszor free
-t írunk, gondolkozzunk el, hogy mi mutathat még
az éppen felszabadítani kívánt területre. Valami módon meg kell oldani, hogy azok a mutatók is nullázódjanak. Ez nem
egy egyszerű dolog. A probléma megoldható, de ez túl mutat (az amúgy is túl hosszúra sikerült) leíráson.
Puffer túlcsordulás
Ilyen akkor történik, amikor egy tömböt túlindexelünk, vagy éppen negatív indexeket alkalmazunk rajta.
Egy ilyen hibát használhatnak ki a cyberbűnözők, hogy gépünkön tetszőleges kódot hajtsanak végre, egy jól összerakott inputtal, ehhez csak az kell, hogy a programvermen tárolt visszatérési címet felülírják valahogy, ehhez elég egyetlen egy ellenőrizetlen tömb, melyet túlírhatunk, és kész is a baj.
A problémát úgy orvosolhatjuk, hogy odafigyelünk, és mindig ellenőrizzük az indexhatárokat, hogy nehogy túlírjunk, és olyan függvényeket használunk, amely figyel az indexhatárokra de legalább is a felhasználható memóriaterület méretére.
Referenciák
Eddig főképp C-vel foglalkoztunk, az eddigi tudással már lényegében bármilyen programot megírhatunk, a C++ csak egy kis hasznos (és kevésbé hasznos) pluszt ad hozzá a dolgokhoz. És ezzel át is tértünk a C++ specifikus dologra. Az eddigi C tudásunkkal már bármit le tudunk programozni. Vannak a C++-nak dolgai, melyek csak több problémát okoznak, mint megoldanak.
A referenciák tulajdonképpen a mutatók egy kicsit biztonságosabb változatai. Legegyszerűbben egy példán tudjuk megérteni őket:
int a; int *m = &a; *m = 2; // Az a értékének 2-re állítása mutatón keresztül. |
int a; int &m = a; m = 2; // Az a értékének 2-re állítása referencián keresztül. |
Pontokba szedve a különbségeket:
- A referenciákat * helyett & jellel jelöljük.
- A referenciáknak azonnal értéket kell adni, az értéknek egy lvalue-nak kell lennie, erre fog mutatni a referencia. Ez az értékadás történhet közvetlenül a deklarációkor, paraméterátadáskor (referencia típusú paraméter), vagy objektumok konstruktorainak inicializációs listájában (de ezekről sokkal később).
- A referencia nem módosítható, ha egyszer értéket adtunk neki, akkor mindig ugyanoda fog mutatni.
- A referencia egy teljes értékű lvalue. Mindenféle dekoráció nélkül úgy használható, mint a hivatkozott változó.
- A referenciának legfelső szinten kell lennie. Tehát nem készíthetünk mutatót, mely egy referencia típusú adatra mutat, nem készíthetünk referenciákból álló tömböt, nem készíthetünk referenciára mutató referenciát, és még struktúrába se tehetjük be csak úgy.
-
Nem lehet NULL. Bár bele lehet trükközni, ha nagyon akarjuk:
int &ref = *(típus*)0
. De ez ronda. Ez esetben inkább használjunk mutatót. - A mutatók mutathatnak egyetlen egy dologra, de mutathatnak egy egész tömbre is. A referenciák mindig csak egyetlen egy dologra mutatnak, tömbre nem.
Ha a mutatókat száműzzük mindenhonnan, ahonnét csak lehet, akkor egy fokkal stabilabbá vált a programunk.
Legalább is pontosan láthatjuk, hogy a programunk hol szállhat el: a *
, []
és a ->
operátorok használatánál.
Referenciák használatával ezeknek a számát csökkenthetjük.
Persze az itt említett dolgokon kívül még vannak más dolgok is, ahol jól jönnek.
Saját operátorok írása
Tegyük fel, hogy egy játékot programozunk, mely gyakran foglalkozik vektorokkal, és milyen jó lenne, hogy ha két vektort egyszerűen a + operátorral adhatnánk össze. C++-ban van erre is lehetőség.
Megadásuk
struct Vektor { double x, y, z; }; Vektor operator+(const Vektor &a, const Vektor &b) { Vektor result; result.x = a.x + b.x; result.y = a.y + b.y; result.z = a.z + b.z; return result; }
A függvény neve az operator
kulcsszóval kezdődik, majd ezután a felülbírálandó operátor jele jön.
Figyeljük meg, hogy a függvény referenciát fogad paraméterként, így írhatjuk azt, hogy A + B
,
és nem azt kell, hogy &A + &B
. Mivel referenciáról van szó az érték se
másolódik, csak a címe, így hatékonyabb, mintha másolni kellene. Teljes példa:
int main() { Vektor A; Vektor B; Vektor C; A.x = 1; A.y = 2; A.z = 3; B.x = 5; B.y = 8; B.z = 11; C = A + B; printf("C = [%.f, %.f, %.f]\n", C.x, C.y, C.z); return 0; }
Természetesen ilyen módon az összes operátorból írható saját változat, amit még tudni kell, hogy a precedenciáját az operátornak nem változtathatjuk meg. Szaknyelven a saját operátorok írását operator overloading-nak nevezik.
Ez egy jó lehetőség, csak innentől kezdve a programozónak, mikor más kódját olvassa gondolkoznia kell, hogy egy adott operátor mit csinál. Egy + operátor leformázhatja a merevlemezt, ha úgy írják meg, C-ben ettől nem kellett tartani, mert ott nincs ilyen. Valamint az operátorfüggvény definícióját sem egyszerű megtalálni egyszerű többfájlos kereséssel, ha többféle típusra is alkalmazzák. Emiatt érdemes a használatukat korlátozni csak és kizárólag ilyen matematikai célú dolgokra, amikor várhatóan egy komolyabb matematikai kifejezéssé fűzzük őket össze.
Természetesen hatékonysági problémák is felmerülhetnek: gyorsabb lehet egyszerűen egy vektor objektumot csűrni csavarni, mint minden egyes számolás után egy újat visszaadni.
Természetesen az új operátor nem fogja felülírni a régit. A régi jelentése ugyanúgy megmarad. Tehát továbbra is használható számok összeadására.
A ++ és a -- operátor felülbírásása
Ezek egyparaméteres operátorok, felülbírálásuk egyszerűnek tűnik. Mondjuk egy Foo
struktúra esetén (melynek van egy bar
mezője):
Foo operator++(Foo &x) { x.bar++; return x; }
Ez értelemszerű, csak ez a preinkrementálás operátorát bírálja felül. Tehát egy Foo
típusú változóra máris használhatjuk így: ++foo
.
Ha posztinkrementálás műveletét is felül akarjuk bírálni, azt egyetlen egy plusz int
típusú paraméterrel lehet megtenni, az a paraméter nem lesz használva semmire,
csak a különbségtétel a célja:
Foo operator++(Foo &x, int) { Foo tmp = x; x.bar++; return tmp; }
Ezzel már használhatjuk a foo++
-t is.
Több függvény azonos névvel
C++-ban a függvények neve többé nem egyedi azonosítója a függvénynek, a név és a paraméterlista együtt azonosítja a függvényt. Ezt nevezik szaknyelven overloading-nak, vagy magyarosítva függvénytúlterhelésnek (mondjuk ez elég nevetséges fordítás...).
Például C++-ban a matematikai függvényeknél már nem szükséges az f
és az l
, mert azonos névvel, de más paraméterezéssel vannak:
float sin(float x); double sin(double x); long double sin(long double x);
Az overloading hasznos, mert nem kell többé megkülönböztető betűket írnunk a függvény nevébe, ha azonos dologról van szó. Viszont ugyanez a hátránya is, mivel nehezebb lesz megkeresni, hogy egy függvényt hol használnak a kódban (egyszerű többfájlos keresés nem működik többé).
C stílusú függvények használata
C-ben a függvény neve azonosítja a függvényt, C++-ban már a neve + a paraméterei. Viszont akármilyen DLL-ről, vagy statikus függvénytárról legyen szó, a függvény neve fogja azonosítani a függvényt, és semmi más. Ehhez meg kell mondani a fordítónak, hogy egy függvényt úgy kezeljen, mint C függvényt. Ezt így lehet:
extern "C" { Függvénydeklarációk }
Ekkor a függvény neve fogja azonosítani a függvényt, és az overloading nem fog működni rá. Ez a megadás szükséges, hogy a függvényt egy dinamikus vagy statikus függvénytárba szánjuk, és szeretnénk, hogy más nyelven is lehessen boldogulni vele.
Névterek
Névterek segítségével osztályozhatjuk a szimbólumainkat egy fa struktúrába.
Megadása
namespace név { deklarációk }
Példa:
namespace A { int B; }
A névtér zárójelein belül azt a B
változót közvetlenül a nevével érhetjük el.
Névtéren kívül az A::B
jelölést kell használni, tehát azonosítani kell a névteret is.
A névterek egymásba is ágyazhatók:
namespace A { namespace C { int B; // B elérése itt: B } // B elérése itt: C::B } // B elérése itt: A::C::B
Using deklaráció
Ha egy névtérben lévő szimbólumokat a névtér feltüntetése nélkül is használni akarjuk, akkor using
deklarációt használunk. Például az előző névtér megadása után írhatjuk hogy:
using namespace A;
Ezután már az A
névtér elemeit azon kívül is el lehet érni.
Az előbb látott egymásba ágyazott névterek esetén B közvetlen eléréséhez using namespace A::C
-t kell írnunk.
Globális névtér
Azokat a dolgokat, melyek nincsenek névtérben, a globális névtérben vannak. Ha
a globális névtér elemeit akarjuk elérni, akkor azt is a :: segítségével csináljuk, csak nem írunk elé semmit.
Például: ::printf
.
Névtelen névtér
Ekkor nem írunk a namespace után semmit:
namespace { // stb... }
Ennek a hatása nagyjából az, mint a static
szónak a globális függvények és változók előtt:
az adott dolog csak az adott forrásfájlban érhető el.
De mivel a static
-cal sincs semmi baj, meg a helyben
deklarált típusok amúgy is csak a fájlon belül érvényesek, így ennek nem sok értelme van.
Ráadásul még a szimbólumok exportálódnak is, valami halandzsa néven, hogy nehogy ütközzön valamivel.
Generikus függvények
A C-ben voltak a #define
makrók, ezek C++-ban is vannak.
Viszont a C++ biztosít egy második metaprogramozási módszert is. A sablonokat.
Típusparaméterű sablonok
Első körben ezt függvényekre nézzük meg. Lássuk egy példán:
template <class T> T osszead(const T &egyik, const T &masik) { return egyik + masik; }
Ez egy függvénysablon, van egy T sablonparamétere. Amely be lesz helyettesítve mindenhova, így a fordító minden egyes használatkor, amikor különböző paraméterekkel használjuk, újabb és újabb függvényt generál. Ez hasznos lehet, mivel nem kell nekünk megírni az overloadokat.
A függvényt a következő módokon hívhatjuk meg:
osszead<double>(123.45, 123.567) vagy osszead<float>(123.45, 123.567) vagy osszead<long double>(123.45, 123.567) vagy osszead<int>(123, 123)
Mindegyikhez egy külön összeadó függvény generálódik majd. A megadott sablon alapján. A hegyes zárójel elhagyható, hogy ha a fordító ki tudja találni a paraméterekből, hogy mit kell alkalmaznia (de ha lehet ne szívassuk a fordítót ilyennel, mert megjárjuk).
A generikus függvény akkor generálódik, amikor a fordító meglátja, hogy használva van, így a generálásához szükség van annak a teljes forráskódjára, ezért a generikus függvényt nem lehet függvénytárba linkelni. Mindig ott kell lennie egy fejléc fájlban a teljes kódjának.
Értékparaméterű sablonok
Nem csak típus lehet a sablonokban, hanem konkrét érték is:
#include <stdio.h> template <int X> void foo() { printf("%d\n", X); } int main() { foo<123>(); }
Ilyenkor fordítási időben beledrótozunk a függvénybe egy számot, és minden különböző számra, ami a kódunkban előfordul, új függvény generálódik. Értékparaméterű sablonok alkalmazásának a függvényekre általában nem sok értelme van.
Több paraméterű sablonok
Semmi akadálya sincs, hogy több paramétert is megadjunk egy sablonnak, vagy a típus és az értékparamétereket keverjük:
template <class T, int X> T *csinaljTombot() { return new T[X]; }
Sablon specializáció
Van lehetőségünk arra, hogy egy adott sablonparaméter-összeállításra külön definíciót adjunk, ezt nevezik sablon specializációnak.
Teljes specializáció
A következő példában, az előbbi értelmetlen tömbcsináló függvényünk külön ki is írja, hogy ha pont 100-elemű char
-tömböt
akarunk csinálni:
#include <stdio.h> template <class T, int X> T *csinaljTombot() { return new T[X]; } template<> char *csinaljTombot<char, 100>() { printf("Pontosan 100 elemű karakteres tömb!\n"); return new char[100]; } int main() { int *intTomb = csinaljTombot<int, 200>(); char *charTomb = csinaljTombot<char, 100>(); delete intTomb; delete charTomb; }
Látható, hogy ilyenkor a függvény neve után vannak felsorolva hegyes zárójelben a konkrét értékek, amelyre ez a speciális implementáció vonatkozik. Természetesen hogy tudjunk specializálni, előbb szükségünk van egy sablonra, amit specializálunk, ez itt esetünkben az előbb lévő függvény. A példában a sablonnak a zárójele üres, ezt nevezik teljes specializációnak, ilyenkor minden paraméter le van fedve.
Részleges sablonspecializáció
Ez az az eset, amikor nem specializáljuk az összes paramétert, csak egy részét. Például így nézne ki, hogy ha nem csak a 100 elemű char
tömbre írná ki, hogy hány elemű, hanem bármilyen char
tömbre:
template<int N> char *csinaljTombot<char, N>() { printf("%d elemű karakteres tömb!\n", N); return new char[N]; }
Csak a részleges sablonspecializációt függvényekre nem engedélyezi a szabvány, így ez csak az osztálysablonoknál működik.
Majd később az objektumorientált programozás keretében megismerkedünk majd az osztálysablonokkal is.
Alapértelmezett argumentumok
C++-ban van lehetőség arra, hogy a függvények paramétereinek alapértelmezett értékeket adjunk. Lássuk ezt egy példán:
void akarmi(int x, int y = 2, int z = 3);
Jobbról kezdve lehetséges alapértelmezett paramétereket megadni, ilyenkor függvényhíváskor a következőket tehetjük:
akarmi(4, 5, 6); // Itt minden paramétert megatunk. akarmi(4, 5); // Az utolsót nem adtuk meg, így annak az alapértelmezett értékét veszi, azaz akarmi(4, 5, 3). akarmi(4); // Az utolsó kettőt nem adtuk meg, így ez a függvényhívás lényegében akarmi(4, 2, 3);
Objektumorientált programozás a C++-ban
Eddig az adatok statikus dolgok voltak, amelyek nem tudtak csinálni semmit, és a számítógép kezelte őket a függvények segítségével. Az OOP egy kisebb paradigmaváltás: innentől kezdve az adatok most már nem inaktív dolgok lesznek, hanem ők is tudnak már csinálni dolgokat. Olyanok lesznek, mint egy fekete doboz, amelyen vannak gombok, és ha megnyomunk egyet, akkor történni fog valami.
Ez első körben annyit jelent, hogy a struktúráinkba függvényeket is írhatunk.
Metódusok
Megadásuk
Kezdetnek egészítsük ki a Vektor
struktúránkat egy függvénnyel, amely visszaadja a hosszát:
struct Vektor { double x, y, z; double hossz() { return sqrt(x*x + y*y + z*z); } };
Tehát egy függvényt írtunk a struktúránkba. Látható, hogy ez a függvény eléri a struktúra mezőit, mint a változókat.
Tehát magán a Vektor
adatstruktúrán tud dolgozni.
Az ilyen struktúrába rakott függvényeket nevezik metódusnak, ez amolyan szintaktikai cukorka, mint minden más a C++-ban.
Az objektumorientált programozásban a metódusokat tartalmazó struktúrákat osztályoknak nevezik.
Az osztály típusú változókat, pedig objektumoknak vagy példányoknak.
Használatuk
Használni pedig így lehet a metódusokat:
... Vektor v; double hossz; v.x = 3; v.y = 4; v.z = 0; hossz = v.hossz(); // 5 kerül bele. ...
Rejtett this
paraméter
A hossz
metódus teljesen ekvivalens azzal, mintha ezt írnánk egy objektumok kívüli függvény esetén:
double hossz(Vektor * const this) { return sqrt(this->x*this->x + this->y*this->y + this->z*this->z); }
Azaz egy rejtett paraméterként megkapja azt az objektumot, amely tartalmazza a függvényt,
és ha egy változót nem talál sem a paraméterek között, sem a lokális változók között, akkor automatikusan ezen a
rejtett this
nevű paraméteren kezdi el keresni.
Minden metódus megkapja az őt tartalmazó osztály példányát mint this
paramétert. A korábbi példában
A this
paraméter a v
változó címe volt.
Metódusdefiniálás az osztályon kívül
Általában nem szokták a metódusokat kifejteni a struct
zárójelei között. Hanem csak deklarálják
és később fejtik ki részletesen. Valahogy így:
struct Vektor { double x, y, z; double hossz(); }; double Vektor::hossz() { return sqrt(x*x + y*y + z*z); }
Tehát itt az osztály úgy viselkedik, mint egy névtér.
Általában a típusmegadást egy fejlécfájlba (.h) írják, a függvényt pedig egy (.cpp) fájlba.
A típuson belüli és a típuson kívüli megadásnál az a különbség, hogy a típuson belüli megadás automatikusan inline
-ként
lesz értelmezve. Az azon kívüli pedig olyan, mint minden más hagyományos függvény.
Konstans metódusok
Ezek olyan metódusok, amelyek nem módosítják az objektumot, így meghívhatók konstans objektumokon is. Ehhez a metódus neve után egy const
kulcsszót kell írni, például az előző hossz
metódus nem változtatja meg az objektumot, így az nyugodtan lehet konstans módban:
struct Vektor { double x, y, z; double hossz(); }; double Vektor::hossz() const { return sqrt(x*x + y*y + z*z); }
Így most már ez a metódus meghívható egy konstans objektumon is:
const Vektor *pVektor = &valamiVektor; //... double hossz = pVektor->hossz();
Saját operátor metódusok írása
Korábban már volt szó saját operátorok írásáról. Van arra is lehetőség, hogy metódusként írjuk
meg ezeket az operátorokat. Ez esetben az operátorfüggvény első paramétere mindig a rejtett this
paraméter lesz, így eggyel kevesebb paramétere lesz az operátor függvénynek.
Így példaként írjuk meg metódus operátorként a korábban említett vektorokat összeadó operátort:
struct Vektor { double x, y, z; Vektor operator+(const Vektor &b) { Vektor result; result.x = x + b.x; result.y = y + b.y; result.z = z + b.z; return result; } };
Típuskonvertáló operátorok
A C++-ban lehetséges típuskonvertáló operátorokat is írni. Ezeknek mindenképp metódusoknak kell lenniük, tehát belőlünk nem lehet szabadon lévő operátort írni. Legfőképp azért írunk ilyeneket, hogy a saját objektumainkat tudjuk beépített típusra átalakítani. Lássuk egy példán, hogy hogyan kell ilyet csinálni:
#include <cstdio> #include <cmath> using namespace std; struct Vektor { double x,y,z; operator double() { return sqrt(x*x + y*y + z*z); } }; int main() { Vektor v = {1, 2, 3}; double d = v; printf("%g\n", d); }
A lényeg az az operátormetódus a Vektor
osztályunkban.
Láthatjuk, hogy nincs visszatérési értéke hiszen az operátor neve a visszatérési érték.
Valamint nincs paramétere sem.
Ez az operátor annyit csinál, hogy visszatér a vektor hosszával, így a vektor struktúránk
értékadásban adható egy egyszerű double
típusú változónak, és a konverzió implicit megtörténik.
Ha explicit konverziót szeretnénk, akkor ne írjunk konvertáló operátort, hanem maradjunk a korábbi hossz
metódusnál.
Konstruktorok, destruktorok és az = operátor
Nagyon gyakori, hogy az objektum létrehozása után inicializálni kell azt, illetve a megsemmisülése előtt, bizonyos felszabadítást végezni.
Például fájlkezelésnél ilyen az fopen
és az fclose
. A legfoglalási és a felszabadítási folyamattal egyesíti
az inicializálást és az eltakarítási műveleteket.
Konstruktorok
A konstruktor is egy metódus, amely, akkor fut le, amikor az adott objektum létrehozódik. Több overload is lehet belőle.
A neve megegyezik az osztály nevével, valamint nincs neki visszatérési típusa.
Példaként adjunk hozzá két konstruktort a Vektor
osztályunkhoz:
Megadása
struct Vektor { double x, y, z; Vektor() { x = 0; y = 0; z = 0; } Vektor(double x, double y, double z) { this->x = x; this->y = y; this->z = z; } }
Használata
Innentől kezdve, ezeket a konstruktorokat a következőképpen hívhatjuk meg:
//... { Vektor nullVektor; //< Paraméter nélküli konstruktor hívódik. Vektor vektor(1, 2, 3); //< Paraméteres konstruktor hívódik. } //...
A konstruktor változódeklarációkor hívódik, ha zárójelben odaírjuk a megfelelő paramétereket, akkor a paraméteres konstruktor fog hívódni.
Ha egyáltalán nincs konstruktor az osztályban, akkor is létezik egy alapértelmezett konstruktor, melynek nincsenek paraméterei. Ez az alapértelmezett konstruktor nem csinál semmit. Ez azért van így, hogy C++-ban is lehessen C stílusban programozni (tehát hagyományos struktúrákkal, melyeknek nincsenek metódusaik). A konstruktor hívása elején történik meg a struktúra tagjainak az inicializálása. Ha osztály típusúak, azokon is meghívódik a megfelelő alapértelmezett konstruktor ekkor, kivéve, hogyha inicializációs listába tettük be őket. Az alapértelmezett elemi típusok, mutatók és tömbök nem incializálódnak maguktól. Ezeket a C típusokat nevezik a C++ terminológiában plain old type-nak (egyszerű régi típus).
Másoló konstruktor
A másoló konstruktor akkor hívódik, amikor egy új objektumot hozunk létre egy meglévő alapján. Bővítsük ki a Vektor
osztályunkat egy másoló konstruktorral:
Megadása
struct Vektor { double x,y,z; //... Vektor(const Vektor &ebbol) { x = ebbol.x; y = ebbol.y; z = ebbol.z; } }
Használata
A másoló konstruktor feladata, hogy egy meglévő objektum mezőit egyéb másoláskor elvégzendő feladatot elvégezzen. Amint a példán is látszik, ez egy, az osztállyal azonos típusú, objektumot kér paraméterként (a rá mutató referenciát). Ez a másoló konstruktor kezdő-értékadáskor hívódik. Tehát, amikor egy változónak kezdőértéket adunk, vagy amikor egy függvénynek átadjuk az értékét.
Ha nem adunk meg másoló konstruktort, akkor is lesz másoló konstruktor, ami annyit csinál, hogy az objektum összes mezőjét másolja (a másolókonstruktoraik segítségével). Ez is szintén a C-vel kompatibilitás miatt van, mert ott a struktúrák értékadása úgy működik, hogy minden mezőt másolunk.
void valami(Vektor x) { //... } //... { Vektor a(1, 2, 3); Vektor b = a; // Másoló konstruktor hívódik. Vektor c(a); // Ilyenkor is (explicit meghívjuk) valami(a); // A függvény x paraméterének kezdőértékadása szintén a másoló konstruktor segítéségével történik. } //...
Egyparaméteres konstruktorok
A másoló konstruktor általánosítása az egyparaméteres konstruktor, vagy típuskényszerítő konstruktor. Lássuk egy példán, hogy mit is jelent ez:
class Foo { int _x; public: Foo(int x) {_x = x;} }; void valami(Foo &f) { //... } int main() { Foo f1(123); // explicit meghívjuk a Foo(int) konstruktort. Foo f2 = 123; // itt is meghívjuk. valami(345); // Szintén implicit módon meghívódik ez a konstruktor. }
Látható, hogy a másoló konstruktorhoz hasonlóan más típusokra is működik az előbb említett logika. Ez tulajdonképpen a típuskonvertáló operátor fordítottja.
Explicit konstruktorok
Gyakori, hogy nem szeretnénk, hogy ez a konstruktor implicit módon hívódjon, mikor nem kéne.
Így egy explicit
kulcsszóval megadályozhatjuk, hogy a fordító meghívja nekünk.
Érdemes minden konverziós konstruktort explicitre megadni, hogy ne zavarjuk össze másokat, akik a kódunkkat olvassák:
class Foo { int _x; public: explicit Foo(int x) {_x = x;} }; void valami(Foo &f) { //... } int main() { Foo f1(123); // explicit megadás, ez jó. Foo f2 = 123; // implicit megadás, ez most hibát okoz. valami(Foo(345)); // Függvényhívás csak explicit konstruktorhívás után lehetséges. valami((Foo)345); // Vagy típuskényszerítéses formájában. }
Többparaméteres konstruktor is lehet explicit, ha az első paraméterén kívül a többi paraméterének alapértelmezett értéke van.
A destruktorok
A destruktor feladata az, hogy felszabadítsa az objektumhoz rendelt erőforrásokat, mielőtt maga az objektum megsemmisül. Ha ez nem történne meg, akkor a lefoglalt erőforrások továbbra is le lennének foglalva (és kész a memóriaszivárgás).
Megadása
Egészítsük ki a Vektor
osztályunkat destruktorral:
struct Vektor { //... ~Vektor() { } }
A destruktor neve is megegyezik az osztályéval, csak egy ~ jel van előtte, és soha nincsen egy paramétere sem. Mivel a vektor osztályunknak semmi extra felszabadítási dolga nincs, ezért a destruktora üres. Mivel a destruktor paraméter nélküli, ezért csak egyetlen egy darab lehet belőle.
Ha nem adunk meg destruktort, akkor van egy alapértelmezett destruktor, amely nem csinál semmit.
Használata
A destruktor akkor hívódik, amikor az adott objektumnak meg kell semmisülnie, pl:
//... { Vektor a; //... } //< A blokkból való kilépés előtt az 'a' változó megsemmisül, a destruktora hívódik. //...
Az = operátor
Az = operátor feladata, hogy egy változó értékét megsemmisítse, és felülírja a jobboldalán álló kifejezés értékével. Azaz ez lényegében egy destruktor hívás, majd egy új objektum beállítása a másoló konstruktorral.
A mi vektorunk esetén az = operátor lényegében megegyezik a másoló konstruktorral, csak az
= operátornak ugye vissza kell térnie az értékadás baloldalán álló dolog lvalue referenciájával.
Mivel az operátor metódusok esetén az első argumentum mindig a this
, ezért
*this
lesz az az lvalue referencia, amely nekünk kell.
struct Vektor { double x, y, z; Vektor &operator=(const Vektor &b) { if (this == &b) return *this; x = b.x; y = b.y; z = b.z; return *this; } };
Mikor = operátorokból írunk saját verziót, figyeljünk oda az önértékadásra. Ugyanis, ha az értékadás két oldalán ugyanaz a dolog áll, akkor a régi megsemmisítése után a megsemmisített érvénytelen objektumból akarnánk másolni, ami nem jó, ezért mindig az = operátor első sora legyen egy ellenőrzés, az önértékadásra, és ezután jön minden más.
Ha nincs = operátor megadva, akkor van alapértelmezett = operátor, amely szépen a mezőket egyenként másolgatja (ha azok is objektumok, akkor alkalmazza rájuk a = operátort, ha definiálva van).
A beépített típusok konstruktorai
A C++-ban a beépített típusoknak is vannak konstruktorai. A következő példán néhány adattípus alapértelmezett konstruktorát hívjuk:
#include <stdio.h> int main() { typedef void *PVoid; printf("%g\n", double()); // 0 printf("%p\n", PVoid()); // NULL printf("%d\n", bool()); // false }
Számok esetén az alapértelmezett érték a 0, mutatók esetén a NULL, logikai esetén pedig a false, enumok esetén pedig a 0 értékű elem.
Szintén van ezeknek a típusoknak egyparaméteres konstruktorai, amelyek a konvertálásokat végzik:
#include <stdio.h> int main() { typedef void *PVoid; printf("%.3f\n", double(123)); // 123.000 printf("%p\n", PVoid(12)); // 12. memóriacímre mutat printf("%d\n", bool(1)); // true printf("%d\n", int(123.456)); // 123 }
Ezt a formát nevezik függvénystílusú típuskényszerítésnek is.
A hármas szabály
Az esetek egy részében az alapértelmezett másoló konstruktor, = operátor és destruktor megfelel nekünk,
mert semmi mást nem kell csinálni, egyszerű másoláson kívül, illetve a megsemmisítés előtt se kell
semmi extra lépést tennünk. Ilyen objektumra példa az előbb említett Vektor
osztály.
Viszont, ha szükségünk van például destruktorra, akkor arra azért van szükségünk, mert az osztályunk tartalmaz valami olyan erőforrást, amit külön fel kell szabadítunk, ha ez így van, akkor szükségünk van másoló konstruktorra is, amely az másolatban is lefoglalja azt az erőforrást, és szükségünk van az = operátorra is, hiszen előbb meg kell semmisíteni az objektumot, majd a másoló konstruktor alapján újra létrehozni azt.
Szintén ugyanez elmondható a másik két dologról is: ha úgy érezzük, hogy másoló konstruktorra van szükségünk, akkor az azért van, mert valamelyik mező speciális kezelést igényel, és nem lehet csak úgy másolni, akkor ugyanúgy kell az = operátor is, meg valószínűleg a destruktor is. Ha úgy érezzük, hogy saját = operátor kell, akkor megint csak ugyanitt vagyunk.
Ezt nevezzük a hármas szabálynak: bármelyik is kell a következő három közül:
- Destruktor
- Másoló konstruktor
- = operátor
Akkor meg kell írnunk a másik kettőt is.
Egy példa:
struct Foo { int *eroforras; Foo() { letrehoz(); } Foo(const Foo &from) { letrehoz_ebbol(from); } ~Foo() { megsemmisit(); } Foo &operator=(const Foo &ebbol) { if (this == &ebbol) return *this; megsemmisit(); letrehoz_ebbol(ebbol); return *this; } private: void letrehoz() { eroforras = new int[100]; } void letrehoz_ebbol(const Foo &ebbol) { letrehoz(); memcpy(eroforras, ebbol.eroforras, sizeof(int[100])); } void megsemmisit() { delete[] eroforras; } };
Ez tartalmaz egy két dolgot, amelyről nem esett még szó, de a lényeg valószínűleg érthető.
Hozzáférés szabályozása
Alapértelmezésben egy struktúrának az összes mezője kívülről elérhető és átírható, mint azt egy korábbi példában mutattuk. Van, hogy nem szeretnénk valamiről, hogy elérhető legyen kívülről, ez megoldható.
Megadása
Ha valamit el szeretnénk rejteni kívülállóktól, azt tegyük egy private szekcióba.
A szekció végét jelezhetjük egy újabb public
szekció bevezetésével.
Példa:
struct Akarmi { //... Az itteni dolgokat elérik kívülről. private: //... Az itteni dolgokat nem lehet elérni kívülről public: //... Az itteni dolgokat elérik kívülről private: //... Az itteni dolgokat nem lehet elérni kívülről public: //... Az itteni dolgokat elérik kívülről };
A privát dolgokat csak az osztály metódusai érik el (még más azonos típusú példányokon is elérhetik, ha kell). A publikus dolgokat mindenki eléri.
A létrehozást, másolást és megsemmisítés lehetőségét is korlátozhatjuk, ha a konstruktorokat, = operátort vagy a destruktort priváttá tesszük.
Van egy harmadik hozzáférés módosító is, ez a protected
, de erről majd később, az öröklésnél lesz majd szó.
Osztályok és struktúrák
A struktúrák alapértelmezett hozzáférése publikus, de van egy másik is, a class
melynél az alapértelmezett hozzáférés privát.
Minden ugyanúgy van, mint a struct
-nál csak éppen class
-t írunk helyette.
class Akarmi { //... Az itteni dolgokat nem lehet elérni kívülről - privát az alapértelmezett! public: //... Az itteni dolgokat elérik kívülről. private: //... Az itteni dolgokat nem lehet elérni kívülről public: //... Az itteni dolgokat elérik kívülről private: //... Az itteni dolgokat nem lehet elérni kívülről public: //... Az itteni dolgokat elérik kívülről };
Az osztályok megadásához a class
-t szoktuk használni.
Csak azért használtam eddig struct
-ot, mert nem akartam idő előtt összezavarni az olvasót.
Barát függvények és osztályok
Előfordulhat, hogy egy-egy osztálynak esetleg függvénynek kizárolagos hozzáférést kell adnunk a privát tagokhoz.
Erre használatos a friend
deklaráció az osztályokban:
class Akarmi { friend MasikAkarmi; // Ezen osztály tagjai elérik a privátokat; friend int foo(); // Ez a függvény eléri a privátokat friend HarmadikAkarmi::metódus(int x, int y); // Ez a metódus eléri a privátokat. //... };
Ezt főleg debugolásra szokás használni.
Futásközbeli memóriafoglalás és felszabadítás C++-ban
C-ben malloc
-cal és free
-vel lehet memóriát kérni és felszabadítani futásidőben.
C++-ban van arra mód, hogy konstruktorhívással és destruktorhívással egybekössük a lefoglalást és a felszabadítást.
A new
operátor
A C++-ban a new
oprátorral foglalhatunk memóriát:
Vektor *futasKozbenFoglalt = new Vektor;
Ez automatikusan lefoglalja a területet, és hívja is a Vektor
osztály alapértelmezett konstruktorát.
A kifejezés típusa pedig Vektor*
lesz. (A C malloc-ával együtt nem lehet konstruktort is hívni.)
Természetesen paraméteres konstruktor is hívható, így:
Vektor *futasKozbenFoglalt = new Vektor(3, 4, 5);
Tömbök allokálása történhet így:
Vektor *vektorTomb = new Vektor[100];
Ekkor lefoglal 100db Vektor
példánynak memóriát, és mindegyiket inicializálja az alapértelmezett konstruktorral.
Ez a new
operátor egy másféle, ez a tömb allokáló new[]
operátor.
Ha a memória allokálás sikertelen, a program std::bad_alloc
kivételt dob (a kivételekről később).
Tehát nem kell allokálás után ellenőrizni, hogy NULL-e az eredmény, mint malloc
esetében.
A delete
opertátor
Ha nincs szükségünk a lefoglalt memóriára, akkor a delete
operátorral lehet azt törölni:
delete futasKozbenFoglalt;
Ez először meghívja a destruktort a blokkon, majd felszabadítja azt.
Tömbök felszabadításához egy másik operátort kell használni:
delete[] vektorTomb;
Ez a tömb összes elemén meghívja a destruktort majd felszabadítja a tömböt.
Ügyeljünk arra, hogy a new[]
operátorral lefoglalt tömböket a delete[]
operátorral,
a new
operátorral lefoglalt tömböket a delete
operátorral szabadítsuk fel, különben progblémák lehetnek.
A C free
függvényéhez hasonlóan delete
és delete[]
előtt nem kell ellenőrizni, hogy a mutató NULL-e. Az operátor nem fogja NULL-ra állítani a mutatót a törlés után, ezt
nekünk kell megtenni.
Öröklés
Az öröklés az általános és a speciális osztály között létesít kapcsolatot. Az általános osztály lehet mondjuk az állat, míg a speciális osztály lehet mondjuk a kutya. Nyilvánvaló, hogy a kutya rendelkezik mindazon tulajdonságokkal, mint az állat, és még ugatni is tud. Az állatok általános tulajdonságai az állat osztályba, míg a kutyára vonatkozó speciális tulajdonságok a kutya osztályba kerülnek.
Megadása
Nézzük meg, hogy ez hogyan működik szintaktikailag:
class Allat { // Állat mezői }; class Kutya : public Allat { // Kutya mezői. Viszont megkapja az Allat mezőit is. };
A lényeges rész a Kutya
osztály neve után írt dolog.
Az azt jelenti, hogy az Allat osztály mindenét örökli, azaz az összes mező és metódus, amely
az Allat
osztályban benne volt, elérhető lesz a Kutya
osztályban is, és ehhez jönnek még a Kutya
saját mezői és metódusai.
A public
szó az ősosztály neve előtt azt jelenti, hogy ez az
öröklés látható a világ számára, és felhasználható.
Ha ez private
(az alapértelmezett), akkor
az öröklés nem látható a világ számára, az osztály ugyan örökli az összes tulajdonságot, de az kívülről
nem látható, így a külvilág számára olyan, mintha nem is lenne öröklés,
a leszármazott osztályon keresztül nem érhetőek el az ősosztály funkciói sem.
A leszármazott osztályt szokás alosztálynak, míg azt az osztályt, amelyből örökölnek, szokás ősosztálynak nevezni.
Az öröklés két osztály osztály között „... egy ...” (angolul is a)kapcsolatot hoz létre. A fenti példával élve: „A kutya egy állat”.
Típuskompatibilitás az ős és alosztályok között
Ősosztályra mutató típusú változóba szabadon beletehetünk bármilyen leszármazott osztály típusú adatot:
Allat *allat = new Kutya;
Hiszen a kutya osztály is tudja mindazt, amit egy állat is tud, mivel ezeket a tulajdonságokat örökölte tőle. Illetve, ahogy előbb mondtuk: A kutya egy állat.
Láthatjuk, hogy a mutató típusa és a mutatott objektum típusa különbözik. A változó típusa az úgynevezett statikus típus,
míg a benne tárolt objektum típusa az ún. dinamikus típus.
A fenti esetben a statikus típus az Allat
a dinamikus pedig a Kutya
.
Nyilván a dolog fordítva nem működik, mert az állat osztály nem tudja azokat a dolgokat, amit egy kutya tud (csak az általános minden állatra vonatkozó dolgokat tudja elvégezni.)
Konstruktorok és destruktorok és az öröklés
Ha létrehozzuk egy leszármazott osztály példányát, akkor a konstruktorában incializálhatjuk azt, viszont az ősosztályt is inicializálni kell, még ez előtt. Ha a leszármazott osztály destruktora meghívódik, akkor az utána az ősosztály destruktorát is le kell futtatni.
Ősosztály konstruktorának hívása
A következőképpen kell az ősosztály konstruktorát meghívni a leszármazottból:
class Alap { //... public: Alap(int x) { //... } } class Leszarmazott : public Alap { //... public: Leszarmazott() : Alap(123) { //... } }
Azaz a leszármazott osztály konstruktorának a fejlécében egy kettőspont után írva kell az ősosztály konstruktorának a hívását elvégezni. Ha ez elmarad, akkor az ősosztály paraméter nélküli konstruktora lesz meghívva (ha az elérhető). Ezeket a kettőspont utáni dolgokat a konstruktorban taginicializációs listának nevezzük, de erről majd később.
Ősosztály destruktorának hívása
Itt különösebb dolgunk nincs. Az ősosztályok destruktora is meg lesz hívva miután a destruktor lefutott.
A protected
hozzáférés módosító
Örökléskor van szerepe ennek az hozzáférés módosítónak. Azt jelenti, hogy az adott mező elérhető a leszármazott osztályokból is, de a külvilág számára továbbra is elérhetetlen. Példa:
class Alap { //... protected: int v; private: int p; } class Leszarmazott : public Alap { //... void foo(Leszarmazott &l, Alap &a) { v = 33; // this->v-t tehát Leszarmazott::v-t érjük el, ilyet lehet. l.v = 34; // Leszarmazott::v-t érjük el, ilyet lehet. a.v = 666; // Alap::v-t akarnánk elérni, ilyet nem lehet. p = 666; // p privát az ősosztályban, hiába örököltük, elérni nem tudjuk. } }
Láthatjuk, hogy az ősosztályban megadott v
változót elérjhetjük a leszármazott osztályban,
még más azonos típusú példányokon is. Viszont hiába örököltünk az Alap
osztályból, számára mi
a külvilág vagyunk, így az ősosztály mezőit egy ősosztály statikus típusú változón keresztül nem érhetjük el (és más leszármazottjain keresztül sem).
A privátokat a leszármazott osztály sem éri el.
Hasonlóan az öröklés módja is lehet protected
ilyenkor a külvilág nem tud az öröklésről, de a leszármazottak igen.
De ennek semmi gyakorlati hasznát nem láttam eddig.
Töbszörös ősosztály
Nem csak egy darab osztálytól örökölhetünk, hanem többtől is, ilyenkor több őst sorolunk fel megadáskor. Példa:
class Alap1 { }; class Alap2 { }; class Leszarmazott : public Alap1, public Alap2 { };
Ilyenkor mind a két osztály összes mezőjét és metódusát örökli. Konstruktorhíváskor mind a két ősosztály konstruktora lefut. Destruktorhíváskor pedig mind a kettőnek le fog futni a destruktora is.
Objektumok tagjainak az elérése teljes névvel
Előfordulhat, hogy nem egyértelmű, amikor egy objektum tagját elérjük, ilyen előfordulhat akkor, amikor az ősosztályokban és a leszármazottakban is azonos nevű tag van. A megoldás az, hogy kiírjuk, hogy melyik osztály mezőjére gondoltunk:
class A1 { public: int x; }; class A2 { public: int x; }; class B : public A1, public A2 { public: int x; }; int main() { B b; b.B::x = 34; b.A1::x = 34; b.A2::x = 34; }
Azaz a pont után a mező neve előtt odaírjuk az osztály nevét is. Persze ezzel a gyakorlatban nem sűrűn találkozunk, meg amúgy se jó ötlet azonos névvel szívatni magunkat és másokat...
Virtuális ősosztályok
Tegyük fel, hogy adott a következő szituáció:
class A { }; class B1: public A { }; class B2: public A { }; class C: public B1, public B2 { };
A B1
és B2
is örökli az A
mindenét, és
C
örökli B1
és B2
mindenét, amelyben már benne van az A
kétszer.
Ha nem szeretnénk, hogy az A
kétszer legyen benne a C
-ben, akkor virtuális ősosztályt kell használni.
Ez annyiból áll, hogy az örökléskor oda kell írni, hogy virtual
:
class A { }; class B1: virtual public A { }; class B2: virtual public A { }; class C: public B1, public B2 { };
A virtuális ősosztály úgy viselkedik, mintha a legkésőbbi leszármazott közvetlenül örökölte volna, míg nem virtuális ősök lényegében nem öröklik azt.
Tehát az előző tulajdonképpen ekvivalens ezzel, amikor a C
-t használjuk:
class C: public B1, public B2, public A { };
Tehát a C
közvetlenül örökli az A
-t, míg a B1
és a B2
nem örököl semmit.
Még a konstruktorhívás felelőssége is a C
osztályon van. Így kaphatunk rejtélyes hibákat, amikor az A
osztály
nem biztosít paraméter nélküli konstruktort.
Ez a virtuálisan öröklős dolog a gyakorlatban ritkán kell.
Polimorfizmus
A polimorfizmus segítségével megoldhatjuk, hogy egy osztály leszármazottai máshogy működjenek, mint az ősosztály.
Megadása
A polimorfizmust virtuális metódusokkal valósíthatjuk meg:
Példa:
#include <stdio.h> class A { public: virtual void kiir() {printf("A\n");} }; class B1 : public A { public: void kiir() {printf("B1\n");} }; class B2 : public A { public: void kiir() {printf("B2\n");} }; class B3 : public A { public: void kiir() {printf("B3\n");} }; int main(void) { A *a1 = new A(); A *a2 = new B1(); A *a3 = new B2(); A *a4 = new B3(); a1->kiir(); a2->kiir(); a3->kiir(); a4->kiir(); }
Tehát van egy ősosztályunk, melyből leszármazik 3 másik. Az érdekes rész a main
függvényben van. Létrehozunk mindegyikből egy-egy példányt, és
egy A
típusa mutató változóba tesszük be őket. Majd sorban az összes változón meghívjuk a kiir
függvényt. Ezt írja majd ki:
A B1 B2 B3
Pedig A
típusú objektumon hívtuk meg ezeket a függvényeket, tehát 4db A-t kellene látnunk, de nem ez a helyzet.
Ez azért van, mert a kiir
függvény virtuális, és a futtatandó függvény címe futási időben dől el az objektum
létrehozásakor.
Tehát ha egyszer egy ősosztályban virtuálisként hoztunk létre egy függvényt, akkor azt az a leszármazottakban egy azonos nevű és paraméterezésű függvényben felülbírálhatjuk,
így futási időben a dinamikus típusnak megfelelő függvény fog lefutni. Ha egy metódus nem virtuális, akkor ez a dolog nem működik, és akkor a statikus típusnak
megfelelő függvény fut le (ki lehet ezt próbálni úgy, hogy levesszük a virtual
szót a függvényről).
Implementációja
A virtuális metódusokat virtuális metódus táblában tárolják, melynek a címe az objektum létrehozása után lesz beállítva, emiatt a konstruktorokban nem működik a polimorfizmus. Ennek az egésznek a működését fel lehet fogni valahogy így:
#include <stdio.h> struct vtable { void (*kiir)(void *pThis); }; void kiir_A(void *pThis) {printf("A\n");} void kiir_B1(void *pThis) {printf("B1\n");} void kiir_B2(void *pThis) {printf("B2\n");} void kiir_B3(void *pThis) {printf("B3\n");} static vtable vmt_A = {kiir_A}; static vtable vmt_B1 = {kiir_B1}; static vtable vmt_B2 = {kiir_B2}; static vtable vmt_B3 = {kiir_B3}; class A { public: vtable *vmt; A() { vmt = &vmt_A; } }; class B1 : public A { public: B1() { vmt = &vmt_B1; } }; class B2 : public A { public: B2() { vmt = &vmt_B2; } }; class B3 : public A { public: B3() { vmt = &vmt_B3; } }; int main(void) { A *a1 = new A(); A *a2 = new B1(); A *a3 = new B2(); A *a4 = new B3(); a1->vmt->kiir(a1); a2->vmt->kiir(a2); a3->vmt->kiir(a3); a4->vmt->kiir(a4); }
Azaz egy függvénymutatókat tartalmazó rejtett struktúra címét állítja be a fordító a konstruktor végén.
Utána a virtuális metódusokat ezen a mutatón keresztül hívja meg.
Így működik a polimorfizmus. Persze mindezt megcsinálja a fordító, nekünk csak a virtual
kulcsszót kell kiírni.
A nem virtuális függvények nem kerülnek ilyen struktúrába, így a statikus típus szerint hívódik.
Absztrakt osztályok
Az absztrakt osztályok olyan osztályok, amelyek tartalmaznak nyers virtuális metódust (pure virtual method). Azaz olyat, ami nincs implementálva, az = 0
jelöléssel tehetünk egy virtuális metódust nyerssé.
class A { public: virtual void kiir() = 0; };
Nyers virtuális metódust tartalmazó osztály nem példányosítható (tehát nem hozhatunk létre példányt a new
-val, és közvetlenül se deklarálhatunk olyan típusú változót, csak rámutató pointert).
Ha mégis megpróbáljuk, akkor fordítási hibát kapunk. Írni kell egy osztályt, amely örökli az absztrakt osztályt, és felülbírálja az adott virtuális metódust,
amint minden virtuális metódus normális virtuális metódus lesz, és nem nyers, akkor az osztály példányosítható lesz.
Az általános és a speciális objektumosztályok közül az általános változat gyakran absztrakt osztály. (A kutya – állat példánkban az állat az általános).
Virtuális destruktorok
Írjuk bele a korábbi példa main
függvényébe, hogy felszabadítsuk a memóriát, ezt egyyszerűen megtehetjük a delete
operátorral:
int main(void) { A *a1 = new A(); A *a2 = new B1(); A *a3 = new B2(); A *a4 = new B3(); a1->kiir(); a2->kiir(); a3->kiir(); a4->kiir(); delete a1; delete a2; delete a3; delete a4; }
Mivel a változók típusa A*
, ezért az A
osztályé fog hívódni, minden esetben. Ez nem feltétlenül az a dolog, amit szeretnénk.
Mi valószínűleg azt szeretnénk, hogy a dinamikus típus alapján a B1
, B2
és B3
osztályok destruktora hívódjon.
Ezt úgy lehet megtenni, hogy virtuális destruktort adunk meg az A
osztályban:
class A { public: virtual void kiir() {printf("A\n");} virtual ~A() {} };
Mivel minden osztályban van destruktor (ha nem adjuk meg, akkor implicit van), ezért ha azt valahol virtuálisnak adjuk, meg akkor a leszármazottak rendre felülbírálják azt.
Így virtuális metódus táblán keresztül hívódnak, és ekkor a fenti négy delete
meg fogja hívni a megfelelő destruktorokat, így a felszabadítás megfelelő lesz.
Általános szabály, hogy polimorf osztályban (azon osztályok, melyek tartalmaznak virtuális metódust), mindig virtuális legyen a destruktor.
Ideiglenes objektumok
Van arra is lehetőségünk, hogy rövid életű objektumokat hozzunk létre, amelyek csupán egy kifejezés erejéig léteznek. Ezt úgy tehetjük meg, hogy az osztály nevét és zárójelben a konstruktor paramétereit írjuk be, ezzel létrehozva egy ideiglenes objektumot. Például így adhatunk össze kettő vektort, anélkül, hogy előbb változóba írnánk őket:
//... Vektor eredmeny = Vektor(1, 2, 3) + Vektor(3, 2, 1); //...
Ezzel a szintaxissal csupán a számolás erejéig létrejön a két Vektor
, amelyet a korábban általunk megadott + operátor segítségével összeadunk,
így az eredmeny
vektor (4; 4; 4) lesz.
A számolás után (a ;
után), már hívódik is rájuk a destruktor és meg is szűnnek létezni.
Ezeket a rövid életű objektumokat nevezzük ideiglenes objektumoknak.
Statikus metódusok, mezők és belső típusok
Ahogy korábban írtuk, azt osztály tulajdonképpen egy névtere a benne lévő dolgoknak, ily módon deklarálhatunk benne függvényeket, változókat és még típusokat is, akár csak egy névtérben. Csak a névtérrel ellentétben (ahol minden elérhető) rájuk vonatkoznak a hozzáférés módosítók is. Tehát egy statikus dolog is lehet privát vagy védett.
Statikus függvények
A statikus függvény egy olyan függvény, amely nem egy adott objektum példánnyal dolgozik, hanem csak valami módon szemantikailag tartozik a objektumhoz, ezeket nevezik osztályszintű metódusoknak is.
Megadása
A static
kulcsszót kell a metódus neve elé írni, példa:
class Vektor { public: //... static Vektor leker123Vektort() {return Vektor(1, 2, 3);} //... };
Természetesen ugyanaz ér itt is, mint a sima metódusoknál: ha a osztály zárójelén belül kifejtjük a
metódust, akkor az olyan, mintha inline
lenne, így nem lesz külső linkelése,
ha meg nem fejtjük, csak a .cpp modulban, akkor pedig rendes függvény lesz.
Meghívása
A statikus függvények úgy hívhatók meg, mint a névtérbeli függvények. Ezek nem példányokkal dolgoznak, így példányon nem is lehet őket meghívni. Példa:
Vektor _123Vektor = Vektor::leker123Vektort();
Statikus mezők
A statikus mezők is lényegében névtérként használják csak az osztályt.
Megadásuk
Megadásuk olyan, mint bármilyen más mezőé, csak elé kell írni egy static kulcsszót:
class Vektor { public: //... static Vektor nullVektor; //... }
A statikus mező megadása deklaráció csak, tehát olyan, mint egy extern
megadás.
Ahhoz, hogy ne kapjunk linkelési hibát, valahol valamelyik forrásfájlban definiálnunk kell a mezőt.
Ezt például így lehet:
Vektor Vektor::nullVektor(0, 0, 0);
Azaz, mint egy globális változó megadása, csak névteres formában. Konstruktorhívás és minden egyéb a szokásos.
Használatuk
Mintha névtérben lenne:
Vektor::nullVektor
Belső típusok
Az osztály zárójelén belül tetszőleges típusmegadás lehet, akár egy belső osztályt is megadhatunk, ha gondolunk. Ezeket úgy érhetjük el, mintha névtérben lenne. Példa:
class Akarmi { public: enum Evszak { TAVASZ, NYAR, OSZ, TEL }; //... }
Ezt a típust úgy érjük el, mintha névtérben lenne: Akarmi::Evszak
.
Osztálysablonok
A korábban említett függvénysablonokhoz hasonlóan léteznek osztálysablonok is. Ezeket az osztálysablonokat általában adatszerkezetekhez használják: amelyek minden esetben ugyanúgy működnek, csak éppen a bennük tárolt adat típusa más.
Megadás
Példa, a szintaxissal együtt:
template <class T> class Verem { T *adatok; public: Verem(); ~Verem(); void betesz(const T &adat); int elemszam(); bool kivesz(T *adat); };
Ez például egy verem adatszerkezetnek jó lesz.
Használata
Típusnév után hegyes zárójelbe beírjuk a sablonparaméternek használt típust:
Verem<int> intVerem;
Ezt, amikor meglátja, a fordító, a sablon alapján megírja az osztályt, és használja.
A példában egy int
típusú adatot tartalmazó vermet hozunk létre.
Természetesen itt is alkalmazhatók az érték szerinti sablonparaméterek, és több paraméter is lehet belőlük, és lehet sablon specializációt is csinálni, ahogyan korábban is írtam már róla.
Alapértelmezett sablonparaméterek
Az alapértelmezett függvényparaméterekhez hasonlóan lehetnek alapértelmezett sablonparamétereink is, viszont ez csak osztálysablonoknál működik,
függvénysablonoknál nem. Vegyük példaként az előző sablont úgy, hogy az alapértelmezett az int
:
template <class T = int> class Verem { T *adatok; public: void betesz(const T &adat); int elemszam(); bool kivesz(T *adat); }; int main() { Verem<> intVerem; Verem<double> doubleVerem; }
Alapértelmezésnek vettük a az int
típust, így ha nem írjuk oda, akkor azt veszi alapértelmezésnek.
Ha az összes paraméter alapértelmezését akarjuk használni, akkor is ki kell írni a hegyes zárójeleket.
Ez a dolog akkor hasznos, hogy ha sok paramétere van egy sablonnak, és ritkán kell az alapértelmezetten kívül mást használni.
Osztálysablon specializáció
A függvényeknél említett sablonspecializáció, működik osztályokra is. A részleges sablonspecializáció is működik osztályokra.
A typename
használata
Ritkán használatos, akkor kell használni, ha sablonparamétertől függő típust akarunk használni egy másik sablonban. Gyakorlatban szinte sose találkozik ilyennel az ember, példa a használatra:
struct X { typedef int int_type; }; template<class Stuff> struct Y { typedef typename Stuff::int_type it; }; int main() { Y<X> zizzz; return 0; }
A példában az Y
sablonban használtuk a typename
kulcsszót, jelezve, hogy az egy típus lesz majd,
és nem valami más (mondjuk egy statikus értékmező).
Tag inicializációs lista
Ez egy lista a konstruktorban, ahol a mezőket inicializálhatjuk, ezen a helyen szoktuk hívni az ősosztály konstruktorait is.
Lássuk ezt egy példán a Vektor
osztályunkkal:
class Vektor { public: double x,y,z; Vektor() : x(0), y(0), z(0) { } }
Ez beállítja az x
, y
és z
értékét 0-ra.
Azonban van különbség a konstruktor belsejében való hagyományos x = 0
értékadáshoz képest.
Ez ugyanis a másoló konstruktor segítségével inicializál, míg az értékadás a korábbi érték megsemmisítésével teszi ezt.
Alap típusoknál és mutatóknál ennek nincs sok jelentősége, de ha objektumokat tartalmaz az osztály, akkor azt érdemes
az inicializációs listában inicializálni, különben az történik, hogy az alapértelmezett konstruktor hívódik, majd az = operátor
használatakor a jobb oldalán létrejön egy ideiglenes objektum, amely belemásolódik, aztán megsemmisül. Ez sok felesleges lépést
jelent. Sőt, ha az inicializálandó objektumnak nincs paraméter nélküli konstruktora, akkor máshogy nem is lehet inicializálni az objektumot.
Ez a helyzet referencia típusú objektumoknál is, azok is csak az inicializációs listában inicializálhatók.
Az inicializáció sorrendje, függetlenül attól, hogy milyen sorrendben soroltuk fel őket, megegyezik az osztályban a mezők sorrendjével.
Metódusmutatók
Ahogy léteznek függvénymutatók, úgy léteznek metódusmutatók is. Megadásuk hasonló a függvénymutatóéhoz, csak meg kell adni, hogy mely osztály metódusára mutathat.
Megadása
A következő példán láthatjuk, hogy hogyan lehet ilyen megadni:
double (Vektor::*vektorFuggvenyMutato)();
Ezzel deklarálunk egy vektorFuggvenyMutato
nevű változót, amelyben a Vektor
osztály metódusaira mutató címeket lehet tárolni.
Használata
Értéket így adunk neki:
vektorFuggvenyMutato = &Vektor::hossz;
Azaz a Vektor
osztály metódusainak a címeit tárolhatjuk benne. Itt a függvénymutatókkal ellentétben kötelező a &.
Ezután így hívhatjuk meg a metódus egy példány segítségével:
Vektor vektor(1, 2, 3); Vektor *pVektor = &vektor; double akarmi = (vektor.*vektorFuggvenyMutato)(); double akarmi2 = (pVektor->*vektorFuggvenyMutato)();
A zárójel kötelező itt, mivel a ezeknek az operátoroknak a precedenciája gyengébb, mint a függvényhívás (nem tudom, hogy miért).
A .*
operátor az, amelyikkel egy objektumpéldányon meg lehet hívni a tárolt metódusmutatót.
A ->*
operátor az, amelyikkel egy objektummutatón meg lehet hívni a tárolt metódusmutatót.
Ezt a metódusmutatót azért hagytam a végére, mert nem látom értelmét, hogy mire lenne egyáltalán jó...
C++ stílusú típuskonverzió
C-ben a típuskényszerítés módja az, hogy a kifejezés elé zárójelben odaírjuk, hogy mivé szeretnénk kényszeríteni az adott típust. Ezzel elég sok dolgot meg lehet csinálni, többek között potenciálisan olyan dolgokat, amelyek nem biztonságosak. Ezért vannak a C++-ban más típuskényszerítő operátorok, amely használata jobban korlátozva van, mint a C stílusú típuskényszerítésé. A következőkben felsorolom ezeket az operátorokat:
static_cast
Ezzel a következő dolgok lehetségesek:
- Lefelé kasztolás
-
Ha a
D
osztály egy leszármazottja aB
-nek, akkor a B-re való mutatók és referenciák a D-re mutatóra alakíthatók vele:static_cast<D*>(&b)
, illetvestatic_cast<D&>(b)
, aholb
a B osztály egy példánya. Tényleges konvertálás nem történik, csak a mutatót értelmezi úgy, hogy más típusra mutat. - Minden implicit típuskényszerítés
-
A numerikus típusok között implicit típuskényszerítés van, ezek általában gond nélkül elvégződnek maguktól is, néha figyelmeztetést ír a fordító rájuk.
Pl.
static_cast<int>(123.456)
. Azaz egy lebegőpontos szám egésszé alakítása. Természetesen sok más implicit típuskényszerítés van: constossá alakítás, lvalue-ből rvalue, tömbből mutató, mutató void*-gá stb. Ezek mind static cast-tal is elvégezhetők. - Egyparaméteres konstruktorok hívása
-
Ha
T
egy osztály, és tegyük fel, hogy van egy egyparaméteresT(X &x)
konstruktora, akkor astatic_cast<T>(x)
konverzió meg fogja hívni az még akkor is, hogy ha explicitként van megadva (x az X típus egy példánya). - A
void
típusra való konvertálás - Bármit lehet
void
típusra konvertálni ezzel. - Egész számok enummá való konvertálása
-
Ha
E
egy enum típus, akkor astatic_cast<Evszak>(i)
, aholi
egy egész szám, elvégezhető. Azi
értékének megfelelő elem lesz az művelet eredménye. - A
void*
mutató konvertálása - A
void*
bármilyen másik mutatóvá konvertálható astatic_cast
-tal.
const_cast
Ezzel a típuskényszerítési móddal a const
és a volatile
módosítókat lehet levenni egy adattípusról. Például:
const char *cc; char *c = const_cast<char*>(cc);
Általában a konstans adatok nem írható memóriaterületen vannak, szóval lekasztolni a const
-ot róla és írni rá nem biztos, hogy bölcs döntés.
reinterpret_cast
Ezzel lehet durva dolgokat csinálni:
- Két tetszőleges típusra mutató között lehet típust kényszeríteni. (De a
const
ésvolatile
nem vehető le vele). - Mutatók és egész számok közötti típuskényszerítés. Azt nem határozza meg a szabvány, hogy miként történik ez, de azt igen, hogy ha egy mutatót egésszé konvertálunk, majd vissza, akkor ugyanazt a mutatót kell kapnunk. (Gyakorlatban meg azt jelenti, hogy a memóriacímet egyszerűen egészként értelmezi.)
dynamic_cast
Ez a típuskonverzió polimorf osztályok példányaira mutatók és referenciák közötti konverzióra való, ezt a többivel ellentétben a program futási időben végzi el.
Tegyük fel, hogy van egy D
osztályunk, amely a B
osztályt örökli, és B
egy polimorf osztály (tartalmaz virtuális metódust).
Legyen b
egy B*
típusú változó (mutató mely B típusú adatra mutat).
Ekkor a dynamic_cast<D*>(b)
egy D*
-gá konvertálja a b
-t, ha a mutatott objektum dinamikus típusa valóban
egy D
objektumra (vagy annak leszármazottjaira) mutat, ha nem arra mutat, akkor NULL-lal tér vissza.
Referenciákat (lvalue-t) lehet konvertálni vele hasonlóképpen (dynamic_cast<D&>(*b)
), ha konverzió nem sikerül,
akkor std::bad_cast
kivételt dob (Ez a typeinfo
fejlécben van).
Ahhoz, hogy a dinamikus típuskényszerítés működjön, a fordítónak futás közbeni típusinformációt kell generálni (RTTI).
Függvény stílusú típuskényszerítés
Ez lényegében az egyparaméteres konstruktor hívását jelenti. Azaz, ha van egy T
objektumunk, amelynek
van egy T(int)
paraméterezésű konstruktora, akkor ha azt írjuk, hogy T(123)
akkor az
meghívja az egyparaméteres konstruktort, és csinál egy T
típusú objektumot. A (T)123
típuskényszerítés is ezt csinálja.
De a dolog lényege az, hogy még a beépített típusokkal is el lehet játszani ezt: double(123)
double-lé alakítja az
egész számot, akár csak a (double)123
jelölés.
Kivételkezelés
A kivételkezelés a C++-ban a hibakezelésnek egy módja. Mivel alkalmazásakor nehéz követni, hogy a program hol tart, ezért alkalmazása elsősorban kivételes problémák lekezelésére való: nincs elég hely a lemezen, elfogyott a memória, füstöl a számítógép stb.
Kivétel feldobása
Hiba esetén kivételt dobunk fel. Ezt a throw
utasítással lehet.
Ezzel az utasítással tetszőleges típusú objektumot fel lehet dobni, egész számtól kezdve
komplett objektumokon át. Példa:
throw 666;
Ezzel egy int
típusú hibaobjektumot dobtunk fel.
Kivétel elkapása
A kivétel elkapására a try-catch
szerkezet szolgál, lássuk egy példán a működését:
#include <stdio.h> int main() { try { //... throw 666; //... } catch (int x) { printf("int Kivételt dobtak! %d\n", x); } catch (double x) { printf("double Kivételt dobtak! %g\n", x); } catch (...) { printf("Valamilyen kivételt dobtak!\n"); } printf("A program itt folytatódik.\n"); }
A try
blokkban van az a kód, amely hibát generálhat, így a teszt kedvéért oda írtunk egy throw
-ot.
Ha hiba történt, akkor a try
blokkhoz tartozó catch
hiba kezelők közül az hívódik, amely paraméterének a típusa
megfelel a feldobott objektum típusának. Itt nincs különösebb konverzió. A 666 egy int
típusú konstans, hozzá
az int
, int&
, const int
és volatile int
típusok illenek.
Egy int[]
-hez (tömbhöz) illik az int *
és a void *
,
illetve ezek const
-os, volatile
-os és referenciás változatai.
Osztály típus esetén ugyanezek a szabályok érvényesek, csak még megfelel az adott osztály publikus ősosztályainak egyike is.
Szóval semmilyen lényegi konverzió nincs. Nincs int
-ről long
-ra való konverzió sem, és
double
-ra való sem.
Szóval, ha nincs int
-es hibakezelő, akkor a double
-ös se fogja elkapni a hibát.
Az illesztés sorban megy, és az első megfelelő típusú hibakezelő fut le, majd a catch
-ek után folytatódik
a program. A ...
-os hibakezelő minden kivételt elkap, típustól függetlenül, ezért azt mindig utolsónak kell írni (ha feltüntetjük).
Ha nincs megfelelő hibakezelőblokk, amely kezelni tudná feldobott kivételt, akkor a try
utasítás tovább dobja felfelé, hogy esetleg
egy külsőbb kivételkezelőnél (ha több try
-t próbálnánk egymásba ágyazni) lekezelődjön, és így tovább.
Ha a függvényben nincs felsőbb try
utasítás, akkor a függvény kilép
(lokális változók destruktora hívódik, akárcsak return-kor), és a függvény hívása helyén is feldobódik a kivétel, hogy
egy esetleges ottani try
utasításban lekezelődhessen.
Ha nem try
utasításban történik a kivétel feldobása, akkor a függvény rögtön kilép, és a hívás helyén
is feldobódik a kivétel, és így tovább.
Ha a hiba egészen a main
függvényig feljutott, és még ott sincs lekezelve, akkor programunk hibával le fog állni.
Ez azt jelenti, hogy ha kivétel történik, azt mindenképp le kell kezelni, ha nem akarjuk, hogy kilépjen a program.
Fontos tudni, hogy ez a kivétel feldobás eléggé erőforrásigényes, és lassú, így tényleg csak kivételes esetben
használjuk, ahogy korábban is írtam. Ha valami hiba van, amely normális futás közben is előfordulhat, akkor azt kezeljük le másképp
(például visszatérési értékben adjuk vissza a hibát).
Kivétel újra feldobása
ha egy catch blokkban, nem akarjuk teljesen lekezelni a kivételt, van rá mód, hogy újra feldobjuk.
Ezt a catch
blokkban írt throw;
utasítással lehet elkövetni.
#include <stdio.h> int main() { try { //... throw 666; //... } catch (int x) { printf("int Kivételt dobtak! %d\n", x); throw; } catch (double x) { printf("double Kivételt dobtak! %g\n", x); } catch (...) { printf("Valamilyen kivételt dobtak!\n"); } printf("A program itt folytatódik.\n"); }
A példában az elkapott 666-ot egyszerűen újra feldobjuk, és ettől a programunk ki fog lépni. Természetesen nem előírás, hogy az elkapottat dobjuk tovább, feldobhatunk egy másik kivételt is.
Egyéb C++ dolgok
Itt felsorolok pár C++ dolgot, amelyet általában nem szoktunk használni csak jó tudni róla.
Helyi new
operátor
Ezt szokták angolul „placement new”-nek is nevezni. Ezt akkor használjuk, ha egy már lefoglalt memóriaterületen szeretnénk egy objektumot konstruálni. A szintaxisa a következő:
new (mutató) Típus(paraméterek)
Azaz annyi a különbség, hogy az operátor után egy zárójelben meg kell adni egy mutatót, hogy hol szeretnénk objektumot létrehozni. Normál C++ programok esetén ilyen trükközésre általában nincs szükség.
Fontos tudni, hogy ennek az operátornak nincs delete
párja. Ha helyben deinicializálni akarjuk az objektumot,
akkor azt megtehetjük a destruktorának a közvetlen hívásával:
mutato->~Típus();
A helyi new
működik a tömbös formában is, viszont „placement delete[]” operátor sem létezik: hívogassuk meg a destruktorokat
egyesével a tömb elemein és kész.
Azért nincs helyi delete
, mert a tényleges memóriaterületet, amelyre objektumokat pakolunk, hagyományos módon is fel lehet szabadítani
(delete
és delete[]
).
Felülbírált new
operátorok
A new
operátor egy olyan operátor, amelynek tetszőleges számú paramétere lehet. De az első mindig a standard size_t
típus,
amely az allokálandó memóriaterület méretét adja meg. A többi paraméter bármilyen típusú lehet. Például így csinálhatunk egy saját new
operátor
amely mondjuk egy saját memóriakezelő segítségével allokál:
void* operator new(size_t meret, MemoriaKezelo &kezelo) { //... }
Meghívni pedig a placement new-hez hasonlóan lehet:
new (memoriakezeloObjektum) Típus(paraméterek)
A mutable kulcsszó
Osztályokban adhatunk meg mutable
módosítóval adatokat (ez olyan, mint a const
),
ez azt jelenti, hogy az adott osztályban, a mutable
adat még akkor is módosítható, ha az osztály konstans.
A bevezetésének a lényege az volt, hogy bevezethessék a „logikai konstans” fogalmát, pl. hiába konstans az adott osztály, azért tudjon például sebességnövelés miatt adatokat cache-lni.
Megadására példa:
class Akarmi { //... mutable int x; //... };
De ha ilyenre van szükség, akkor egyszerűen ne legyen const
az osztály.
Kivétel specifikációk
Ezeknek a segítségével megadható egy függvénynek, hogy milyen kivételeket dobhat. Ha mégsem a megadott kivételt dobná egy függvény, akkor a program kilép. A legfrissebb C++ szabványban ezt elavultnak minősítették, és majd eltávolításra fog kerülni előbb vagy utóbb. Különben se volt sok haszna.
Megadására példa:
void akarmi(int x) throw(ilyen_exception, amolyan_exception) { //... }
Azaz egy throw
kulcsszó után zárójelbe felsoroljuk, hogy az adott függvény milyen kivételeket dobhat.
Ha nem adunk meg kivételspecifikációt, akkor a függvényünk bármilyen kivételt dobhat. Ha üresen hagyjuk, akkor a függvényünk nem dobhat kivételt.
A typeid
operátor
Ezzel az operátorral futási időben lekérdezhetjük egy változó adattípusát. A szintaxisa egyszerű: typeid(változó)
.
Az operátor eredménye egy type_info
osztály, amelyet a <typeinfo>
fejlécben deklarálnak.
Az osztályon alkalmazható az ==
operátor, így két változó típusa menet közben összehasonlítható.
Használatára általában nincs szükség.
És most hogyan tovább?
Most már lényegében mindent tudunk már a C és C++ nyelvről magáról, most ideje egy kicsit arról írni, hogy mit lehet a nyelvvel kezdeni.
Ebben a részben összefoglalom, hogy merre érdemes szétnézni, hogyha tényleg szeretnél-e valamit csinálni. Konrét linkeket nem adok, mert azok változhatnak, viszont egy Google keresés mindig segít.
Parancssori alkalmazások írása, fájlkezelés, adatszerkezetek és matematikai műveletek
Ezt nagyjából lefedi C standard könyvtár és a C++ standardkönyvtár.
Könyvtárkezelés és rendszerműveletek
Ehhez az adott operációs rendszer SDK-ját kell használnod, Windows esetén a WinAPI-t, Linux esetén pedig a standard POSIX fejléceket.
A Windows SDK letölthető a Microsoft holnapjáról, a Linuxok esetén pedig a POSIX szabvány definiálja.
Ha az elvégzendő feladat parancssori parancsok kiadásával is elvégezhető, akkor érdemes megismerkedni a system
függvénnyel.
Ablakos alkalmazások (GUI) fejlesztése
Ha Windows alkalmazást akarunk csinálni, akkor érdemes lesz megismerni a Windows API megfelelő függvényeivel.
Linuxon a fő ablak kezelő rendszerek alapja az X ablak rendszer (X Window System), melyet szoktak X11-nek is nevezni, és erre épül számtalan tényleges ablakkezelő, mint pl. a Gnome, KDE, XFCE és sokan mások. Mindegyiknek megvan a maga API-ja (application programming interface, azaz a függvényei).
Léteznek GUI framework-ök, amelyek megpróbálják ezt az összevisszaságot rendbetenni, és ugyanazt a kódot működtetni a több platformon is. Például a wxWidgets framework az ablakkezelő rendszernek megfelelő ablakokkal dolgozik. Illetve ott van a Qt, amely saját ablakokat és vezérlőket fejlesztett ki.
De manapság ablakos alkalmazások csinálására jobb platformok és nyelvek is vannak már, pl. .NET vagy Java.
3D grafika
Windows esetén a Direct3D a legjobb választás, Linux esetén pedig az OpenGL. Az OpenGL platformfüggetlen.
Hanglejátszás, hangfelvétel
Egyszerű hangkezelésre a Windows és a többi operációs rendszer API-ja is biztosít lehetőséget. A komolyabb dolgokra, mint a hangeffektek, térhatású hang már mást kell használni. Windows esetén erre jó a DirectSound, Linux esetén pedig az OpenAL (amely Windows alatt is képes működni).
Hálózati programozás
Ezt socket programozással lehet megvalósítani, UNIX alatt és Windowson is ugyanazok a függvények használhatók.