Magyar oldal English site

Oldaltérkép
2021-12-11 13:52:26 (Eredeti megjelenés dátuma: 2013-07-19)

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

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:

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:

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:

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:

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:

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 ):

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

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.

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.

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:

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.

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:

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:

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ő:

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:

  1. Ha az egyik double típusú, akkor másik is double típusúra lesz alakítva.
  2. Ha az egyik float típusú, akkor a másik is float típusúra lesz alakítva.
  3. Ha az egyik unsigned long típusú, akkor másik is unsigned long típusúra lesz alakítva.
  4. 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.
  5. Ha az egyik long típusú, a másik is long típusúra lesz alakítva.
  6. Ha az egyik valamilyen unsigned típusú, mindkettő unsigned int-re lesz alakítva.
  7. 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:

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:

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:

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:

Á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:

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:

  1. ++ -- [] . ->: 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.
  2. ++ -- * & 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ő:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

TrigraphMegfelelő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:

DigraphMegfelelő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:

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:

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 a B-nek, akkor a B-re való mutatók és referenciák a D-re mutatóra alakíthatók vele: static_cast<D*>(&b), illetve static_cast<D&>(b), ahol b 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éteres T(X &x) konstruktora, akkor a static_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 a static_cast<Evszak>(i), ahol i egy egész szám, elvégezhető. Az i é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ó a static_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:

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.

Nemrég frissült:
Logo