A C++ nem objektumorientált új nyelvi elemei
Dobra Gábor, Horváth János · 2019.02.27.
Jegyzet 1. fejezet: 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.
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++!";
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;
};
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
const
ekvivalens az "eggyel jobbra" írtconst
-tal (const char* == char const*
). - A
const
arra 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.
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!
Ebből
baj lesz!
#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.
// a makrók függvényre mutató pointernél is problémásak
typedef int (*puts_fptr)(char const * str);
puts_fptr p = PUTS; // ERROR
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:
Hibás!
#ifndef MIN_H_INCLUDED
#define MIN_H_INCLUDED
int min(int a, int b) {
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:
inline
-nal már jó
#ifndef MIN_H_INCLUDED
#define MIN_H_INCLUDED
inline int min(int a, int b) {
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. A témáról részletesebben ebben az írásban olvashatsz.
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.
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;
}
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. Így nem fogjuk lehagyni a címképző operátort sem (&
).
A változtatandó függvényparaméternél többé nem pointert használunk. A referenciák bevezetésével a pointer nem szűnik meg, csak az egyik eddigi használatára jobb megoldásként a referenciákat használjuk. 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). 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ű adatok á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. A felesleges másolást így 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. A konstanssal egyébként azt a célt is elérjük, hogy az eredeti példány nem módosítható.
struct X {
int tomb[256];
}
void kiir(X const& x) {
// ...
}
Ezzel elválik egymástól a pointer (int*
), a változtatandó paraméter (int&
) és a túl nagy paraméter (X const&
).
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. 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. Fontos kiemelni emiatt, hogy mit is jelent a const
, hova kell írni azt. A const int&
és az int const&
mindkettő ugyanazt jelenti: a const
a referencia által hivatkozott az egész számra vonatkozik. Ha a referenciajel után írnánk a const
jelzőt, akkor vonatkozna magára a referenciára: int & const
, de ez meglehetősen értelmetlen lenne, mert a referencia már alapból konstansnak számít, nem állítható át másik változóra. Amikor pongyolán "konstans referenciát" mondunk, olyankor sem azt értjük alatta, hogy a referencia konstans (az mindig az), hanem hogy a hivatkozott adat konstans.
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). Egyelőre ennyit jegyezzünk meg: itt is érvényes a pointerekre vonatkozó ökölszabály, miszerint lokális változóra mutató referenciával tilos visszatérni, éppúgy, mint lokális változó címével.
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;
kilepes = true;
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.
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
}
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++-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');
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.
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.
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.