C++
Főnév
C++ (tsz. C++s)
- (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) vagyfalse
(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 *p
– mutató konstans értékre:p
olyanint
é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 p
– konstans 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 p
– konstans mutató konstans értékre: ap
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:
- Névtér prefix használata: Minden standard szimbólum előtt kiírjuk, hogy
std::
. Példáulstd::cout
,std::string
stb. Ez az ajánlott mód nagyobb projektekben, mert egyértelművé teszi, honnan jön az adott név. - Névtér beemelése:
using namespace std;
utasítással a teljesstd
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ála
é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 átadjuka
címét. A függvény ezen a címen keresztül a*p
kifejezéssel az eredetia
értékét változtatja meg 10-re. - A
valtoztatRef(a)
hívásnál a függvény paraméterer
egy referenciaa
-ra, vagyis a függvényen belülir = 15;
utasítás közvetlenüla
-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 lehetnullptr
(é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 lehetnullptr
, 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 egyint
méretű memóriát és visszaadja a címét. Hasonlóan,int *tomb = new int[10];
lefoglal 10 darabint
-nyi folytonos memóriát. - A
delete
operátor felszabadítja anew
által foglalt memóriát. Fontos: mindennew
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éndelete[] 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 csakdelete
hatására fut le. Mindig biztosítsuk, hogy mindennew
-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 egyfor
ciklus fejrésze is blokk: afor(int i=0; i<10; ++i) { ... }
ciklus után azi
változó már nem létezik. Ugyanígy egyif
vagywhile
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
). Ezprotected
lett, így azAllat
leszármazottai közvetlenül hozzáférnek a névhez (mint pl. aKutya
ésMacska
osztályok anev
taghoz). Kívülről viszont a név nem elérhető közvetlenül, csak agetNev()
éssetNev()
függvényeken keresztül (melyekpublic
-ok). - Az
hangotAd()
függvény azAllat
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 egyAllat
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
ésMacska
osztályok azAllat
publikus öröklése révén megöröklik annak publikus (és védett) tagjait. AKutya
ésMacska
konstruktora hívja azAllat
konstruktorát, hogy beállítsa a nevet (initializer lista használatával:: Allat(nev)
). - A
Kutya::hangotAd()
ésMacska::hangotAd()
függvények használják azoverride
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 azoverride
).
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
éskerulet
. Így azAlakzat
absztrakt, nem hozható létre belőle objektum (pl.Alakzat a;
fordítási hiba lenne). - A
Teglalap
ésKor
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 egyAlakzat*
mutatóval hivatkozunk egyTeglalap
objektumra, és meghívjuk rajta adelete
-et, akkor a megfelelő (leszármazott) destruktor fusson le. Ha nem lenne virtuális, akkordelete alakzatPtr;
csak azAlakzat
destruktort hívná, aTeglalap
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étPont
objektumot összeadunk a+
operátorral. Jelen esetben egy újPont
példányt hoz létre, melynekx
koordinátája az eredetix
plusz amasik.x
, és hasonlóan azy
. 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==
ésoperator!=
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 aostream
miatt (nem a mi osztályunk), ezért globális függvényként definiáljuk. Ahhoz, hogy elérje aPont
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 aoperator+
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
ésfrom_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, amikorjson 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 aj2.get<Ember>()
híváskor afrom_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ásstd::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 azEmber
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 afrom_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:
- Definiálunk egy Observer interfészt (absztrakt osztályt), mondjuk
IHőmérsékletObserver
, aminek van egyertesites(float ujErtek)
tiszta virtuális metódusa. - 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 observerertesites()
metódusát meghívja az új értékkel.
- A konkrét Observerek (pl.
KijelzoLCD
,KijelzoGrafikus
) megvalósítják az Observer interfészt, tehát sajátertesites
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
ésstd::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
ésfor
ciklusokkal
- A ciklusvezérlés eszközei:
break
éscontinue
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
ésdelete
(tömbök eseténnew[]
ésdelete[]
) 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 agets
-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
ésclass
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
(ésprotected
) 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ármaztatottKutya
ésMacska
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álisterulet()
függvénnyel, melyet aTeglalap
ésKor
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öbbcatch
á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ésestd::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
ésstd::queue
használata (belsőleg ezek alapértelmezettendeque
-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 esetbenO(1)
műveletek vs garantáltO(log n)
)
- Példafeladat: szógyakoriság számlálása egy szövegben
std::map
vagystd::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) ésstd::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ásastd::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
ésstd::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 acin/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.