Ugrás a tartalomhoz

C++

A Wikiszótárból, a nyitott szótárból

c++

Főnév

C++ (tsz. C++s)

  1. (informatika) A C++ az egyik legismertebb és legelterjedtebb programozási nyelv, amelyet gyakran használnak nagy teljesítményt igénylő alkalmazások fejlesztésére. Ez a bevezető anyag átfogó képet nyújt a C++ nyelv alapjairól és az objektumorientált programozás (OOP) fontos fogalmairól. Kitérünk a nyelv szintaktikai és struktúrális alapjaira – például adattípusokra, konstansokra, névterekre, bemenet-kimenetre és a függvények paraméterátadására –, majd bemutatjuk az OOP pilléreit: osztályokat, objektumokat, konstruktorokat, öröklést, egységbezárást, absztrakt osztályokat és interfészeket. Megismerkedünk továbbá a C++ sablonok (template-ek) használatával, az operátorok túlterhelésével, valamint haladóbb témaként a JSON formátumú adatmentéssel és az Observer tervezési mintával. Mindezt számos C++ példakód és magyarázat kíséri az érthetőség kedvéért.

A C++ nyelv alapjai

Ebben a fejezetben áttekintjük a C++ nyelv alapvető elemeit. Szó lesz a fontosabb adattípusokról, konstansokról, a szabványos bemeneti és kimeneti műveletekről (I/O), a névterek szerepéről, valamint arról, hogyan működik a függvények paraméterátadása érték, mutató és referencia szerint.

Adattípusok és változók

A C++ statikusan típusos nyelv, ami azt jelenti, hogy minden változónak meg kell adni a típusát fordítási időben. A leggyakoribb alap adattípusok a következők:

  • Egész számok: int (általános egész), short, long, long long – különböző méretű és tartományú egész típusok.
  • Valós számok: float (egyedi pontosságú lebegőpontos szám), double (kettős pontosságú), long double – különböző pontosságú valós számok.
  • Karakter: char – egyetlen karakter (egy bájt).
  • Logikai: bool – logikai érték, true (igaz) vagy false (hamis).
  • Egész számok előjel nélkül: unsigned verziók (unsigned int, unsigned long, stb.), amelyek nem vehetnek fel negatív értéket.
  • Autómatikus típus (C++11-től): auto kulcsszóval a fordító kitalálja a változó típusát a kezdőérték alapján.

Például egy egész és egy lebegőpontos változó deklarációja és inicializálása:

int egeszSzam = 42;
double tortSzam = 3.14;

A változókat deklaráláskor érdemes rögtön inicializálni. Ha nem adjuk meg az induló értéket, akkor a változók tartalma nem meghatározott (kivéve statikus vagy globális változók, amelyek alapértelmezetten 0 értéket kapnak).

Konstansok (const és constexpr)

A konstans olyan változó, amelynek az értéke a program futása során nem változhat. C++-ban konstans változót a const kulcsszóval hozunk létre. A konstansokat célszerű csupa nagybetűs névvel ellátni a kódban, hogy kitűnjön, de ez nem kötelező, csupán konvenció. Például:

const double PI = 3.1415926536;
const int MAX_ELETKOR = 120;

A PI és MAX_ELETKOR változók értékét a program sehol sem írhatja felül. Ha mégis megpróbálnánk, fordítási hiba keletkezik. Konstansokat használhatunk függvény paramétereknél is, ha biztosítani akarjuk, hogy a függvény ne módosítsa a bemenő értéket:

void kiirNegyzet(const int szam) {
    std::cout << szam * szam;
}

A fenti kiirNegyzet függvény garantáltan nem változtatja meg a szam változót, mert az konstans paraméterként van megadva.

Konstans kifejezések: C++11-től bevezetésre került a constexpr kulcsszó, amellyel olyan konstansokat vagy függvényeket jelölhetünk, amelyek értéke már fordítási időben kiszámítható. A constexpr változókra is igaz, hogy futási időben nem változhatnak, de ráadásul használhatók olyan helyeken is, ahol fordítási idejű érték szükséges (pl. tömbméret meghatározásánál régi szabványokban, vagy sablon paramétereként). Például:

constexpr int NAPOK_SZAMA_EVES = 365;

A NAPOK_SZAMA_EVES értéke már a fordításkor ismert lesz, és bárhol használható olyan contextusban, ahol fordítás idejű konstans kell.

Konstans mutatók és a const használata pointerekkel: A const kulcsszó a mutatók esetében két dolgot is jelenthet attól függően, hova írjuk:

  • const int *pmutató konstans értékre: p olyan int értékekre mutathat, amelyeket nem változtathatunk meg a mutatón keresztül (azaz a mutató által mutatott érték konstans). Magát a mutatót azonban átirányíthatjuk más címre.
  • int * const pkonstans mutató: p mutató értéke (tehát hogy hova mutat) nem változhat, viszont a címen lévő értéket módosíthatjuk. Vagyis a mutató “rögzítve van” egy adott változóra.
  • const int * const pkonstans mutató konstans értékre: a p mutató nem változtatható meg, és a rajta keresztül elérhető értéket sem módosíthatjuk. Ez a legszigorúbb kombináció.

Be- és kimenet: iostream használata

A C++ szabványos könyvtára támogatja a konzolos bemenetet és kimenetet az <iostream> fejléccel. A két legfontosabb osztály ebben a fejlécrészben:

  • std::cout – standard kimeneti folyó (általában a konzolra ír).
  • std::cin – standard bemeneti folyó (általában a konzolról olvas).
  • (Továbbá std::cerr a hibakimenetre íráshoz, ami tipikusan a konzolra kerül, de külön csatornán.)

Ezek az I/O objektumok a << (kimeneti) és >> (bemeneti) operátorokkal használhatók. Például egy “Hello World” kiírása és egy szám bekérése:

#include <iostream>

int main() {
    std::cout << "Hello, World!" << std::endl;      // kiír egy üzenetet és sortörést
    std::cout << "Adj meg egy számot: ";
    int n;
    std::cin >> n;                                  // beolvas egy egész számot a standard bemenetről
    std::cout << "A megadott szám duplája: " << n*2 << std::endl;
    return 0;
}

Magyarázat: Az első std::cout a képernyőre írja a "Hello, World!" szöveget, majd az std::endl hatására sortörést végez és ki is üríti a kimeneti puffer tartalmát. Ezután újabb kiírás kéri a felhasználótól, hogy adjon meg egy számot. Az std::cin >> n; vár a felhasználó inputjára és a beolvasott értéket az n változóba tölti. Végül a program ismét std::cout segítségével kiírja a kapott szám kétszeresét.

Látható, hogy a << operátorral tetszőleges típusú adatokat fűzhetünk a kimeneti folyamhoz (cout), míg a >> operátor a bemeneti folyamról (cin) olvas a jobb oldalon megadott változóba. A C++ bemenet formátumérzékeny: például szóköz választja el a beolvasott értékeket, és a megfelelő típussá konvertálja azokat (pl. számokká).

Névterek használata

A névtér (namespace) mechanizmus a C++-ban arra szolgál, hogy elkerülje a névütközéseket nagyobb programokban vagy könyvtárak használatakor. A standard könyvtár összes eleme (pl. std::cout, std::string, stb.) az std nevű névtérben található. Ezért kell minden használatkor kiírnunk elé, hogy std:: vagy pedig a forrás elején egy using namespace std; utasítással jelezhetjük, hogy a std névtér neveit névtér-prefix nélkül is használni akarjuk.

Kétféle használati mód:

  1. Névtér prefix használata: Minden standard szimbólum előtt kiírjuk, hogy std::. Például std::cout, std::string stb. Ez az ajánlott mód nagyobb projektekben, mert egyértelművé teszi, honnan jön az adott név.
  2. Névtér beemelése: using namespace std; utasítással a teljes std névteret “megnyitjuk”, így utána a benne lévő neveket (cout, string, stb.) közvetlenül használhatjuk. Figyelem: Ezt kis méretű, tanuló programokban gyakran alkalmazzák, de nagyobb projekteknél kerülendő, mert könnyen okozhat névütközést más könyvtárakkal.

Példaként nézzük meg ugyanazt a “Hello World” programot névtér prefixekkel és névtér megnyitásával:

#include <iostream>

int main() {
    // 1. eset: névtér prefixszel
    std::cout << "Hello World!" << std::endl;

    // 2. eset: névtér megnyitása
    using namespace std;
    cout << "Második sor, amelynél már nem kell std:: prefix." << endl;

    return 0;
}

Az első kiírásnál std::cout-ot használtunk, míg a using namespace std; után a második kiírásnál már elhagyhattuk a std:: részt. Kezdőként a using namespace std; megkönnyítheti a tanulást, de mindig legyünk tudatában a lehetséges névütközéseknek.

A névterek segítségével saját kódban is csoportosíthatjuk a változókat, függvényeket, osztályokat. Például:

namespace SajatTer {
    int ertek = 42;
    void fuggveny() {
        std::cout << "Saját névtérbeli függvény, ertek = " << ertek << std::endl;
    }
}

int main() {
    SajatTer::fuggveny();            // a névtér nevét használva érjük el a függvényt
    using namespace SajatTer;
    std::cout << ertek << std::endl; // using után már közvetlenül is használható a névtér tartalma
}

Függvények és paraméterátadás módjai

A függvények a programkód újrafelhasználható egységei, amelyek bemenetként paramétereket kaphatnak, és visszatérhetnek egy értékkel. A C++ függvények paramétereit alapértelmezés szerint érték szerint adja át, de lehetőség van mutatóval vagy referenciával is átadni őket. Ezek a módok különböző viselkedést eredményeznek:

  • Érték szerinti átadás: A függvény a paraméter változójának egy másolatán dolgozik. A hívó kód által átadott eredeti értéket nem tudja módosítani. Ez biztonságos, mert a függvény nem hat vissza a hívóra, viszont nagy objektumok másolása költséges lehet.
  • Mutatóval való átadás: A függvény egy címét kapja meg a változónak (pointer), és ezen a címen keresztül eléri az eredeti változót. Így a függvény képes módosítani a hívó által átadott értéket, mert a mutatóval közvetlenül az eredeti példányt érjük el. A mutató használatához a hívásnál címet kell átadni (pl. fv(&változó)).
  • Referenciával való átadás: A C++ sajátossága, hogy használhat hivatkozásokat (reference). A referencia egy adott változó másik neveként fogható fel. Referenciával átadva a függvény paraméterét, a függvény ugyanarra a változóra hivatkozik, mint a hívó kódban, így a változtatások egyből az eredeti példányon történnek. A hívásnál ugyanúgy adjuk át a változót, mint érték szerinti hívásnál, de a függvény fejléce Típus& param formában definiálja a paramétert.

Nézzünk egy konkrét példát, ahol mindhárom módszert összehasonlítjuk. Célunk egy valtoztat nevű függvény, amely megpróbál egy külső változót módosítani háromféleképpen:

#include <iostream>
using namespace std;

void nemValtoztat(int x) {      // érték szerint
    x = 5;
}
void valtoztatPtr(int *p) {     // pointer (cím szerint)
    *p = 10;
}
void valtoztatRef(int &r) {     // referencia szerint
    r = 15;
}

int main() {
    int a = 0;
    nemValtoztat(a);
    cout << "nemValtoztat után a = " << a << endl;   // várható: a = 0, mert másolatot módosított
    
    valtoztatPtr(&a);
    cout << "valtoztatPtr után a = " << a << endl;   // várható: a = 10, mert pointerrel módosítottuk
    
    valtoztatRef(a);
    cout << "valtoztatRef után a = " << a << endl;   // várható: a = 15, mert referenciával módosítottuk
    
    return 0;
}

Magyarázat:

  • Az nemValtoztat(a) hívásnál a értéke nem változik, mivel a függvény csak egy lokális másolatot (x) állított át 5-re.
  • A valtoztatPtr(&a) hívásnál átadjuk a címét. A függvény ezen a címen keresztül a *p kifejezéssel az eredeti a értékét változtatja meg 10-re.
  • A valtoztatRef(a) hívásnál a függvény paramétere r egy referencia a-ra, vagyis a függvényen belüli r = 15; utasítás közvetlenül a-t módosítja.

A futtatás eredménye:

nemValtoztat után a = 0  
valtoztatPtr után a = 10  
valtoztatRef után a = 15  

Látható, hogy csak az érték szerinti átadás nem változtatta meg a külső változót. A pointeres és referencias átadás is módosítani tudta, de eltérő szintaxissal: pointer esetén a hívásnál &a kellett, referencia esetén pedig egyszerűen a.

Mikor melyiket használjuk?

  • Ha nem szeretnénk, hogy a függvény módosítsa a küldött adatot, vagy a másolás költsége elhanyagolható (pl. beépített típusoknál), használjunk érték szerinti átadást.
  • Ha a függvénynek módosítania kell a hívó változóját, választhatunk pointert vagy referenciát. A referencia szintaxisa egyszerűbb (nem kell *-ot használni a hozzáféréshez), és nem lehet nullptr (érvénytelen cím) a paraméter, így biztonságosabb. A pointerek használata akkor jön szóba, ha például nem kötelező paraméterként átadni valamit (mert a pointer lehet nullptr, jelezve a paraméter hiányát), vagy tömböknél, illetve ha dinamikusan kell tömbökön végigiterálni.
  • Fontos megjegyezni, hogy C++-ban a tömbök automatikusan pointerként adódnak át függvénynek, tehát ha egy tömböt adunk át paraméternek, valójában a kezdőelem címét kapja meg a függvény. Emiatt a függvény nem tudja a tömb méretét (ezért gyakran külön paraméterként átadják a méretet is).

Dinamikus memória kezelése röviden

