Honlap, adminisztrációs portál
Az információ hiteles forrása: https://cpp11.eet.bme.hu/
Számonkérések jellege
- Részvétel: laboron maximum 4 db hiányzás + 2 db csak online beadás
- Nagy házi: 2 db egyénileg írt C++ program
- Szorgalmik: jegybe beszámítanak!
- (Vizsga): megajánlott jegy vagy vizsga
Nagy házi feladatok
- 1. feladat: MyString osztály
- Ki: 5. hét, Be: 8. hét
- 2. feladat: Szabadon választott (minták)
- Ki: 9. hét, Választás: 10. hét, Be: 13. hét
- Pótlás: Pótlási héten
Pontszámok
- Nagy házi: 2x5 pont (késve: max. 2x4 pont)
- Szorgalmik: szinte minden héten, 1-2 pont/db, össz: ~15 pont
- Vizsga: 40 pont
Megajánlott jegy pontszáma
Pontszám = ∑NHF + ∑szorgalmik
Megajánlott jegyként 4-est és 5-öst lehet szerezni. A megajánlott 4-est 15 ponttól, a megajánlott 5-öst 20 ponttól lehet megkapni. Ennek feltétele, hogy a nagy házi feladatok határidőre elkészüljenek, vagyis ne pótlásként legyenek beadva. (Ha megvan a két elfogadott nagyházi, akkor viszont javítani lehet több pontért, akár megajánlott jegyért.)
Vizsga
- Vizsga pontszám: 40 pont
- Nagyházi pontszám: 2x5
Összesen max. 50
Ponthatárok
- –19: elégtelen
- 20–31: elégséges
- 32–38: közepes
- 39–44: jó
- 45–: jeles
Honlap, adminisztrációs portál
Az információ hiteles forrása: https://cpp11.eet.bme.hu/
Számonkérések jellege
- Részvétel: laboron maximum 4 db hiányzás + 2 db csak online beadás
- Nagy házi: 2 db egyénileg írt C++ program
- Szorgalmik: jegybe beszámítanak!
- (Vizsga): megajánlott jegy vagy vizsga
Nagy házi feladatok
- 1. feladat: MyString osztály
- Ki: 5. hét, Be: 8. hét
- 2. feladat: Szabadon választott (minták)
- Ki: 9. hét, Választás: 10. hét, Be: 13. hét
- Pótlás: Pótlási héten
#include <stdio.h>
/* rational */
typedef struct Rat Rat;
struct Rat { int num, den; };
Rat make_rat(int n, int d);
Rat plus_rat(Rat r1, Rat r2);
void print_rat(Rat r);
int main(void) {
Rat r1 = make_rat(1, 10);
Rat r2 = make_rat(5, 10);
Rat r3 = plus_rat(r1, r2);
print_rat(r1);
printf("+");
print_rat(r2);
printf("=");
print_rat(r3);
}
#include <iostream>
class Rat { /* rational */
public:
Rat(int num, int den);
private:
int num_, den_;
};
Rat operator+(Rat r1, Rat r2);
std::ostream &operator<<
(std::ostream &os, Rat r);
int main() {
Rat r1(1, 10);
Rat r2(5, 10);
Rat r3 = r1 + r2;
std::cout << r1 << '+'
<< r2 << '=' << r3;
}
Tekintsük az alábbi két kódrészletet.
#include <stdio.h>
typedef struct Ratio Ratio;
struct Ratio { /* rational number */
int num; /* numerator */
int den; /* denominator */
};
Ratio make_ratio(int num, int den) {
/* Euclidean: gcd -> a */
int a = num, b = den, t;
while (b != 0)
t = a%b, a = b, b = t;
Ratio new = { num/a, den/a };
return new;
}
Ratio plus_ratio(Ratio r1, Ratio r2) {
return make_ratio(
r1.num * r2.den + r2.num * r1.den,
r1.den * r2.den);
}
void print_ratio(Ratio r) {
printf("%d/%d", r.num, r.den);
}
int main(void) {
Ratio r1 = make_ratio(1, 10);
Ratio r2 = make_ratio(5, 10);
Ratio r3 = plus_ratio(r1, r2);
print_ratio(r1);
printf("+");
print_ratio(r2);
printf("=");
print_ratio(r3);
return 0;
}
#include <iostream>
class Ratio { /* rational number */
public:
Ratio(int num, int den);
int num() const { return num_; }
int den() const { return den_; }
private:
int num_; /* numerator */
int den_; /* denominator */
};
Ratio::Ratio(int num, int den) {
/* Euclidean: gcd -> a */
int a = num, b = den, t;
while (b != 0)
t = a%b, a = b, b = t;
num_ = num/a;
den_ = den/a;
}
Ratio operator+(Ratio r1, Ratio r2) {
return Ratio(
r1.num() * r2.den() + r2.num() * r1.den(),
r1.den() * r2.den());
}
std::ostream &operator<<(std::ostream &os, Ratio r) {
os << r.num() << '/' << r.den();
return os;
}
int main() {
Ratio r1(1, 10);
Ratio r2(5, 10);
Ratio r3 = r1+r2;
std::cout << r1 << '+' << r2 << '=' << r3;
}
Mi a különbség a kettő között? Legfeljebb a nyelv. Az első láthatóan C-ben van írva, egy C++ fordítóval le sem lehetne
fordítani, mivel a make_ratio() függvény new lokális változójának neve C++-ban kulcsszó. A második kód
C++, használ osztályokat, operátor átdefiniálást, konstruktort, és egyéb jellegzetes nyelvi elemeket is.
De ez csak a felszín. Mi a különbség tervezési szempontból közöttük? A válasz: semmi. A két program egymásnak
tükörfordítása. Mindkét program objektumorientált (OO), még akkor is, ha a C változat egy nem objektumorientáltnak
tartott nyelven íródott. Az objektumorientáltság nem arra utal, hogy milyen programozási nyelvvel dolgozunk, hanem arra, hogyan
építjük fel a programunkat. Attól még, hogy nincsen a programban class kulcsszó, lehetnek benne osztályok.
Az osztály nem más fogalom, mint a típus. Mindkettő egy lehetséges értékkészlet és műveletek összessége. Egy típusba (vagy osztályba) sorolhatók a racionális számok, egy típusba (vagy osztályba) a sztringek, a képernyőn megjelenő ablakok és így tovább. Mindegyik értékek (1/2, 9/4, szavak, képernyőpozíciók, színek, feliratok) és hozzájuk tartozó műveletek (összeadás, konkatenálás, mozgatás) összessége.
A fenti C++ kódban tág értelemben véve nem csak a
konstruktor, hanem az operator+ és az operator<< is részei az osztálynak. Hiszen a törtek
összeadása ugyanúgy művelete ennek az osztálynak, mint a létrehozás. Nem volt rá szükségünk, hogy nyelvileg is tagfüggvényként
definiáljuk azt (operator+ esetén ez a helyzet), vagy valamilyen szintaktikai okból egyáltalán nem tudtuk tagfüggvényként megadni
(operator<< esetén pedig ez). A C kód Ratio struktúrája is egy osztály, a make_ratio(),
plus_ratio(), print_ratio() pedig annak kvázi tagfüggvényei.
Tovább is mehetünk. Az stdio.h-ban definiált FILE típus is tekinthető osztálynak. Ugyan nyelvi eszköz nem
fejezi ki, de az fopen(), fwrite(), fprintf() attól még ennek az osztálynak a
tagfüggvényei. A nullával lezárt, sztringként használt, nyelvi szinten char*-gal reprezentált típust is
nevezhetnénk osztálynak, lehetséges értékekkel: szavak, szövegek; és hozzájuk tartozó műveletekkel: strcat(),
strlen(), strstr(). Az elvégezhető műveletek határozzák ezt meg, nem a szintaktika: nem csak a tagfüggvényeket
kell néznünk, hanem az összes függvényt. Mindezt az angol szakirodalomban így nevezik: „the interface principle”.
A típus és az osztály tehát ugyanaz, ahogy a változó és az objektum is. (A C szabvány mindenhol objektumokról beszél változók helyett. Az objektum az ottani definíció szerint egy hely, ami adatot tárol.) A megkülönböztetést elméleti szempontból lényegtelen dolgok miatt szoktuk tenni. Egy C-ről szóló könyvben inkább típust mondunk, egy C++-ról szóló könyvben pedig csak akkor használjuk a típus szót, ha ki akarjuk fejezni, hogy esetleg szintaktikailag nem osztályról van szó. A C++ nyelv lehetővé teszi azt, hogy az adatok (tagváltozók) és függvények (metódusok) közötti összetartozást nyelvi szinten is kifejezzük, de a nyelvi eszköz hiánya nem kell megakadályozzon bennünket abban, hogy akár C-ben objektumorientált kódot írjunk. A nyelvi eszköz megléte csak annyit jelent, hogy a C++ szintaktikailag egyszerűbbé teszi az így szervezett program leírását, és segít betartani az OOP megszokott tervezési szabályait.
Apró megjegyzés azért kívánkozik a fentiekhez. Van, ahol az objektum mégsem ugyanaz, mint a változó. Például a referenciákat változóknak tekintjük, de nem objektumok. A temporális objektumok ugyan objektumok, de nincs nevük. Ezért nem szoktuk őket változóknak nevezni, és kijelölt helyük sincsen a memóriában.
Mitől lesz az egész számpárból tört objektum? Bár egy racionális szám két egész szám hányadosa, ez nem jelenti azt, hogy a
racionális szám ugyanaz, mint egy két egész számból álló számpár. A racionális szám több ennél, mivel az (1;2) számpár nem ugyanaz,
mint a (2;4), viszont az 1/2 tört ugyanaz, mint a 2/4. Ha két egész számról azt mondjuk, az egyik egy tört számlálója, a másik
pedig a nevezője, akkor egy teljesen új dolgot kapunk, ami több, mint a részek összege. Ezt hivatott kifejezni a Ratio
osztály konstruktora és a make_ratio() függvény. Ennek a függvénynek a bemenete a két egész szám, és a kimenete pedig
az előállított új objektum, a tört.
Amikor törtekkel műveleteket végzünk, rendszeresen közös nevezőre kell hozni a két törtet. Emiatt gyakran egyre nagyobb és
nagyobb számokkal dolgozunk. Papíron érdemes is folyamatosan egyszerűsíteni. Egy programban pedig szinte muszáj, nehogy
túlcsorduljanak az int változóink. Ennek az egyszerűsítésnek a legjobb helye a programban az a pont, ahol azt
mondjuk, hogy „itt van két egész szám, mostantól kezdve ezek együtt egy törtet alkotnak”, tehát a konstruktor. Figyeljük meg,
hogy ezzel a tervezési döntéssel – az egyszerűsítés konstruktorba helyezésével – garantáljuk azt, hogy a programunk összes
törtje mindig egyszerűsítve lesz. Ez a kódunk egyetlen olyan függvénye, amely látja és írni is tudja a számlálót és a
nevezőt, az a konstruktor. A C++-os, vagyis inkább az OOP-s gondolkodás hamarabb rávezet minket erre a helyes tervezési döntésre,
hogy az egyszerűsítésnek a konstruktorban van a helye.
A C++ kódban a létrehozás után kapott tört adattagjait már meg sem tudjuk változtatni, és ez így van rendjén. Mivel az adattagok privátak, a nyelv biztosítja is, hogy ne férjünk hozzájuk sehol máshol a programban. C-ben csak a fegyelmünkön múlik, hogy betartjuk-e a szabályokat:
- Törtet csak a
make_ratio()-val hozunk létre. - Tört adattagjait mindig csak olvassuk, soha nem írjuk.
De amíg betartjuk ezeket, addig nincs gond. Mindezek az osztály használóira (OOP terminológiában: ügyfeleire) vonatkoznak. Az osztály tagfüggvényeire, vagyis azokra a kódrészletekre, amelyek közvetlenül hozzáférnek az osztály adattagjaihoz, egy egész más szabály érvényes. Mégpedig az, hogy biztosítaniuk kell, hogy a tört objektumban tárolt számláló és nevező relatív prímek, tehát a tört mindig egyszerűsítve van. Ez az osztály belső invariánsa.
Mitől lesz az egész számpárból tört objektum?
- Racionális szám != két egész számból álló számpár.
- (1;2) számpár != (2;4) számpár
- de 1/2 tört = 2/4 tört
- Tört: két egész szám = számláló és nevező.
- Ezt fejezi ki a:
Ratosztály konstruktora.make_rat()függvény.
- Be: két egész szám, Ki: új objektum.
C99 újdonságok a C89-hez képest
C-ben struktúra inicializálásakor a tömbök inicializálásához hasonló szintaktikát alkalmazhatunk: kapcsos zárójelek között
megadhatjuk az egyes adattagok értékét, ahogyan azt az alábbi sor is teszi. Ezt a C++ is megengedi, amennyiben nem definiálunk
konstruktort vagy destruktort egy struktúrához (és persze nem próbáljuk new-nak elnevezni a változónkat).
Ratio new = { num/a, den/a };
Designated initializer
Tudjuk, hogy a fenti kódrészlet egy veszélyforrás a programban. Az adattagokra név szerint szoktunk hivatkozni, de ebben a nyelvi szerkezetben erre nincs lehetőség: itt az adattagok deklarációjának sorrendje számít. Ha megcseréljük a deklarációban egy sorrendet, esetleg felveszünk egy új adattagot, a program azonnal felborul, rossz esetben hibaüzenetet sem kapunk.
Ezt a hibalehetőséget kivédendő, bevezettek egy új szintaxist a C99-ben, amely lehetővé teszi, hogy az inicializálás helyén is név szerint hivatkozzunk rájuk. A szabvány ezeket designated initializer-eknek nevezi:
Ratio half = { .num = 1, .den = 2 };
Így olvashatóbb és robusztusabb is a kód; ha változtatjuk a struktúrát, nem kell végigböngésznünk a kódot, hol inicializáljuk rosszul. Ez a szintaktika megengedi azt is, hogy tetszőleges sorrendben adjuk meg az adattagokat, vagy csak némelyik adattagot adjuk meg. A többi olyankor a tömbökhöz hasonlóan nulla lesz:
/* így is 1/2 lesz */
Ratio half = { .den = 2, .num = 1 };
/* a nevező 1, a többi adattag 0 */
Ratio zero = { .den = 1 };
Apropó tömbök. Ez a szintaktika a tömböknél is működik, ott szögletes zárójelek között kell megadnunk azokat a (fordítási idejű konstans) indexeket, amelyeket inicializálni szeretnénk. A többi nulla lesz:
/* 1, 2, 0, 0, 0, 0, 0, ... */
int arr1[10] = { 1, 2 };
/* 0, 0, 5, 0, 3, 0, 0, ... */
int arr2[10] = { [2] = 5, [4] = 3 };
Compound literal
A C99-ben ennél tovább is mentek. A C nyelvben nincsenek konstruktorok, amelyeket függvényként használva objektumokat lehet létrehozni. Így bár egy struktúra változó létrehozásakor megadhattuk az értékeket, más kontextusban nem működött a struktúra, mint literális megadása:
/* ezzel minden rendben */
Ratio r1 = { 1, 2 };
/* ez viszont nem működik */
r1 = { 2, 3 };
/* és ez sem */
print_ratio({ 2, 3 });
Az egyetlen olyan összetett adattípus, amelynek megadásához külön szintaxis
tartozott, a sztring volt C89-ben. Gondoljunk bele, a "hello"
literális egy char [6] típusú értéket ad meg, ahogyan az
5 literális egy int típusút. Hogy ne kelljen az
értékadást adattagonként elvégezni, vagy egy függvényhívás miatt változót
létrehozni, a C99 megengedi azt, hogy egy cast operátorhoz hasonló szintaxissal
megadjuk egy kapcsos zárójelpárról, hogy milyen összetett típusú értéket (compound
literal) adunk meg vele:
/* ezzel minden rendben */
Ratio r1 = { 1, 2 };
/* C99-ben már lehet ilyen értékadást is */
r1 = (Ratio) { 2, 3 };
/* és bárhol névtelenül létrehozhatjuk */
print_ratio((Ratio) { 2, 3 });
Így az eredeti make_ratio() függvényünket így is írhatnánk:
Ratio make_ratio(int num, int den) {
/* Euclidean: gcd -> a */
int a = num, b = den, t;
while (b != 0)
t = a%b, a = b, b = t;
return (Ratio) { .num = num/a, .den = den/a };
}
Ugyanez tömbökre is működik. Természetesen ott a típus megadásánál típus[]-t
kell írnunk, nem típus*-ot, hiszen tömböt adunk meg, nem pointert. Például így:
#include <stdio.h>
void print_chars(char const *p) {
while (*p != '\0') {
putchar(*p);
p++;
}
printf("\n");
}
void print_ints(int const *p) {
while (*p != 0) {
printf("%d ", *p);
p++;
}
printf("\n");
}
int main(void) {
print_chars( "hello" );
print_ints( (int[]) { 2, 3, 4, 0 } );
}
Az így megadott objektumok automatikus memóriakezelésűek, ugyanaddig élnek,
mint az ugyanabban a blokkban létrehozott változók. A fenti
print_ints() függvényhívás tulajdonképp egy rövidítés ehhez:
int main(void) {
/* ... */
int __névtelen_tömb__[] = { 2, 3, 4, 0 };
print_ints( __névtelen_tömb__ );
/* ... */
}
Ne feledjük, ezek a nyelvi elemek csak a C-ben léteznek. De a C++-ban ott vannak a konstruktorok, amelyek többet tudnak ennél.
A C++ számos nyelvi eszközt ad a kezünkbe ahhoz, hogy a saját típusainkat a
beépített típusokhoz hasonló könnyedséggel kezelhessük. Az operator[]
átdefiniálásának lehetősége, a másoló konstruktorok és a destruktorok például
lehetővé teszik azt, hogy az std::vector osztály teljes értékű
tömb típusként viselkedjen. A C nyelvben erre nincsen lehetőség, de
a megfelelő nyelvi szerkezetekkel azért próbálják elérni azt, hogy kényelmes
legyen az összetett típusok használata. A C99-ben a nyelvbe épített egyetlen
tároló, a tömb lehetőségeit megpróbálták minél hatékonyabban kibővíteni.
Térjünk most el a törtes példától, és nézzük meg az új nyelvi elemket!
A tömbök indexelését a C nyelv soha nem ellenőrzi. Ennek két oka van. Az egyik egyszerűen a teljesítmény: ha a program helyes, akkor sosem indexeli túl a tömbjeit, ezért felesleges futási időben ezzel bajlódni. A másik pedig az, hogy tömbműveleteknél legtöbbször nem is látjuk az eredeti tömb definícióját, hanem csak pointerekkel dolgozunk, amelyek a tömbbe mutatnak. Különösen igaz ez akkor, amikor tömböt függvénynek adunk át, ugyanis ott a paraméterátadás mindig indirekt. A függvények fejlécében megadott tömb típus alatt mindig pointert ért a nyelv, ezért az alábbi deklarációk teljesen egyenértékűek.
void func(char arr[100]);
void func(char arr[]);
void func(char *arr);
A híváskor így mindig csak a tömb kezdőcímét látja a függvény. Ez
azonban nem jelenti azt, hogy a hívás helyén ne ismerhetnénk a tömb méretét, és
bizonyos esetekben ne ellenőrizhetnénk azt. Gyakori eset, hogy egy függvény azért
vár paraméterként egy tömböt, hogy az általa előállított eredményt oda tegye. A
tömbnek ilyenkor általában van egy minimális elvárt mérete: például ha egy
éééé-hh-nn formátumú dátumot szeretnénk bele írni, ez a méret 11. Az ilyen
eseteket a static kulcsszóval jelölhetjük meg a fordító számára:
void date_tostring(Date d, char str[static 11]) {
sprintf(str, "%04d-%02d-%02d", d.year, d.month, d.day);
}
char const *ratio_tostring(Ratio r, char str[static 24]) {
sprintf(str, "%d/%d", r.num, r.den);
return str;
}
Az így deklarált (fentebb definiált) függvényeknél a fordító, ha tudja, fordítási időben ellenőrzi, hogy
- nem null pointert adunk-e át paraméterként,
- hogy a hivatkozott tömbnek van-e annyi eleme, mint a megadott méret.
Ha ezt nem tudja megtenni (mert pl. pointert adunk, nem tömböt), a függvény használója számára akkor is hasznos ez, dokumentációként. Ne feledjük: ez C99 nyelvi elem, ilyen C++-ban nincs.
C-ben és C++-ban a függvények fejlécében megadott tömb mindig pointer, ezért az alábbi deklarációk egyenértékűek:
void func(char arr[100]);
void func(char arr[]);
void func(char *arr);
A minimális elvárt méret megadható C99-ben (a fordító, ha tudja, fordítási időben ellenőrzi):
nincs ilyen!
void date_tostring(Date d, char str[static 11]) {
sprintf(str, "%04d-%02d-%02d", d.year, d.month, d.day);
}
char const *ratio_tostring(Ratio r, char str[static 24]) {
sprintf(str, "%d/%d", r.num, r.den);
return str;
}
A tömböket gyakran ideiglenes tárolónak használjuk, és elég gyakori az is,
hogy bár fordítási időben nem, de futási időben előre ismerjük a létrehozandó
tömb méretét. A C99 megengedi azt, hogy a veremben létrehozott tömböknél futási
időben kiértékelődő kifejezéssel adjuk meg a tömb méretét. Az ilyen tömböket a
szabvány VLA-nak nevezi (variable length array). (Vigyázat: bár a lenti
kódrészletet a legtöbb C++ fordító megérti és lefordítja, a C++ szabvány semelyik
változata szerint sem elfogadott! A C++-ban ilyen célra egy
std::vector<int>-et kell használni.)
int n;
printf("Hány szám lesz?\n");
scanf("%d", &n); /* garantáltan futási időben :) */
int arr[n];
Az ehhez hasonló kódrészletek támogatása szinte elvárásunk lehet azután, hogy
a C99-ben megengedték a változódefiníciók és az utasítások keverését (mixed
declarations and code), vagyis hogy elengedték azt a megkötést, hogy az
utasításblokkok tetején kell megadni a változókat. Az eszköz használatát senkinek
nem kell bemutatni, mert tudjuk, ahol választhatunk a dinamikus (kézi) és az
automatikus memóriakezelés között, ott mindig az utóbbit célszerű választani. Az
így létrehozott tömbök egyébként teljesen ugyanúgy viselkednek, mint a
konstanssal megadott méretű társaik; még a sizeof operátor is
működik rajtuk. Azért sokat ne várjuk, az ilyen tömbök nem méreteződnek át
mágikusan: minden VLA akkora marad megszűntéig, amekkora a létrehozása
pillanatában volt.
for (int i = 0; i != 10; ++i) {
int arr[rand() % 10 + 10];
printf("Most %d eleme lett.\n", (int) (sizeof(arr)/sizeof(arr[0])));
}
A VLA-k mögött azonban több van, mint elsőre gondolnánk. Egy programozási nyelvnél nem „csak úgy” megy, hogy behozunk egy újfajta nyelvi elemet egy probléma megoldására és kész: gyakran helyette inkább olyan lehetőségeket kell a nyelvbe építeni, amiből már következik, hogy az áhított kódrészlet leírható.
Gondoljunk például a printf()-re. A Pascal writeln() eljárása tetszőlegesen sok
paramétert kaphat, mindegyiket kiírja a kimenetre. A C printf()-je ugyanerre képes. Azonban a Pascal writeln()
eljárása egy különleges, egyedi nyelvi elem; a programozó nem tud olyan saját függvényeket írni, amelyek akárhány
paramétert kaphatnak. C-ben a printf() nem nyelvi elem, hanem egy szokványos függvény. Helyette a ...-tal
jelölt, változó hosszúságú paraméterlista a nyelvi elem. Így C-ben tetszőlegesen írhatunk saját függvényeket is, amelyek bárhány
paramétert kaphatnak.
Lássuk, miről van szó! A C89-ben semmilyen körülmények között nem lehetett leírni olyan kódot, amelyben egy típus mérete
nem fordítási idejű konstans. Vagyis nem csak az int arr[n]; változódefiníció volt hibás, hanem helytelen
volt a triviálisnak tűnő sizeof(int[n]) kifejezés is. A C99-ben pedig ezen változtattak. Az új verzió azt engedi
meg, hogy egy lokális változó, tömb típus elemszámát egy kifejezés adja meg. Mivel egy ilyen típusnak a méretét már ki is tudja
számolni, ezért megengedi azt is, hogy a veremben ilyen típusú változót hozzunk létre. Emiatt szabad leírnunk az alábbi sorokat:
int n;
scanf("%d", &n);
int *my_heap_arr = (int*) malloc(sizeof(int[n]));
int my_stack_arr[n];
A dolog onnantól válik izgalmassá, ha nem csak egy, hanem több dimenziós tömbökkel is dolgozunk. Ugyanis ezek összes dimenzióját megadhatjuk kifejezéssel. Ami önmagában megint triviálisnak hangzik, de a fordítónak meg kell oldania azt, hogy akárhány változó méretű dimenzió szerint lehessen indexelni. Ehhez pedig az kellett, hogy olyan pointert is lehessen létrehozni, amelynek típusa kifejezések értékeitől függ. Mert ha egy 20×10-es tömb egyes soraira mutató pointer „10 elemű tömbökre mutató pointer” típusú, akkor egy m×n-es tömb soraira mutató pointer „n elemű tömbökre mutató pointer” kell legyen:
int arr1[20][10];
int (*parr1)[10];
parr1 = arr1;
int arr2[m][n];
int (*parr2)[n];
parr2 = arr2;
A méretinformációra mindenképpen szükség van a pointer típusában, mert egy cím számításakor (pointer aritmetika), azaz pl. a
parr1+1 kifejezés kiértékelésekor tudnia kell a fordítónak, hány bájtot kell ugrani a memóriában. A parr1+1
kifejezésnél sizeof(int[10])-et, mivel *parr1 típusa int[10]; a parr2+1 esetén
pedig sizeof(int[n])-t, mivel *parr2 típusa int[n].
Egy VLA-ra mutató pointer azért hasznos, mert így egy ilyen tömböt át tudunk adni függvénynek is paraméterként. Ehhez csak egy megfelelő függvénydefiníció kell, amelynél a függvény fejlécében jelezzük azt, hogyan függ a paraméterektől a pointer típusa. Az egyedüli korlátozás, hogy a méretet megadó paramétert előbb kell szerepeltetni, mint a pointert. Mire a pointer deklarációjával találkozik a fordító, addigra a méretet megadó kifejezés változói már ismertek kell legyenek:
/* deklaráció */
void print(int, int, char (*)[*]);
/* definíció */
void print(int height, int width, char (*arr)[width]) {
for (int y = 0; y != height; ++y) {
for (int x = 0; x != width; ++x)
putchar(arr[y][x]);
putchar('\n');
}
}
A függvény definíciója a formális paraméterek neveit is tartalmazza. Ha csak deklarációt adunk meg, a nevek szerepeltetése
nem kötelező; a kifejezéssel megadott pointer típusú paramétereknél is elhagyható a név. Mivel azonban a deklarációban
char(*)[]-ot nem írhatunk (definiálatlan méretű tömb továbbra sem létezik!), valahogy jelölni kell azt, hogy a
méret is meg lesz adva. Ezt a deklarációban a tömb mérete helyére beírt * karakterrel tudjuk megtenni. Vagyis a
fenti függvénydeklaráció valami ilyesmit jelent: „A függvény vár két int típusú értéket és egy pointert. A pointer
char[] tömbökre mutat, amelyek méretét a függvény majd valahonnan tudni fogja.” Hogy honnan tudja, az pedig már a
függvény dolga, és azt a definíció adja meg.
Talán mégis a legjobb, ha inkább a deklarációkba is beírjuk a változók nevét, és kifejezzük azt, hogy mi mitől függ, hiszen ez a függvény használóit segíti. De ne feledjük, a tömbök helyett mindig az első elemükre mutató pointerek adódnak át paraméterként! Ezért az összes alábbi deklaráció egyenértékű:
void print(int height, int width, char arr[height][width]);
void print(int height, int width, char arr[*][width]);
void print(int height, int width, char arr[*][*]);
void print(int height, int width, char (*arr)[width]);
void print(int height, int width, char (*arr)[*]);
void print(int, int, char (*)[*]);
Az így átadott tömbnek egyébként nem kell feltétlenül VLA-nak lennie, hanem lehet konstans méretű tömb is. A C99 nyelv a változóval megadott típusú pointer által képessé vált arra is, hogy tetszőleges méretű (szélességű és magasságú) kétdimenziós tömböt adjon át függvényparaméterként.
#include <stdio.h>
#include <stdlib.h>
void print(int height, int width, char arr[height][width]) {
for (int y = 0; y != height; ++y) {
for (int x = 0; x != width; ++x)
putchar(arr[y][x]);
putchar('\n');
}
}
int main(void) {
char man[4][3] = {
{ ' ', 'o', '/' },
{ '/', '|', ' ' },
{ ' ', '|', ' ' },
{ '/', ' ', '\\' },
};
char diamond[2][2] = {
{ '/', '\\' },
{ '\\', '/' },
};
print(4, 3, man);
printf("\n");
print(2, 2, diamond);
}
változata sem támogatja!
Van helyette std::vector.
int n;
printf("Hány szám lesz?\n");
scanf("%d", &n); /* garantáltan */
int arr[n]; /* futási időben :) */
A stack-en jön létre, még a sizeof operátor is működik rajta:
for (int i = 0; i != 10; ++i) {
int arr[rand() % 10 + 10];
printf("Most %d eleme lett.\n", (int) ( sizeof(arr) /
sizeof(arr[0]) ));
}
A C-ben gyakran dinamikus memóriakezelés használatára kényszerülünk, és ez lassíthatja a programot nem csak a foglaláskor, hanem az adatok elérése közben is. Tekintsük az alábbi két kódrészletet:
Statikus eset
typedef struct MyArrayStat {
size_t siz;
double data[200];
} MyArrayStat;
/* foglalás */
MyArrayStat ma;
/* használat */
ma.data[12] = 37.3;
Dinamikus eset
typedef struct MyDynArr {
size_t siz;
double *data;
} MyDynArr;
/* foglalás */
MyDynArr *ma;
ma = malloc(sizeof(MyDynArr));
ma->data = malloc(sizeof(double[200]));
ma->siz = 200;
/* használat */
ma->data[12] = 37.3;
Nézzük, mi történik az objektumok foglalásakor!
Statikus eset
- Az
maváltozó létrehozásakor a stack pointer („meddig foglalt a verem”) arrébb állítódikMyDynArrstruktúra méretével.
Dinamikus eset
- Az
mapointer létrehozásakor a stack pointer arrébb állítódik egy pointernyivel. - Az első
malloc()hívás elkezdi vizsgálni a foglalt/szabad területek nyilvántartását. Talál egy megfelelő méretű üres helyet. Bejelöli foglaltnak, frissíti az adatszerkezetet. Visszatér a címével. - Ezt beírjuk az
mapointerbe. - A második
malloc()hívás újból nekiáll helyet keresni. Ha megvan a hely, bejelöli foglaltnak, frissíti az adatszerkezetet. - A cím bekerül az
ma->datapointerbe.
Ez csak egyszeri költség, a tömb létrehozásakor. Viszont mi történik
egy elem elérésekor, pl. a fenti esetben, amikor a 37.3 bemásolódik
a tömbbe? Ez érdekesebb, hiszen ez nem egyszeri költség, hanem a
tömb minden egyes használatakor megtörténhet.
Statikus eset
- Az
maváltozó valahol a veremben van. A címéhez hozzáadódik annyi bájt, ahányadik bájttól a struktúrában adata[]tömb kezdődik. Ez még fordítási időben elvégezhető. - Ezután jön egy címszámítás: az előző címhez hozzáadódik
index*sizeof(double). - Az így kapott helyre beíródik a
37.3.
Dinamikus eset
- Kiolvasódik a memóriából az
mapointer értéke. - A statikus esethez hasonlóan kiszámolódik ehhez képest a
dataadattag címe, de csak futási időben. - Ezután jön egy újabb memóriaolvasási művelet, mivel nem tudjuk, hogy
a
double[]tömb hol van. Annak címét adatapointer tárolja. - A pointer kiolvasása után jön a címszámítás,
index*sizeof(double). - És végül a kapott című helyre íródik az érték.
Ez az indirekció költsége. A pointer változó értékét ki kell olvasni, mielőtt a címszámítást el tudjuk végezni, és ez egy plusz memóriaművelet lehet minden alkalommal. Ha magát a struktúrát is egy indirekción keresztül érjük csak el (esetleg dinamikusan van foglalva), akkor még rosszabb a helyzet.
A C99 bevezetett egy új nyelvi eszközt, amellyel egyszerűbb esetekben az indirekciók és a dinamikus memóriafoglalások száma csökkenthető. A flexible array member egy olyan tömb egy struktúra utolsó adattagjaként, amelynek a mérete nem definiált:
typedef struct MyArrayFlex {
size_t siz;
double data[];
} MyArrayFlex;
Egy ilyen struktúrát nincs értelme a veremben foglalni. A struktúra méretébe a flexibilis tömb nem számít bele. A flexibilis
tömb adattaggal azt jelezzük a fordítónak, hogy ott majd egy tömb lesz; a létrehozásáért mi felelünk. A fordító ilyenkor a tömb
helyét meg tudja határozni, tehát indexelhetjük is a tömböt, csak létre kell hozni azt valahogyan. Ezt egy megfelelően
összerakott malloc() hívással tehetjük meg, amelyben több helyet kérünk, mint amekkora a struktúra üresen lenne:
MyArrayFlex *ma;
ma = (MyArrayFlex*) malloc(sizeof(MyArrayFlex) + sizeof(double[200]));
ma->siz = 200;
ma->data[12] = 37.3;
Az így létrehozott tömbök több okból is gyorsabbak, mint az előbb bemutatott párjuk:
- Gyorsabb foglalás és felszabadítás: két
malloc()és kétfree()helyett csak egy-egy van. - Gyorsabb adatelérés: a tömb hivatkozásakor eggyel kevesebb indirekció, eggyel kevesebb memóriaművelet.
Mivel egy speciális helyzetről van szó, sok korlátozás is van ennél a nyelvi eszköznél. A megkötések nagy része abból adódik, hogy a fordítónak el kell tudnia végezni a címszámítást a flexibilis tömbön:
- Csak a struktúra végén lehet ilyen tömb. (Különben az utána lévő adattagokat nem lehetne elhelyezni.)
- Csak egy ilyen tömb lehet benne. (Különben nem lehetne tudni, melyik hol kezdődik.)
- Ilyen tömböt tartalmazó struktúrát nem lehet másik struktúrába vagy tömbbe tenni.
- Dinamikusan kell foglalnunk a struktúrát, különben nem tudjuk megadni a tömb méretét.
Csak C99-ben van ilyen, C++-ban nincs.
Statikus eset
typedef struct MyArrayStat { size_t siz; double data[200]; } MyArrayStat;
MyArrayStat ma; /* foglalás */
ma.data[12] = 37.3; /* használat */
Dinamikus eset
typedef struct MyDynArr { size_t siz; double *data; } MyDynArr;
MyDynArr *ma; /* foglalás */
ma = malloc(sizeof(MyDynArr));
ma->data = malloc(sizeof(double[200]));
ma->siz = 200;
ma->data[12] = 37.3; /* használat */
class Example {
double a = -2.0; // C++11
double b = 0.0;
public:
Example() : a(0), b{ -2 } { // C++11, Osztályhierarchiák ea
a = 5;
b = -5.0;
}
private:
static double c;
inline static double d = 33.2; // C++17
};
double Example::c = 0;
Sorrend: a = -2.0; => a(0) => a = 5;
Az újabb C++ szabványok kényelmesebbé teszik a tagváltozók inicializálását.
class Example {
double a = -2.0; // C++11
double b = 0.0;
public:
Example() : a(0), b{ -2 } { // C++11, Osztályhierarchiák ea
a = 5;
b = -5.0;
}
private:
static double c;
inline static double d = 33.2; // C++17
};
double Example::c = 0;
Immár a C-ben megszokott változóinicializálási módon (double a = -2.0;), közvetlenül a definíció helyén adhatunk kezdőértéket a
tagváltozóknak. Objektumok esetén ez a konstruktoruk meghívását jelenti. Így sok esetben szükségtelenné válik, hogy konstruktort írjunk az
osztálynak. Ez a megadási módszer persze csak szintaktikai cukor, valójában az inicializálást a (z adott esetben automatikusan létrejövő) konstruktorok végzik.
Természetesen a megszokott inicailizációs módszereket továbbra is használhatjuk: a konstruktor inicailizálólistájával történő (a(0))
és a konstruktor törzsében értékadással (a = 5;) történő kezdőérték adást. Ha ezek közül többet is megvalósítunk, akkor az a(0)
felülírja a double a = -2.0;-t, és az a = 5; mindkettőt felülírja.
A kerek zárójel helyett kapcsos zárójelet is használhatunk az inicailizálólistában (és máshol is). Ennek okáról az Osztályhierarchiák előadásban lesz szó.
A statikus tagváltozók definíciója elég speciális. Mivel ezek a változók tulajdonképpen az osztály névterébe helyezett globális változók, hagyományosan valamelyik forrásmodulban (.cpp fájl) kell ezeket definiálnunk. Nem lehet a definíció fejlécben, mert akkor több modulba is beszerkesztenénk, ami linkelési hibát okozna, ill. nem lehet az osztályban sem. Ez sokszor kényelmetlen, ezért a C++17 szabvány bevezette az inline statikus tagváltozót. Ebben az esetben a fordítóprogram hozza létre a statikus változó definícióját az általa kiválasztott modulban. A módszer előnye, amellett, hogy egyszerűbb a programozónak, hogy a teljes osztályt megvalósíthatjuk a fejlécben.
int main() {
int a = 26, b = 032, c = 0x1A;
int d = 0b11010; // C++14
double e = 1'000'000.000'001; // C++14
int f = 1'2'3;
}
Az aposztrof előtt és után számjegy kell álljon.
Sem C-ben, sem a C++ korábbi verzióiban nem volt lehetőség bináris számliterális megadására, pedig sokszor hiányzott ez a lehetőség, a C++14 ezt is lehetővé
teszi. A hexadecimális literális (0x) mintájára 0b előtaggal kell megadni.
A literálisok olvashatóságát nagymértékben javítja, hogy a C++14-ben bevezetett szabály alapján aposztrofokat helyezhetünk el a literálisban. A fordító az aposztrofokat figyelmen kívül hagyja, mintha ott sem lennének. Az aposztrofokat úgy kell elhelyezni, hogy előtte és mögötte is számjegy legyen (hexa literális esetében az A...F betűket is számjegynek tekintjük). Tehát előjel után vagy tizedespont mellé nem tehetjük, és két aposztrofot sem tehetünk egymás mellé. Az aposztrofok szokásos elhelyezése, hogy 3-3 számjegyet választanak el, de a C++ nem tesz ilyen megkötést.
int main() {
int a = 26, b = 032, c = 0x1A;
int d = 0b11010; // C++14
double e = 1'000'000.000'001; // C++14
int f = 1'2'3;
}
Az explicit kulcsszó
Az automatikus, felhasználó által definiálható konverziók nagyban növelik a
nyelv használhatóságát. A saját típusainkat beilleszthetjük a beépített típusok
közé: bármikor sztring objektummá válhat egy char const *, bármikor
komplex számmá egy double, és így tovább. Vigyázni kell azonban
arra, hogy ne történjen szándékolatlan konverzió. Nem minden
konstruktor számít értelmes konverziónak. Hogy melyik igen, azt viszont a fordító
nem tudja eldönteni! Példának definiáljunk egy dinamikus tömb osztályt, amelynek
konstruktora a méretet adja meg:
class DynArray {
public:
DynArray(size_t siz);
};
Ezt is fogja konverzióra használni a fordító, nem csak a dedikált konverziós operátort. Emiatt viszont az alábbi, értelmetlen programrészletek lefordulnak:
DynArray a = 5; /* DynArray a = DynArray(5); */
void func(DynArray const &arr);
func(6); /* func(DynArray(6)); */
Azért értelmetlenek ezek, mert a DynArray(size_t) nem jelenti
azt, hogy „így kell egész számból tömböt csinálni”. Erre való az
explicit kulcsszó, amely az automatikus konverziót akadályozza meg.
Ha egyparaméterű konstruktort írunk, mindig gondolkozzunk el rajta, hogy az
konverziót jelent-e, és ha nem, írjuk elé az explicit szót:
class DynArray {
public:
explicit DynArray(size_t siz);
};
C++11 óta az explicit kulcsszó több paraméterű konstruktoroknál is használható
lehet. Az alábbi kódot akkor fogadja el a fordító, ha T-nek van (int, int)
paraméterű, nem explicit konstruktora. Ha a konstruktor explicit, akkor csak a return T(2, 3)
engedélyezett.
T get_t() {
return {2, 3};
}
Hasonló a helyzet a konverziós operátoroknál is. Gyakran írunk például az osztályoknak
operator bool() konverziót, hogy lehetővé tegyük az if
(obj) alakú utasításokat. Például egy hálózati kapcsolat osztálynál:
class NetworkConnection {
public:
/* ... */
operator bool() const { return is_open(); }
};
NetworkConnection conn1{/* ... */};
if (conn1)
/* ... */;
Ez különösen veszélyes, mert a C-ből örökölten a logikai típus aritmetikai típusnak is számít. Emiatt lefordulnak az alábbi kódrészletek is:
NetworkConnection conn1{/* ... */}, conn2{/* ... */};
conn1 + 19; /* static_cast<bool>(conn1) + 19 */
conn1 >= conn2; /* static_cast<bool>(conn1) >= static_cast<bool>(conn2) */
A C++11 a konverziós operátorokra is bevezette az explicit kulcsszót,
hogy ilyesmi ne történhessen:
class NetworkConnection {
public:
/* ... */
explicit operator bool() const { return is_open(); }
};
NetworkConnection conn1{/* ... */}, conn2{/* ... */};
conn1 + conn2, conn1 >= conn2; /* fordítási hiba */
if (conn1) /* rendben, az if () speciális, mert bool-t vár */
/* ... */;
if (conn1 && conn2) /* rendben, a && operátor speciális */
/* ... */;
Nem minden konstruktor számít értelmes konverziónak. Hogy melyik igen, azt a fordító nem tudja eldönteni. Pl.:
class DynArray {
public: DynArray(size_t siz);
};
Ezt is használja konverzióra a fordító:
DynArray a = 5; /* DynArray a = DynArray(5); */
void func(DynArray const &arr);
func(6); /* func(DynArray(6)); */
Az explicit kulcsszó az automatikus konverziót akadályozza meg.
class DynArray { // Ha nem konverziót akarunk, mindig használjuk!
public: explicit DynArray(size_t siz);
};
A vektor
ad egy iterátor típust, amely segítségével az elemei bejárhatóak. Ezt az iterátort a
pointerekhez hasonlóan lehet használni, főként a ++ és *
operátorokkal. Eddig ezt írtuk:
std::vector<int> v = { 4, 5, 6 };
for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
std::cout << *it << std::endl;
}
4 5 6
A bonyolult típusnévvel rendelkező iterátorok használata kényelmetlen lehet,
ezért a C-ből örökölt auto kulcsszót egy új szereppel ruházták fel a
C++11-ben. Azokban a változódefiníciókban, ahol rögtön értéket is kap egy
változó, a típus helyén az auto kulcsszót használhatjuk, és ilyenkor
a típust a fordító automatikusan kitalálja, figyelembe véve a megadott módosítókat:
autoauto x = 5; // x = int lesz
int i;
auto &j = i; // int &j = i;
int *foo();
auto *k = foo(); // int *k = foo();
auto const l = 5; // int const l = 5;
Természetesen az auto-t ennél bonyolultabb típusoknál szoktuk
használni. De épp arra való! A fenti iterátoros bejárásokat így írhatjuk
az auto segítségével:
for (auto it = v.begin(); it != v.end(); ++it) { // C++11
std::cout << *it << std::endl;
}
Ha konstans iterátort szeretnénk,
akkor figyelembe kell venni azt,
hogy eddig az it változó típusának megadása miatt lett az iterátor
konstans, nem pedig a hívott tagfüggvény miatt. Most pedig az iterátor változó
típusát pont a függvény visszatérési értékének típusából fogja majd meghatározni
a fordító! Ezért C++11-ben a beépített tárolók új tagfüggvényeket kaptak,
cbegin() és cend(). Ezek const_iterator-okkal
térnek vissza:
for (auto it = v.cbegin(); it != v.cend(); ++it) {
*it += 1; /* FORDÍTÁSI HIBA */
std::cout << *it << std::endl; /* OK */
}
Az auto kulcsszónak sokkal nagyobb szerepe van annál, minthogy kényelmesen
megadhatjuk vele a változók típusát. Sokszor template kódban előfordul,
hogy nem is ismerjük pontosan a típust. Tegyük fel, hogy adott a lenti függvényünk.
Ez az egyszerűség kedvéért csak annyit csinál, hogy összead két értéket, pl.
2+3 = 5 (egészek) és 2.2+3.3 = 5.5 (valósak).
template <typename T>
T add(T a, T b) {
return a+b;
}
A probléma csak az, hogy ezzel nem adható össze két eltérő típusú érték.
Az add(2, 2.3) és add(2.3, 2) kifejezéseknél a fordító
nem tudja levezetni a sablonparaméter típusát, mivel a két paraméter típusa nem egyforma.
Ezek a függvényhívások fordítási hibához vezetnek.
Ha két sablonparaméterünk van a két paraméterhez, akkor vajon abból hogyan mondjuk
meg a visszatérési érték típusát? int+double → double-t ad,
és fordítva, double+int → double eredmény áll elő.
Ha elképzeljük ugyanezt kivonással, ott például létezik egy pointer-pointer →
egész szám eset is... Legszívesebben a visszatérési értékhez is auto-t
írnánk. Határozza meg azt az operátor, amelyik az a+b kifejezés értékét adja!
template <typename T1, typename T2>
auto add(T1 a, T2 b) {
return a+b;
}
Ez C++14 óta jó is. Sőt C++17-ben
már a paraméterek típusaihoz is írhatunk auto-t! Bár első ránézésre nem tűnik úgy,
a lenti kódban egy sablonfüggvény látható, hiszen az auto helyére bármi kerülhet.
Meg kell szoknunk, hogy nem csak az a kód sablon, ahol látjuk a template kulcsszót,
hanem egy ilyen is:
auto add(auto a, auto b) {
return a+b;
}
Egy kis történelem
Az auto kulcsszó a C-ben az automatikus
memóriakezelésű változókra vonatkozott, mint tárolási osztály megnevezése.
Azért nem találkozni vele soha, mert semmilyen többletjelentése nincs:
ha elhagyjuk, ugyanúgy automatikus memóriakezelésű lokális változókat kapunk.
Csak a többi tárolási osztály nevét kellett kötelezően kiírni, pl. static,
extern vagy register.
A C++11-ben ezért az auto kulcsszót az eredeti jelentésétől teljesen
megfosztották, helyette az „automatikusan kitalált típusú változó” jelentést kapta.
Mennyire jó ez? Arról a vélemények megoszlanak. Sokszor rövidebbé, áttekinthetőbbé
teszi a kódot: egy auto iter = valami.begin() változót látva egyből
tudjuk, hogy egy iterátorral van dolgunk. Máskor viszont elrejti az információt,
hogy milyen típusú objektumokkal dolgozunk épp, és megnehezíti a kód olvasását.
Ezt mindenképp mérlegelni kell a használatakor.
autoauto x = 5; // x = int lesz
int i;
auto &j = i; // int &j = i;
int *foo();
auto *k = foo(); // int *k = foo();
auto const l = 5; // int const l = 5;
A tárolók bejárását még egyszerűbbé teszi az ún. range-based for szintaxis. Ez ennyire egyszerű:
range-based for
std::vector<int> v = { 3, 4, 5 };
for (int i : v)
std::cout << i << std::endl;
A „range-based for” ciklusban kötelező egy változót deklarálni. Ez
a változó fogja felvenni a tároló értékeit. A típust nem kötelező megadni,
lehet auto is:
for (auto i : v)
std::cout << i << std::endl;
Tudni kell azt, hogy ez a változó érték szerint fogja felvenni a tároló által tárolt elemeket, tehát ebbe másoló konstruktoraikkal bemásolódnak a tároló elemei. Ha ezt el szeretnénk kerülni, akkor referencia típust kell megadni. Ilyen esetben a tároló elemeinek referenciáját látjuk, és módosítani is tudjuk az elemeket:
for (auto & i : v)
i *= 2;
Az ilyen módon használt for() ciklus nagy bravúrja, hogy
nem csak az STL tárolóin, hanem saját tárolóinkon, és akár tömbökön is működik.
Ezeket a ciklusokat a fordító a háttérben mindig átírja egy olyan változatra, amely a C++11-ben
új, globális std::begin() és std::end() függvényt használja
(#include <iterator>) a tartomány elejének és végének lekérdezésére.
Az előző ciklust például így fejti ki magának a fordító:
for (auto it = std::begin(v); it != std::end(v); ++it) {
auto &i = *it;
std::cout << i << std::endl;
}
A globális std::begin() és std::end() függvénysablon
úgy van megírva, hogy az adott tároló begin() és end()
tagfüggvényét hívva kérdezze le a tartomány két szélét mutató iterátorokat.
C++14-ben további kényelmi függvények lettek.
A tárolók bejárását még egyszerűbbé teszi az ún. range-based for szintaxis:
range-based for
std::vector<int> v = { 3, 4, 5 };
for (int i : v)
std::cout << i << std::endl;
Lehet auto is:
for (auto i : v)
std::cout << i << std::endl;
Alapértelmezés szerint értéket tárol a futóváltozó, de lehet ref. is:
for (auto & i : v)
i *= 2;
Az interfész fájl: általában: pelda.cppm, MS: pelda.ixx
export module pelda;
import std; // C++23
import <iostream>; // ha csak C++20 van
export import masikmodul;
int negyzet(int a) { return a*a; } // nem exportált
export int kob(int a) { return a*a*a; }
export class Example {
public:
void hello() const{ std::cout << "Hello" << std::endl; }
};
export {
class valami {...};
void fv() {...}
}
A továbbiakban a C++20 és C++23 néhány újdonságáról lesz szó. Ezeket az elemeket egyelőre nem építettük be a tárgy fővonalába.
A fejlécfájlok a C nyelv létrehozásától kezdve elválaszthatatlan részét képezték a C és C++ nyelvnek, és a kezdetektől nem sokban változtak. A preprocesszor segítségével kerülnek beépítésre a fordítás során. Céljuk, hogy interfészt biztosítsanak a különválasztott programrészek és a felhasználási helyük között. Sok probléma van velük, pl. a többszörös beszerkesztés elkerülése, a fejlécfájlok sorrendje, vagy az, hogy sokszor akár több tízezer kódsorból állnak, amivel a fordítónak minden fordításkor meg kell birkóznia.
A C++20-ban bevezetett modulok sok problémát kiküszöbölnek.
- A modulok importjának sorrendje tetszőleges.
- A modulok lefordítása külön történik, így importálásukkor nem kell újrafordítani ezeket, így a fordítás sokkal gyorsabb lehet.
- A makrók hatása modulon belül marad, nem rondítanak bele más fejlécfájlba vagy a .cpp fájlba.
- A
usingutasítás is aggodalom nélkül használható a modulinterfész fájlban. - Nem szükséges szétválasztani a deklarációt és az implementációt, ahogy a fejlécfájl és a hozzá tartozó cpp esetében történt.
További részletek itt
Az interfész fájl kiterjesztése általában .cppm, a Visual Studio azinban jelenleg .ixx kiterjesztést ír elő. A következő példa egy modulinterfész fájlt mutat be:
export module pelda;
import std; // C++23
import <iostream>; // ha csak C++20 van
export import masikmodul;
int negyzet(int a) { return a*a; } // nem exportált
export int kob(int a) { return a*a*a; }
export class Example {
public:
void hello() const{ std::cout << "Hello" << std::endl; }
};
export {
class valami {...};
void fv() {...}
}
export module pelda;: ez apeldanevű modul.import std;: Az összes szabványos modult importálja, nem kell egyenként importálni pl. az iostream-et, a string-et, exception-t, stb. Csak a C++23 szabványtól él.import <iostream>;: C++20 szabványú fordítóval egyenként importálhatjuk a modulokat.export import masikmodul;: ha lenne egymasikmodulnevű modul, és szeretnénk azt használni apeldamodulban, akkor importáljuk, de azt is szeretnénk, hogy amasikmodultartalma apeldamodult beépítő számára is elérhető legyen, és ne kelljen külön importálnia, akkor használjuk azexport importszerkezetet.- A
negyzetfüggvény a pelda modult beépítő számára nem elérhető, mert nem exportáljuk. Akobfüggvény viszont kívülről is elérhető. - Mindent exportálhatunk, aminek neve van, pl. függvényt,
class-t, változót. Csoportosan is exportálhatjuk, haexportcsoportba tesszük.
main.cpp
import pelda;
import std;
using namespace std;
int main() {
int k = kob(8);
Example x;
fv();
println("8^3 = {}", k);
}
A #include helyett az import utasítást használjuk. Mivel ezt nem a preprocesszor dolgoza fel, van pontosvessző a végén. A main.cpp:
import pelda;
import std;
using namespace std;
int main() {
int k = kob(8);
Example x;
fv();
println("8^3 = {}", k);
}
pelda.cpp
module pelda; // nincs export kulcsszó
using namespace std; // az import std;-t örökli az interfészből
void Example::valami() const {
...
}
Mindent megvalósíthatunk az interfész fájlban, de ha szeretnénk, használhatunk külön implementációs fájlt is. Itt nem kell külön importálni az interfészt,
ez implicite megtörténik. Az interfészben importált modulok az implementációban is használhatók. Az inline és template függvényeket
az interfészben implementáljuk. pelda.cpp:
module pelda; // nincs export kulcsszó
using namespace std; // az import std;-t örökli az interfészből
void Example::valami() const {
...
}
printf("%11.6f", d); // C
std::cout << std::fixed << std::setw(11)
<< std::setprecision(6) << d; // C++
print("{:11.6f}", d); // C++23
int n { 75 };
std::println("<{:4}>", n); /* < 75> */
std::println("<{:{}}>", n, 6); /* < 75> */
std::println("<{1:{0}}>", 6, n); /* < 75> */
std::vector<int> v { 16, 32, 64 };
std::println("{}", v); // [16, 32, 64]
std::println("{:n}", v); // 16, 32, 64
Egyedi formázás saját típussal: std::formatter template class specializációja.
A C++-ban bevezetett stream kiírás előnye, hogy saját típussal is működik, ha a >> operátort megírjuk hozzá. Hátránya, hogy sokkal nehézkesebb,
mint a C-s printf. A C++23-ban bevezetett kiírás függvények ezt a problémát megoldják. A print és println függvénysablonok saját típussal is működnek.
Egyedi formázást is készíthetünk a std::formatter template class specializációjával. A print/printlnfüggvényekkel fájlba is írhatunk.
Az újfajta kiírás a példában bemutatott dolgoknál sokkal többet tud.
printf("%11.6f", d); // C
std::cout << std::fixed << std::setw(11)
<< std::setprecision(6) << d; // C++
print("{:11.6f}", d); // C++23
int n { 75 };
std::println("<{:4}>", n); /* < 75> */
std::println("<{:{}}>", n, 6); /* < 75> */
std::println("<{1:{0}}>", 6, n); /* < 75> */
std::vector<int> v { 16, 32, 64 };
std::println("{}", v); // [16, 32, 64]
std::println("{:n}", v); // 16, 32, 64
„C++-ban objektum nem jön létre anélkül, hogy ne lenne inicializálva.” Igaz
ez? Osztálynál igen, de beépített típusnál nem. A beépített int,
char, double stb. típusú változók ugyanúgy viselkednek, mint
C-ben: ha nem adunk nekik kezdeti értéket, inicializálatlanok maradnak, és ez
hibalehetőség a programban.
De nem csak hibalehetőség, hanem programozási kényelmetlenséget is jelenthet. Tegyük fel, hogy van egy osztályunk, sok
adattaggal. Az osztály alapértelmezett konstruktorában minden adattag alapértelmezett konstruktorát szeretnénk hívni. Mi a
helyzet, ha van egy int adattagunk? Akkor amiatt az egyetlen adattag miatt régen konstruktort kellett írnunk, sok
int adattagnál pedig mind fel kellett sorolnunk azokat. Ha csak egy kimarad, máris gond van. A C++11-ben odaírhatjuk
melléjük, hogy = 0, de ez továbbra is kifelejthető.
Mi lenne, ha nem int-eket használnánk? Hanem
helyettük valami mást, ami magától is nullára inicializálódik, ugyanakkor bárhol
használható, ahol egy normál int is. Mint például az alábbi osztály.
Ennek alapértelmezett konstruktorába a fordító beépíti az i_ = 0
inicializálást; így egy Int objektum külön kérés nélkül is nullára inicializálódik:
class Int {
private:
int i_ = 0;
public:
Int() = default;
Int(int i) : i_{i} {}
operator int() const { return i_; }
};
A konstruktornak azonban van még egy szerepe. Egyparaméterű konstruktor lévén
ez egy konverziós lehetőséget is ad. Ezeket úgy tekinti a fordító, mintha azt
mondanánk: így kell int-ből Int-et csinálni. És ezt
használja is, ha egy kifejezést csak így tud értelmezni. A konverzió teszi lehetővé
az alábbi sorok leírását:
Int i = 5; /* Int i = Int{5}; */
i = 6; /* i = Int{6}; */
Az operator int() függvény pedig egy konverziós operátort ad meg:
éppen a fordított irányt. Emiatt a fordító elfogadja az alábbi sorokat is:
Int i = 5;
std::cout << i + 3; /* static_cast<int>(i) + 3 */
std::cout << i; /* static_cast<int>(i) */
double *arr = new double[i]; /* new double[static_cast<int>(i)]; */
Mindez azért működik, mert a fordító előbb a kifejezéseket nyelvtanilag elemzi
(operátorok, precedenciák), és utána nézi meg azt, hogyan helyettesíthetők be a
konkrét értékek. Ha pedig a típusok nem stimmelnek, akkor még nem adja fel,
megpróbálkozik konverziókkal is. Az i+3 kifejezésnél előbb megnézi,
talál-e Int+int operátort, de nincs. Utána azt próbálja meg, hogy a
létező operátorok (int+int, double+double, ...) közül
nem lenne-e használható valamelyik konverzióval. Így talál rá az
(Int→int)+int lehetőségre: ha a bal oldali operandusból
int-et csinál, akkor az int+int jó lesz. Ezért nem
kell saját kiíró operátort sem írni: az
std::cout<<i kifejezés értelmezése is megoldható a konverzióval.
Akár mindkét irány használható egyszerre:
Int i = 5;
i = 4 + i; /* i = Int{4 + static_cast<int>(i)}; */
Fontos: sose felejtsük el, hogy az ilyen jellegű kódolásnak nincsen
futási idejű költsége! A sok inline függvény miatt a fordító szinte
teljesen kioptimalizálja az egész varázslást, és vissza fog cserélni mindent egy ahhoz
hasonló, vagy akár ugyanolyan kódra, mintha sima int-ekkel írtunk
volna mindent.
Az Int objektum címe?
Ha azt szeretnénk, hogy egy Int objektumnak még a címe is int*
legyen, semmi más dolgunk nincs, mint átdefiniálni az addressof
operátort: operator&. Nem konstans Int objektumra int* mutat, konstans
Int-re int const *, ezért:
class Int {
public:
/* ... */
int * operator&() { return &i_; }
int const * operator&() const { return &i_; }
};
int main() {
Int i;
int *pi = &i;
*pi = 4;
std::cout << i;
}
Az addressof operátorra gondolhatunk úgy is, mint az alapértelmezett
konstruktorhoz hasonlóan automatikusan megírt tagfüggvényre. Ha nem mondunk mást,
a függvény ennyit csinál: return this. Hivatalosan viszont nem számít
ún. special member function-nek, mint mondjuk az alapértelmezett konstruktor.
A fenti osztályhoz hasonlóan csinálhatunk 0.0 értékűre inicializálódó Double-t, false
alapértékű Bool-t, vagy akár nullptr értékűre inicializálódó pointereket. Ha kiírjuk ezen típusok
paraméter nélküli inicializálását, akkor az említett nullaszerű értékek éppen ezen típusok alapértékei is:
bool b1; /* inicializálatlan */
bool b2{}; /* false */
char *p1; /* inicializálatlan */
char *p2{}; /* nullptr */
A C-s örökség miatt ezek a típusok mind inicializálhatóak a
0 értékkel is. Ezt hagyjuk meg a C programoknak: a bool b=0
C++-ban inkább képzavarnak tűnik.
Az automatikusan inicializálódó beépített típus emiatt sablon osztályként is
megírható. A T{} formájú inicializálás használható az inicializáló
listán és alapértelmezett paraméterértékként is, így az alábbi két osztálysablon
egyformán jól megoldja a feladatot. Ezek bármelyikéből példányosíthatunk
okosított int-et, double-t vagy különféle
pointereket:
template <typename T>
class InitBuiltin {
private:
T val_;
public:
InitBuiltin(): val_{} {}
InitBuiltin(T val): val_{val} {}
operator T() const { return val_; }
};
template <typename T>
class InitBuiltin {
private:
T val_;
public:
InitBuiltin(T val = T{}): val_{val} {}
operator T() const { return val_; }
};
A fenti sablonból létrehozott osztályoknak rövid neveket is adhatunk. A
C++11-ben a typedef helyett a típusok átnevezésére a
using kulcsszót illik használni, mert ez tisztább, és támogatja a
template-eket is (a typedef ilyet nem tudott). A
szintaktikája egyszerű, using újnév = réginév:
using Int = InitBuiltin<int>;
using Bool = InitBuiltin<bool>;
template <typename T> using InitPtr = InitBuiltin<T*>;
Bool b; /* == false */
InitPtr<int> p; /* == nullptr; */
A using kulcsszavas típusdefiníció olvashatóbb is, mint a
typedef-es párja, mert segít, hogy a definiált típus és a név ne
keveredjenek szintaktikailag. A C-s operátorok hol bal, hol jobb oldalon állnak,
így a keveredés gyakori. Gondoljunk csak a függvényekre mutató pointerekre:
typedef double (*FuncPtr)(double); /* C++98 */
using FuncPtr = double (*)(double); /* C++11 */
Az osztályok alapértelmezett konstruktora képes kezdeti értéket beállítani, de a beépített típusok inicializálatlanok kezdeti érték nélkül.
int helyett Int osztály => külön kérés nélkül is 0-ra inicializálódik:
class Int {
private:
int i_ = 0;
public:
Int() = default;
Int(int i) : i_{i} {}
operator int() const { return i_; }
};
Az egyparaméteres konstruktor konverziós lehetőséget is ad. Így tud a fordító int-ből Int-et csinálni:
Int i = 5; /* Int i = Int{5}; */
i = 6; /* i = Int{6}; */