A C++ nem objektumorientált új nyelvi elemei
Ugyan a tárgy célja nem a C++ megismertetése az olvasóval, a nyelv OOP elemeinek használatához szükség van a nyelv bizonyos szintű bemutatására, ezen kívül a C++ bizonyos kényelmi elemeit is használjuk. A C nyelvvel való kompatibilitás ezek mindegyikénél biztosított, azaz használhatjuk az elavult formát is, bár ellenjavallott. Számos új nyelvi elem a C99-ben is megjelenik, de ez utóbbival nem foglakozunk. A C++ szabvány sok tekintetben szigorít a C89 lehetőségein. Ezek a megkötések manapság alapvetőnek számítanak, a Programozás alapjai 1. tárgyban nem is esett szó a megengedőbb nyelvtani szabályokról, így ezekkel nem foglalkozunk.
Kommentek
A C89 szabvány szerint a kommenteket csak /* és */ közé helyezhetünk, melyek egymásba nem ágyazhatók. C++-ban használhatjuk az ún. egysoros kommenteket, melyek kezdetét // jelzi, és a sor végéig tartanak.
int main() {
// egysoros komment
/*
akár több soros komment
még egy sor
*/
return 0;
}
Változódeklaráció
A C89 nyelv szigorúan megköti, hogy változókat csak a kapcsos zárójellel jelölt blokkok elején deklarálhatunk, a deklarációkat és a kódot nem "keverhetjük". A C++ ezen a szabályon enyhít, és a blokkon belül bárhol deklarálhatunk változókat, típusokat, akár for ciklus fejlécének elején is.
for(int i = 0; i < n; ++i) {
// itt i látható
tomb[i] = 2 * tomb[i];
}
// itt már nem látható i
n *= 2;
char szoveg[] = "Hello C++!";
typedef struct
C-ben a deklarált struct-ok, union-ok, enum-ok nevei nem önálló típusnevek, csak a struct, union, enum kulcsszóval együtt hivatkozhatunk a típusra. Ezt a problémát jellemzően typedef használatával kerüljük ki. C++-ban ezek a nevek önállóan is használhatóak.
typedef struct Lista { // C-ben és C++-ban is helyes
int adat;
struct Lista * kov;
} Lista;
struct Lista { // C++-ban így is helyes, ajánlott ennek a formának a használata
int adat;
Lista * kov;
};
A const kulcsszó
C89-ben fordítási idejű konstansok létrehozására egyedül a #define szabványos eszköz áll rendelkezésünkre, ami pl. tömbök deklarálásánál kritikus. A makrók használata ellenjavallott, nem biztonságos a használatuk, gyakran lehet nem kívánt hatásuk. Illetve meg lehetett oldani enum-mal is, de csak egész értékekkel, és az enum nem arra való.
C89-ben nincs annak jelzésére lehetőség, hogy egy adott változón keresztül nem változtathatunk meg egy adott memóriaterületet. Ez kifejezetten veszélyes pl. függvények paramétereinél.
Mindkét problémára megoldást kínál a const kulcsszó, ezzel némi kétértelműséget okozva.
const int BUFFER_SIZE = 255; double const PI = 3.141592653589;
A fenti példában deklarált változók értékei nem módosíthatóak a létrehozás után (éppen ezért a létrehozáskor azonnal értéket kell adnunk nekik!). A const type és a type const deklarációk egyenértékűek. Alább a const más jellegű használatát demonstrálja egy példa. Ebben az esetben a const nem fordítási idejű állandót jelöl, hanem azt, hogy azon a néven keresztül nem változtatható meg a jelölt memóriaterület.
// a) char const * sptr = "Hello const!"; sptr++; // ok sptr[0] = 'h'; // nem ok // b) char * const cptr = &c; cptr++; // nem ok *cptr = 'f'; // ok // c) char const * const cc = &c; cptr++; // nem ok, a ptr is konstans (* const) *cptr = 'f'; // ez sem ok, a karakterek is azok (char const)
A deklarációk értelmezésében segít két szabály:
- Az előre írt
constekvivalens az "eggyel jobbra" írtconst-tal (const char* == char const*). - A
constarra vonatkozik, ami a tőle balra van.
Ez a megfogalmazás pongyola, de a lényeget jól szemlélteti. Az a) (sptr) megoldásnál a const az egyes karakterekre vonatkozik, azaz a "Hello const!" sztring karaktereit nem változtathatjuk, de a rá mutató pointert átállíthatjuk. A b) példánál viszont a const a char * típusú változóra vonatkozik: magára a cptr pointerre, a cptr által mutatott memóriaterületet változtathatjuk.
Az ábrán sárgával jelöltük a konstans változókat.
Felmerülhet a kérdés, hogy mi történik, ha az alábbiakhoz hasonló kóddal próbálkozunk "átverni" a fordítót.
const int x = 3;
int *px = &x;
*px = 4;
void f(int *i) { *i = 4; }
const int x = 3;
f(&x);
Nem túl meglepő módon a fenti kódok egyike sem működik, ugyanis ha const int x típusú változónak képezzük a címét (& operátor), akkor const int * típusú pointer keletkezik. Ez a C++ szabvány szerint nem konvertálható int * típusú pointerré, tehát errort kapunk, nem tudtuk kijátszani a fordítót.
Ezekből adódik, hogy a const int típusú változóra nem mutathat int* típusú pointer, hanem csak a const int* típusú, viszont int típusú változóra hivatkozhat const int* típusú pointer is.
Azokban az esetekben, amikor a deklarált változó maga konstans, értelemszerűen kötelező inicializálni.
Az inline függvények
Számos programozó örömmel él a C preprocesszor függvényszerű makrói nyújtotta lehetőséggel, általában a függvényhívás költségének megspórolása érdekében. Egyrészt ez barbár dolog, a 21. században néhány függvényhívás többletköltsége szinte mindig elhanyagolható, másrészt az ilyenek rendkívül veszélyesek is. Lássuk, miért!
// NEM SZÉP KÓD, CSAK DEMONSTRÁCIÓS CÉLLAL
#define MIN1(a, b) a < b ? a : b
#define MIN2(a, b) (((a) < (b)) ? (a) : (b))
#define PUTS(s) fputs(s, stdout)
int main() {
int i1 = MIN1(0, 1); // 0, eddig ok
int i2 = 2 * MIN1(2, 3);
/* kifejtve:
int i2 = 2 * 2 < 3 ? 2 : 3; // 3! ???
A költő 4-re gondolt. Sebaj, ez javítható
némi zárójelezéssel, lásd MIN2. */
int i3 = 2 * MIN2(2, 3); // 4, úgy tűnik, így már jó
int a = 2, b = 3;
int i4 = MIN2(a++, b++); // Baj van!
/* kifejtve:
int i4 = (((a++) < (b++)) ? (a++) : (b++));
Itt a nagyobb változó értéke kétszer nő meg,
ami nem volt a célunk a makró megírásakor. */
typedef int (*puts_fptr)(char const * str);
puts_fptr p = PUTS; // ERROR
// a makrók függvényre mutató pointernél is problémásak
return 0;
}
Ez az erőlködés fölösleges, jobb volna inkább a fenti makrókat függvényként megírni. A fordító képes arra, hogy a függvényhívás helyett a függvény törzsét lefordításkor a függvényhívás helyére beillessze, ezzel megspórolva a függvényhívás költségét. A makrókkal problémás kódok függvények használatával gond nélkül működnek, mivel ott a szokásos precedencia- és kiértékelési szabályok érvényesek – például a min2(a++, b++) esetén nem lesz semelyik változó kétszer megnövelve:
int min(int a, int b) {
return a < b ? a : b;
}
int main() {
int a = 5, b = 8;
std::cout << min(a++, b++) << std::endl; // 5
std::cout << a << b << std::endl; // 6 9
return 0;
}
Ahhoz, hogy a törzs beépítése, az inline-olás megtörténhessen, természetesen a fordítónak látnia kell a függvény törzsét. Egy fejlécfájlba csak a függvény deklarációját tesszük, akkor azzal elesünk ettől az optimalizációs lehetőségtől, hiszen a fordító nem látja, mik azok az utasítások, amiket be kellene építeni a hívás helyére. Ezért kénytelenek vagyunk a fejlécfájlba tenni a függvény definícióját is. De ezzel elég hamar problémába ütközhetünk:
#ifndef <MIN_H_INCLUDED>
#define <MIN_H_INCLUDED>
int min(int a, int b) { // EZ ÍGY HIBÁS!!!
return a < b ? a : b;
}
#endif
Ugyanis most ahány forrásfájlban (*.c) include-oljuk ezt a fejlécfájlt, a min() függvény látszólag annyiszor definiálódik – és hibaüzenetet kapunk, mert egy függvénynek csak egy definíciója lehet. Ne feledjük, az include-olás lényegében olyan, mintha copypaste-elnénk a fájlt.
Ennek a problémának a megoldására találták ki az inline kulcsszót. Ezt a függvény fejléce elé írva jelezzük a fordítónak, hogy a függvény inline-olhatóságát szeretnénk elérni, és emiatt a fejlécfájlban szerepeltetnénk a törzsét – és ha emiatt többször, több fordítási egységben is találkozik ugyanannak a függvénynek a definíciójával, az nem hiba.
A helyes fejlécfájlunk tehát így fest:
#ifndef <MIN_H_INCLUDED>
#define <MIN_H_INCLUDED>
inline int min(int a, int b) { // elején inline szó
return a < b ? a : b;
}
#endif
Az inline kulcsszóra akkor van igazán szükség, ha a header-be kerül a függvény. Ekkor enélkül a linker multiple definitions miatt hibát dob: hiszen egy szokványos függvény csak egy fordítási egységben lehet definiálva. Az inline ezért másfajta linkelési típust vezet be. A fordító az inline kulcsszó nélkül is használhatja ezt az optimalizációs technikát, amennyiben a függvény törzse is ismert számára.
Függvények túlterhelése
A C++ – a C-től eltérően – lehetőséget ad, hogy több azonos nevű, de eltérő paraméterlistájú függvényt deklaráljunk, definiáljunk. Ennek hiánya azt a problémát vonta maga után (C-ben), hogy azonos célú, különböző típusokkal dolgozó függvényeknek különböző neveit kellett használnunk. Ismert példa a C standard könyvtárának abs, fabs, labs, llabs függvényei.
double max(double a, double b) {
return a < b ? b : a;
}
int max(int a, int b) {
return a < b ? b : a;
}
int main() {
int i = max(1, 2);
double d = max(0.0, 2.1);
max(1.1, 3); // ERROR
// mindkét max függvény hívásához konverziót kell végezni, a fordító
// nem tud dönteni, melyiket kell hívni, kétértelmű hívás
// a fenti két példánál nem kellett konverzió, ezért nem volt baj
return 0;
}
Ilyenkor a fordító a hívás helyén megadott paraméterekből kitalálja, hogy melyik overload-ot kell hívni. Értelemszerűen ahol a típusokból nem eldönthető, mert pl. mindegyik overload típuskonverziót vonna maga után, fordítási hibát kapunk.
Default paraméter
Szintén C++ újdonság, hogy a függvények paramétereinek adhatunk default értéket. Pontosabban: a függvény utolsó néhány paraméterének adhatunk, melyek közül az utolsó néhányat híváskor elhagyhatjuk:
void szamot_kiir(int szam, int szamrendszer = 10) {
// ...
}
int main() {
szamot_kiir(42); // ezzel egyenértékű: szamot_kiir(42, 10);
szamot_kiir(42, 16);
return 0;
}
Ez nagyjából annyit jelent, hogy:
void szamot_kiir(int szam, int szamrendszer) {
// ...
}
inline void szamot_kiir(int szam) {
szamot_kiir(szam, 10);
}
Előfordulhat az, hogy a függvénynek létezik kétparaméterű változata, ahol a második default, és egy egyparaméterű változata default paraméter nélkül.
void szamot_kiir(int szam, int szamrendszer = 10) { // (1)
// ...
}
void szamot_kiir(int szam) { // (2)
// ...
}
szamot_kiir(1);
Itt az utolsó sorban a függvényhívásnál a szamot_kiir függvény egy paramétert kapott, ezért a fordító nem tudja eldönteni, hogy az egyparaméterű változatot hívja (1) vagy a kétparaméterűt (2) 10-es default második paraméterrel. Ilyen esetben errort kapunk, a kód nem fordítható le.
Természetesen több paraméter is kaphat default értéket, akár az összes, de a default paraméterek közül híváskor mindig csak az utolsó néhányat hagyhatjuk el.
Ha a függvénynek van default paramétere, a deklarációt illetően két lehetőségünk van:
- A függvényt nem deklaráljuk előre, ekkor triviális, hova kerülnek a default paraméterek
- A függvényt pontosan egyszer deklaráljuk előre, a default paraméternek ilyenkor a deklarációban a helye, a definícióba tilos kiírni.
Ha a default paraméterrel rendelkező globális függvényeket másik fordítási egységből (.cpp fájl) is el szeretnénk érni, csak a második opció működik. Egy header-ben deklaráljuk előre a default paraméterrel együtt, és a .cpp-ben definiáljuk.
void f(int a = 2, int b = 3, int c = 5, int d = 8) {
// ...
}
int main(void) {
f(); // f(2, 3, 5, 8);
f(42); // f(42, 3, 5, 8);
f(42, 43); // f(42, 43, 5, 8);
f(42, 43, 44); // f(42, 43, 44, 8);
f(42, 43, 44, 45);
return 0;
}
Referenciák, cím szerinti paraméterátadás
C-ben, C++-ban a függvénynek átadott paraméterek alapesetben másolatként adódnak át, a függvény nem az adott változón dolgozik, hanem annak egy ugyanolyan értékű másolatán. Pl.:
void nyolc(int x) {
x = 8; // a másolat módosul, az eredeti értéket nem tudjuk megváltoztatni
}
int x = 10;
nyolc(x);
// x még mindig 10...
Ahhoz, hogy a változó értékét meg tudjuk változtatni C-ben cím szerint kellett átadni, pointerrel (plusz egy indirekció):
void nyolc(int *x) {
*x = 8; // az eredeti változó címén keresztül annak értékét változtatjuk meg
}
int x = 10;
nyolc(&x);
// x most már 8
Bár a megoldással elértük célunk, mégis a plusz indirekcióval bonyolultabbá tettük a programunkat, ráadásul erre elég gyakran is volt szükség. Ennek megkönnyítésére a C++-ban bevezették a referenciát. A referencia egy alternatív név, amivel ugyanarra a változóra egy másik névvel is hivatkozhatunk. Ennek szintaktikája a következő:
int i = 1; // semmi új, egy int típusú változó int& r = i; // inicializálunk egy int referenciát r = 2; // i = 2; // Itt i és r értéke is 2, mivel ugyanazt a változót // érjük el mindkettővel, r igazából csak egy másik név i-hez
"Null referencia" C++-ban nem létezik, egy referenciának mindig egy változóra kell "mutatnia". Ezért a referenciát kötelező inicializálni a létrehozáskor, mivel valójában a hivatkozott változó címét tárolja (csak a C++ ezt elfedi, hogy érthetőbb kódot írhassunk).
A fenti függvény referenciát ismerve most már így írható meg egyszerűen:
void nyolc(int& x) {
x = 8; // az eredeti változó referenciáján keresztül
// annak értékét változtatjuk meg
}
int x = 10;
nyolc(x);
// x most is 8
Vegyük észre, hogy nem kellett a függvényparaméteren kívül sehol sem jelölnünk, hogy referenciával dolgozunk, így kódunk sokkal tisztább és érthetőbb maradt, emellett nem fogjuk lehagyni a címképző operátort sem (&).
A referenciák bevezetésével a pointer nem szűnt meg, csak egy eddigi használatára (amit igazából jobb megoldás hiányában használtunk) jobb megoldásként a referenciákat használjuk. A változtatandó paraméter többé nem egyenlő a pointerrel. A pointert továbbiakban is használjuk pl. dinamikus memóriakezelésnél vagy láncolt listáknál, stb.
A referenciákból ugyanúgy létezik konstans változat is, mint a pointerekből, ezeket pl. int-nél így jelölhetjük: const int& és int const& (a két jelölés jelentése megegyezik). A referenciák, miután inicializáltuk őket, nem állíthatóak át másik változóra, élettartamuk végéig ugyanarra a változóra fognak hivatkozni. Ezért ez a kód meglehetősen értelmetlen: int& const. Itt magát a referenciát próbálnánk konstanssá tenni (sikertelenül, mert rácsap a kezünkre a fordító), de az már alapból konstansnak számít, hiszen inicializálás után nem állíthatjuk át a hivatkozást.
Mivel másik változóra nem állíthatóak át, struktúra adattagjaként kellemetlen használni, főleg, mivel kötelező inicializálni. Ilyen esetekben szinte mindig pointereket használunk.
Nyilvánvalóan const int típusú változóra nem hivatkozhat int& típusú referencia, hanem csak a const int& típusú, viszont int típusú változóra hivatkozhat const int& típusú referencia is (ugyanúgy mint a konstans pointereknél).
A túl nagy méretű paraméterek átadásra C-ben gyakran használtunk pointert, mert kellemetlen lett volna egy nagy struktúrát feleslegesen lemásolni. Erre C++-ban konstans referenciát használunk. Ezzel a célt elértük, az eredeti példány nem módosítható a const miatt, a felesleges másolást elkerültük, és a kód nem bonyolódott címképzéssel, dereferálással. Kis méretű paraméternél felesleges a const&, mert az indirekció többe kerül, mint a másolás.
Ezzel elválik egymástól a pointer (int*), a változtatandó paraméter (int&) és a túl nagy paraméter (int const&).
struct X {
int tomb[256];
}
void kiir(X const& x) {
// ...
}
Itt is érvényes a pointerekre vonatkozó ökölszabály: lokális változóra mutató referenciával tilos visszatérni, éppúgy, mint lokális változó címével.
Függvényhívás mint balérték
Ha egy függvény referenciát ad vissza, akkor a visszatérési értéke állhat egyenlőségjel bal oldalán, és ilyenkor az eredeti változó kap értéket:
int x;
// ...
int& f() { return x; }
// ...
int main() {
f() = 5;
f()++;
}
Persze, értjük ezt a kódot, de hogy mi értelme... Később azonban meg fogjuk látni, hogy nagyon is fontos (indexelő operátor).
A logikai típus
Nem kis zavart tud okozni, hogy C89-ben nincsen önálló logikai típus, hanem általában int-eket használunk logikai változókként. C++-ban beépített nyelvi elem a bool típus, mely egybájtos, és kétféle értéket vehet fel: true vagy false. Egész típusra, és egész típusról automatikusan tud konvertálódni, a C konvencióit követve. Ha tehetjük, ennek ellenére használjuk a true és a false kulcsszavakat az érthetőség kedvéért.
bool kilepes = false; kilepes = 1; // true kilepes = -34; // true; kilepes = 6 < 4; // false kilepes = 0; // false; int x = kilepes; // 1 int y = !kilepes; // 0
C-ben a logikai értékkel visszatérő függvények int-el tértek vissza, a legjobb példa erre a C-s ctype.h isspace, isdigit, stb. függvényei, amik nem feltétlenül 0-val vagy 1-gyel tértek vissza, hanem kihasználták a C-s "logikai típus" értelmezési szabályait.
Névterek
C-ben a programunk több modulra bontásának egyetlen eszköze van: a több fájlra bontás, ami elég rugalmatlan, például a névütközéseket nem lehet vele jól kezelni.
// SDL header
int init(int flags);
// másik lib-hez tartozó header
int init(int flags);
// harmadik lib
void init();
// main.c
#include "SDL.h"
#include "masik_lib.h" // <- ERROR: conflicting declarations
#include "harmadik_lib.h"
int main() {
init(0); // <- ???
return 0;
}
Ezért a C-s konvenciók szerint a lib-ek összes függvénye így néz ki, hogy ne legyenek névütközések:
int SDL_init(int flags);
Ennél jóval kényelmesebb és rugalmasabb C++-ban a namespace.
// pl. SDL header
namespace SDL {
int init(int flags);
struct Rect {
// ...
};
}
Az init függvényt kívülről SDL::init-ként érjük el, a Rect-et pedig SDL::Rect-ként. A :: operátor neve scope operator, később látni fogjuk egy másik jelentését is.
A névterek egymásba is ágyazhatók, ezen kívül van lehetőségünk névtelen namespace-be rejteni a lokális változóinkat, típusainkat. Utóbbi nagyjából egyenértékű a static kulcsszó ezen jelentésével, de így típusokat is el tudunk rejteni.
namespace {
uint16_t swap_bytes(uint16_t in);
struct internal_structure {
// ...
};
}
namespace SDL {
namespace Encoding {
void unicode_2_utf8(uint16_t const *in, uint8_t *out) {
// ...
}
void utf8_2_unicode(uint8_t const *in, uint16_t *out) {
// ...
}
}
int init(int flags) {
// ...
}
}
Mivel névtelen namespace-ben van, ezért a swap_bytes függvény a forrásfájlon kívülről nem érhető el.
Ha egy névtér elemeit sokszor használjuk, névütközés pedig nem áll fenn, feleslegesnek érezhetjük minden egyes alkalommal kiírni a névtér nevét: SDL::Encoding::unicode_2_utf8. Ezért vezették be a using kulcsszót, mellyel egy névteret "nyithatunk ki", vagy egy névtér egy adott elemét tehetünk láthatóvá, vagy a névtér nevét is rövidíthetjük, alias-t adhatunk neki.
Egy névtér teljes kinyitása veszélyes dolog, erősen ellenjavallott, header fájlban különösen, inkább soha ne tegyük.
using namespace SDL::Encoding;
namespace enc = SDL::Encoding;
using SDL::Rect;
using SDL::init;
int main() {
init();
enc::unicode_2_utf8(...);
unicode_2_utf8(...);
Rect r;
return 0;
}
A scope operátor használható a globális függvények, típusok, stb. direkt elérésére is.
int f() {
// ...
}
namespace N {
int f() {
// ...
}
}
using N::f;
int main() {
int a = f(); // ERROR: melyik f?
int b = ::f(); // globális f
int c = N::f(); // N::f
}
Kiírás, beolvasás
C-ben a kiírásra és a beolvasásra elsősorban a printf és scanf függvényeket használjuk. Ezekkel két fő probléma adódik: nincs típusellenőrzés, és nem tudjuk megtanítani, hogyan kell a mi típusainkat kiírni ill. beolvasni. A scanf használatánál arra is oda kell figyelni, hogy cím szerint kell kapnia a változókat, könnyű a & karaktert lefelejteni, vagy olyankor is kitenni, amikor nincs rá szükség (pl. sztringnél).
int a;
scanf("%s", &a)
Ilyenkor a fordító nem ellenőrzi a paraméterek típusát, így lefordul, pedig nyilvánvalóan helytelen a kód: a scanf sztringet fog beolvasni, char* paramétert vár, és int*-ot kapott egyetlen int-nyi hellyel. Hosszabb beírt sztringnél a kapott helyet túlírja, a környező memóriaterület sérül, vagy összomlik a program. Normális fordítók (pl. GCC, Clang, MSVC 2015) erre warning-ot adnak, míg más fordítók (pl. régebbi MSVC) nem.
Mindkét problémára megoldást nyújt a C++ megoldása. Kiírásra az std::cout (C-ben stdout), beolvasásra az std::cin (C-ben stdin), míg error kezelésére a C-s stderr-hez hasonlóan az std::cerr való. Használatukhoz az iostream header szükséges. A következő fejezetben látni fogjuk, hogyan kell őket megtanítani a saját típusunk kezelésére. Sor vége karakter kiírható akár std::endl használatával is, ami annyiban különbözik a '\n'-től, hogy azonnal kiüríti a puffert. (Általában ez azt jelenti, hogy azonnal megjelenik a képernyőn.)
Az std::cin – a scanf-től eltérően – képes referencia szerint átvenni a változókat, így nincs probléma a címképzéssel sem.
std::cout << 1.0 << 'x' << "szoveg" << std::endl; int a, b; std::cin >> a >> b;
Az std::cin és az std::cout, és minden azonos típusú objektum, automatikusan tud konvertálódni igaz vagy hamis logikai értékre, attól függően, volt-e hiba, EOF a beolvasás vagy kiírás során. Példaként egy egyszerű átlagszámoló program:
#include <iostream>
int main() {
int darab = 0;
int osszeg = 0;
int szam;
while(std::cin >> szam) {
++darab;
osszeg += szam;
}
std::cout << "Az átlag: " << (double)osszeg / darab << std::endl;
}
Mindezek az előnyök eltörpülnek amellett, hogy az std::cin és std::cout megtanítható arra, hogyan kezelje az általunk definiált típusokat, nem csak beépített típusokat tud kezelni.
A formátum változtatására – pl. hexadecimális formátum, + előjel – az iomanip standard header elemeit használhatjuk, a jegyzetben később lesz példa a kulturált felhasználásra.
Érdemes még megjegyezni, hogy az inserter operátor (<<) és az extractor operátor (>>) nem új nyelvi elemek, hanem a C-s shiftelő operátor felüldefiniálásai. Ezért egyrészt láncolható, másrészt helyenként vigyázni kell a precedenciára.
std::cout << 1 << 2 << std::endl; // output: 12
C header-ök használata
C++-ban a C header-ök ugyanúgy használhatóak, ahogy C-ben is. Illik azonban a C++-os "változatukat" használni, aminek a képzése:
stdio.h → cstdio
Például:
#include <cstdio> #include <cctype>
A C szabványos könyvtár függvényei, típusai pedig bekerültek a std névtérbe is, de globálisként is használhatjuk őket (a C-vel való kompatiblitás jegyében).
std::size_t s = sizeof(int);
bool b = std::isspace('\n');
Kivételek
A hibakezelés megvalósítása C-ben több okból kifolyólag is nehéz volt. Ha egy hiba keletkezett, nem tudtuk mit csináljunk vele:
- írjuk ki a felhasználónak (miért használ olyan programot amiben nincs rendes hibakezelés)
- állítsuk le a programot (végzetes hibáknál)
- menjünk tovább, mintha mi sem történt volna és adjunk vissza valami értéket (???)
Sokszor nem is ott kell kezelni a hibát, ahol keletkezett. Például ha valahol nullával kéne osztanunk, akkor valószínűleg a hiba miatt egy sokkal előbb elkezdett kódrészlet is hibával zárul majd a hibás (nem meghatározható) eredmény miatt.
A C++-ban a hibák kezelésére bevezették a kivételkezelést. A hiba keletkezésének helyén egy kivételt dobunk (throw utasítás), amit majd az arra alkalmas kód lekezel: a veremben a kivétel dobásához legközelebbi, a hiba kezelésére alkalmas catch blokk. Legrosszabb esetben (ha a programban nem kezeltük a hibát) az operációs rendszer fogja kezelni (kilövi a programot).
A try blokkban keletkező kivételt a try blokk után megadott catch blokkokal tudjuk elkapni. A catch(...) minden kivételt elkap. Pl.
try {
// ...
if(hiba) throw kivétel1_típusú_kifejezés;
// ...
if(hiba) throw kivétel2_típusú_kifejezés;
// ...
}
catch (kivétel1 e) {
// kivétel1 típusú kivétel kezelése
}
catch (kivétel2 e) {
// kivétel2 típusú kivétel kezelése
}
Ha a throw utasításhoz kerül a vezérlés, akkor a kód futása megszakad, és a vezérlés azonnal átkerül a megfelelő catch blokkhoz. A kivételkezelés használatával így elérhetjük, hogy azokat a hibákat, amiket a hívó okozott, ne nekünk kelljen lekezelni (hiszen mi nem tudjuk, hogy a hívó mit szeretne hiba esetén), hanem átadjuk a vezérlést a hívónak.
Az stdexcept header-ben számos kivétel van definiálva, melyek mind az std::exception-ből származnak (erről később), arról is lesz szó, hogyan definiálhatunk std::exception-ből származó típusokat. Ezeken kívül más típusú kivételt dobni nem illik, barbár dolog.
#include <stdexcept>
int osztas(int a, int b) {
if (b == 0)
throw std::runtime_error("Nullaval osztas!");
return a / b;
}
int main(void) {
try {
std::cout << "5 / 1 = " << osztas(5, 1) << std::endl;
std::cout << "6 / 0 = " << osztas(6, 0) << std::endl;
// az osztas függvényben megszakad a végrehajtás,
// a függvény nem tér vissza, a 6 / 0 osztás eredménye
// nem jelenik meg, idáig nem jut el a vezérlés
std::cout << "Ez sose fog lefutni." << std::endl;
}
catch(std::runtime_error& x) {
std::cerr << x.what() << std::endl;
}
catch(...) {
std::cerr << "Baj van, valami ismeretlen kivételt dobtak!" << std::endl;
}
}
Elsőre meglepőnek tűnhet, de az osztas függvényben nem kapjuk el a kivételt. Persze, hiszen azért dobtuk, mert nem tudunk vele mit csinálni.
Ilyenkor az osztás függvényből azonnal kilép a vezérlés, visszakerül a hívóhoz. Itt épp egy olyan try blokkból hívták meg, amihez tartozik a dobott kivétel típusát kezelni tudó catch ág, így abban a catch ágban folytatódik a program végrehajtása. Ilyen catch ág nélkül természetesen az azt hívó függvény végrehajtása is megszakadna.
Fontos megjegyezni, hogy a kivételeket mindig nem konstans referencia szerint érdemes elkapni. A nyelv megenged mást is, de jobb helyeken azért letörik az ember kezét. Ennek a pontos okáról az öröklés témakört követően lesz szó.
A throw-catch szerkezet nagy előnye a C-s, visszatérési értéken alapuló "hibakezeléssel" szemben, hogy sok hibát tudunk egyetlen helyen kezelni.
Dinamikus memóriakezelés
C-ben a dinamikus memóriakezelés nem volt szigorú értelemben a nyelv része (include-olni kellett a használatához), a C++-ban használt new, new[], delete és delete[] operátorok már a C++ nyelv részei.
Inkább zárójeles megjegyzés, hogy a C-ből ismert malloc és free is használható megfelelő include-ok után (C++-ban azonban kötelező megfelelő típusra castolni), de használatuk nem ajánlott és nagy körültekintést igényel, mivel használatukkor nem hívódnak meg a konstruktorok és a destruktorok (ezekről később).
#include <stdlib.h>
// lista típus definiálása
lista *l;
l = (lista*) malloc(sizeof(lista));
if(l == NULL)
// ...
free(l);
Míg C-ben a fentihez hasonlóan nézett ki egy dinamikus memóriafoglalás, addig C++-ban jópár dolog felesleges ezekből:
- castolás (típuskonverzió)
- méret kiszámítása
- mivel nyelvi szinten támogatott a dinamikus memóriakezelés, ezért nem kell include
Tehát ugyanez C++-ban így néz ki:
lista *l; l = new lista; // ... delete l; // Tömb: int *t; t = new int[10]; // ... delete[] t;
Fontos, hogy C++-ban a new operátor használatával foglalt változókat szigorúan a delete operátorral, míg a new[] operátorral foglalt tömböket a delete[] operátor használatával kötelező felszabadítani, különben memóriaszivárgás vagy futási hiba lesz az eredmény.
Ha a memóriaterület lefoglalása közben valamilyen hiba adódik, akkor a new std::bad_alloc típusú kivételt dob.
nullptr
C-ben a NULL makró definíciója a következő:
#define NULL ((void*)0)
Ezt kényelmesen használhatjuk, mivel a void* pointer típus, nem kell félni egész műveletektől. Mellesleg a 0 integer literális is a null pointert jelenti, éppúgy, mint C++-ban.
C++-ban a void* -> akarmi* konverzió már nem automatikus, mert veszélyes, hibalehetőséget hordoz magában. Már láttuk, hogy pl. malloc esetében ezért kell kiírni a cast-ot. Így viszont a NULL makró C-s definíciója sem állja meg a helyét:
char * ptr = NULL;
Ezért C++-ban a NULL definíciója általában:
#define NULL 0
Ezzel visszakaptuk azt a problémát, hogy a NULL pointer egész számként képes viselkedni, bár a kódban legalább elválik egymástól az egész szám és a pointer. Itt is hasonló a helyzet, mint a bool esetében: nem célszerű egy típust másra használni, mint amire való. Ezért vezették be C++11-ben a nullptr kulcsszót. Ennek a típusa nullptr_t, és bármilyen pointer típusra automatikusan tud konvertálódni, míg egész műveleteket nem végezhetünk rajta.
Amennyiben a fordító támogatja, érdemes ezt használnunk.