C++-ban a dinamikus memóriakezelést a C nyelv malloc/free függvényei helyett a new és delete operátorokkal végezzük. A dinamikus memória használata akkor hasznos, ha futásidőben szeretnénk eldönteni, mekkora tárterületre van szükségünk, vagy olyan objektumot akarunk létrehozni, amely túléli a lokális változók élettartamát (pl. vissza akarjuk adni egy függvényből a címét).

  • A new operátor lefoglal egy adott típusú objektum számára memóriát a szabad tárból (heap), és visszaad egy mutatót rá. Például: int *ptr = new int; lefoglal egy int méretű memóriát és visszaadja a címét. Hasonlóan, int *tomb = new int[10]; lefoglal 10 darab int-nyi folytonos memóriát.
  • A delete operátor felszabadítja a new által foglalt memóriát. Fontos: minden new hívásnak kell, hogy legyen megfelelő delete hívása, különben memóriaszivárgás lép fel (a lefoglalt memória nem szabadul fel a program futása végéig). Tömbök esetén delete[] tomb; szintaxist kell használni a felszabadításhoz.

Példa:

int *p = new int;     // egy int-nek helyfoglalás
*p = 7;               // használjuk a dinamikus memóriát
delete p;             // felszabadítjuk a memóriát

int *arr = new int[5]; // 5 elemű tömb helyfoglalás
// ... használjuk a tömböt ...
delete[] arr;          // tömb felszabadítása

A modern C++ bevezette az okos mutatókat (std::unique_ptr, std::shared_ptr, stb. a <memory> fejlécből), amelyek automatikusan felszabadítják a memóriát, amikor már nincs rá szükség. Kezdőként azonban fontos megérteni a nyers new/delete működését is, de a gyakorlatban, haladó szinten már inkább okos mutatókat alkalmazunk a biztonságosabb memóriahasználat érdekében.

Objektumorientált programozás (OOP) alapjai

Az objektumorientált programozás olyan programozási paradigma, amely az adatokat és a hozzájuk tartozó műveleteket egységekbe, úgynevezett objektumokba szervezi. Az OOP négy fő alappillére: egységbezárás (encapsulation), öröklés (inheritance), polimorfizmus (polymorphism) és absztrakció (abstraction). Ezek közül az első kettőt (és részben a harmadikat) már az osztályok és objektumok használatakor is megismerjük, az absztrakcióra pedig az absztrakt osztályok és interfészek kapcsán térünk ki.

Osztályok és objektumok

Az osztály egy programozási nyelvi elem, amely az adatok (ún. tagváltozók vagy attribútumok) és a rajtuk végzett műveletek (ún. tagfüggvények vagy metódusok) összekapcsolására szolgál. Az osztály definíciója egy sablon vagy tervrajz, amely alapján konkrét példányokat, objektumokat hozhatunk létre. Minden objektum rendelkezik az osztályában meghatározott adatokkal és műveletekkel.

Egy egyszerű példa osztály definícióra C++-ban:

#include <iostream>
#include <string>
using namespace std;

class Ember {
private:
    string nev;       // név (magán adattag, kívülről közvetlenül nem elérhető)
    int eletkor;      // életkor (magán adattag)
public:
    // Tagfüggvények deklarációja
    void beallitNev(const string& ujNev) {
        nev = ujNev;
    }
    string lekérNev() const {
        return nev;
    }
    void beallitEletkor(int kor) {
        eletkor = kor;
    }
    int lekerEletkor() const {
        return eletkor;
    }
    void koszont() const {
        cout << "Szia, " << nev << "! Te " << eletkor << " éves vagy." << endl;
    }
};

Itt definiáltunk egy Ember osztályt két adattaggal: nev és eletkor. Ezek privát hozzáférésűek (private), ami azt jelenti, hogy az osztályon kívülről közvetlenül nem érhetőek el. Az osztály nyilvános részében (public) függvényeket adtunk meg, melyeken keresztül beállítható és lekérdezhető a név és életkor, illetve van egy koszont() függvény, amely kiír egy köszönő üzenetet az adott ember adataival.

Objektum létrehozása és használata: Osztály definíciója után létrehozhatunk objektumokat (példányosíthatjuk az osztályt). Például:

int main() {
    Ember ember1;                           // létrehozunk egy Ember objektumot
    ember1.beallitNev("Gábor");
    ember1.beallitEletkor(30);
    ember1.koszont();                       // meghívjuk a köszönő függvényét

    Ember ember2;
    ember2.beallitNev("Judit");
    ember2.beallitEletkor(25);
    cout << ember2.lekérNev() << " kora: " 
         << ember2.lekerEletkor() << endl;  // kiírjuk Judit életkorát
}

A fenti kód kimenete:

Szia, Gábor! Te 30 éves vagy.
Judit kora: 25

Látható, hogy az Ember osztály jól elhatárolja az adatokat: kívülről nem tudtunk közvetlenül ember1.nev vagy ember1.eletkor értékeket állítani vagy olvasni, csak az osztály által biztosított függvényeken (metódusokon) keresztül. Ez a egységbezárás alapelve (a következő pontban részletesen tárgyaljuk).

Konstruktorok és destruktorok

A konstruktor egy speciális tagfüggvény, amely az objektum létrehozásakor automatikusan meghívódik, és feladata az objektum kezdő állapotának beállítása (az adattagok inicializálása, erőforrások foglalása stb.). A konstruktor neve megegyezik az osztály nevével, és nincs visszatérési típusa (még void sem). Konstruktorból több is lehet egy osztályban, különböző paraméterlistákkal (túlterhelés, erről később). Ha nem definiálunk konstruktort, a fordító automatikusan generál egy alapértelmezett konstruktort, amely az adattagokat típustól függően default értékre állítja (alapvetően beépített típusoknál nem inicializálja őket, ami veszélyes lehet, ezért jobb, ha mi magunk definiálunk konstruktort).

Destruktor: ennek neve egy ~ jellel kezdődik, utána az osztálynév, és akkor hívódik meg, amikor egy objektum élettartama véget ér (például lokális változó kilép a hatókörből, vagy egy dinamikusan foglalt objektumot delete operátorral felszabadítunk). A destruktor feladata az erőforrások felszabadítása, rendrakás (pl. fájl lezárása, memória felszabadítása). Minden osztálynak legfeljebb egy destruktora lehet, nem lehet paramétere és nincs visszatérési értéke.

Vegyük az előbbi Ember osztályt, és adjunk hozzá konstruktort és destruktort:

class Ember {
private:
    string nev;
    int eletkor;
public:
    // Konstruktor deklarációja
    Ember(const string& kezdoNev, int kezdoKor) {
        nev = kezdoNev;
        eletkor = kezdoKor;
        cout << nev << " létrejött." << endl;
    }
    // Destruktor deklarációja
    ~Ember() {
        cout << nev << " objektum törlődik." << endl;
    }
    // ... (a többi tagfüggvény változatlan) ...
};

Ebben a változatban az Ember osztálynak van egy konstruktora, amely két paramétert vár: egy nevet és egy életkort. Létrehozza az objektumot ezeknek az értékeknek a beállításával, és kiír egy üzenetet is (ez csak a példa kedvéért van itt, hogy lássuk mikor fut). A destruktor szintén kiír egy üzenetet az objektum törlésekor.

Használat:

int main() {
    Ember ember("Ákos", 20);    // konstruktor hívódik
    cout << ember.lekérNev() << " kora: " << ember.lekerEletkor() << endl;
    // program vége, destruktor hívódik, amikor az 'ember' objektum kilép a hatókörből
}

Kimenet például:

Ákos létrejött.
Ákos kora: 20
Ákos objektum törlődik.

Fontos megjegyezni:

  • Ha definiálunk bármilyen konstruktort paraméterekkel, a fordító nem generál alapértelmezett konstruktort, hacsak mi magunk nem adjuk meg (akár üres törzzsel). Így ha szükségünk van paraméter nélküli konstruktorra is, azt külön deklarálni kell (vagy C++11-től használhatjuk a = default szintaxist).
  • Létezik másoló konstruktor is: ezt a fordító akkor hívja meg automatikusan, ha egy objektumból egy másikat hozunk létre értékadás-szerűen, például Ember e2 = e1;. Alapértelmezett másoló konstruktor bitenkénti másolatot készít az adattagokról. Később, haladóbb szinten fontos lehet a másoló konstruktort (és a hozzárendelő operátort) felülírni, ha az osztály erőforrásokat foglal (lásd Mélységi másolat vs sekély másolat problémája). Kezdő szinten elég tudni, hogy létezik.
  • Destruktor automatikusan fut statikus, automatikus (stacken lévő) objektumoknál. Dinamikusan (new-val) foglalt objektumnál a destruktor csak delete hatására fut le. Mindig biztosítsuk, hogy minden new-val létrehozott objektumhoz tartozzon destruktorhívás (vagy használjunk okos mutatókat, amelyek ezt garantálják).

Egységbezárás és hozzáférési szintek

Az egységbezárás (encapsulation) az OOP egyik alappillére. Lényege, hogy az adatokat és a hozzájuk tartozó műveleteket összezárjuk egy osztályba, és kontrolláljuk, hogy kívülről mi látható belőle. Ennek fő eszköze a hozzáférési szintek használata az osztály definíciójában. C++-ban három hozzáférési szint van:

  • public (nyilvános): Az így megjelölt tagok (változók vagy függvények) bárhonnan elérhetők, az osztályon kívülről is.
  • private (magán): Az így megjelölt tagok csak az osztályon belül érhetők el, az osztály tagfüggvényei férhetnek hozzájuk. Külső kód vagy más osztály példányai nem férnek hozzá közvetlenül.
  • protected (védett): Az így jelölt tagok kívülről nem érhetők el, de az adott osztály leszármazottai (gyerekosztályai) hozzáférnek. (Az öröklés témakörénél visszatérünk rá.)

Az adat elrejtés és egységbezárás érdekében tipikusan az osztály adattagjait private módosítóval látjuk el, és szükség esetén public metódusokat biztosítunk az értékük módosítására vagy lekérdezésére (getter/setter metódusok). Így kontrollálhatjuk, hogy az adatok mindig érvényes állapotban legyenek (például a setter függvényben ellenőrzéseket végezhetünk).

Az előző Ember osztály példában ezt láthattuk: a nev és eletkor magán tagok, és csak nyilvános függvényeken át olvashatók vagy írhatók. Ennek köszönhetően például megtehetnénk, hogy a beallitEletkor függvény ne engedje az életkor 0 alatti vagy irreálisan nagy értékre állítását:

void beallitEletkor(int kor) {
    if(kor >= 0 && kor <= 150) {
        eletkor = kor;
    } else {
        cout << "Hibás életkor érték!" << endl;
    }
}

Így az eletkor adattag mindig valid marad, a kívülről jövő helytelen adat nem rontja el az objektum belső állapotát.

Egységbezárás előnyei:

  • Rejtett implementáció: a felhasználók (más kód, amely az osztályt használja) nem függnek az osztály belső megvalósításától. Ha az adattárolás módját megváltoztatjuk, a publikus interfész változatlanul hagyása mellett a külső kódot ez nem érinti.
  • Érvényesség megőrzése: a privát adatok csak kontrollált módon módosíthatók, így könnyebb biztosítani, hogy az objektum mindig érvényes állapotban legyen.
  • Olvashatóság, karbantarthatóság: az osztály interfésze (publikus része) elválik a belső implementációtól, így a kódot használó fejlesztőnek elég az interfészt ismernie.

Friend (barát) deklaráció: Speciális esetben előfordulhat, hogy egy külső függvény vagy másik osztály közvetlen hozzáférését akarjuk engedélyezni a privát tagokhoz. Erre szolgál a friend kulcsszó. Ha egy osztály törzsében egy függvényt vagy osztályt friend-nek nyilvánítunk, az megkerülheti a hozzáférési korlátokat. Ezt azonban óvatosan kell használni, mert gyengíti az egységbezárást. Tipikusan operátor túlterhelésnél (pl. operator<< túlterhelése, lásd később) vagy erősen összetartozó osztályoknál használják.

Hatókörök (scope) a C++-ban

A hatókör azt határozza meg, hogy a program mely részéről látható vagy érhető el egy adott név (változó, függvény, osztály, stb.). C++-ban több fajta hatókört különböztetünk meg:

  • Globális hatókör: A globálisan, minden függvényen vagy osztályon kívül definiált változók és függvények a program egész területén láthatók. A globális változók élettartama a program teljes futási ideje alatt tart. (Jegyezzük meg, hogy túl sok globális változó használata nem jó gyakorlat, mert átláthatatlanná teheti a kódot és nehezíti a hibakeresést.)
  • Névtér (namespace) hatókör: Ahogy korábban tárgyaltuk, a névterekkel csoportosított neveket csak akkor érjük el, ha a megfelelő névtérben vagyunk, vagy használjuk a névtér nevét. Ez egy logikai hatókör, amely segít a névütközés elkerülésében.
  • Fájl hatókör: Ha egy változót vagy függvényt egy forrásfájlban, de egyetlen névtéren kívül, static kulcsszóval jelölünk meg, akkor annak a szimbólumnak a láthatósága csak arra a fájlra korlátozódik. Régebbi kódokban használt idiom, mellyel elérhető, hogy a globális szimbólum ne legyen látható más fordítási egységekben. (C++17-től egyébként inkább anonim névteret használnak ugyanerre a célra.)
  • Osztály hatókör: Egy osztály definícióján belül megadott neveket (pl. tagváltozók, tagfüggvények) csak az osztály tagjaként lehet értelmezni. Ugyanazon név jelenthet mást egy osztályon belül és kívül. Például két külön osztályban lehet privát int id; adattag anélkül, hogy ütköznének, mert a két azonos nevű id változónak más az osztály hatóköre.
  • Objektum hatókör: Minden objektum példány saját hatókörrel bír az adatai tekintetében: vagyis egy objektum tagváltozói nincsenek közvetlenül láthatóak egy másik objektum számára (kivéve ha a tagváltozó static, ami osztály-szintű, vagy ha barátként fér hozzá).
  • Függvény hatókör: A függvény törzsén belül definiált változók (lokális változók) csak a függvényen belül láthatók és élnek. A függvény paraméterei is lokális változók, amelyek a függvény futásakor jönnek létre és a visszatéréskor megsemmisülnek.
  • Blokk hatókör: Általános szabály, hogy minden { } blokk külön hatókör. Például egy for ciklus fejrésze is blokk: a for(int i=0; i<10; ++i) { ... } ciklus után az i változó már nem létezik. Ugyanígy egy if vagy while blokkjában létrehozott változó a blokk után nem látható. Ezért is lehetséges azonos nevű változót használni különböző blokkokban (noha nem mindig jó ötlet az olvashatóság miatt).

