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.

1. 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;
}

2. 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++!";

3. 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;
};

4. 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 const ekvivalens az "eggyel jobbra" írt const-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.

5. 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!


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.

6. 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.

7. 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;
}

8. 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. Í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.

9. 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;

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.

10. 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
}

11. 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

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');

13. 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.

14. 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.

15. 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.