Példa a különböző hatókörök illusztrálására:

int x = 1;               // globális x

void fuggveny() {
    int x = 2;           // lokális x, eltakarja a globális x-et ebben a függvényben
    std::cout << x << std::endl;      // 2-t ír ki
    {
        int x = 3;       // belső blokkban újabb x
        std::cout << x << std::endl;  // 3-at ír ki
    }
    std::cout << x << std::endl;      // ismét 2-t ír ki (belső blokk x-e megszűnt)
}

int main() {
    std::cout << x << std::endl;      // 1-et ír ki (globális x)
    fuggveny();
    std::cout << x << std::endl;      // megint 1-et ír ki (globális x nem változott)
}

A példában a globális, a függvény lokális és a blokk-lokális változó névütközés nélkül tudott együtt létezni, mert más-más hatókörben vannak. Fontos: ha a belső blokkban nem hozzuk létre újra az x-et, akkor a külső x értékét módosíthattuk volna. Azaz ha a blokkban azt írtuk volna: x = 5;, akkor az a függvény x változóját (érték 2) írta volna felül 5-re.

Összefoglalva: mindig ügyeljünk a változók hatókörére, mert a C++ fordító a legbelső látható deklarációt veszi figyelembe egy név feloldásakor.

Öröklés (Inheritance) és származtatás

Az öröklés lehetővé teszi, hogy egy már meglévő osztály (ősosztály vagy bázisosztály) tulajdonságait és viselkedését kiterjesszük egy új osztályban (leszármazott vagy származtatott osztály). Az új osztály örökli az ősosztály minden tagját (adattagot és tagfüggvényt), de:

  • hozzáadhat új tagokat (további adattagokat vagy függvényeket),
  • módosíthatja bizonyos viselkedését (felülírhat virtuális függvényeket – erről majd a polimorfizmusnál),
  • illetve elérheti és használhatja az ős meglévő funkcionalitását (ha a hozzáférési szintek engedik).

Az öröklés C++-ban a következő szintaxissal történik:

class OsSzulo {
    // ...
};

class OsGyerek : public OsSzulo {
    // ...
};

Itt az OsGyerek örökli az OsSzulo minden adattagját és metódusát. Fontos megemlíteni a : public OsSzulo részt: ez azt jelenti, hogy publikus öröklést alkalmazunk. C++-ban ugyanis megadható, hogy az öröklés milyen hozzáférési módon történik:

  • public öröklés: Az ős publikus tagjai publikusak maradnak a leszármazottban is (és a védettek védettek maradnak). Ez a leggyakoribb, ezt használjuk az “is-a” (egy X egy fajta Y) kapcsolat kifejezésére.
  • protected öröklés: Az ős publikus tagjai védettként öröklődnek a gyerekbe. Így az új osztályon kívülről már nem lesznek közvetlenül elérhetők ezek a tagok, csak a gyerekosztályon belül és további leszármazottakban. (Ez ritkábban használt, speciális esetekre.)
  • private öröklés: Az ős publikus és védett tagjai is mind priváttá válnak a leszármazottban, azaz kívülről és gyerekosztályokból sem érhetők el közvetlenül. (Legritkábban használt, inkább belső implementációs trükkökhöz.)

A továbbiakban alapértelmezetten publikus öröklést feltételezünk, hacsak másként nem jelezzük.

Példa öröklésre: definiáljunk egy Allat (állat) ősosztályt, és származtassunk belőle két speciálisabb osztályt, mondjuk Kutya és Macska. Az Allat rendelkezzen egy nev adattaggal és egy hangotAd() metódussal, ami az állat hangját adja ki. A kutya és macska osztály felülírja ezt a metódust a saját hangjával.

#include <iostream>
#include <string>
using namespace std;

class Allat {
protected:
    string nev;
public:
    Allat(const string& nev) : nev(nev) {}   // konstruktor
    void setNev(const string& ujNev) {       // név beállítása
        nev = ujNev;
    }
    string getNev() const {                 // név lekérdezése
        return nev;
    }
    virtual void hangotAd() const {         // virtuális függvény - alapértelmezett viselkedés
        cout << nev << " hangot ad." << endl;
    }
};

class Kutya : public Allat {
public:
    Kutya(const string& nev) : Allat(nev) {}
    void hangotAd() const override {        // virtuális függvény felülírása
        cout << nev << " mondja: vau vau!" << endl;
    }
};

class Macska : public Allat {
public:
    Macska(const string& nev) : Allat(nev) {}
    void hangotAd() const override {
        cout << nev << " mondja: miau!" << endl;
    }
};

Magyarázzuk a példát:

  • Az Allat osztály tartalmazza a közös mezőt, a nevet (nev). Ez protected lett, így az Allat leszármazottai közvetlenül hozzáférnek a névhez (mint pl. a Kutya és Macska osztályok a nev taghoz). Kívülről viszont a név nem elérhető közvetlenül, csak a getNev() és setNev() függvényeken keresztül (melyek public-ok).
  • Az hangotAd() függvény az Allat osztályban virtuális (virtual), és ad egy alapértelmezett viselkedést (csak annyit ír ki, hogy az állat hangot ad). A virtuális kulcsszó azt jelzi, hogy ezt a függvényt a leszármazottak felülírhatják, és ha egy Allat típusú hivatkozáson vagy mutatón keresztül hívjuk meg, akkor a konkrét objektum osztályának megfelelő verzió fut le (ezt nevezzük polimorfizmusnak, lásd következő pont).
  • A Kutya és Macska osztályok az Allat publikus öröklése révén megöröklik annak publikus (és védett) tagjait. A Kutya és Macska konstruktora hívja az Allat konstruktorát, hogy beállítsa a nevet (initializer lista használatával: : Allat(nev)).
  • A Kutya::hangotAd() és Macska::hangotAd() függvények használják az override kulcsszót, ami nem kötelező, de erősen ajánlott C++11-től: jelzi, hogy ez egy bázisosztálybeli virtuális függvény felülírása. A felülírás akkor sikeres, ha a szignatúra pontosan megegyezik az ősben lévő virtuális függvényével (ellenkező esetben a fordító hibát jelez, pont ezért hasznos az override).

Objektumok és öröklés viszonya: Ha példányosítunk egy Kutya vagy Macska objektumot, akkor az belsőleg tartalmazni fogja az Allat adattagjait is. Tehát egy Kutya objektum memóriájában ott van a string nev adattag, amit az Allat definiált, plusz a saját tagjai (jelen esetben nincsenek extra tagjai). Ugyanígy egy Macska is tartalmaz nev adattagot.

Példa a használatra és az öröklés nyújtotta előnyökre:

int main() {
    Kutya kutyus("Bodri");
    Macska cicus("Cirmos");

    kutyus.hangotAd();   // Bodri mondja: vau vau!
    cicus.hangotAd();    // Cirmos mondja: miau!

    // Polimorfizmus bemutatása:
    Allat* allPointer = &kutyus;
    allPointer->hangotAd();  // *virtuális hívás* -> a kutya hangotAd() fut, nem az Allat-é
}

Az öröklés egyik nagy előnye, hogy a különböző, de közös ősű objektumokat egységesen tudjuk kezelni. A fenti példában az allPointer egy Allat* típusú mutató, de valójában egy Kutya objektumra mutat. Amikor a hangotAd() függvényt hívjuk rajta, a futásidő eldönti, hogy az allPointer valójában milyen típusú objektumra mutat, és mivel ez egy Kutya, a Kutya::hangotAd() fog lefutni. Ez a dinamikus kötés (runtime polymorphism) lényege, ami a virtuális függvények által valósul meg.

Többszörös öröklés: C++ támogatja, hogy egy osztálynak több közvetlen ősosztálya legyen (szemben pl. a Java-val vagy C#-szal, ahol egy osztály csak egy osztályból származhat). Például: class D : public B, public C { ... }; esetén D egyszerre örökli B és C tagjait. A többszörös öröklés nagyobb rugalmasságot ad, de körültekintést igényel, mert bonyolultabbé teheti a struktúrát (pl. gyémánt öröklési problémák, ha ugyanaz az ős többször szerepel a láncban – erre nyújt megoldást a virtuális öröklés haladó témája). Kezdő és középhaladó szinten elég annyit tudni, hogy lehetséges, de óvatosan használjuk.

Öröklés és hozzáférési szintek: Ahogy említettük, a leszármazott osztály hozzáfér:

  • az ős public tagjaihoz (attól függően, public/protected öröklésnél publikus vagy védett lesz nála),
  • az ős protected tagjaihoz (mint védett tagok a gyerekben),
  • nem fér hozzá az ős private tagjaihoz közvetlenül. Az ős privát adatai csak az ős metódusain keresztül érhetők el még a leszármazottban is.

Polimorfizmus és virtuális függvények

A polimorfizmus szó szerint “sokalakúságot” jelent, és OOP-ben arra utal, hogy ugyanaz a művelet különböző objektumok esetén máshogy valósul meg. A C++-ban ennek leggyakoribb eszköze a virtuális függvény és annak felülírása a leszármazott osztályban.

A virtuális függvényeket már érintettük az öröklés részben. Lényege, hogy ha egy függvényt az ősosztályban virtual kulcsszóval látunk el, és a leszármazott osztályban ugyanilyen szignatúrával megírjuk (felülírjuk), akkor a függvényhívás dinamikusan kötődik. Ez azt jelenti, hogy ha van egy ős típusú pointer vagy referencia, ami valójában egy gyerekobjektumra mutat, akkor a gyerekosztály implementációja fog lefutni:

Allat* ptr = new Kutya("Bodri");
ptr->hangotAd();  // Mivel hangotAd() virtuális, a Kutya verziója fut: "Bodri mondja: vau vau!"

Ha a függvény nem lenne virtuális, akkor ebben az esetben is az ősosztály (Allat) függvénye futna, mivel a pointer típusa határozza meg statikusan, melyik függvény hívódik meg (ezt hívják korai kötésnek vagy statikus bindingnek). A virtuális kulcsszóval azonban késleltetett kötést (late binding) érünk el, vagyis a futásidő dönti el.

Felülírás és felüldefiniálás: Fontos különbség van a függvény túlterhelése (overload) és felülírása (override) között:

  • Túlterhelés: Ugyanabban az osztályban több függvény lehet azonos névvel, de különböző paraméterlistával. Ezt nevezzük túlterhelésnek, és a fordító dönt arról, melyiket hívjuk éppen a paraméterek alapján (ez nem polimorfizmus, hanem compile-time jelenség).
  • Felülírás: Az öröklési hierarchiában egy leszármazott osztály felülírja az ős egy virtuális függvényét, vagyis újrafogalmazza a működését. Itt a név és a paraméterlista is megegyezik, csak a törzs más. A futásidő dönti el, hogy az ős vagy a gyerek verzióját hívjuk, attól függően, mi a konkrét objektum típusa.

Tiszta virtuális függvények és absztrakt osztályok: Ha egy ősosztályban egy függvénynek nincs értelmezhető alapértelmezett működése, hanem azt szeretnénk, hogy minden leszármazottja saját maga valósítsa meg, akkor a függvényt tiszta virtuálisnak jelöljük. Ezt úgy tesszük meg, hogy a függvény deklarációjában a prototípus után = 0 értéket adunk. Például:

class Alakzat {
public:
    virtual double terulet() const = 0;  // tiszta virtuális függvény, nincs törzs
};

Ezzel az Alakzat osztály absztrakt osztály lett, ugyanis tartalmaz legalább egy tiszta virtuális függvényt. Absztrakt osztályból nem lehet objektumot létrehozni (nem példányosítható), hiszen nincs teljes implementációja (a terulet() nincs megírva). Csak olyan osztályból hozhatunk létre példányt, amely az absztrakt ős minden tiszta virtuális függvényét megvalósította (override-olta).

A polimorfizmus erősen kötődik az absztrakcióhoz: absztrakt ősosztályokat használunk gyakran arra, hogy egy általános interfészt definiáljunk, és a konkrét megvalósításokat a leszármazottakra bízzuk.

Absztrakt osztályok és interfészek

Az absztrakt osztály olyan osztály, amelyből közvetlenül nem hozható létre objektum, csupán azért definiáljuk, hogy a közös tulajdonságokat, műveleteket deklaráljuk vele. Absztrakt osztályt C++-ban úgy hozunk létre, hogy legalább egy függvényt tiszta virtuálisként deklarálunk (azaz = 0-val).

Példa folytatva az előző alakzatos témát: definiáljunk egy absztrakt Alakzat osztályt, aminek van egy terulet() tiszta virtuális függvénye és egy kerulet() (amit akár adhatunk alapértelmezett implementációval is mondjuk nullát, de jobb, ha azt is absztraktnak vesszük, hacsak nem tudunk valami általánosat mondani), majd valósítsuk meg két konkrét alakzattal:

class Alakzat {
public:
    virtual double terulet() const = 0;
    virtual double kerulet() const = 0;
    virtual ~Alakzat() {}  // virtuális destruktor, üres törzzsel (fontos, lásd alább)
};

class Teglalap : public Alakzat {
private:
    double szelesseg;
    double magassag;
public:
    Teglalap(double sz, double m) : szelesseg(sz), magassag(m) {}
    double terulet() const override {
        return szelesseg * magassag;
    }
    double kerulet() const override {
        return 2 * (szelesseg + magassag);
    }
};

class Kor : public Alakzat {
private:
    double sugar;
public:
    Kor(double r) : sugar(r) {}
    double terulet() const override {
        return 3.14159 * sugar * sugar;
    }
    double kerulet() const override {
        return 2 * 3.14159 * sugar;
    }
};
  • Az Alakzat osztály két tiszta virtuális függvényt definiál: terulet és kerulet. Így az Alakzat absztrakt, nem hozható létre belőle objektum (pl. Alakzat a; fordítási hiba lenne).
  • A Teglalap és Kor osztály minden tiszta virtuális metódust megvalósít (override-olják azokat), ezért ezek már konkrét osztályok, példányosíthatók.
  • Észrevehettük a virtual ~Alakzat() {} destruktort. Ez egy fontos idiom: ha egy osztályt polimorf módon (virtuális függvényekkel) használunk, és lehet rá mutatóval vagy referenciával hivatkozni, akkor mindig tegyük a destruktorát virtuálissá. Erre azért van szükség, hogy ha például egy Alakzat* mutatóval hivatkozunk egy Teglalap objektumra, és meghívjuk rajta a delete-et, akkor a megfelelő (leszármazott) destruktor fusson le. Ha nem lenne virtuális, akkor delete alakzatPtr; csak az Alakzat destruktort hívná, a Teglalap rész nem takarítana (ez memória- vagy erőforásszivárgást okozhat). Ökölszabály: ha egy osztályban van legalább egy virtuális függvény, legyen virtuális destruktora is.

Interfészek: Mivel a C++ nem rendelkezik külön interface kulcsszóval (mint pl. Java vagy C#), a tiszta virtuális függvényekkel felruházott absztrakt osztályokkal valósíthatjuk meg az interfész fogalmát. Egy interfész-szerű osztály jellemzően:

  • Minden függvénye tiszta virtuális (nem feltétlen, de tipikusan így van).
  • Nem tartalmaz adattagokat, csak a leszármazottakon keresztül megvalósítandó műveletek deklarációját.
  • C++-ban szokás őket I betűvel kezdeni (pl. IRajzolhato egy rajzolható interfész), de ez csak egy konvenció.

Például definiálhatnánk egy IRajzolhato interfészt így:

class IRajzolhato {
public:
    virtual void rajzol() const = 0;
    virtual ~IRajzolhato() {}
};

Majd egy Haromszog osztály megvalósíthatja ezt (az implementálás C++-ban azt jelenti, hogy örökli és felülírja a függvényt):

class Haromszog : public IRajzolhato {
public:
    void rajzol() const override {
        cout << "Egy háromszöget rajzolunk a képernyőre." << endl;
    }
};

Egy osztály több interfészt is megvalósíthat, mivel C++-ban megengedett a többszörös öröklés. Így például egy osztály örökölhet egyszerre a IRajzolhato és mondjuk egy ISzimulalhato interfészt (ha létezne), és mindkettő függvényeit implementálja. Ezzel elérjük a Java/C# interfészek hasonló hatását.

Összefoglalva: Az absztrakt osztályok és interfészek lehetővé teszik, hogy a közös műveleteket előírjuk, de a konkrét megvalósítást a gyerekosztályokra bízzuk. Így biztosítva van, hogy bizonyos metódusok minden konkrét osztályban meglesznek (fordítási időben ellenőrzi a nyelv), de a működésük testreszabott lehet. Ez az absztrakció magasabb szintjét és a kód újrafelhasználhatóságát növeli.

Sablonok (Templates) használata

A sablonok a C++ generikus programozási eszközei. Lehetővé teszik, hogy típustól független, általános kódot írjunk, amelyből a fordító konkrét típusokra szabott kódot generál. Két fő típusa van: függvénysablonok és osztálysablonok.

Függvénysablonok

Egy függvénysablonnal leírhatunk egy műveletet anélkül, hogy előre rögzítenénk a paraméter(ek) típusát. Például szeretnénk egy maximum függvényt, ami két paraméter közül visszaadja a nagyobbat. Írhatnánk külön maxInt(int, int), maxDouble(double, double), stb., de sablon segítségével egyetlen definíció elegendő:

template<typename T>
T maximum(const T& a, const T& b) {
    return (a < b) ? b : a;
}

Itt a template<typename T> sor bevezeti a sablont, ahol T egy típust helyettesít. A maximum függvény bementi paraméterei és visszatérési típusa is T típusú. Használatkor a fordító automatikusan kitalálja a típusokat a megadott argumentumokból:

int x = 5, y = 7;
cout << maximum(x, y) << endl;         // automatikusan maximum<int>-t hív, kimenet: 7

double a = 3.14, b = 2.71;
cout << maximum(a, b) << endl;         // automatikusan maximum<double>-t hív, kimenet: 3.14

string s1 = "alma", s2 = "korte";
cout << maximum(s1, s2) << endl;       // maximum<string>-et hív, lexikografikus összehasonlítás alapján

A fenti maximum sablon feltételezi, hogy a T típusra értelmezett a < operátor, mert használjuk. Ha olyan típussal hívnánk meg, aminél nincs < operátor (vagy nincs értelmezve összehasonlítás), fordítási hibát kapunk. Ez a sablon használat egyik jellegzetessége: a kód generálása fordítási időben történik, és csak akkor jön létre a konkrét függvény, amikor meghívjuk a sablont egy adott típussal. Ezért a sablon kód hibái is csak akkor derülnek ki, amikor egy bizonyos típussal instanciáljuk (helyettesítjük).

Több típusparaméter: Lehetséges több sablon paraméter is. Például egy függvény, ami két különböző típusú paramétert hasonlít össze valami alapján, vagy akár nem csak típus paramétere, hanem érték paramétere is lehet sablonnak (pl. egy egész szám, ami egy sablonbeli tömb méretét adja meg). Példaként egy egyszerű függvény, ami fix méretű tömböt bejár és kiír:

template<typename T, int N>
void kiirTomb(const T (&arr)[N]) {
    for(int i = 0; i < N; ++i) {
        cout << arr[i] << " ";
    }
    cout << endl;
}

Itt a sablonnak van egy T típus paramétere és egy N egész szám paramétere. A függvény úgy van definiálva, hogy bármilyen típusú és méretű C stílusú tömböt (amikor a méret a típussal része a deklarációnak) képes kiírni. A const T (&arr)[N] szintaxis egy konstans referenciát vár egy T típusú, N méretű tömbre. Így a fordító N értékét is automatikusan kitalálja a paraméter alapján, nem kell megadnunk híváskor.

Használat:

int arr1[3] = {1, 2, 3};
double arr2[4] = {1.1, 2.2, 3.3, 4.4};

kiirTomb(arr1);   // T=int, N=3 lesz
kiirTomb(arr2);   // T=double, N=4 lesz

Kimenet:

1 2 3 
1.1 2.2 3.3 4.4 

Sablon függvény túlterhelés és explicit típusmegadás: Időnként előfordulhat, hogy egy sablon mellett speciális verziókat is szeretnénk (pl. bizonyos típusokra másképp működjön). A sablonokat lehet specializálni adott típusokra, illetve túlterhelni is lehet más függvényekkel. Ez azonban már összetettebb téma (részleges specializáció, SFINAE, stb.), amit haladóbb szinten érdemes tanulmányozni.

Osztálysablonok

Az osztálysablonok segítségével típustól független adatstruktúrákat vagy osztályokat hozhatunk létre. A standard könyvtár sok része (pl. konténerek: std::vector<T>, std::map<Key,Value> stb.) sablonként van megvalósítva.

Egy egyszerű példa: implementáljunk egy Par (pár) osztályt, ami két értéket tartalmaz, lehetőleg tetszőleges típusúakat (akár különböző típusúakat is):

template<typename A, typename B>
class Par {
private:
    A elso;
    B masodik;
public:
    Par(const A& a, const B& b) : elso(a), masodik(b) {}
    A getElso() const { return elso; }
    B getMasodik() const { return masodik; }
};

Itt a Par sablon két típust vár: A és B. Így például létrehozhatunk Par<int, string> típusú objektumot, amely egy egész számot és egy szöveget párosít:

Par<int, string> szemely(23, "Anna");
cout << szemely.getElso() << ", " << szemely.getMasodik() << endl;  // 23, Anna

Minden különböző sablon paraméterezés egy külön típust eredményez a fordítás során. Tehát a Par<int,string> és a Par<string,string> két különböző típusnak számít a programban, noha ugyanazon sablonból származik a definíciójuk. Emiatt a sablonok használatakor a fordítási idő megnőhet (sok típussal való használat sok másolt kódot generál), de futásidőben hatékony, mert a fordító konkrét típusra optimalizált kódot hoz létre (nincs runtime overhead, minden a fordításkor dől el).

Sablon osztályok és tagfüggvények megvalósítása: Gyakorlati részlet, hogy ha osztálysablon tagfüggvényeit osztályon kívül definiáljuk, a templte paramétereket mindkét helyen (deklaráció és definíció) jelezni kell, és a névteret is template-szel kell prefixálni. Ez néha összetett szintaxis, itt most nem megyünk bele részletesen, de érdemes tudni, hogy a sablonok implementációját tipikusan a fejléceken belül (header file-okban) tartják, mert a fordítónak minden sablon kódot látnia kell, amikor egy bizonyos típussal használatba vesszük (különben nem tudja generálni a kódot).

Összetett sablonok: A sablonokon belül is lehetnek sablonok (pl. sablon paraméterként másik sablon), vannak ún. metaprogramozási technikák, de ez már nagyon haladó terület (template metaprogramming). Kezdőként a legfontosabb, hogy a sablonokat generikus tartalom létrehozására használjuk, pl. saját adatstruktúrák vagy algoritmusok írására, amik többféle típuson működnek.

Operátorok túlterhelése (operátorok definíciójának kiterjesztése)

A C++ lehetőséget ad a beépített operátorok túlterhelésére (operátor overloading), ami azt jelenti, hogy meghatározhatjuk, bizonyos operátorok hogyan viselkedjenek felhasználói (user-defined) típusok esetén. Ezáltal az objektumainkat hasonlóan használhatjuk, mint a beépített típusokat.

Mire jó az operátor túlterhelés? Például van egy Komplex szám osztályunk komplex számokhoz. Jó lenne, ha tudnánk használni rá a + operátort két komplex szám összeadására, ahogy az int vagy double esetén tesszük. Vagy túlterhelhetjük a << operátort, hogy az std::cout << objektum segítségével szépen ki tudjuk íratni egy objektum állapotát. Fontos azonban, hogy operátor túlterhelést csak akkor alkalmazzunk, ha annak jelentése intuitív és “természetes” marad. Például egy Matrix osztály esetén a * operátort használhatjuk mátrix-szorzásra, mert matematikailag ez megszokott, de mondjuk egy Person osztályra a * operátorral nem egyértelmű műveletet társítani, ezért nem lenne jó ötlet túlterhelni.

Operátor túlterhelés módjai:

  • Lehet tagfüggvényként definiálni az osztályon belül, ha az operátor bal oldali operandusa maga az osztály (vagy általában mindkét operandus, de a bal mindenképp).
  • Lehet globális függvényként (osztályon kívül) definiálni, általában friend kulcsszóval, ha az operátor bal oldala nem az osztály, vagy ha hozzáférést kell kapjon a privát tagokhoz.

Példa: Írjunk egy egyszerű Pont osztályt kétdimenziós pontokhoz, és defináljuk a + operátort két pont összeadására (értve ez alatt a komponensenkénti összeadást), valamint a << operátort a kiíratáshoz.

#include <iostream>
using namespace std;

class Pont {
private:
    int x, y;
public:
    Pont(int x=0, int y=0) : x(x), y(y) {}   // konstruktor alapértelmezett értékekkel
    // Getterek
    int getX() const { return x; }
    int getY() const { return y; }

    // '+' operátor túlterhelése tagfüggvényként
    Pont operator+(const Pont& masik) const {
        // Új pontot ad vissza, amelynek koordinátái az összeadott koordináták
        return Pont(x + masik.x, y + masik.y);
    }

    // '==' operátor túlterhelése tagfüggvényként (két pont egyenlősége)
    bool operator==(const Pont& masik) const {
        return (x == masik.x && y == masik.y);
    }

    // '!=' operátor is érdemes definiálni, összhangban az '=='-el
    bool operator!=(const Pont& masik) const {
        return !(*this == masik);  // kihasználjuk az == eredményét
    }

    // friend deklaráció a '<<' operátorhoz
    friend ostream& operator<<(ostream& os, const Pont& p);
};

// '<<' operátor túlterhelése globális függvényként (friendként hozzáfér a privát tagokhoz)
ostream& operator<<(ostream& os, const Pont& p) {
    os << "(" << p.x << ", " << p.y << ")";
    return os;
}

Magyarázat:

  • A Pont operator+(const Pont& masik) const függvény definiálja, hogy mi történik, ha két Pont objektumot összeadunk a + operátorral. Jelen esetben egy új Pont példányt hoz létre, melynek x koordinátája az eredeti x plusz a masik.x, és hasonlóan az y. Fontos, hogy nem módosítja egyik pontot sem, hanem új objektumot ad vissza (ez általában elvárás a + esetén – lásd funkcionalitás és meglepetés-elv).
  • Az operator== és operator!= példaként mutatja két pont egyenlőségének vizsgálatát (akkor egyenlő két pont, ha mindkét koordinátájuk megegyezik). Az != logikusan az == negációja.
  • A operator<< túlterhelés nem lehet tagfüggvény a ostream miatt (nem a mi osztályunk), ezért globális függvényként definiáljuk. Ahhoz, hogy elérje a Pont privát tagjait (x, y), frienddé tettük a deklarációját az osztályon belül. Az implementáció egyszerűen kiírja a pontot a formátumban: (x, y).

Most használjunk néhány operátort:

int main() {
    Pont p1(2, 3);
    Pont p2(5, 1);
    Pont p3 = p1 + p2;            // '+' operátor hívása
    cout << "p1 + p2 = " << p3 << endl;  // '<<' operátor hívása, várható kimenet: (7, 4)

    if(p1 != p2) {
        cout << "p1 és p2 különböző pontok." << endl;
    }
    Pont p4(2, 3);
    if(p1 == p4) {
        cout << "p1 és p4 megegyeznek." << endl;
    }
}

Futási eredmény lehet:

p1 + p2 = (7, 4)
p1 és p2 különböző pontok.
p1 és p4 megegyeznek.

Az operátorok túlterhelésénél figyeljünk a következőkre:

  • Ne változtassuk meg az operátor “természetes” jelentését. Pl. a + operátor esetén várható, hogy az nem destruktív (az eredeti operandusokat nem módosítja, hanem egy új értéket ad), míg mondjuk a += már módosítja a bal oldalt. Tehát ha definiáljuk a +=-t, az általában a bal oldali objektumot változtassa meg (és visszatér *this referencia).
  • Tartsuk be az operátorokra vonatkozó matematikai/logikai törvényszerűségeket. Ha definiáljuk ==-t, akkor illik definiálni !=-t is, és a két művelet legyen egymásnak ellentettje. Hasonlóképp, ha van <, akkor érdemes lehet > vagy <=, >= is, bár ezeket nem kötelező mind túlterhelni.
  • Bizonyos operátorokat nem enged a nyelv túlterhelni: pl. a .: (tagsági), ?: (feltételes), sizeof nem túlterhelhető. Illetve új operátort sem hozhatunk létre, csak a meglévőket definiálhatjuk újra saját típusokra.
  • Konverziós operátorok is definiálhatók (pl. egy osztály hogyan alakuljon át int-é), de ezekkel vigyázni kell, mert a fordító váratlanul is hívhatja őket (rejtett konverziók), ezért csak szükség esetén használjuk.
  • Az operátor függvény lehet const tagfüggvény, ha nem változtatja az objektumot (ahogy láttuk a operator+ példában, ami nem változtatja a meglévő pontot, csak újat ad vissza).

Összességében az operátor túlterhelés hasznos eszköz, amivel a kódunk kifejezőbbé válhat, mintha sima függvényhívásokat (pl. add(p1, p2)) alkalmaznánk, de mindig törekedjünk arra, hogy a kód olvashatósága és a megszokott jelentéstartalom ne sérüljön.

Fájlkezelés és adatok mentése JSON formátumba

A programok gyakran igénylik, hogy adataikat tartósan elmentsék vagy más rendszerekkel (pl. web szolgáltatások) adatot cseréljenek. Az egyik modern és elterjedt formátum az adatok reprezentálására a JSON (JavaScript Object Notation), amely egy könnyen olvasható szöveges formátum kulcs-érték párokkal, beágyazott struktúrákkal.

C++-ban a JSON kezelésére nincs beépített nyelvi konstrukció, de számos könyvtár elérhető. Az egyik népszerű a nlohmann/json könyvtár, amely header-only, könnyen használható. Ezzel a könyvtárral akár olyan integrációt is készíthetünk, hogy saját osztályainkat közvetlenül lehessen JSON-né konvertálni, ill. JSON-ból visszaalakítani.

JSON mentés alapötlete: egy objektumot (pl. egy osztály példányát) valamilyen struktúrált formában egy fájlba írunk. A JSON lényegében szöveg, ami tartalmazza az objektum adattagjait kulcsokkal ellátva. Például legyen egy egyszerű Ember osztályunk, és mentsük JSON-be:

#include <fstream>
#include <nlohmann/json.hpp>
using json = nlohmann::json;

class Ember {
private:
    string nev;
    int kor;
public:
    Ember(string n, int k) : nev(n), kor(k) {}
    string getNev() const { return nev; }
    int getKor() const { return kor; }
};

// to_json függvény a konvertáláshoz
void to_json(json& j, const Ember& e) {
    j = json{{"nev", e.getNev()}, {"kor", e.getKor()}};
}

// from_json függvény a visszaalakításhoz
void from_json(const json& j, Ember& e) {
    // Itt mivel privát tagokról van szó, megoldhatjuk úgy,
    // hogy ideiglenes változókat olvasunk ki, majd e = Ember(név, kor) ha lenne értelme.
    string n = j.at("nev").get<string>();
    int k = j.at("kor").get<int>();
    e = Ember(n, k);
}

int main() {
    Ember ember("Géza", 45);
    json j = ember; // to_json segítségével átalakítja

    // JSON objektum szöveggé alakítása (pretty print 4 space indenttel) és fájlba írás
    ofstream fajl("ember.json");
    fajl << j.dump(4);
    fajl.close();

    // Olvasás fájlból és visszaalakítás Ember objektummá
    ifstream be("ember.json");
    json j2;
    be >> j2;              // beolvassuk a JSON tartalmat
    Ember ujEmber = j2.get<Ember>();  // from_json segítségével Emberré alakítjuk
    cout << "Név: " << ujEmber.getNev() << ", Kor: " << ujEmber.getKor() << endl;
}

Magyarázat:

  • A to_json és from_json függvényeket a nlohmann/json könyvtár speciális sablonmechanizmus útján automatikusan használja, mikor egy adott típusú objektumot közvetlenül JSON-né konvertálunk vagy vissza. A fenti kódban, amikor json j = ember; sor fut, a fordító megtalálja a megfelelő to_json(json&, const Ember&) függvényt, és azt hívja meg. Hasonlóan a j2.get<Ember>() híváskor a from_json(const json&, Ember&) függvényt hívja, hogy létrehozza az Ember példányt.

  • A JSON formátum szerint itt az Ember objektum így nézne ki a fájlban (ember.json):

    {
        "nev": "Géza",
        "kor": 45
    }
    

    Ez egy egyszerű kulcs-érték struktúra.

  • A fájlba írás std::ofstream-fal, olvasás std::ifstream-fal történik (fejléc: <fstream>). Egyszerűen úgy használjuk, mint a cout-ot vagy cin-t, csak fájlba irányítva.

  • Fontos megjegyezni, hogy a fenti from_json megoldás akkor működik, ha az Ember osztálynak van megfelelő konstruktora, vagy default konstruktora és setterei. Máskülönben a közvetlen visszaalakítás trükkösebb lehet. Alternatív megoldás, ha a from_json egy külön publikus statikus függvényt hív, ami például privát tagokkal is boldogulhat, vagy baráttá tesszük.

JSON használata Observer minta kapcsán: Az Observer mintánál tipikusan arról van szó, hogy ha egy adatmodell változik, értesítjük a megfigyelőket, például hogy frissítsék a kijelzőt vagy mentsék az adatokat. Ekkor hasznos lehet JSON formátumba kimenteni az aktuális állapotot, vagy a változásokat logolni.

Összességében a JSON mentés megkönnyíti az adatok más rendszerekkel való cseréjét (mivel a JSON univerzálisan érthető formátum, pl. webes API-k is használják), és ember számára is olvasható. Alternatívaként persze használhatunk más formátumokat is (CSV, XML, vagy saját bináris formátum), de a JSON manapság egy jó választás, ha platformfüggetlen, könnyen debug-olható formátumot szeretnénk.

Observer tervezési minta

Az Observer minta egy szoftvertervezési minta, amely a publikáló-feliratkozó (publish-subscribe) elvet követi. A lényege, hogy van egy Subject (téma, megfigyelt alany) objektum, amely állapotváltozásairól értesíteni szeretne más objektumokat, az Observer-eket (megfigyelőket). Az Observer-ek feliratkoznak a Subject-nél, és amikor a Subject-ben valami történik (például megváltozik egy adat), akkor a Subject értesíti az összes megfigyelőt valamilyen módon (tipikusan egy meghívott metóduson keresztül).

Ez a minta lazán csatolt rendszert eredményez: a Subject nem tud sokat az Observer-ekről, csak azt, hogy van egy közös interfészük, amin keresztül értesíteni lehet őket. Az Observer-ek pedig csak a feliratkozást végzik el, utána “passzívan” várják az értesítéseket.

Tipikus példa: Vegyünk egy egyszerű példát: van egy adatforrás objektum (Subject), mondjuk egy hőmérő szenzor adatait tárolja, és több kijelző (Observer) van, ami kiírja a hőmérsékletet különböző formátumban. Amikor a hőmérséklet változik, a szenzor objektum értesíti a kijelző objektumokat, hogy frissítsék magukat.

Observer minta C++ megvalósítása:

  1. Definiálunk egy Observer interfészt (absztrakt osztályt), mondjuk IHőmérsékletObserver, aminek van egy ertesites(float ujErtek) tiszta virtuális metódusa.
  2. Definiálunk egy Subject osztályt, pl. Homero (hőmérő), ami tárol egy listát az Observerekről (tipikusan pointereket vagy okos pointereket az interfész típusra) és van benne:
    • feliratkozik(IHőmérsékletObserver* obs) – ezzel lehet hozzáadni egy megfigyelőt.
    • leiratkozik(IHőmérsékletObserver* obs) – ezzel eltávolítani.
    • Valamint amikor változik a hőmérséklet (pl. setTemperature függvény), akkor meghívja a privát ertesitMindenkit() függvényét, ami végigmegy a listán és mindegyik observer ertesites() metódusát meghívja az új értékkel.
  3. A konkrét Observerek (pl. KijelzoLCD, KijelzoGrafikus) megvalósítják az Observer interfészt, tehát saját ertesites függvényük van, amiben mondjuk kiírják a kapott értéket valahová.

Egyszerűsített példa kóddal:

#include <vector>
#include <algorithm>

// Observer interfész
class IHőmérsékletObserver {
public:
    virtual void frissit(float homerseklet) = 0;
    virtual ~IHőmérsékletObserver() {}
};

// Subject osztály
class Homero {
private:
    float homerseklet;
    std::vector<IHőmérsékletObserver*> megfigyelok;
public:
    void feliratkozik(IHőmérsékletObserver* obs) {
        megfigyelok.push_back(obs);
    }
    void leiratkozik(IHőmérsékletObserver* obs) {
        megfigyelok.erase(std::remove(megfigyelok.begin(), megfigyelok.end(), obs),
                           megfigyelok.end());
    }
    void beallitHomerseklet(float ujErtek) {
        homerseklet = ujErtek;
        ertesitMegfigyelok();
    }
private:
    void ertesitMegfigyelok() {
        for(IHőmérsékletObserver* obs : megfigyelok) {
            obs->frissit(homerseklet);
        }
    }
};

// Két példa observer osztály:
class LCDKijelzo : public IHőmérsékletObserver {
public:
    void frissit(float homerseklet) override {
        std::cout << "[LCD] Új hőmérséklet: " << homerseklet << " C fok" << std::endl;
    }
};

class GrafikusKijelzo : public IHőmérsékletObserver {
public:
    void frissit(float homerseklet) override {
        std::cout << "[Grafikus] Hőmérséklet grafikon frissítése az új értékkel: " 
                  << homerseklet << " C" << std::endl;
    }
};

Használat:

int main() {
    Homero homero;
    LCDKijelzo lcd;
    GrafikusKijelzo grafikus;
    homero.feliratkozik(&lcd);
    homero.feliratkozik(&grafikus);

    homero.beallitHomerseklet(22.5f);
    homero.beallitHomerseklet(23.0f);

    homero.leiratkozik(&lcd);
    homero.beallitHomerseklet(21.0f);
}

Lehetséges kimenet:

[LCD] Új hőmérséklet: 22.5 C fok
[Grafikus] Hőmérséklet grafikon frissítése az új értékkel: 22.5 C
[LCD] Új hőmérséklet: 23 C fok
[Grafikus] Hőmérséklet grafikon frissítése az új értékkel: 23 C
[Grafikus] Hőmérséklet grafikon frissítése az új értékkel: 21 C

Látható, hogy az Observer minta segítségével rugalmasan hozzáadhatunk vagy eltávolíthatunk megfigyelőket anélkül, hogy a Homero osztályt módosítani kellene. A Homero csak az interfészen keresztül kommunikál (hívja a frissit-et), nem tudja, valójában milyen konkrét osztályok vannak a listában. Ugyanígy a kijelzők sem “kérdezik le” a hőmérőt folyamatosan, hanem a hőmérő tolja ki nekik az új adatot.

Observer minta a gyakorlatban: Számos GUI keretrendszer eseménykezelése (pl. kattintás esemény figyelése) vagy akár a standard könyvtár std::condition_variable jelzésmechanizmusa is hasonló elven működik. Az Observer minta segít szétválasztani a modellt és a nézetet (model-view), illetve implementálni lehet vele az MVC (Model-View-Controller) architektúrában a modell->view értesítést.

Megjegyzés: Fontos a leiratkozás kezelése (fent egyszerűen kivettük a vektorból az elemet). Bonyolultabb helyzetekben figyelni kell arra is, ha egy Observer életciklusa véget ér (pl. törlődik) akkor leiratkozzon, különben a Subject egy lógó pointert próbálna értesíteni. Erre okos megoldás lehet gyenge pointerek (weak_ptr) használata, vagy az, hogy az Observer értesítéskor vissza is igazolhat, stb., de ez már részletkérdés.

Összegzés

Ez a bevezető áttekintette a C++ nyelv alapvető elemeit és az objektumorientált programozás főbb koncepcióit a nyelvben. Megismertük a nyelvi alapokat (típusok, konstansok, I/O, névterek, függvények és paraméterátadás, memória kezelés), majd elmélyedtünk az OOP alapelvekben: osztályokat definiáltunk, láttuk az egységbezárás fontosságát a privát adatokkal és publikus metódusokkal, a konstruktorok és destruktorok szerepét az objektumok életciklusában, valamint az öröklés mechanizmusát, amellyel újrahasznosítható és kiterjeszthető kódot írhatunk. A polimorfizmus révén a program képes a futás idején az adott objektum típusának megfelelő viselkedést megvalósítani, amit a virtuális függvények és az absztrakt osztályok tettek lehetővé. Szó esett továbbá a sablonokról, amelyekkel típusfüggetlen kódot írhatunk, illetve az operátorok túlterheléséről, mellyel természetesebb szintaxist adhattunk saját típusainknak. Végül kitekintettünk a fájlkezelésre, különösen a JSON formátum használatára az adatok mentésében, és az Observer tervezési mintára, amely összetettebb alkalmazásokban segít az objektumok közti esemény-alapú kommunikáció megszervezésében.

A C++ egy erős és sokrétű nyelv, de komplexitása miatt fokozatosan érdemes elsajátítani. A fenti témák megértése szilárd alapot nyújt a kezdő és középhaladó programozóknak, hogy magabiztosan írjanak C++ programokat és kiaknázzák a nyelv nyújtotta lehetőségeket. Fontos a sok gyakorlás: a koncepciók akkor rögzülnek igazán, ha kipróbáljuk őket valódi kódban, kísérletezünk, és akár hibákon keresztül tanulunk. A következő lépésként javasolt mélyebben foglalkozni a modern C++ további funkcióival (okos mutatók, lambda kifejezések, mozgástechnika stb.), valamint további tervezési mintákkal és a szabványos könyvtár eszközeivel.


C++ Programozás tanterv

Ez az egyéves, BSc szintű tanterv a C++ programozás elsajátítását tűzi ki célul. Különös hangsúlyt fektet a versenyprogramozásra és a szoftverfejlesztői gyakorlatban is hasznosítható tudásra. A tananyag lépcsőzetesen, több mint 100 lépésben épül fel az alapoktól a középhaladó szintig és azon túl. A hallgatók megismerkednek a C++ nyelv alapvető szintaxisával és programozási koncepcióival, majd fokozatosan továbblépnek a haladóbb C++ technikákra (például az STL használata, memóriaoptimalizálás, többszálú programozás). Emellett elmélyülnek az algoritmusok és adatstruktúrák világában (dinamikus programozás, gráfalgoritmusok, keresési és rendezési algoritmusok stb.).

A tanterv kitér a versenyprogramozási stratégiákra és problémamegoldási módszerekre is, minden szakaszban gyakorlati példákkal és feladatokkal segítve a tanultak alkalmazását. A tanulási folyamat projekt alapú megközelítést is alkalmaz: a hallgatók kisebb-nagyobb programozási projekteken keresztül fejlesztik képességeiket és tanulnak a kódoptimalizálási szempontokról is. Az alábbiakban a tanterv egy éves ütemezését heti bontásban mutatjuk be, hogy a hallgatók egyenletes ütemben, szervezetten haladhassanak.

1. hónap: C++ programozás alapjai

Az első hónap célja, hogy a hallgatók stabil alapokat szerezzenek a C++ nyelvben. Ezen hetek alatt megismerik a fejlesztői környezet beállítását, a programok fordításának és futtatásának menetét, valamint a nyelv alapvető elemeit: a változókat, adattípusokat, bemenetet-kimenetet és operátorokat. Sorra kerülnek az egyszerű vezérlési szerkezetek (feltételes elágazások és ciklusok), amelyekkel már kisebb, de működőképes programokat írhatnak.

1. hét: Fejlesztői környezet és első program

  • Fejlesztőkörnyezet (IDE) és C++ fordító telepítése, beállítása
  • Egyszerű Hello, World! program megírása, fordítása és futtatása
  • A C++ program alapvető szerkezete: #include direktívák, main() függvény, kimenet a konzolra

2. hét: Változók és adattípusok, I/O műveletek

  • Alapvető adattípusok (egész, lebegőpontos, karakter, logikai) és változók deklarálása, inicializálása
  • Konzolos bemenet és kimenet használata: std::cout és std::cin alapok
  • Alapvető műveletek változókkal: értékadás, aritmetikai műveletek, műveleti sorrend és zárójelezés

3. hét: Feltételes elágazások (if/switch) és logikai műveletek

  • Feltételes utasítások: if-else szerkezet használata egyszerű programokban
  • Összehasonlító és logikai operátorok (==, !=, <, >, &&, ||, !) használata összetettebb feltételekben
  • Többágú elágazás: switch szerkezet bemutatása és példák (pl. egyszerű menü rendszer)
  • Példafeladatok: páros vagy páratlan szám eldöntése, értéktartomány ellenőrzése, érdemjegyhez szöveges minősítés rendelése

4. hét: Ciklusok (for, while) és egyszerű algoritmusok

  • Ismétlődő műveletek megvalósítása while, do-while és for ciklusokkal
  • A ciklusvezérlés eszközei: break és continue kulcsszavak használata
  • Egyszerű algoritmusok ciklusokkal: összegzés, szorzat számítása, faktoriális, Fibonacci-számok generálása
  • Gyakorló feladatok: adott szám faktoriálisának kiszámítása, első N szám összegének meghatározása, számsorozatok és egyszerű minták kiíratása

2. hónap: Programozási alapismeretek elmélyítése

A második hónapban a hallgatók tovább bővítik alapismereteiket és elkezdik összetettebb programstruktúrák alkalmazását. Terítékre kerülnek a függvények mint a kód strukturálásának eszközei, valamint a tömbök használata az adatok tárolására és kezelésére. Emellett megismerkednek az algoritmikus gondolkodás alapjaival, például a rekurzió fogalmával, amely fontos szerepet játszik számos algoritmusban.

5. hét: Függvények és modularitás alapjai

  • Saját függvények írása és hívása: függvényfej definíciója, paraméterátadás és visszatérési értékek
  • A program strukturálása függvényekkel a jobb áttekinthetőség érdekében (például matematikai műveletek külön függvénybe szervezése)
  • Lokális és globális változók hatóköre, változók élettartama és a névütközés fogalma

6. hét: Haladó függvénykezelés és rekurzió

  • Érték szerinti és referencia szerinti paraméterátadás megkülönböztetése (pass by value vs. pass by reference)
  • Függvény-túlterhelés (function overloading) alapelve: több függvény definiálása azonos névvel, de különböző paraméterlistával
  • A rekurzió fogalma: önmagukat hívó függvények és a verem (stack) szerepe a rekurzív folyamatban
  • Egyszerű rekurzív példák: faktoriális és Fibonacci számítás rekurzívan, rekurzió vs. iteráció összehasonlítása

7. hét: Tömbök használata és algoritmikus gyakorlat

  • Egydimenziós tömbök deklarálása és használata, elemek elérése index segítségével
  • Tömbök bejárása ciklusokkal (pl. tömb elemeinek összege, minimum/maximum keresése)
  • Többdimenziós tömbök alapjai (mátrixok) és egyszerű műveletek velük (pl. mátrix inicializálása és kiíratása)
  • Gyakorló feladatok: tömb elemeinek átlagának kiszámítása, adott érték keresése a tömbben, mátrix transzponálása

8. hét: Mutatók (pointerek) és a memória kezelése alapfokon

  • A mutató fogalma: memória címek kezelése, pointer változók deklarálása és inicializálása
  • Pointer műveletek: érték elérése pointeren keresztül (dereferálás, * operátor) és cím lekérdezése (& operátor)
  • Mutatók használata tömbökkel: tömbnevek mint pointerek, pointer aritmetika alapjai (előre léptetés a memóriában)
  • Gyakori pointer hibák (null pointer, vad pointer) fogalma és elkerülésük módszerei

3. hónap: Memóriakezelés és összetettebb adatszerkezetek

A harmadik hónap a memória kezelésének mélyebb megértésére és további adatkezelési eszközök elsajátítására koncentrál. A hallgatók megtanulják a dinamikus memóriafoglalás módját és azt, hogyan bánjanak felelősen a foglalt memóriával (felszabadítás, memóriahibák elkerülése). E hetek során terítékre kerül a hivatkozás (reference) fogalma, a C++ string osztály használata a karakterláncok kényelmes kezeléséhez, valamint egyszerű adatszerkezetek, például a struktúrák definiálása és alkalmazása.

9. hét: Dinamikus memóriafoglalás

  • Memóriamodellek áttekintése: stack (verem) vs heap (kupac) és szerepük a program futása során
  • Dinamikus memória foglalása és felszabadítása C++-ban: new és delete (tömbök esetén new[] és delete[]) használata
  • Példa: dinamikusan méretezhető tömb létrehozása futási időben (pl. a felhasználó által megadott méret alapján)
  • Memóriaszivárgás (memory leak) fogalma és elkerülése a foglalt memória felszabadításával a megfelelő helyen

10. hét: Referenciák és mutatók haladó használata

  • Referenciák (&) fogalma és különbsége a pointerhez képest; a referencia mint állandó alias egy változóra
  • Referencia szerinti paraméterátadás függvényeknek (hatékonyság: másolás elkerülése nagyobb objektumok esetén)
  • Konstans referenciák használata (pl. csak olvasható paraméter átadásához)
  • Példa: értékek cseréje egy függvényben pointerekkel vs. referenciákkal (különböző megoldások összehasonlítása, pl. swap megvalósítása)

11. hét: Sztringek kezelése (std::string)

  • A std::string osztály használata a C stílusú karaktertömbök helyett (alap műveletek: összefűzés, hossz lekérdezése, karakter elérése indexszel)
  • Gyakoribb string műveletek: összehasonlítás, részstring kivágása (substr), keresés egy stringben (find)
  • Teljes sor beolvasása a bemenetről std::getline segítségével (buffer overflow problémák elkerülése a gets-szel szemben)
  • Példák: két string összefűzése és összehasonlítása, szóösszeszámolás (hány szó van egy beolvasott mondatban)

12. hét: Struktúrák és felhasználói típusok

  • Strukturált adatok tárolása struct segítségével: saját adattípus létrehozása több adat (mező) csoportosítására
  • Struktúra definiálása és használata példával (pl. Diak struktúra: név, életkor, átlag)
  • Struktúratömbök létrehozása és feldolgozása (pl. több diák adatainak kezelése egy tömbben, keresés adott nevű diákra)
  • Kitekintés: a struct és class közti formai különbség C++-ban (alapértelmezett hozzáférési szint: struct-nál public, class-nál private)

4. hónap: Algoritmusok alapjai és egyszerű adatstruktúrák

A negyedik hónap az algoritmikus gondolkodás megalapozásával foglalkozik. A hallgatók megtanulják, hogyan értékeljék egy algoritmus hatékonyságát (számítási komplexitás), és megismerik az adatok rendezésének és keresésének alapvető módszereit. Emellett sor kerül néhány egyszerűbb adatstruktúra (például verem és sor) működési elvének és felhasználásának bemutatására is. Ezek az ismeretek létfontosságúak a hatékony programok írásához és a versenyprogramozási feladatok megoldásához.

13. hét: Algoritmikus komplexitás és hatékonyság

  • Algoritmusok időbeli és memória komplexitásának fogalma (Big O notáció alapjai)
  • Példák különböző időkomplexitású kódokra: lineáris (O(n)), négyzetes (O(n^2)), logaritmikus (O(log n)), kombinált (O(n log n))
  • Egyszerű teljesítményelemzés: a bemeneti méret növekedésének hatása a futási időre (kis vs nagy inputesetek összehasonlítása)
  • Gyakorlati megfigyelés: különböző megoldások futási idejének mérése (pl. ciklussal összegzés vs. matematikai képlettel számítás)

14. hét: Rendezési algoritmusok alapjai

  • Az adatrendezés szükségessége és gyakori alkalmazásai (pl. adatok előkészítése kereséshez)
  • Egyszerű rendezési algoritmusok megismerése és implementálása: buborékrendezés, kiválasztásos rendezés
  • Az algoritmusok összehasonlítása hatékonyság szempontjából (műveletszám becslése kis adatméreten)
  • Beépített rendezés használata: std::sort bemutatása kis példával, és utalás a mögöttes algoritmusokra (quicksort, mergesort)

15. hét: Keresési algoritmusok

  • Lineáris keresés egy sorozatban és annak időkomplexitása (O(n))
  • Hatékonyabb keresés rendezett adatokban: bináris keresés algoritmusa (O(log n)) és implementációja (rekurzív és iteratív változat)
  • A bináris keresés alkalmazási feltétele: rendezett bemenet szükségessége a helyes működéshez
  • Példafeladat: egy adott érték keresése rendezett tömbben lineáris vs bináris kereséssel, az eredmények és futási idők összehasonlítása

16. hét: Adatstruktúrák – verem és sor

  • Absztrakt adatstruktúrák: verem (stack – LIFO) és sor (queue – FIFO) működési elve, felhasználási területei
  • Verem implementálása tömb segítségével vagy std::stack konténeradapter használata
  • Sor implementálása vagy std::queue használata (alapértelmezett megvalósítás: deque)
  • Példafeladat: zárójelek helyességének ellenőrzése veremmel (illeszkedő zárójel-párok keresése egy kifejezésben), ügyfélkiszolgálás modellezése sor adatszerkezettel

5. hónap: Objektumorientált programozás (OOP) alapjai

Az ötödik hónapban a tananyag az objektumorientált programozás (OOP) elveire vált, amelyek a modern C++ fejlesztés alapját képezik. A hallgatók megtanulják, miként hozhatnak létre saját osztályokat és objektumokat, és hogyan alkalmazhatják az OOP főbb fogalmait (például az öröklődést és a polimorfizmust) a kód újrafelhasználhatóságának és karbantarthatóságának javítására. Ezek az ismeretek nem csak a versenyprogramozásban, hanem a nagyobb szoftverprojektekben is kulcsfontosságúak.

17. hét: Osztályok és objektumok

  • Saját osztály definíciója C++-ban: adattagok (mezők) és függvénytagok (metódusok) deklarálása
  • Objektum példányosítása és használata: például egy Pont osztály létrehozása (x, y koordinátákkal) és metódus a távolság számítására
  • Adatelrejtés és hozzáférés-módosítók: public, private (és protected) kulcsszavak szerepe az osztályokban
  • Egyszerű példa: egy Számláló osztály, amely növeli vagy csökkenti egy belső számlálót és lekérdezhető az aktuális értéke

18. hét: Konstruktorok, destruktorok és az osztályok részletei

  • Konstruktorok szerepe és definíciója: alapértelmezett és paraméterezett konstruktor; konstruktor túlterhelése többféle inicializáláshoz
  • Destruktor jelentősége a dinamikus memória felszabadításánál (ha az osztály dinamikus erőforrást használ, a destruktorban való felszabadítás)
  • A this mutató fogalma és használata (pl. folytonos metódushívás esetén vagy amikor egy objektum önmagára mutató pointere szükséges)
  • Példa: Komplex szám osztály konstruktorral (két double adattaggal) és hozzá destruktor (bár itt konkrét erőforrás nincs, a példa kedvéért megvalósítva)

19. hét: Öröklés (inheritance) és származtatott osztályok

  • Öröklés fogalma: egy bázisosztály tulajdonságainak és metódusainak átvétele egy származtatott osztályban
  • Öröklési láthatóság (public, protected, private öröklés) és hatása a tagok elérhetőségére a leszármazottban
  • Példa: Allat bázisosztály, és belőle származtatott Kutya és Macska osztályok, amelyek további tulajdonságokat vagy metódusokat adnak hozzá (ugatás, nyávogás metódus stb.)
  • Az is-a kapcsolat megértése és helyes használata (mikor érdemes öröklést alkalmazni és mikor nem)

20. hét: Polimorfizmus és virtuális függvények

  • Polimorfizmus fogalma: ugyanazon típusú pointer/reference alatt futásidőben többféle konkrét osztály viselkedhet eltérően (virtuális függvények révén)
  • Virtuális függvények deklarálása a bázisosztályban és felüldefiniálása (override) a leszármazott osztályokban
  • Absztrakt osztályok és tisztán virtuális függvények: ha egy osztályt nem példányosítunk közvetlenül, csak keretet ad a leszármazottaknak (interface jelleg)
  • Példa: Geometriai alakzatok osztályhierarchiája – egy Alakzat absztrakt osztály virtuális terulet() függvénnyel, melyet a Teglalap és Kor osztályok saját képlettel valósítanak meg

6. hónap: Haladó C++ technikák és STL bevezetése

A hatodik hónapban a hallgatók megismerkednek a C++ nyelv néhány haladóbb lehetőségével (például az operátorok túlterhelésével és a sablonok használatával), és elkezdik felfedezni a Standard Template Library (STL) alapjait. Az operátor túlterhelés (operator overloading) segítségével a saját osztályok viselkedését a beépített operátorokhoz hasonlóvá tehetik, míg a sablonok (template-ek) használata lehetővé teszi általános érvényű kód írását. A kivételkezelési mechanizmus megismerése segít a robusztusabb programok készítésében. Ebben a szakaszban megkezdjük az STL főbb komponenseinek (konténerek és algoritmusok) tárgyalását is, melyek hatékony eszközöket nyújtanak a gyakori programozási feladatokhoz.

21. hét: Operátor túlterhelés és egyéb nyelvi finomságok

  • Az operátor-túlterhelés fogalma és egyszerű esetei: pl. a + operátor túlterhelése egy Komplex szám osztályban, hogy két komplex objektumot össze lehessen adni
  • Felhasználói típusok integrálása a nyelvbe operátorokkal: összehasonlító operátorok (==, <) definiálása saját osztályokhoz a rendezés vagy keresés támogatására
  • Barát függvények (friend) szerepe operátorok túlterhelésénél (pl. << operátor túlterhelése ostream-hez, hogy az objektum kiírható legyen)
  • Figyelmeztetés: az operátorok túlterhelését ésszerűen használjuk – a kód olvashatóságának megőrzése fontos szempont

22. hét: Sablonok (Templates) alapjai

  • Függvény sablonok: általános függvények írása, amelyek különböző típusú paraméterekkel is működnek (példa: típusfüggetlen minimumot kereső függvény sablonja)
  • Osztály sablonok: saját általános osztály készítése sablonként (egyszerű példa: Par sablon két tetszőleges típusú érték párban történő tárolására)
  • A sablonok jelentősége: kód újrafelhasználása típusfüggetlen módon, és az STL konténerek megértésének alapja (mivel az STL implementációk sablon osztályok)
  • Gyakorlati példa: generikus swap függvény sablon létrehozása, mely bármilyen típusú két értéket meg tud cserélni

23. hét: Kivételkezelés (Exception handling)

  • A kivételek szerepe a hibakezelésben: különbség a hagyományos hibakód-visszaadáshoz képest (a vezérlés átugorhat több hívási szintet is hiba esetén)
  • try-catch blokkok használata, több catch ág különböző kivétel típusokra (standard kivételek: pl. std::exception, std::out_of_range stb.)
  • Saját kivétel definíciója szükség esetén (egyedi osztály, ami öröklődik std::exception-ből) és kivétel dobása (throw)
  • Példa: hiba kezelése osztással nullával – dobjunk std::runtime_error kivételt a függvényben, és kezeljük a hívó kódban a hibát egy megfelelő üzenet kiírásával

24. hét: A Standard Template Library (STL) áttekintése (vektorok)

  • Az STL fogalma és részei: konténerek, iterátorok, algoritmusok, függvényobjektumok rövid áttekintése
  • A vector (std::vector) konténer részletes megismerése: dinamikus tömbként való használat, elemek hozzáadása (push_back), elérése indexxel, méretének lekérdezése (size)
  • Összehasonlítás: statikus tömb vs std::vector (előnyök: rugalmasság futásidőben, biztonságos memóriahozzáférés at() használatával, méretezhetőség)
  • Egyszerű példa: egész számok listájának tárolása és rendezése std::vector segítségével (beolvasott adatok rendezése std::sort használatával)

7. hónap: STL konténerek és algoritmusok mélyítése

A hetedik hónapban a hallgatók elmélyednek az STL konténerek és algoritmusok használatában, valamint megismerkednek a modern C++ egy-két további vívmányával. Tovább tárgyaljuk a különböző konténertípusokat (lista, halmaz, asszociatív tömb stb.), valamint az ezekhez tartozó algoritmusokat és függvényeket. Emellett szó lesz a lambda kifejezések használatáról az STL-lel együtt, és az okos pointerekről, amelyek a biztonságos memóriakezelést segítik elő a fejlesztői gyakorlatban.

25. hét: Gyakori STL konténerek I. (lista, verem, sor)

  • A lista (std::list, dupla láncolt lista) konténer használata és jellemzői, összehasonlítva a vektorral (hol előnyös: pl. lista közepébe beszúrás/törlés hatékonyabb)
  • Verem és sor konténer-adapterek: std::stack és std::queue használata (belsőleg ezek alapértelmezetten deque-ot vagy listát használnak a tároláshoz)
  • A deque (std::deque, kettős végű sor) bemutatása röviden mint a stack/queue alapértelmezett tárolója (elejéről és végéről is hatékony műveletek)
  • Példák: verem használata visszafelé történő bejáráshoz, sor használata egy egyszerű ügyfélsor modellezéséhez

26. hét: Gyakori STL konténerek II. (halmaz és asszociatív tömbök)

  • Halmaz (std::set) és multiset (többszörös előfordulást engedő halmaz) használata, tipikus műveletek (beszúrás, törlés, keresés) és ezek időkomplexitása (O(log n))
  • Térkép (std::map, kulcs-érték párok rendezett tárolója) és hash tábla (std::unordered_map) bemutatása, használata
  • Hash alapú konténerek (unordered_set, unordered_map) összehasonlítása a rendezett változatokkal (átlagos esetben O(1) műveletek vs garantált O(log n))
  • Példafeladat: szógyakoriság számlálása egy szövegben std::map vagy std::unordered_map segítségével (kulcs: szó, érték: előfordulások száma)

27. hét: STL algoritmusok és lambda kifejezések

  • Az <algorithm> könyvtár gyakran használt algoritmusai: pl. std::sort, std::reverse, std::lower_bound, std::unique, std::find – használatuk konkrét példákkal
  • Iterátorok használata az algoritmusokkal: begin/end iterátorok lekérése, konstans iterátorok, valamint a C++11 range-based for ciklus konténerek bejárására
  • Lambda kifejezések (C++11) bemutatása: névtelen függvények írása helyben, például egyedi rendezési kritérium megadására a std::sort-nál
  • Példa: összetett objektumok (pl. diák struktúrák) rendezése többféle szempont szerint lambda kifejezéssel (jegy vagy név szerinti rendezés)

28. hét: Okos pointerek és erőforrás-kezelés

  • Okos pointerek szerepe: automatikus memóriafelszabadítás RAII elv alapján. std::unique_ptr (egyedi tulajdonlás) és std::shared_ptr (megosztott tulajdonlás) működése, alapvető használata
  • Mikor melyik okos pointert használjuk: egyedüli tulajdonlás vs megosztott (példák: egy objektum több helyen is szükséges, megosztott pointer használata; vagy egyszerű lokális erőforrás, unique_ptr használata)
  • Gyakorlati példa: dinamikusan foglalt objektum kezelése okos pointerrel manuális new/delete helyett (pl. dinamikus tömb átcsomagolása std::unique_ptr-be)
  • Egyéb RAII példák: fájl megnyitása és automatikus zárása egy objektum destruktorában (erőforrások biztos felszabadítása kódlezáráskor)

8. hónap: Többszálú programozás és alacsony szintű optimalizációk

A nyolcadik hónap során a hallgatók betekintést nyernek a párhuzamos programozásba és néhány alacsony szintű optimalizációs technikába. Megismerik a többszálú programok alapjait C++ környezetben, beleértve a szálak létrehozását és a szinkronizáció egyszerű módszereit. Ezt követően olyan nyelvi eszközökkel és programozási technikákkal foglalkozunk, amelyekkel a program teljesítménye tovább javítható, például a bitműveletek és a bitmezők használatával történő memóriaoptimalizálás.

29. hét: Többszálú programozás alapjai (multithreading)

  • Új szál indítása C++11 std::thread használatával; példa: párhuzamos feladat futtatása két szálon egyszerre
  • A párhuzamos végrehajtás előnyei (gyorsítás többmagos rendszeren) és buktatói (versenyhelyzetek – data race – kialakulása)
  • Adatverseny (race condition) fogalma és megelőzésének alapjai
  • Egyszerű szinkronizáció std::mutex és std::lock_guard használatával egy kritikus szekció védelmére

30. hét: Többszálúság – szinkronizáció és kommunikáció

  • További szinkronizációs eszközök: feltételi változók (std::condition_variable) használata (példa: termelő-fogyasztó probléma megoldásának felvázolása)
  • Szálak közti kommunikáció megvalósítása: például egyik szál számokat generál egy listába, a másik szál folyamatosan figyeli és feldolgozza az új elemeket (wait-notify mechanizmus)
  • A többszálú programok tervezési szempontjai: hol érdemes párhuzamosítani, és hol nem (túl sok szál kezelése okozta overhead, kontextusváltások költsége)
  • Hibakeresés többszálú környezetben: tipikus problémák felismerése (deadlock, livelock) és elkerülése, debug technikák párhuzamos kódban

31. hét: Bitműveletek és bitoptimalizációk

  • Bitműveleti operátorok (&, |, ^, ~, <<, >>) használata és tipikus felhasználásai (bitösszehasonlítás, bitmanipuláció trükkök)
  • Bitmaszkok alkalmazása: adott bitek kiolvasása, beállítása, törlése konkrét példákon keresztül
  • Hatékony tárolás bit szinten: std::bitset konténer használata nagyszámú logikai érték (bit) tárolására, pl. bitmezők (flags) kezelése
  • Gyakorlati példa: bitműveletekkel megoldott feladat – két szám cseréje extra változó nélkül XOR műveletekkel, illetve halmazműveletek (metszet, unió) bitmaskok segítségével

32. hét: Memória- és teljesítményoptimalizálás

  • A programok teljesítményét és memóriahasználatát befolyásoló tényezők áttekintése
  • Ésszerű optimalizálási technikák: pl. memóriafoglalások számának csökkentése, cache-tudatosság (adatok egymás melletti tárolása a jobb kihasználtságért)
  • Inline függvények szerepe a teljesítményben, valamint a túlzott optimalizáció veszélyei (a premature optimization problémája)
  • Gyakorlati tippek: fordító optimalizációs kapcsolók (pl. -O2, -O3 flag-ek) és profilozó eszközök említése a teljesítmény mérésére és szűk keresztmetszetek megtalálására

9. hónap: Haladó algoritmusok és adatstruktúrák (Gráfok)

A kilencedik hónap visszatér az algoritmusok és adatstruktúrák témaköréhez, immár haladó szinten. A hallgatók megtanulják a gráfok ábrázolásának és bejárásának módjait, valamint néhány alapvető gráfalgoritmust (pl. legrövidebb út keresése, minimális feszítőfa készítése). Emellett a fákat mint speciális adatstruktúrákat is megismerik, amelyek számos probléma hatékony megoldásához szükségesek. Ezek a témák különösen fontosak a versenyprogramozási feladatok magasabb szintjén, és megalapozzák a komplex problémák kezelését.

33. hét: Gráfok ábrázolása és bejárása

  • A gráf fogalma (csúcsok és élek halmaza) és gyakori ábrázolási módjai: szomszédsági mátrix vs. szomszédsági lista
  • Mélységi (DFS) és szélességi (BFS) gráfbejárás algoritmusok ismertetése és implementációja
  • BFS és DFS alkalmazási területei: pl. összefüggő komponensek számlálása, útkeresés egy labirintusban (rácsgráfban)
  • Gyakorlati feladat: adott gráf komponenseinek megállapítása (hány különálló részgráf), vagy legrövidebb út megtalálása egy adott start ponttól BFS segítségével egy nem súlyozott gráfban

34. hét: Útvonal-keresési algoritmusok (legrövidebb út)

  • Súlyozott gráf fogalma (élhez tartozó súlyok/távolságok) és a legrövidebb út problémája
  • Dijkstra algoritmusa a legrövidebb út megtalálására egy forrásból – prioritásos sor (std::priority_queue) használata a megvalósításban
  • Egyéb legrövidebb út algoritmusok említése: Bellman–Ford (negatív élhosszak esetére), Floyd–Warshall (minden páros legrövidebb utak mátrixa)
  • Példafeladat: városok és utak gráfján a legrövidebb út és távolság kiszámítása két megadott város között Dijkstra algoritmussal

35. hét: Fa adatszerkezetek és alkalmazásuk

  • A fa mint speciális gráf: nincs benne kör, N csúcs esetén N-1 él; példák: családfa, fájlrendszer
  • Bináris fa és bináris keresőfa (BST) alapelvei és műveletei (beszúrás, keresés, törlés) – magas szintű áttekintés vagy egyszerű implementáció
  • Kiegyensúlyozott fák említése (AVL, piros-fekete fa) és megjegyzés, hogy az std::map/set belső implementációja egy kiegyensúlyozott fa
  • Fa bejárási módszerek: preorder, inorder, postorder; alkalmazásuk (pl. kifejezésfák kiértékelése inorder bejárással)
  • Példa: egyszerű telefonkönyv megvalósítása bináris keresőfával, ahol a kulcs a név, az adat a telefonszám (keresés név alapján)

36. hét: Minimális feszítőfa és unió-talál adatszerkezet (Union-Find)

  • Minimális feszítőfa (Minimum Spanning Tree – MST) problémája és jelentősége (pl. legrövidebb hálózat kiépítése adott pontok összekötésére)
  • Kruskal algoritmusa MST feladatára: élek növekvő súly szerinti rendezése és felvétele, Union-Find adatszerkezet használata a ciklusok elkerülésére
  • Union-Find (Disjoint Set Union) adatszerkezet működése: unió és find műveletek, útösszevonás (path compression) és rang alapján egyesítés a hatékonyságért
  • Prim algoritmus rövid ismertetése és összehasonlítása Kruskallal (más megközelítés: csúcsok hozzáadása, prioritásos sor használatával)
  • Példafeladat: hálózattervezési probléma (pl. minimális összköltségű úthálózat) megoldása Kruskal algoritmussal és Union-Find struktúrával

10. hónap: Dinamikus programozás és visszalépéses keresés

A tizedik hónap középpontjában a dinamikus programozási technikák és a visszalépéses keresés (backtracking) állnak. Ezek a módszerek számos összetett probléma megoldását teszik lehetővé, és gyakran szerepelnek versenyprogramozási feladatokban. A hallgatók megismerkednek a dinamikus programozás alapötletével és klasszikus példáival, majd a visszalépéses kereséssel mint az összes lehetőség bejárásának módszerével. Kiemelten foglalkozunk azzal is, hogyan lehet ezeket a módszereket hatékonyabbá tenni optimalizációkkal.

37. hét: Dinamikus programozás alapjai

  • A dinamikus programozás koncepciója: probléma felbontása kisebb alproblémákra, részmegoldások eltárolása a későbbi felhasználásra (memoizáció vagy táblázatos kitöltés)
  • Egyszerű példa: Fibonacci-számok számítása dinamikus programozással (összehasonlítva a tisztán rekurzív megoldással, időbeli nyereség bemutatása)
  • Tipikus DP felépítés: állapottér definiálása, átmeneti függvény meghatározása, optimális megoldás rekonstrukciójának lehetősége
  • Tárhely-idő kompromisszum: memóriahasználat növelésével (táblázat fenntartása) csökkenthető a futási idő, és fordítva

38. hét: Klasszikus dinamikus programozási feladatok

  • Leghosszabb közös részszekvencia (Longest Common Subsequence – LCS) problémája és dinamikus programozásos megoldása
  • 0-1 hátizsák probléma (Knapsack) megfogalmazása és megoldása DP-vel (táblázat kitöltés soronként vagy oszloponként)
  • További alapvető DP példák: lépcsőmászás problémája (hányféleképpen lehet feljutni n lépcsőn 1 vagy 2 lépésekkel), minimális érmefelhasználás adott összeg kifizetésére
  • Gyakorló feladat: kisebb DP problémák kódolása és megoldása, a DP tábla kitöltésének vizualizálása a megértés érdekében

39. hét: Haladó DP technikák és optimalizációk

  • Egy- és többdimenziós DP összehasonlítása: állapottér méretének csökkentése (pl. két soros táblázattal való megoldás memóriamegtakarítás céljából)
  • Megoldás visszafejtése a DP táblából: pl. LCS megoldás rekonstruálása (a közös szekvencia előállítása a táblázatból), döntések nyomon követése
  • Bitmask DP fogalma: dinamikus programozás bitmaszkkal reprezentált állapotokkal (pl. Utazó ügynök probléma (TSP) megemlítése mint példa)
  • Gyakorlati példa: kis méretű TSP megoldása bitmask DP-vel, vagy más kombinatorikus optimalizációs feladat állapotainak bites reprezentációja

40. hét: Visszalépéses keresés (Backtracking) és heuristikák

  • A backtracking lényege: próbálkozás és visszalépés – a keresési fa teljes bejárása minden lehetőség kipróbálásával
  • Klasszikus backtracking példák: n királynő probléma (8 királynő sakkfeladvány általános esetre), Sudoku megoldó algoritmus vázlata
  • Metszés (pruning) technikák: hogyan gyorsítható a backtracking ésszerű vágásokkal (pl. n-királynőnél azonos oszlopba vagy átlóba nem helyezünk királynőt)
  • Gyakorló feladat: egy kombinatorikus probléma megoldása backtrackinggel (pl. összes permutáció generálása egy számhalmazra, majd szűrés egy feltétellel)

11. hónap: További algoritmusok és versenytechnikai trükkök

A tizenegyedik hónapban kiegészítjük az algoritmusok tárházát néhány további fontos témával, valamint elkezdjük a kifejezetten versenyprogramozási technikák és trükkök megismerését. Sorra kerülnek a mohó algoritmusok (greedy) alapelvei és tipikus esetei, a szöveges algoritmusok (például mintaillesztés hatékony módszerei), valamint néhány matematikai algoritmus, amelyek gyakran felbukkannak versenyfeladatokban. Ezek a témák teljessé teszik az algoritmikai eszköztárat és felkészítik a hallgatókat a változatos feladattípusokra.

41. hét: Mohó algoritmusok (Greedy) és alkalmazásaik

  • A mohó stratégia lényege: mindig a pillanatnyilag optimális döntés meghozatala a globális optimum reményében
  • Tipikus példa: intervallumütemezési probléma – a lehető legtöbb nem átfedő intervallum kiválasztása adott időszakból mohó algoritmussal (rendezés befejezési idők szerint)
  • Kontrapélda: ahol a mohó algoritmus nem ad optimális megoldást (pl. pénzérmék rendszere, ha nem “kánonikus”, ahol a mohó nem működik minden összegre)
  • Gyakorlati feladat: tevékenység-kiválasztási probléma implementálása (feladatok kezdési és befejezési idővel), illetve az érmés probléma megvizsgálása mohó vs. DP megoldással

42. hét: Szöveges algoritmusok (string keresés)

  • Egyszerű mintaillesztési feladat: adott szövegen belül keresni egy adott mintát (részsztringet) – naiv algoritmus és annak O(n*m) bonyolultsága (n: szöveg hossza, m: minta hossza)
  • Hatékonyabb mintaillesztés: Knuth–Morris–Pratt (KMP) algoritmus alapötlete, prefix függvény (lps array) jelentése és használata; KMP komplexitása O(n+m)
  • Hash alapú keresés említése: Rabin–Karp algoritmus, mint alternatív megközelítés (görgető hash, ütközések lehetősége)
  • Példafeladat: keressük meg egy rövidebb string összes előfordulását egy hosszú szövegben KMP algoritmussal, és hasonlítsuk össze a lépések számát a naiv kereséssel

43. hét: Matematikai alapok a versenyprogramozásban

  • Prímszámok és prímszámtesztelés: egyszerű osztókeresés vs. Eratosthenész szitája (Sieve of Eratosthenes) nagy N-ig; prímszámok generálása hatékonyan
  • Legnagyobb közös osztó (Greatest Common Divisor – GCD) és legkisebb közös többszörös (LCM): Euklideszi algoritmus GCD meghatározására, LCM kiszámítása GCD segítségével
  • Gyors hatványozás (fast exponentiation) moduláris aritmetikában: ismételt négyzetre emelés módszere, hatványozás O(log e) időben; moduláris hatvány számítása (pl. nagy kitevők gyors számítása mod M)
  • Kombinatorikai alapok: faktoriális és binomiális együtthatók (n choose k) számítása iteratívan vagy DP-vel; modulo melletti számolás (nagy számok kezelése modként, túlcsordulás elkerülése)
  • Példák: prímszámok kiszűrése 1000000-ig szitával, két szám GCD-jének és LCM-jének kiszámítása, a^b mod m gyors kiszámítása, Pascal-háromszög felépítése 𝑛 sorig

44. hét: Haladó adatszerkezetek a versenyfeladatokhoz

  • Szegmentfa (Segment Tree) alapelve: tartomány-lekérdezések (pl. intervallumösszeg, minimum) és frissítések hatékony (O(log n)) végrehajtása egy tömb adaton
  • Egyszerűbb alternatíva bizonyos feladatokra: Fenwick-fa (Binary Indexed Tree, BIT) bemutatása és összehasonlítása a szegmentfával (implementációs egyszerűség vs. sokoldalúság)
  • Tipikus alkalmazás: dinamikusan változó adathalmaz kezelése, ahol gyakoriak az elemmódosítások és részösszeg-lekérdezések (pl. pontértékek frissítése és prefixösszeg lekérdezése)
  • (Emlékeztető) Union-Find újra: tipikus használata versenyfeladatban (pl. összefüggő komponensek vizsgálata, halmazok egyesítése műveletsorozatok alapján)
  • Gyakorló feladat: szegmentfa vagy Fenwick-fa implementálása egy egyszerű problémára – például egy tömb részösszegének gyors lekérdezése és elemeinek módosítása különböző időpontokban

12. hónap: Versenyprogramozási stratégiák és projektmunka

Az utolsó hónap a megszerzett tudás gyakorlati alkalmazására és a versenyprogramozási készségek csiszolására koncentrál. A hallgatók különböző nehézségű programozási feladatokat oldanak meg, miközben stratégiákat tanulnak a versenyhelyzetek hatékony kezelésére. Szó esik arról is, hogyan kell egy versenyfeladatot elemezni, megtervezni a megoldást, beosztani az időt, valamint hogyan lehet a kódot gyorsan ellenőrizni és hibákat javítani. Emellett egy záró projekt keretében a hallgatók komplex feladatot oldanak meg, amely integrálja a tanult algoritmusokat és technikákat, hasonlóan egy valós fejlesztési projekthez.

45. hét: Feladatok elemzése és megoldástervezés (versenystratégia)

  • Feladat értelmezése versenyen: hogyan érdemes egy versenyfeladat szövegét gyorsan átolvasni és a lényeget kiemelni (bemenet/kimenet formátum, elvárások)
  • Követelmények és korlátok elemzése: a bemeneti méretekből algoritmus választása (pl. ha N ≤ 100, megengedhető lehet egy O(N^2) megoldás is, de N = 10^6 esetén hatékonyabb kell)
  • Megoldási stratégia kidolgozása: feladat részekre bontása, példákon tesztelés papíron (kézi számolás kis esetekre), megfelelő adatstruktúra és algoritmus kiválasztása a részegységekhez
  • Pszedokód vagy folyamatábra készítése a bonyolultabb feladatokhoz, mielőtt kódot írnánk – a tervezés fontossága a kapkodás helyett

46. hét: Hatékony kódolás és hibakeresés versenykörnyezetben

  • Gyors és megbízható kódolás gyakorlása: ismerős minták, sablonkódok használata (pl. gyors beolvasó függvény, tipikus ciklusszerkezetek) a rutin műveletekhez
  • Bemenet/kiement optimalizálás nagy adatmennyiségnél: ios::sync_with_stdio(false); cin.tie(NULL); használata a cin/cout gyorsítására, szükség esetén C stílusú scanf/printf bevetése
  • Saját tesztesetek készítése és futtatása kódolás közben: lefedni a szélsőséges eseteket (minimális, maximális bemenet, speciális esetek: üres halmaz, negatív értékek, stb.)
  • Gyors hibakeresés: tipikus hibaforrások (tömb túlindexelés, nullpointer, inicializálatlan változó, egész túlcsordulás, végtelen ciklus) felismerése; debug output használata (ideiglenes cout a kritikus pontokon) a hiba izolálására

47. hét: Versenyszimuláció és időbeosztás

  • Szimulált programozó verseny lebonyolítása: a hallgatók számára 2-3 órás, több feladatból álló házi verseny szervezése az elsajátított ismeretek gyakorlására
  • Időbeosztási stratégia versenyen: feladatok gyors áttekintése az elején, priorizálás (könnyű feladatok előre vétele a biztos pontokért, nehéz feladatok időigényének felmérése)
  • Stresszkezelés és koncentráció: tippek verseny közbeni leblokkolás ellen (ha elakad, kis szünet vagy feladatváltás; folyamatos időellenőrzés, hogy ne ragadjunk bele egy megoldásba túl hosszan)
  • A szimuláció utáni kiértékelés: megoldások összehasonlítása, közös megbeszélés az esetleges más megoldási lehetőségekről, tanulságok levonása a versenyhelyzetből

48. hét: Záró projekt és összefoglalás

  • Záró projektmunka: egy nagyobb szabású program vagy komplex algoritmikus feladat megvalósítása, ami integrálja a tanultakat. (Példa: egy mini “online judge” rendszer részegységeinek lefejlesztése, vagy egy összetett probléma megoldása, amely több algoritmust igényel – pl. útvonaltervező program grafikus felülettel, ami gráfalgoritmust és OOP-t is használ)
  • A projekt tervezése és megvalósítása: követelmények meghatározása, a feladat felosztása részfeladatokra, verziókezelés alapjai (egyszerű git használat a projektben), csapatmunka (ha páros vagy csoportos projekt) megszervezése
  • A kódoptimalizálási szempontok alkalmazása a projektben: tiszta struktúra, dokumentáció és kommentelés, megfelelő algoritmusok kiválasztása az egyes részfeladatokhoz, futási idő- és memória-profilozás a kész programon
  • Visszatekintés és összegzés: mely területeken fejlődtek a legjobban a hallgatók, milyen további témák felé érdemes nyitni (pl. haladó algoritmusok: grafikus algoritmusok, mesterséges intelligencia alapok, vagy a C++ újabb standardjainak funkciói), hangsúlyozva az élethosszig tartó tanulás fontosságát a programozásban

Összegzés: Az egyéves tanterv elvégzésével a hallgatók magabiztosan mozognak a C++ nyelvben, ismerik annak alap- és haladó funkcióit, valamint széles körű algoritmikai tudásra tettek szert. A versenyfeladatok megoldásán edződve képessé válnak hatékonyan elemezni a problémákat, megtervezni az algoritmusokat és optimalizált kódot írni a megoldáshoz. Ez a tanmenet megalapozza nemcsak a versenyprogramozásban való sikeres részvételt (például egyetemi programozó versenyeken, online versenyeken), hanem a szoftverfejlesztői munka során felmerülő kihívások kezelését is. Fontos kiemelni, hogy a programozásban való jártasság folyamatos gyakorlást igényel – az egy év anyagának elsajátítása után is érdemes rendszeresen további versenyfeladatokat megoldani, új projekteket indítani, és nyomon követni a C++ és az algoritmusok fejlődését a szakmai naprakészség érdekében.


  • C++ - Szótár.net (en-hu)
  • C++ - Sztaki (en-hu)
  • C++ - Merriam–Webster
  • C++ - Cambridge
  • C++ - WordNet
  • C++ - Яндекс (en-ru)
  • C++ - Google (en-hu)
  • C++ - Wikidata
  • C++ - Wikipédia (angol)