CPPFTW Jegyzet

Dobra Gábor, Czirkos Zoltán, Horváth János, Fintha Dénes · 2020.03.28.

A jegyzet összes fejezete egy oldalon

Ez az oldal elsősorban azért jött létre, hogy a jegyzetet egyszerűen lehessen böngészővel PDF-be exportálni. Kérjük, az oldalak kinyomtatása előtt gondolj a környezetre.

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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Az OOP alapjai, operátorok túlterhelése

Az informatika fejlődésével és elterjedésével a programok egyre nagyobbá, és egyre bonyolultabbakká váltak. A nagy és áttekinthetetlen programok fejlesztésének és karbantartásának megkönnyítésére találták ki az objektumorientált programozást (továbbiakban: OOP). Az OOP-ben a programot kisebb modulokból építjük fel (osztályok) és ezeknek az osztályoknak a példányai (objektumok) kommunikálnak egymással. Ennek több előnye is van:

  • Az egyes modulok önmagukban könnyebben megérthetőek, mint az egész program.
  • A projekt egyes részei önmagukban is felhasználhatóak másik projekthez.
  • A ma használatos hatalmas projekteknél egyszerűen kivitelezhetetlen, hogy egy fejlesztő az egész programot fejlessze, fel kell bontani a programot modulokra, amin az egyes fejlesztők (inkább fejlesztőcsapatok) egymással párhuzamosan tudnak dolgozni.
  • Az egyes modulok önállóan is fordíthatóak, tesztelhetőek, nem kell megvárni az egész projekt elkészültét ahhoz, hogy kiderüljön egy modul hibája.
  • Ha egy modul belső szerkezetét át kell alakítani, akkor az anélkül megtehető, hogy az egész projektet teljesen át kéne alakítani.
  • Ha egy modul konzisztenciája megszakad, egészen biztos, hogy csak a modulon belül lehet a hiba, ha kívülről nem lehet elrontani. Ezzel a hiba helyét nagyságrendekkel pontosabban be lehet határolni.

A függvényeknél megtanultuk, hogy tekinthetünk rájuk átlátszatlanként: egyfajta fekete dobozok, melynek nem kell a működésébe belelátnunk, elég, ha tudjuk, milyen feladatot végeznek el, és hogyan kell használni őket. Ez a szemléletmód lehetővé tette, hogy top-down módszerrel tervezzünk programokat, nagy vonalakban lehetett vázolni, hogyan kell működniük, a pontos részletek ismerete nélkül.

C-ben a fájlkezelés is egy ilyen absztrakciós rétegen keresztül működött: ez a FILE típus. Fogalmunk sincs, hogy van megvalósítva, de minket úgyis csak a használatának módja érdekel. Az összetartozást azonban csak a függvények nevei jelezték. Az objektumorientáltság a típusok absztrakcióját nyelvi szintre emeli, lehetővé teszi, hogy egy típusra átlátszatlanként tekintsünk, és hogy a beépített típusokhoz hasonlóan viselkedhessenek.

Az objektumra – éppúgy, mint eddig a függvényre – tekinthetünk fekete dobozként, magic box-ként. A dobozba nem látunk bele, csak bedobhatjuk az érméket, a rajta lévő gombokat nyomogathatjuk, figyelhetjük a kijelzőt, és ha ügyesek voltunk, kipottyan az üdítő. Maga a doboz az, ami a bezárt dolgokat légmentesen elkülöníti a külvilágtól, a doboz kezelhető egységként. Nem tudjuk, nem is kell tudnunk, hogyan válogatja szét az érméket, mitől mozog oda a kóla, ahol megtaláljuk. Egy ilyen automatát letehetünk a pályaudvarra, a büfébe, az irodába, a Q-I melletti folyosóra anélkül, hogy a működésén bármit változtatnánk, de különböző fajta üdítőkkel tölthetjük fel mindegyiket.

Mivel az automata külseje és belseje el van zárva egymástól, más környezetben ugyanúgy tudnak működni. Ez fordítva is igaz: egy automata belsejében akármit megváltoztathat a szerelő úgy, hogy ebből mi semmit ne vegyünk észre, maximum annyit, hogy már kapni kávét is, vagy gyorsabban, messzebbre dobja ki az üdítőt. Innentől ha nem működik, a szerelő nem mutogathat ránk, ha elromlott az automata: biztosan nem mi turkáltunk az adatok – üdítők, pénzérmék – között, nem is tudnánk belenyúlni.

Egy ilyen modulokból felépített program hasonlítható egy PC-hez. Ha meg akarjuk növelni a tárhelyet, elég betenni egy plusz HDD-t, nem kell ezért átírni a BIOS-t, hogy kezelje az új vietnami gyártó modelljét. Videokártya-csere után nem kell forrásból lefordítani a Doll of Cutie 42-t, ugyanaz a bináris ugyanúgy fog működni, esetleg jobban.

18. Objektumorientált programozás elvei

Egységbe zárás

Az egységbe zárás (encapsulation) azt jelenti, hogy az osztályba "becsomagoljuk" az adatokat és a rajta elvégezhető műveleteket. C-ben egyedül az adatszerkezetet tudtuk struktúrába tenni, a típushoz tartozó műveleteket nyelvi elemen keresztül nem tudtuk a típushoz kötni, egyedül a névben jelezhettük (pl.: Tort_osszead, Tort_kiir, stb.). C++-ban az osztályokban a tagváltozókon kívül tagfüggvényeket is megadhatunk, amelyekkel a típuson elvégezhető műveleteket definiálhatjuk.

Adatrejtés

Az objektumorientált programozásban jellemző modularitást úgy tudjuk maximális mértékben kihasználni, ha az egyes osztályok jól meghatározott interfészeken keresztül kommunikálnak egymással. Ezzel azt érjük el, hogy ha egy osztály megvalósítja a használója számára elérhető műveleteket, akkor a belső működése, adatszerkezete a többi modul befolyásolása nélkül is megváltozhat. Erre egy példa a C-s FILE típus, ahol bár nem tudjuk, hogy pontosan hogyan működik belülről, de kaptunk hozzá néhány függvényt, amely segítségével minden ezzel kapcsolatos feladat megoldható, vagy másképp fogalmazva: amelyek segítségével bármely feladat elvégzésére megkérhető.

Azzal, hogy az objektum belső működését elrejtettük a használója elől, megakadályoztuk, hogy olyan műveletet végezzen rajta, ami esetleg inkonzisztenssé tenné a belső adatszerkezetét. Tehát osztályunk két részből fog állni: a külső használó számára is elérhető publikus interfész (mire való, hogyan használható – a használójának ez a fontos), és az implementáció (hogyan működik belül). A használó számára az utóbbi mellékes: mindegy, hogyan történik meg a fprintf(fp, "helló világ\n"), aki ezt leírta, az a hatásra volt kíváncsi.

Felelősség

Az OOP alapgondolata, hogy minden osztálynak jól meghatározott felelőssége van, mégpedig egyetlen dologért felelős. Ennek a neve Single Responsibility Principle, röviden SRP, ennek az alkalmazásáról későbbi fejezetekben lesz szó. Az addig bemutatott osztályok is követik ezt az elvet, de ilyen osztályok kommunikációjáról csak később lesz szó.

Például a FILE típus egy megnyitott fájlért felel, és csak azért, a hozzá tartozó függvények pedig a fájlműveletekért. Semmiképpen sem felelős azért, hogy mi hogyan használjuk fel a fájl tartalmát. Ha mi a fájlban italautomaták adatait tároljuk, akkor az automata-osztály felelőssége a fájlból érkező adatok helyes használata.

Öröklés

Sokszor előfordul, hogy több osztály viselkedésében vannak közös pontok, de ugyanazt a viselkedést máshogy valósítják meg. Ilyenkor a közös viselkedésre egy új típust vezetünk be, és a különböző felelősségű osztályok a közös osztály minden tulajdonságát öröklik. Erről fog szólni a teljes 5. fejezet, a fogalom pontos magyarázatával együtt.

Ráadás

Az OOP elvekhez szigorúan véve csak a fenti négy tartozik hozzá, azonban a C++ és néhány más nyelv is további lehetőségeket biztosít az OOP támogatására.

Típustámogatás

Néhány nyelv lehetőséget biztosít, hogy az általunk definiált típusok a beépített típusokkal teljesen egyenrangúak lehessenek. Ez elsősorban az operátorok átdefiniálását jelenti, például két törtet össze lehessen adni úgy, hogy t1 + t2, pont, mint valós számoknál, és ne kelljen Tort_osszead(t1, t2)-t írni.

Generikus programozás

Számos esetben fordul elő, hogy pontosan ugyanazt kell csinálni teljesen különböző típusokkal: gondoljunk a Prog1 tárgyban felmerülő láncolt listákra, ahol az int-eket és a double-öket tároló lista csak a típus nevében különbözött egymástól. Az ilyen problémák megoldásáról fog szólni a 6. fejezet.

19. OOP előnyei a gyakorlatban

Nézzünk egy kézzel fogható példát annak demonstrálására, hogy ezeknek az elveknek a betartása nem csak a kódot bonyolítja. A kód eleinte félig-meddig C stílusú, és szépen fokozatosan áttérünk a C++ OOP nyelvi elemeinek használatára.

struct Tort {
    int szamlalo;
    int nevezo;
};

int main() {
    Tort t1 = {2, 1}; // 2 egész, C-s struktúra inicializálással
    Tort t2 = {1, 4}; // 1/4
    // legyen t3 a t1 és t2 szorzata!
    Tort t3 = {
        t1.szamlalo * t2.szamlalo,
        t1.nevezo * t2.nevezo,
    };
    std::cout << "t3 = " << t3.szamlalo << '/' << t3.nevezo;
    return 0;
}

Elemezzük a fenti kód veszélyeit!

  • t3 törtet szemantikailag helytelen értékekkel hoztuk létre, hiszen a törtet egyszerűsített formában kellene létrehozni, hogy elkerüljük a túlcsordulást. Ha sokáig számolgatunk egy törttel, és közben nem egyszerűsítünk, túlcsordulhatnak az int-ek, ami egyszerűsítéssel elkerülhető lehet.
  • Senki nem akadályozza meg a Tort használóját, hogy a nevezőt 0-ra állítsa.
  • A törtekkel végzett műveletek sorminta-szagúak, könnyű elrontani, stb.
  • A törtek közötti értékadás ebben a formában nehézkes, sok helyen kénytelenek vagyunk segédváltozót használni.
  • Ha meg kell változtatni a Tort belső működését, az összes rá épülő kód összedől. Ha pl. megcseréljük a struktúrán belül az adattagokat, a C-s inicializálás mást fog jelenteni – a jelenlegi recpirokát –, de lefordul!

C-ben éppen ezért írtunk mindenre függvényeket, tegyünk most is így! Az egyszerűsítéshez az euklideszi algoritmust fogjuk használni.

struct Tort {
    int szamlalo;
    int nevezo;
};

int euklidesz(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

Tort Tort_letrehoz(int szaml = 0, int nev = 1) {
    // Tort_letrehoz()     -> 0/1 -> 0
    // Tort_letrehoz(2)    -> 2/1 -> 2
    // Tort_letrehoz(1, 3) -> 1/3

    if(nev == 0)
        throw std::invalid_argument("Tort nevezője nem lehet 0!");

    Tort uj;
    int lnko = euklidesz(szaml, nev);
    uj.szamlalo = szaml / lnko;
    uj.nevezo = nev / lnko;
    return uj;
}

Tort Tort_szoroz(Tort egyik, Tort masik) {
    return Tort_letrehoz(egyik.szamlalo * masik.szamlalo,
                         egyik.nevezo * masik.nevezo);
}

void Tort_kiir(Tort t) {
    std::cout << t.szamlalo << '/' << t.nevezo;
}

int main() {
    // a függvényes inicializálás már nem érzékeny az adattagok sorrendjére
    Tort t1 = Tort_letrehoz(2, 1);
    Tort t2 = Tort_letrehoz(1, 4);

    Tort t3 = Tort_szoroz(t1, t2);

    std::cout << "t3 = ";
    Tort_kiir(t3);

    return 0;
}

Miért használtuk a Tort_szoroz függvényben a Tort_letrehoz függvényt, miért nem mi magunk bíbelődtünk az adattagokkal?

Lássuk be, anélkül sorminta lenne. Az egyszerűsítés műveletét – amit a szorzásnál, és minden műveletnél mindig meg kell tenni – a létrehozással vontuk össze. Tehettük volna külön is, amit szintén minden függvény belsejében meg kellene hívni, miután létrehoztunk egy változót, és beállítottuk az adattagjait. Mivel minden létrehozás után meg kéne hívni az egyszerűsítést, jobb helyen van a létrehozás belsejében.

Mi a fenti megoldással a gond?

A Tort_letrehoz függvény már biztonságos, azon keresztül nem hozhatunk létre érvénytelen törtet. Azonban továbbra sem akadályoz meg senki abban, hogy az adattagokhoz kézzel nyúljunk hozzá, ugyanúgy lehet nevezőt 0-ra állítani, ugyanúgy lehet egyszerűsítetlen törtet használni. Persze, bele lehet írni a Tort dokumentációjába, hogy tilos az adattagoknak értéket adni, de a fordító nem tud a kezünkre csapni.

"A fordítók nem olvassák el a dokumentációt. Sok programozó nem olvassa el a dokumentációt!" Bjarne Stroustrup

Ahhoz, hogy valaki jól tudja használni a Tort-et, a belső működését érteni kell, tehát nem csak az interfészt, hanem az implementációt is. Nem lehet tudni, hogyan kell ezt a típust használni, és nem lehet kikényszeríteni sem, hogy valaki jól használja. Nézzünk két rövid kódrészletet ennek demonstrálására.

scanf("%d/%d", &t1.szamlalo, &t1.nevezo);

if (t1.szamlalo == t2.szamlalo && t1.nevezo == t2.nevezo)
    // ...

Mindkét sor helyesnek tűnik, nagyon közelről kell nézni, hogy megtaláljuk benne a hibát. A két sor külön-külön működhet jól, de egymás helyességét kizárják. Az első sorban beolvastunk egy törtet egyszerűsítés nélkül: azaz 2/4 esetén 2 és 4 lettek az adattagok. A második sorban összehasonlítottunk két törtet, figyelmen kívül hagyva, hogy esetleg nincsenek egyszerűsítve. Eszerint 2/4 nem egyezik 1/2-del!

Itt jön képbe az adatrejtés. Rejtsük el az implementációt, hogy csak az interfészen keresztül tudjuk használni, és ne lehessen rossz kódot írni!

C++-ban osztályt a class és a struct kulcsszóval hozhatunk létre. Az osztályon belül a private és public módosítókkal szabályozhatjuk az adattagjaik láthatóságát. Egy ilyen módosító az összes alatta lévő adattagra vonatkozik, a következő módosítóig.

  • public: az adattag bárki számára látható, bármelyik kódrészlet eléri.
  • private: az adattag kifelé teljességgel láthatatlan, csak a tagfüggvények látják (ezekről nemsokára lesz szó).

A struct és a class csak annyiban különbözik egymástól, hogy struct esetében az alapértelmezett láthatóság public, míg class esetén private. Minden más tekintetben egyenértékűek.

Mit jelent az, hogy csak belülről látható? Mi van belül?

Lehetőségünk van az osztályokon belül tagfüggvényeket deklarálni, definiálni. A private és public módosítók pontosan ugyanúgy működnek rajtuk, mint az adattagokon: a privát tagfüggvényeket csak az osztályon belülről hívhatjuk meg. Két fő dologban különböznek a standard globális függvényektől:

  • Van egy implicit paraméterük, a this pointer, ami az adott példányra mutat, és rajta keresztül elérik az összes adattagot.
  • Látják az osztály privát adattagjait.

A tagfüggvények szintaktikája a következő:

struct Tort {
  private:
    int szamlalo;
    int nevezo;

  public:
    void kiir() { // <- nem kapott paramétert, anélkül éri el az adattagokat
        std::cout << this->szamlalo << '/' << this->nevezo;
    }
    // ezzel ekvivalens:
    void kiir2() {
        std::cout << szamlalo << '/' << nevezo;
    }
};

A tagfüggvényen belül az adattagokat elérhetjük a this pointeren keresztül vagy anélkül is. Ha a tagfüggvény egyik paramétere ugyanolyan nevet kapott, mint egy adattag, akkor a paraméter kap elsőbbséget, az adattagot elérjük a this pointerrel.

void f(int szamlalo) {
    // szamlalo: paraméter
    // this->szamlalo: adattag
}

Mi baj történhet, ha kívülről megpróbáljuk olvasni a privát adattagokat? Ebben az esetben semmi, sőt, sok esetben hasznos lenne. A private módosító ezt nem teszi lehetővé, ezért ezt a szerepet rendszerint publikus tagfüggvények töltik be: getter-ek. Természetesen vannak olyan osztályok, ahol egyáltalán nem kell / szabad elérhetőnek lennie bizonyos adattagoknak.

struct Tort {
  private:
    int szamlalo;
    int nevezo;

  public:
    int get_nev() {
        return nevezo;
    }

    int get_szaml() {
        return szamlalo;
    }

    void kiir() { // <- nem kapott paramétert, anélkül éri el az adattagokat
        std::cout << this->szamlalo << '/' << this->nevezo;
        // ezzel ekvivalens:
        std::cout << szamlalo << '/' << nevezo;
    }
};

A C++ még egy nyelvi elemet biztosít az objektumok védelmére. Jelezhetjük, hogy egy tagfüggvény nem változtatja meg az objektumot a const kulcsszó egy számunkra új használatával. A fordító garantálja, hogy konstans objektumra csak const tagfüggvény hívható meg, és const tagfüggvény nem adhat értéket a tagváltozóknak.

struct Tort {
    // ...

    int get_nev() const {
        // nevezo = 0; <- ERROR
        return nevezo;
    }

    int get_szaml() const {
        return szamlalo;
    }

    void kiir() const {
        std::cout << szamlalo << '/' << nevezo;
    }
}

Ennek megfeleleően a this pointer deklarációja az egyes tagfüggvényekhez:

Tort * const this;       // nem-const tagfv. esetén
Tort const * const this; // const tagfv. esetén

Előfordulhat, hogy szükség van nemcsak az adattagok lekérdezésére, hanem a beállítására is, ezeket a beállító tagfüggvényeket setter-nek hívjuk. A legtöbb esetben azonban az OOP alapvelveivel ellentétesek, mert akadályozzák, hogy az implementációt kedvünkre változtathassuk a kifelé mutatott viselkedéstől függetlenül. Ahogy a Tort esetében is: mit várunk, mi lesz t értéke a kódrészlet végén?

Tort t(2, 3);
t.set_szaml(9);
t.set_nev(2);

Aki ezt a kódrészletet leírja, valószínűleg azt szeretné, hogy a tört értéke 9/2 legyen. Viszont ha az eddigi logikát szeretnénk tartani, akkor a set_szaml-nak és a set_nev-nek is egyszerűsítenie kell a törtet, és így más fog történni:

Tort t(2, 3);       // 2/3
t.set_szaml(9);     // 9/3 -> 3/1
t.set_nev(2);       // 3/2

Ezért a setter ebben a példában is lehetetlen helyzetet állítana elő. Ehelyett rendelkezésünkre áll a szokásos értékadás, ami miatt a setter igazából felesleges:

Tort t(2, 3);       // 2/3
t = Tort(9, 2);     // 9/2
t = Tort(t.get_szaml(), 4); // 9/4

20. Konstruktor

Minden objektum élete a létrehozásával (példányosítás) kezdődik, ezért ez a "létrehozó függvény" kitüntetett szerepet kap az OOP-ben. Név szerint ez a konstruktor (röviden: ctor), ami automatikusan hívódik az objektum létrehozásakor. A konstruktor minden objektum létrehozásánál mindenképpen meghívódik. Ezért sokkal alkalmasabb az inicializálásra, mint egy tagfüggvény (Tort_letrehoz): nem lehet kikerülni.

A konstruktorok szintaktikája hasonlít a tagfüggvényekére, néhány nagyon fontos különbséggel. Egyik ilyen: nincs visszatérési értékük! Nem void, semmi! A neve megegyezik az osztály nevével. Egy osztálynak több konstruktora is lehet, éppúgy, ahogy a függvényeknek overload-jaik.

struct Tort {
  private:
    int szamlalo;
    int nevezo;

  public:
    Tort(int szaml, int nev) {
        if(nev == 0)
            throw std::invalid_argument("Tort nevezője nem lehet 0!");

        int lnko = euklidesz(szaml, nev);
        szamlalo = szaml / lnko;
        nevezo = nev / lnko;
    }
    // ...
};

// ...

int main() {

    // konstruktorhívás szintaktikája
    Tort t1(2, 1);
    Tort t2(1, 4);
    Tort(5, 3).kiir(); // temporális, névtelen objektum létrehozása és kiírása

    Tort t3 = Tort_szoroz(t1, t2);

    std::cout << "t3 = ";
    t3.kiir();

    return 0;
}

Ha nem lehet kikerülni a konstruktorhívást, mi történt régen (még a konstruktor megírása előtt) ebben a sorban?

Tort t1;

Ez egy olyan konstruktor hívása, ami nulla paramétert vesz át, név szerint a default konstruktor. Ilyet viszont eddig nem írtunk! Azért, hogy a C-ben megírt struktúrák C++-ban is működjenek, a fordító automatikusan generál nekünk egy default ctort, ami meghívja az adattagok default ctor-át. Alaptípusra pedig a "default ctor" nem csinál semmit, memóriaszemetet hagy ott, pont, mint C-ben. Ha viszont írtunk már egy konstruktort, a fordító elveszi tőlünk a default ctor-t, hiszen ahol ctor van, az nem C kód, bizonyára szándékosan nem írtunk default ctort.

Ha nem írunk default ctort, egy komoly problémával szembesülhetünk: nem tudunk tömböt létrehozni: ugyanis a tömb létrehozásakor mindegyik tömbelemre meghívódik a default ctor.

Ezért is jó lenne, ha a Tort-nek lenne default ctor-a. Ez inicializáljon nullára! C++-ban barbár dolog objektumot inicializálatlanul hagyni. Ezen kívül jó lenne, ha lenne egyetlen paraméteres konstruktor, ami a nevezőt 1-re állítja. Szerencsére a konstruktornak is adhatunk default paramétereket, így a három konstruktor helyett elég egyszer dolgoznunk:

struct Tort {
    Tort(int szaml = 0, int nev = 1) {
        // ...
    }
    // ...
};

Konstruktorok és konverziók

Bármely egyparaméteres konstruktor konverziós operátorként szolgálhat. A Tort jelenlegi konstruktora hívódhat egy paraméterrel, így int → Tort automatikus (implicit) konverziót tesz lehetővé.

int f(Tort t) {
    return t.get_szaml();
}

int main() {
    Tort t1 = 4;        // automatikus int -> Tort konverzió
    Tort t2 = Tort(4);  // "kézzel" hívott konverzió
    Tort t3(4);         // sima konstruktorhívás
    int n1 = f(t1);     // semmi
    int n2 = f(8);      // automatikus int -> Tort konverzió
}

Figyeljük meg: a Tort(4) kifejezés olyan, mintha a konstruktort függvényként hívnánk. Az ilyen szintaktikájú konverzió a jobban olvasható, egyértelműbb a precedenciája, mint a C-s stílusúnak, ezért C++-ban bevezették, hogy alaptípusra is használhassuk.

Tort t1 = Tort(4);
int a = abs(int(1.5));
double atlag = double(osszeg) / darab;

A mi Tort osztályunknál ez az automatikus konverzió kapóra jöhet. Számos esetben viszont az ilyen konverzió szemantikailag helytelen: például vegyünk egy dinamikus tömböt, aminek a DinTomb(int n) konstruktora n mérettel inicializálja a tömböt. Az ilyen szituációkra vezették be az explicit kulcsszót, ami letiltja az automatikus konverziót, de az általunk hívott (explicit) konverziót megengedi. Bármely, egy paraméterrel is hívható konstruktorra alkalmazható.

class DinTomb {
  public:
    explicit DinTomb(int meret) {
        // ...
    }
    // ...
};

void g(DinTomb t);

int main() {
    DinTomb t1 = 4;             // ERROR: nincs automatikus int -> DinTomb konverzió
    DinTomb t2 = DinTomb(4);    // "kézzel" hívott konverzió
    DinTomb t3(4);              // sima konstruktorhívás
    g(t1);                      // semmi
    g(8);                       // ERROR: nincs automatikus int -> DinTomb konverzió
}

21. Operátorok túlterhelése

A C-s példában a Tort_szoroz függvényt használhattuk két tört összeszorzására. Ez elég kényelmetlen dolog, és nem fair. Miért kell a Tort osztálynál kiírni szövegesen, hogy szorozni szeretnénk? Sokkal kényelmesebb a beépített típusoknál a * operátort használni.

A C++ nyelv lehetőséget ad arra, hogy az operátorokat felüldefiniáljuk: ez az operator overloading. Nézzünk egy konkrét példát az általános szabályok előtt.

Tort operator*(Tort lhs, Tort rhs) {
    return Tort(lhs.get_szaml() * rhs.get_szaml(),
                lhs.get_nev() * rhs.get_nev());
}

Az operator* egy olyan függvény, aminek két Tort paramétere van, és visszatér egy újonnan létrehozott Tort-tel, ami épp a két paraméter szorzata.

A két operandust a konvencióknak megfelelően neveztük el: left-hand side, right-hand side operand. Ez kétféleképpen is hívható, a két forma tökéletesen ekvivalens. Általában az első változatot használjuk, viszont a második mutat rá legjobban arra, pontosan mi is történik.

Tort t3 = t1 * t2;
Tort t4 = operator*(t1, t2);

Könnyű belátni, ez rendkívül rugalmas, könnyen használható és intuitív, ha ésszerűen használjuk. Azonban számos megkötéssel együtt kell élnünk:

  • Bármilyen operátor overload-olható, kivéve: . (adattag), :: (scope), ?: (ternáris / feltételes), sizeof.

  • nem változtatható meg a szintaxis, azaz kötött:

    • precedencia
    • asszociativitás
    • egyoperandusú – kétoperandusú tulajdonság (viszont egyes operátoroknál mindkettő létezhet, pl. a-b: kivonás, -a: ellentett)
  • Az operátor-függvény neve mindig operator@, ahol a @ helyére az operátort kell írni. Lejjebb találunk néhány példát.

  • Az operátor-függvényt két helyen keresi a fordító:

    • Globális függvényként, ekkor – bináris (kétoperandusú) operátornál – az első paraméter a bal oldali operandus, míg a második a jobb oldali. Vagyis lhs * rhs lehet operator*(lhs, rhs).
    • A bal oldali operandus tagfüggvényeként, pl. lhs * rhs esetén lhs.operator*(rhs). Unáris operátor esetén ez nulla paraméterű tagfüggvény jelent: -op esetén op.operator-().
    • A legtöbb operátor globálisként és tagfüggvényként is megírható, de a kettő közül csak az egyik létezhet. Néhány operátor csak tagfüggvényként valósítható meg.
  • Általában bármi lehet a visszatérési értékük, persze illik a konvenciókhoz igazodni.

  • A paramétereit akár érték, akár referencia, akár konstans referencia szerint átveheti.

A két tört összeszorzását akár megvalósíthattuk volna a Tort osztály tagfüggvényeként is.

struct Tort {
    // ...
    Tort operator*(Tort rhs) const {
        return Tort(szamlalo * rhs.szamlalo,
                    nevezo * rhs.nevezo);
    }
}

Ez az operator* a Tort tagfüggvénye, aminek van egy implicit paramétere (this), ami a bal oldali operandus, és a jobb oldali operandust paraméterként kapja.

Itt is tökéletesen ekvivalens ennek az alábbi két hívási módja:

Tort t3 = t1 * t2;
Tort t4 = t1.operator*(t2);

Gyakorlásképp nézzük meg két tört összeadását!

struct Tort {
    // ...

    Tort operator+(Tort x) const {
        return Tort(
            x.get_szaml() * get_nev() + get_szaml() * x.get_nev(),
            x.get_nev() * get_nev());
    }
};

Minden operátor teljesen önálló, így az operator+ és az operator= meglétéből nem következik az operator+=, azt is nekünk kell megírnunk, de egyik a másikra visszavezethető.

Mi legyen a visszatérési érték? Mivel azt szeretnénk, hogy úgy működjön a Tort, mint egy beépített osztály, pont úgy lehessen vele számolni, ezért illik az operátorainkat úgy megvalósítani, hogy ennek eleget tegyünk. Egész számoknál (int) le tudjuk írni a következőt:

c = a += b;

Ebben az esetben c megkapta a új értékét, azaz a új értékével kell visszatérni. Egy kérdés maradt: érték vagy referencia szerint? Ismét nézzük meg, hogy viselkedik egy int!

(a += b)++;

Ekkor a-hoz előbb hozzáadódik b értéke, majd megnő eggyel. Tehát az operator+=-nek (és az összes operator@=-nek) Tort&-val kell visszatérnie.

struct Tort {
    // ...
    Tort& operator+=(Tort rhs) {
        *this = *this + rhs;
        return *this;
    }
}

Emlékezzünk vissza az std::cout és az std::cin legnagyobb előnyére: megtaníthatjuk nekik, hogyan kezelje a mi típusainkat. Most már kezünkben van a szükséges eszköz, nézzük meg a kiírást Tort esetére. Ehhez fontos tudni, hogy az std::cout típusa std::ostream. Megváltoztatjuk a stream állapotát, – írunk bele, – ezért referenciaként kell átvenni. A félkész verzió:

void operator<<(std::ostream& os, Tort t) {
    os << t.get_szaml() << '/' << t.get_nev();
}

Mint minden operátort, ezt is kétféleképpen hívhatjuk.

std::cout << t3;              // hívás operátorként
operator<<(std::cout, t3);    // hívás függvényként
std::cout << "t3 = " << t3;   // láncolás
std::cout << t3 << std::endl; // ERROR

Mi történik a negyedik sorban, miért hibás?

A shiftelő operátor balról jobbra asszociatív. Bontsuk ki ennek megfelelően az operátorok használatát függvényekre!

std::cout << t3 << std::endl;
operator<<(std::cout, t3) << std::endl;
operator<<( operator<<(std::cout, t3), std::endl);

Először hívódik a bal oldali operator<<, aminek az eredményét kapja bal oldali paraméterként a második operator<<. Mivel az általunk megvalósított operator<< visszatérési értéke void, ez így nem működhet.

Ötlet: adjuk vissza kiírás után magát a streamet! Így már tökéletesen fog működni a láncolás.

std::ostream& operator<<(std::ostream& os, Tort t) {
    os << t.get_szaml() << '/' << t.get_nev();
    return os;
}

Ezt hÍvják inserter operátornak, mert a streambe szúr be. Ezzel analóg módon megírhatjuk a Tort beolvasását is, ez lesz az extractor operátor. Itt már a jobb oldali operandust – a törtet – meg akarjuk változtatni, tehát referencia szerint kell átadni. A hibakezeléssel nem foglalkoztunk.

std::istream& operator>>(std::istream& is, Tort& t) {
    char c;
    int szaml, nev;
    is >> szaml >> c >> nev;
    t = Tort(szaml, nev);
    return is;
}

Ezen kiíró és beolvasó függvényeket nem valósíthattuk meg az std::ostream és std::istream tagfüggvényeként: tagfüggvény formában std::cout.operator<<(t3) lenne, azaz az std::ostream tagfüggvénye. Egy osztályhoz utólag nem adhatunk hozzá tagfüggvényt, így kötött pályán mozgunk. Általánosságban véve érdemes ezen a kérdésen elmélkedni: melyiket célszerűbb használni, a globális függvényt, vagy a tagfüggvényt?

Érdemes arra törekedni, hogy az operátoraink globális függvények legyenek, ha lehetséges, így nem férnek hozzá a privát adattagokhoz, nem tudják elrontani az objektum konzisztenciáját. A Tort-nél kényelmes volt a globális operator*, mert voltak getterek és konstruktor, más nem kellett hozzá.

Ha viszont privát adattagokhoz hozzá kell férniük (majd erre is látunk példát), szemantikailag helytelen lenne egyetlen operátor kedvéért bárkinek hozzáférést biztosítani azokhoz, így marad a tagfüggvény, vagy a friend, amit a 4. fejezetben fogunk megismerni. Bizonyos operátorok ráadásul csak tagfüggvényként valósíthatók meg.

Nézzük meg, hol tartunk most! Az eredeti kód szinte minden sora átalakult, kényelmesebb és biztonságosabb lett.

int main() {
    Tort t1(2, 1);
    Tort t2;

    std::cin >> t2;
    Tort t3 = t1 * t2;

    std::cout << "t3 = " << t3 << std::endl;

    return 0;
}

Konzisztencia

Sok OO nyelvben nincs operátor overloading. Azt mondják, hogy ez a feature obfuszkálja a kódot, túl könnyű érthetetlen kódot írni, és van is benne igazság. Ez egy nagyon hasznos nyelvi eszköz, de sokan rosszul használják, vagy olyankor is használják, amikor nem kéne. Ez nem csak erre igaz, hanem rengeteg más feature-re is: dinamikus memóriakezelés, pointeraritmetika, void*, goto, continue.

Ezért nem magát a nyelvi elemet kell hibáztatni, hanem azokat a programozókat, akik rosszul használják. Ennyi erővel be lehetne tiltani a programozást is, akkor nem lennének bugok. A C++ kezünkbe adja ezeket az eszközöket is, és a mi felelősségünk, hogyan használjuk.

C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off. Bjarne Stroustrup

Hogy ezt elkerüljük, javasolt betartani néhány elvet:

  • Egy operátort csak akkor overload-oljunk, ha tökéletesen egyértelmű és magától értetődő, hogy mit csinál! Például a Tort osztálynak ne írjunk [] operátort, mivel a megszokott jelentése – indexelés – a törteken értelmetlen.
  • Az összetartozó operátorokat vagy mind írjuk meg, vagy egyiket sem! Például ha van operator+, legyen operator+=, operator-, operator-=, stb. is, hiába nem használjuk mindegyiket. A Tort esetében ettől eltekintettünk, ez vehető házi feladatnak.

Speciális operátorok

Szinte az összes operátor felüldefiniálható, láttuk a kivételeket. Egészen a legvadabb operátorokig, amikhez időnként speciális szintaxis tartozik, ezeket nézzük meg közelebbről!

Konverziós operátor

A konstruktor képes konverziós operátorként viselkedni, ezt láttuk, ki is használtuk: így tudunk Tort-et létrehozni int-ből. Mit tehetünk, ha azt szeretnénk, hogy a Tort-ünkből lehessen double? Nyilvánvalóan a double-nek nem írhatunk Tort-et átvevő konstruktort, hiszen nem is osztály, hanem alaptípus. Erre való a konverziós operátor.

class Tort {
    // ...
    operator double() const {
        return double(szamlalo) / nevezo;
    }
};

A szintaxis ennél nagyon kötött: tilos az elejére kiírni a visszatérési érték típusát (hiszen mi mással térne vissza, mint amivé konvertálunk), paramétert nem kaphat, és csak tagfüggvényként valósíthatjuk meg.

A konverziós operátor veszélyes. Ez is hívódhat automatikusan is, olyan helyeken is, amikor nem gondolunk rá. Egy Tort átadható double-t váró függvénynek, és így operátornak is! Például ha a Tort-nek nem írtunk volna kiíró operátort, akkor a konverziós operátor miatt ki tudnánk írni, mintha double lenne. Ezért rendkívül körültekintően járjunk el, ha konverziós operátort írunk!

Inkremens, dekremens operátorok

Írjunk a Tort-nek preinkremens operátort! Általában azt jelenti, hogy következő, de a racionális számoknál ez nem értelmezhető. Ezért jelentse azt, amit az egész számoknál: növelje meg eggyel! Ezeket érdemes tagfüggvényként megvalósítani, bár lehet globális függvényként is.

A visszatérési érték a tört új értéke, ezért térjünk vissza önmagával (*this)! Érték vagy referencia? Do like the ints do!

++i = j;

Itt j felülírja az i változót, tehát referenciával kell visszatérni.

Tort& operator++() {
    szamlalo += nevezo;
    return *this;
}

Mi a helyzet a posztinkremenssel? Az intuitív használat érdekében javasolt megírni, és mivel nem egyezik a preinkremenssel, nekünk kell megírni. C++-ban a preinkremens operátortól való megkülönböztetése furcsa: kap egy plusz int paramétert, aminek az értéke memóriaszemét. (A nyelv úgy tekinti, hogy ez az overload, a fiktív int paraméter különbözteti meg a két függvényt.) A nem használt paraméter nevét nem kell kiírni, ez az olvasást könnyíti.

A posztinkremens a régi értéket adja vissza, miután már megváltoztatta, ezért a növelés előtt el kell mentenünk a régi értéket egy segédváltozóba, majd azt visszaadni. Egy függvény lokális változóját pedig csakis érték szerint adhatjuk vissza.

Tort operator++(int) {
    Tort temp = *this;
    szamlalo += nevezo;
    return temp;
}

A dekremens operátoroknál pontosan ugyanez a helyzet, ugyanígy van egy plusz int paramétere a posztdekremens operátornak.

Függvényhívás operátor

A függvényhívás operátor csak a furcsa szintaktikája miatt került ide. Bármi lehet a visszatérési értéke és a paraméterlistája, csak tagfüggvényként valósítható meg, és természetesen több overload-ja is lehet.

int operator()(double param) {
    // ...
}

Értékadó operátor

Az operator= is overload-olható, de ez is csak tagfüggvényként valósítható meg. A 3. fejezetben látni fogjuk, hogy erre sokszor szükség van, de általában nem kell vele foglalkoznunk.

Fordító által generált "tagfüggvények"

A default ctor-ról és annak szükségességéről már volt szó, de a fordítótól ezeken kívül is kapunk tagfüggvényeket ajándékba.

Másoló konstruktor

Tort t1 = t2;
Tort t1(t2);

Mint tudjuk, a fenti két sor tökéletesen egyenértékű. Deklarálunk egy Tort-et, úgy fogjuk hívni, hogy t1, és kezdeti értéknek t2-t kapja. Az első sor működött C-s struktúrákra is. A második soron egyértelműen látszik: ez bizony egy konstruktor hívása, aminek paraméterként egy Tort típusú objektumot adtunk: Tort-ből hoztunk létre Tort-et. A neve beszédes, másoló konstruktornak (copy ctor) hívják.

Azért, hogy a felső sor működjön C++-ban is, a fordító generál copy ctort, éppúgy, mint default ctort. Ez a tagváltozók másoló konstruktorát hívja meg sorban. A fordítónak meg szabad írnia, hiszen ha van egy objektumunk, konzisztens állapottal (jelenleg: egyszerűsített tört, 0-tól különböző nevező), akkor annak automatikusan elkészíthető a másolata is.

Másolás. Ez a szó C-ben is rengetegszer jött szembe: függvényhívásnál a paraméter és a visszatérési érték lemásolódott. C++-ban ilyenkor is valójában ez a másoló konstruktor hívódik. Igény esetén ezt felüldefiniálhatjuk, a következő fejezetben meg is fogjuk tenni.

Destruktor

Ahogy az objektum létrehozásakor a konstruktor, úgy megszüntetésekor a destruktor (röviden: dtor) hívódik automatikusan, a fordító ezt is alapértelmezetten legenerálja nekünk: a tagváltozók destruktorát hívja (ami alaptípusra semmit nem csinál). Ennek szükségességéről is a következő fejezetben lesz szó.

Az értékadó operátor

"Because of historical accident, the operators `=` (assignment), `&` (address-of), and `,` (sequencing; §6.2.2) have predefined meanings when applied to class objects." Bjarne Stroustrup, The C++ Programming Language 3rd Edition
Tort t1, t2;
t1 = t2;        // *

A megjelölt sorban egy operátort látunk: operator=, melynek mindkét oldalán egy-egy létező Tort áll. C-ben ez működött, ezért C++-ban is működnie kell: ismét a fordító generálja helyettünk. A tagváltozóknak ad értéket szépen sorban, ahogy elvárjuk tőle.

A címképző operátor és a vessző operátor működése egyértelmű, nem igényel magyarázatot.

22. A new és a konstruktor

Az első fejezetben már említettük, hogy a new konstruktort is hív, nézzük, hogy ez pontosan mit jelent.

A new legelőször természetesen lefoglalja az objektum(ok) számára szükséges memóriaterületet. Utána a lefoglalt területre "ráhívja" az általunk megadott konstruktort, tömb esetén mindig a default konstruktort. A delete ugyanezt csinálja, csak visszafelé: meghívja a destruktort, – tömb esetén a destruktorokat, – ezután felszabadítja a memóriaterületet. Így már világos az is, miért különbözik a new és a new[]: előbbi mindig 1 konstruktort hív, de az akármelyik konstruktor lehet, utóbbi n darab default konstruktort hív.

int main() {
    Tort * t1 = new Tort;       // default ctor
    Tort * t2 = new Tort();     // default ctor
    Tort * t3 = new Tort(1, 2); // saját ctor
    Tort * t4 = new Tort(*t3);  // copy ctor
    Tort * t5 = new Tort[100];  // 100 db default ctor

    delete[] t5;
    delete t4;
    delete t3;
    delete t2;
    delete t1;

    return 0;
}

Azért is kellett C++-ban az új dinamikus memóriakezelés szintaxis (new), mert a konstruktorok megjelenésével szükségessé vált az, hogy a dinamikusan foglalt objektumokon ugyanúgy automatikusan fusson a konstruktor, mint ahogy a stacken/globális területen lévő objektumokon is. Egy sima malloc() hívásnál ez nem teljesülne.

23. Több modulra bontás

Eddig az osztályok tagfüggvényeit következetesen az osztály belsejében valósítottuk meg. Nagyobb osztályoknál ez áttekinthetetlenné teheti a kódot. Ha az osztály funkcionalitását szeretnénk vizsgálni, – mire való, mit tud és mit nem, – elég lenne a tagfüggvények deklarációja, igazán nagy osztályokban elvesznének a deklarációk.

Ha a Tort osztályt több helyen is szeretnénk használni, a fenti osztályt úgy, ahogy van, beletehetjük egy header-be (legyen Tort.h), és ahol include-oljuk a Tort.h-t, ott használhatjuk is.

Mi a probléma a ezzel a megoldással?

Egyrészt mindenhol, ahova include-oltuk a Tort.h-t, ott a fordítónak külön-külön le kell fordítania az osztály összes tagfüggvényével együtt. Ha 100 fájlban használjuk akkor 100-szor fogja ugyanazt a Tort osztályt lefordítani a fordító.

Másrészt ha tagfüggvények megvalósításában használjuk bármilyen más header elemeit (mint pl. a lenti Tort osztályban az stdexcept-et), akkor az osztályunk fejlécfájlja elején include-olnunk kellene a stdexcept-et is. Az osztály használatához, tagfüggvényeinek a deklaráiójához egyáltalán nincs szükség stdexcept-beli elemekre: tehát a Tort osztály felhasználóit feleslegesen teleszemetelnénk az stdexcept headerrel.

Ezek elkerülése végett a C++ lehetőséget biztosít, hogy a tagfüggvényeket definiálhassuk az osztályon kívül is, a belsejében elég deklarálni. A definíciókat pedig áttehetjük egy .cpp fájl belsejébe, a többit megoldja a linker.

A tagfüggvényeket az osztályon kívül úgy kell definiálni, mintha globális függvények lennének, de a nevét egyértelműsíteni kell: ki kell egészíteni az osztály nevével, és a scope operátorral (Tort::), hiszen anélkül globális függvény lenne. A definícióban ki kell írni a const minősítőt, viszont tilos kiírni a default értékeket.

A nagyon rövid függvényeket (pl. Tort::get_szaml()) érdemes továbbra is a header-ben definiálni, hogy a fordító tudja inline-olni.

Írjuk át ennek megfelelően a Tort osztályunkat! (Innen letölthető: Tort.zip) Pontosan ugyanúgy néz ki a kód Tort-ekkel, mint int-ekre, mégis működik! Sőt, közben egyszerűsít, közös nevezőre hoz, zenél, csilingel.

main.cpp:

#include <iostream>
#include "Tort.h"

int main() {
    Tort t1(2, 1);
    Tort t2;
    std::cin >> t2;

    Tort t3 = t1 * t2;
    std::cout << "t3 = " << t3 << std::endl;

    t3 += t2;
    std::cout << "t3 = " << t3 << std::endl;

    return 0;
}

Tort.h:

#ifndef TORT_H_INCLUDED
#define TORT_H_INCLUDED

#include <iostream>

struct Tort {
private:
    int szamlalo;
    int nevezo;

public:

    Tort(int szaml = 0, int nev = 1);

    int get_szaml() const {
        return szamlalo;
    }
    int get_nev() const {
        return nevezo;
    }

    Tort& operator+=(Tort rhs);
    Tort& operator++();
    Tort operator++(int);
    operator double() const;

};

Tort operator*(Tort lhs, Tort rhs);
Tort operator+(Tort lhs, Tort rhs);

std::ostream& operator<<(std::ostream& os, Tort t);
std::istream& operator>>(std::istream& is, Tort& t);

#endif // TORT_H_INCLUDED

Tort.cpp:

#include <iostream>
#include <stdexcept>
#include "Tort.h"

static int euklidesz(int a, int b) {
    while (b != 0) {
        int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

Tort::Tort(int szaml, int nev) {
    if(nev == 0)
        throw std::invalid_argument("Tort nevezője nem lehet 0!");

    int lnko = euklidesz(szaml, nev);
    szamlalo = szaml / lnko;
    nevezo = nev / lnko;
}

Tort& Tort::operator+=(Tort rhs) {
    Tort uj = *this + rhs;
    *this = uj;
    return *this;
}

Tort& Tort::operator++() {
    szamlalo += nevezo;
    return *this;
}

Tort Tort::operator++(int) {
    Tort temp = *this;
    szamlalo += nevezo;
    return temp;
}

Tort::operator double() const {
    return double(szamlalo) / nevezo;
}

Tort operator*(Tort lhs, Tort rhs) {
    return Tort(lhs.get_szaml() * rhs.get_szaml(),
                lhs.get_nev() * rhs.get_nev());
}

Tort operator+(Tort lhs, Tort rhs) {
    return Tort(lhs.get_szaml() * rhs.get_nev() + rhs.get_szaml() * lhs.get_nev(),
                lhs.get_nev() * rhs.get_nev());
}

std::ostream& operator<<(std::ostream& os, Tort t) {
    os << t.get_szaml() << '/' << t.get_nev();
    return os;
}

std::istream& operator>>(std::istream& is, Tort& t) {
    char c;
    int szaml, nev;
    is >> szaml >> c >> nev;
    t = Tort(szaml, nev);
    return is;
}

Az objektumok életciklusa és memóriakezelése

A fejezet anyaga videó formában is elérhető, bár kevésbé részletesen.

A típusaink, osztályaink összefogásának és átlátszatlanná tételének számos előnyét láttuk. Meg tudjuk akadályozni, hogy az osztályok belsejébe kívülről bele lehessen látni, és akár beépített típusként is tudnak viselkedni.

Ötlet: Rejtsük el ilyen fekete dobozok belsejébe a memóriakezelés gyötrelmeit, és bízzuk rá magunkat az automatikusan meghívódó, nem elfelejthető destruktorra és társaira!

C-ben úton-útfélén szembe jött velünk a sztringkezelés otrombasága. Egy meglévő típusra – karaktertömb – bíztuk rá egy teljesen más szemantika – szöveg – kezelését. Ez sosem jó jel – lásd bool és nullptr –, ráadásul a memóriakezeléssel is nekünk kellett bíbelődni.

25. Egy String osztály

Kezdjünk egy egyszerű osztállyal, gyakorlásképp: legyen fix méretű a String! Lehessen ugyanúgy indexelni, mint egy karaktertömböt, de az összefűzéshez használjuk az operator+-t! Természetesen lehessen kiírni és beolvasni, valamint némi C-s kompatibilitás sem árt. A több modulra bontás itt is alapvető fontosságú – String-re szinte mindenütt szükség van.

String.h:

class String {
    char str[256];      // nullával lezárt
  public:

    String(char const * s = "");

    String operator+(String const& rhs) const;
    String& operator+=(String const& rhs);
    String& operator+=(char rhs);

    int length() const;
    char const * c_str() const {
        return str;
    }
};

std::ostream& operator<<(std::ostream& os, String const& rhs);
std::istream& operator>>(std::istream& is, String& rhs);

String.cpp:

#include <iostream>
#include <cstring>
#include <cctype>
#include "String.h"

String::String(char const * s) {
    strcpy(str, s);
}

String String::operator+(String const& rhs) const {
    String result = *this;
    result += rhs;
    return result;
}

String& String::operator+=(String const& rhs) {
    strcat(str, rhs.str);
    return *this;
}

String& String::operator+=(char rhs) {
    int len = strlen(str);
    str[len] = rhs;
    str[len+1] = '\0';
    return *this;
}

int String::length() const {
    return strlen(str);
}

std::ostream& operator<<(std::ostream& os, String const& rhs) {
    os << rhs.c_str();
    return os;
}

std::istream& operator>>(std::istream& is, String& rhs) {
    char c;
    String uj;
    while(is.get(c) && !isspace(c))
        uj += c;

    rhs = uj;
    return is;
}

main.cpp:

#include <iostream>
#include "String.h"

int main() {

    String s1, s2;
    std::cin >> s1 >> s2;

    std::cout << "s1 + s2 = " << s1 + s2 << std::endl;

    s2 += s1;
    std::cout << "s2 + s1 = " << s2 << std::endl;

    return 0;
}

Figyeljük meg a C-kompatibilitás elemeit! Egyrészt a konstruktora implicit konverziót tesz lehetővé: akár char const*-gal is hívható egy String-et váró függvény. Másrészt c_str függvénnyel konvertálható C-s "sztringgé". Lehetett volna konverziós operátor is, de annak veszélyessége miatt így jobb: muszáj kiírni, ha használjuk.

A belső megvalósításnál kikötöttük, hogy str nullával lezárt karaktertömb. A használó szempontjából ez lényegtelen: őt csak az érdekli, hogy a hogyan kell használni. A saját érdekünkben döntöttünk úgy, hogy nullával lezárva tároljuk: egyrészt a c_str másképp nagyon macerás lett volna, másrészt így használhattuk a cstring általunk már ismert függvényeit.

Miért tagfüggvényként valósítottuk meg az összefűző operátorokat? Mert így volt kényelmesebb: hozzá kell férniük az str adattaghoz.

Egy operátor még hiányzik a listából: az indexelés, mivel arról eddig nem volt szó, és egy fontos szabállyal ismertet meg minket, így kiemelt helyet kapott.

26. Az indexelő operátor

Eddig szép és jó a saját String-ünk, de mi van akkor, ha ki akarjuk olvasni vagy átírni valamelyik karakterét? C-ben a klasszikus karaktertömböknél ezt meg lehetett tenni. Nem túl meglepő módon erről itt sem kell lemondanunk. Ehhez meg kell valósítanunk az indexelő operátort (operator[]). Mivel ez egy függvény, tudjuk benne ellenőrizni a túlindexelést.

char& String::operator[](int i) {
    if (i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

Álljunk meg egy pillanatra, és nézzünk meg néhány dolgot. Miért char&-t adtunk vissza? Ha simán char-t adnánk vissza, akkor lemásolódna a megfelelő karakter. Ez teljesen jó lenne olvasáshoz, de írásnál nem működne, úgy nem tudnánk módosítani pl. egy s[0] = 'X'; sorral a sztring valamely betűjét. Referenciát, azaz hivatkozást kell visszaadni a tömbben lévő karakterre, hogy az módosítható is legyen.

A következő kérdés a konstans String-ek helyzete. Mi történik az alábbi kód futtatásakor?

const String s1 = "Teszt";
std::cout << s1[2];

Válasz: fordítási hiba, ugyanis nincs konstans minősítőjű indexelő operátorunk. A hiba kijavításához ezt is meg kell írnunk:

char String::operator[](int i) const {
    if(i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

Kettő különbséget kell észrevennünk az előzőhöz képest. Egyrészt a const minősítő, másrészt a visszatérési érték char& helyett char lett. Ezzel elkerültük, hogy konstans String-nél módosítsák a szöveget. A függvény visszatérési értéke lehetett volna char const& is.

Hogyhogy ezt is megírhattuk? Ezek szerint két operator[](int) is lehet? Igen, hiába ugyanaz a paraméterlistájuk, ha az egyik const, a másik nem, ez megengedett. Ez bármilyen tagfüggvényre igaz, nem csak az indexelő operátorra. Nem konstans objektumra a nem konstans tagfüggvény hívódik, ha van, egyébként a konstans tagfüggvény. Ezért nem konstans String-en működni fog az írás és az olvasás is, míg konstans String-nél csak az olvasás engedélyezett.

Itt nem használjuk ki, de amúgy az indexelő operátor paramétere nem feltétlenül kell egész szám legyen, lehetne bármi más. Később majd látunk példát rá (akár a szabványos könyvtárban is), hogy bármivel indexelhetőek az objektumaink, ha a megfelelő operator[] meg van írva hozzá.

Egy String indexelésénél az esetek túlnyomó többségében felesleges az indexhatárok ellenőrzése, C szemlélet szerint a programozó úgyis tudja, mit szeretne. Ezért nem szokás az indexelő operátorban ellenőrizni, ha viszont szükség van ellenőrzésre, akkor helyette az at tagfüggvényt használjuk.

char& String::operator[](int i) {
    return str[i];
}

char& String::at(int i) {
    if(i < 0 || i >= strlen(str))
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

27. Dinamikus String

Barbár dolog fix méretet használni egy String osztálynál, ahol a legkülönbözőbb méretekkel dolgozhatunk. Gyakran túl sok memóriát foglal, ritkán túl kicsi, és még ritkább, amikor pont jó. Cseréljük ezért le a 256 elemű tömböt egy dinamikus tömbre!

class String {
    size_t size;
    char * str;
  public:

    String();
    String(char const * s);
    ~String();

    String operator+(String const& rhs) const;
    String& operator+=(String const& rhs);
    String& operator+=(char rhs);

    int length() const {
        return size;
    }
    char const * c_str() const {
        return str;
    }
};

A belső implementáción ismét érdemes elmélkedni, még mielőtt nekiugrunk. Maradjon a nullával lezárás? A c_str miatt még mindig javasolt nullával lezárni, sőt az eddig bemutatott nyelvi elemekkel nem oldható meg elegánsan a c_str, ha nem zárjuk le nullával. Ezen felül így a cstring függvényei használhatóak maradnak.

A size-ba értsük bele a lezáró nullát, vagy sem? A C-s konvenciók miatt (strlen) úgy döntöttünk, hogy ne tartalmazza. Mindig size+1 méretű tömböt kell majd str-nek foglalni, de más műveleteknél egyszerűbb lesz a számolás. Természetesen csinálhatnánk máshogy is – a String felhasználójának mindegy.

String::String() {
    size = 0;
    str = new char[1];
    str[0] = '\0';
}

String::String(char const * s) {
    size = strlen(s);
    str = new char[size + 1];
    strcpy(str, s);
}

A konstruktor második sorában memóriát foglalunk. Ki fogja ezt felszabadítani? Az lenne a legjobb, ha automatikusan felszabadulna a memória, amint az objektum megszűnik. Emlékezzünk vissza a destruktorra, pont jó lesz nekünk: automatikusan meghívódik a megszűnéskor (a blokk végén). A memória felszabadításának a destruktorban a helye.

A destruktort a ~ karakterrel jelöljük, és nincs paramétere, nincs visszatérési értéke. Nyilvánvalóan emiatt csak egy lehet belőle. Fontos elvárás, hogy destruktor soha nem dobhat kivételt. A nyelv erre nem kötelez minket, de ebből kezelhetetlen helyzetek születnek, ezért a konvenció.

class String {
    // ...
    ~String();
}

A megvalósítása itt triviális, csak annyi a dolga, hogy felszabadítsa a karaktertömböt.

String::~String() {
    delete[] str;
}

Eddig nagyon jól hangzik, a memória felszabadítása automatikusan megtörténik, anélkül, hogy az osztály használója akárcsak a kisujját megmozdítaná. Sajnos nem vagyunk készen, a String osztályon még reszelni kell. Mi történik, ha a String-et lemásolni, értékül adni szeretnénk? Próbáljuk ki ezt a kódsort:

String a = "Hello";
String b = a;

Ennek a kódnak a hatására a String osztályunk hibásan működik. a[0] = 'X' hatására b is megváltozik, márpedig a használója nem ezt várja el. Sőt, a programunk a blokk végén elszáll, valószínűleg a memóriakezelésben vannak gondok. Hol keressük a problémát?

Ha String a = "hello"; egyenértékű String a("hello");-val, akkor String b = a; egyenértékű String b(a)-val, tehát most egy olyan konstruktort kell keresnünk, aminek String típusú objektum a paramétere. Az ilyen konstruktorról már volt szó, ez a másoló konstruktor, és nem mi írtuk. A fordító generálta, és mint tudjuk, meghívja az adattagok másoló konstruktorát.

Ez az alapértelezett viselkedés nekünk nem jó, ugyanis a String által tárolt szöveget egy külön lefoglalt memóriaterületen tároljuk (erre mutat az str pointer). Amikor a fordító által generált másoló konstruktor lefut, akkor a pointer másolódik, nem a szöveg, mindkét String objektum ugyanarra a szövegre fog mutatni a memóriában, ezért a sztringünk már nem úgy fog működni, ahogy elvárjuk. Ha megváltoztatjuk az egyik String karaktereit, a másik is változni fog. Mindkét String objektum desktuktora ugyanarra a memóriaterületre hívja meg a delete[]-et, ez egyenesen katasztrófa.

A megoldás: definiáljuk felül a másoló konstruktort, hogy az a szöveget tároló memóriaterületet is lemásolja, és a másolat erre az új területre tároljon pointert! Ezáltal az új String-nek saját karaktertömbje lesz.

String::String(const String& the_other) {
    size = the_other.size;
    str = new char[size + 1];
    strcpy(str, the_other.str);
}

A másoló konstruktor feladata mindig az, hogy egy másik azonos típusú objektum lemásolásával új objektumot hozzon létre (inicializáljon). A másoló konstuktor elég sokszor meghívódik, olyan esetekben is, amikor nem mondjuk egyértelműen, hogy itt másolást szeretnénk:

  • azonos típussal inicializálunk egy objektumot (pl.: String a = "hello"; String b = a;)
  • függvény érték szerinti paraméterének átadásakor
  • függvény érték szerinti visszatérési értékének átadásakor
  • kivételek kezelésekor

Fontos elidőzni egy kicsit a paraméter típusán (const Típus&). Miért nem jó a sima Típus? Az előbb már láttuk, hogy érték szerinti paraméterátadáskor másoló konstruktor hívódik. Szóval ahhoz, hogy a másoló konstruktor meghívódhasson, először meg kell hívni a másoló konstruktort, hogy a paraméterébe bemásolja a másolandó objektumot. Ahhoz, hogy az a másoló konstruktor meghívódhasson... Fontos tanulság: a másoló konstuktor paramétere mindig konstans referencia.

A programunk még mindig elszáll, valahol a beolvasás környékén. Az extractor operátorban van egy ilyen sor:

rhs = uj;

Ez az értékadó operátor (operator=). Szintén hibás, a fordító által írt értékadó operátor sem úgy működik, ahogy elvárjuk. Mert a fordító által generált értékadó operátor adattagonként másol:

String& String::operator=(const String& the_other) {
    // a fordító által generált értékadó operátor
    // meghívja az adattagokra az értékadó operátort
    size = the_other.size;
    str = the_other.str;
    return *this; // Erre a sorra nemsokára kitérünk
}

Eredeti állapot:

Értékadó operátor után:

Ez – csakúgy mint a másoló konstruktor esetében – most sem jó nekünk. Ugyanis így az "assign"-t tároló memóriaterületet elveszítjük, nem tudjuk felszabadítani, és a "Hello"-t tároló memóriaterületre ismét két destruktor fog delete[]-et hívni.

A megoldás: definiáljuk felül az értékadó operátort is, hogy az először szabadítsa fel a felülírt objektum szövegét tároló memóriaterületet, ezután másolja le a használandó szöveget tároló memóriaterületet, és a felülírt objektum erre az új területre tároljon pointert.

String& String::operator=(const String& the_other) {
    if(this != &the_other) {            // (*)
        delete[] str;                   // régi felszabadítása
        size = the_other.size;
        str = new char[size + 1];       // új hely foglalása
        strcpy(str, the_other.str);     // sztring másolása
    }
    return *this;                       // magyarázat nemsokára
}

Így már helyesen működik az értékadó operátor, azonban néhány dolog magyarázatra szorul. Először nézzük meg a (*)-gal jelölt sort. Ezt az ellenőrzést arra az esetre tettük be, hogyha esetleg (általában nem szándékosan, nem triviális helyen, de előfordul az ilyesmi) önértékadás történne (pl.: a = a;). Ebben az esetben tilos a memóriaterületet felszabadítani, ilyenkor semmit nem csinálunk. Azt ellenőrizzük le, hogy a this pointer a paraméterként kapott objektumra mutat-e, és nem azt, hogy a két sztring tartalma megegyezik-e.

Miért String& a visszatérési típus, miért kell a return *this? Ezzel tettük az értékadást láncolhatóvá. Ezt használjuk ki, amikor több változónak adunk értéket egy utasításban.

String a("A"), b("B"), c("C");
a = b = c;

Itt először c-vel íródik felül b, majd b-vel íródik felül a. Ezt a láncolhatóságot az operátor bal oldalán álló operandus visszaadásával érjük el, ahogy az összes értékadás jellegű operátornál szoktuk (+=, -= stb.) Tagfüggvény lévén, ez a *this objektum. Ahogy az operator+=-nél is, itt is referenciát kell visszaadni.

A hármas szabály

Mi az amit megtanultunk edddig? Ennyiből biztosan látszik: nem mindig jók a fordító által írt tagfüggvények, ezeket mindig ellenőrizzük!

A destruktor azt mondja: ha a sztring objektum megszűnik, akkor megszűnik a karaktertömb is. Ebből az következik, hogy minden sztringnek saját karaktertömb kell, amiből pedig az, hogy másoló konstruktornak is léteznie kell. Mert mindegy, hogy char const*-ból, vagy String-ből inicializálunk egy másik sztringet, kell neki saját tömb. Általában a destruktor létezéséből következni szokott, hogy a másoló konstruktornak is léteznie kell.

Érdemes összehasonlítani a destruktor és a másoló konstruktor kódját az operator= kódjával. Lényegében ugyanazok, mert az operator=-nek az a feladata, hogy elfelejtsen mindent a sztring, és felvegye egy másiknak az értékét. Tehát le kell dózerolni nullára az objektumot (== destruktor), és építeni kell egy újat (== másoló konstruktor). Ebből a kettőből áll össze a lényegében az operator=, és ha ebben a kettőben kellett lennie kódnak, akkor az operator=-ben is kell lennie kódnak, tehát meg kell írnunk.

Az operator= ezért általában így néz ki:

String& String::operator=(const String& the_other) {
    if(this != &the_other) {
        // ... destruktor tartalma
        // ... másoló konstruktor tartalma
    }
    return *this;
}

Általánosságban elmondható: ha egy osztályban a másoló konstruktor, destruktor, értékadó operátor hármasból bármelyiket felül kell definiálnunk, akkor mindhármat kell! Vannak nagyon ritka kivételek, de ebben a tárgyban nem lesz ilyenről szó.

Nézzük, hogyan változott a többi függvény!

String String::operator+(String const& rhs) const {
    String uj;
    delete[] uj.str; // enélkül memóriaszivárgás lenne, házi feladat átgondolni
    uj.size = size + rhs.size;
    uj.str = new char[uj.size + 1];
    strcpy(uj.str, str);
    strcat(uj.str, rhs.str);
    return uj;
}

String& String::operator+=(String const& rhs) {
    return (*this = *this + rhs);
}

String& String::operator+=(char c) {
    // nem a leghatékonyabb megvalósítás
    char tomb[2] = {c, '\0'};
    return (*this = *this + String(tomb));
}

char String::operator[](int i) const {
    if(i < 0 || i >= size)
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

char& String::operator[](int i) {
    if(i < 0 || i >= size)
        throw std::out_of_range("String: túlindexelés");

    return str[i];
}

std::ostream& operator<<(std::ostream& os, String const& str) {
    os << str.c_str();
    return os;
}

std::istream& operator>>(std::istream& is, String& str) {
    // okosabb (pl. kétszeresére) nyújtogatás hatékonyabb lenne
    char c;
    String uj;
    while(is.get(c) && !isspace(c))
        uj += c;

    rhs = uj;
    return is;
}

Nézzük meg, hogyan változott ennek megfelelően a main.cpp!

#include <iostream>
#include "String.h"

int main() {

    String s1, s2;
    std::cin >> s1 >> s2;

    std::cout << "s1 + s2 = " << s1 + s2 << std::endl;

    s2 += s1;
    std::cout << "s2 + s1 = " << s2 << std::endl;

    return 0;
}

Sehogy! A String csak belül változott, a rá épülő kód ugyanúgy működik, mint eddig, de már nem száll el 256-nál hosszabb sztringekre, és nem pazarolja a memóriát rövid sztringeknél.

Tapintani lehet, hogy az itt bemutatott String osztályra úton-útfélén szükség van. Épp ezért a C++ standard library is tartalmaz egy erre való osztályt, ez az std::string. Ez sokkal okosabb, mint a fenti példakód. A használatához include-olni kell a string header-t.

28. Az objektumok életciklusa

Inicializálás

Egy objektum életciklusa a létrehozásával kezdődik. Ilyenkor memória foglalódik az objektum számára, – ez lehet a stack-en vagy a heap-en, ez az objektum szempontjából lényegtelen, – majd meghívódik a konstruktor. C-ből emlékszünk, hogy a memória tartalma lefoglaláskor memóriaszemét. Éppen a konstruktornak kell gondoskodnia arról, hogy miután lefutott (létrejött az objektum), már értelmes, használható adat legyen az objektumban.

Mi történik, ha például az Ember osztálynak egyik adattagja egy másik osztály (String)?

Az előző fejezetben megtanultuk, hogy a default konstruktort megírja helyettünk a fordító: meghívja az adattagok default kostruktorát. Ugyanígy a copy ctor és az op= sem feltétlenül a mi dolgunk.

Ebben a példában nincs default konstruktora a belső osztálynak, mert írtunk saját konstruktort. Akkor a külső osztály default konstruktora mit fog meghívni?

class String {
    char str[42];

    public:
    String(const char * str) {
        // Az egyszerűség kedvéért itt most kihagytuk a hibakezelést!
        strcpy(this->str, str);
    }
};

class Ember {
    String nev;

    public:
    Ember(const char * nev) { // ERROR
        this->nev = nev;
    }
};

Ember e1("Teszt Elek");

Ez a kód hibás, ugyanis a String-nek nincs default konstruktora, vagyis a fordító default konstruktora nem tudja inicializálni a nev adattagot. De hát ott van világosan leírva: this->nev = nev; Ez miért nem elég?

Az Ember konstruktorának törzsében a nev adattag már biztosan inicializálva van. Mire a kapcsos zárójelek közötti részhez, a programozott törzshöz ér a végrehajtás, addigra az adattag objektumok használhatóak. Ezért itt igazából az értékadó operátora hívódna. Akkor pontosan mikor fut le nev konstruktora?

Mindenképpen az e1 objektum számára történő memóriafoglalás után, hiszen abban lesz majd a nev adattagja. Viszont az előbb már megbeszéltük, hogy mire a konstruktor törzséhez ér, addigra inicializálva kell lennie az adattagoknak. A kettő között elvileg nincs semmi, memóriafoglalás után jön a konstruktorhívás.

Akkor hol inicializálódott a nev adattag?

A konstruktor első dolga, hogy az objektum tagváltozóit inicializálja. Még mielőtt a konstruktor törzse lefutna, a fordító által generált része inicializálja az objektumnak, jelen esetben az e1-nek minden adattagját. Beépített, primitív típusoknál (double, bool, int, stb.) ez semmit nem csinál, tehát memóriaszemét lesz bennük, ha a konstruktorban nem adunk nekik értéket! Objektumoknál viszont konstruktor hívódik. Mi történik azonban, ha ezt nem tudja automatikusan megtenni vagy felül akarjuk bírálni az alapértékeket?

Az inicializáló lista

Ahhoz, hogy az ilyen eseteket kezelni tudjuk, felül kell bírálni az adattagok inicializálását. A konstruktor általunk megadott részében ehhez már késő lenne, ott már rendelkezésünkre kell hogy álljanak ezek az értékek.

Erre ad megoldást az inicializáló lista, amivel felül tudjuk bírálni az adattagok inicializálását. Ennek szintaxisa a következő:

class Ember {
    String nev;
public:
    Ember(const char * nev) : nev(nev) {
    }
};

A konstruktor neve és a paramétereket tartalmazó zárójelek után egy kettőspontot követve adhatóak meg a tagváltozó(érték) párosok vesszővel elválasztva. Ezután, mint ahogy eddig is, következik a konstruktor törzse.

Vegyük észre, hogy ennek a szintaxisa a konstruktorhívással megegyezik, ez nem véletlen, ugyanis valóban ez történik (az adattagok megfelelő konstruktorát hívjuk). Szintén megjegyzendő, hogy az inicializáló listán nem kellett jelölnünk, hogy nev az adattagra vonatkozik vagy a paraméterre. A zárójelen kívül nyilván az adattagra, hiszen csak annak adhatunk értéket, azon belül pedig a paraméterre, mint általában.

Nagyon fontos, hogy az inicializáló lista csak konstruktoroknál használható (persze másoló konstruktornál is), mivel csak a konstruktor hívásnál történik meg a tagváltozók inicializálása. Az inicializáló lista előbb fut le, mint a konstruktor törzse, a konstruktor törzsének futásakor már nincs inicializálatlan adattag. Azokat az adattagokat, amiket nem adunk meg az inicializáló listában, a fordító alapértelmezett konstruktorral inicializálja (vagy beépített típus esetén sehogy). Ha pedig ez nem lehetséges (például a lenti 3 ok egyike miatt), akkor hibát kapunk. Általában ha lehetséges, akkor érdemes az inicializáló listát használni az adattagok beállítására. Így ugyanis az inicializálás + értékadás helyett rögtön jó értékkel inicializálódhatnak az adattagok. Ha a konstruktor költséges, mert pl. memóriafoglalás van benne (lásd String), akkor ez kiemelten fontos. Illusztrációként vegyük az alábbi kódot:

String s1;
s1 = "alma";

String s2("alma");

Nem mindegy, hogy csinálok egy sztringet, eleve alma értékkel, vagy előbb csinálok egy üres sztringet (new[] ott van olyankor is!), aztán eldobom a kukába (akkor minek csináltam?), hogy aztán megint csináljak egy újat.

Az inicializáló listán az adattagok sorrendje is fontos. Akármilyen sorrendben írjuk oda az inicializálandó adattagokat, mindig a memóriakép (azaz a struktúra definíciója) szerinti sorrendben kerülnek inicializálásra. Mivel ez – hogy a leírt sorrenddel eltérő sorrendben történhet valami – minden programozónak életidegen, könnyen írhatnánk hibás kódot. Ezért minden rendes fordító warninggal jutalmaz, ha nem jó sorrendben írtuk az inicializáló listát.

Akkor is az inicializáló listát kell használnunk, ha az alábbi 3 eset közül bármelyik fennáll:

  • referencia adattagot kéne inicializálni (nem tudja a fordító, hogy mire hivatkozzon a referencia)
  • konstans adattagot kéne inicializálni (nem tudja a fordító, hogy milyen értékre akarjuk beállítani)
  • objektum adattagot kéne inicializálni (meghívódik a konstruktora), de nincs default konstruktora (nem tudja a fordító, hogy milyen értékekkel hívja a konstruktorát) vagy attól eltérő paraméterezéssel szeretnénk meghívni
class C {
    int& ref;
    const int cvalue;

    public:
    C(int& ref, int cvalue) : ref(ref), cvalue(cvalue) {
        std::cout << "Itt már inicializálva van ref: " << ref << " és cvalue: " << cvalue << std::endl;
    }
};

int a = 1;
C c(a, 2);

Destruktor

Ez eddig szép és jó, de mi a helyzet a tagváltozók destruktoraival? Azoknak is le kell futniuk valamikor.

És le is futnak. Miután a destruktor általunk definiált része lefutott, a fordító által generált része meghívja minden adattagra a destruktorát, a deklarálás sorrendjéhez képest fordított sorrendben. Azért pont akkor, mert az általunk írt részben szükség lehet még az adattagokra (például ha egy fájlt kezelő adattagnál a desktruktorban be akarnánk zárni a fájlt).

A teljes életciklus

Tehát egy objektum életciklusa a következő:

  1. Memóriaterület foglalódik az objektum számára. (Itt lesznek többek között az adattagok is eltárolva.)
  2. A konstruktor fordító által generált része inicializálja az adattagokat (lefut a konstruktoruk). Az inicializáló listával adhatjuk meg, hogy melyik adattagnak hogyan paraméterezzük a konstruktorát.
  3. Ezután lefut a konstruktor törzse.
  4. Innentől az objektum élőnek számít, dolgozunk vele, tagfüggvényeit hívjuk stb.
  5. Amikor az objektum megszűnik, az első dolog, hogy lefut a destruktor törzse.
  6. Utána a destruktor fordító által generált része meghívja az adattagok destruktorát, fordított sorrendben.
  7. Végül felszabadul az objektum memóriaterülete.

OOP tervezés, RAII

A fejezet anyaga kis részben videó formában is elérhető, a videó vége felé.

30. A friend kulcsszó

Az osztályok, objektumok privát tagváltozóit, tagfüggvényeit kívülről nem érhetjük el, így sem globális függvény, sem másik osztály tagfüggvénye nem láthatja. Ritkán, nagyon ritkán (nagyon ritkán!) előfordul, hogy ez alól szeretnénk feloldozást adni egyetlen osztály vagy függvény számára, miközben másoknak továbbra sem engedjük meg a hozzáférést. Ez általában egy code smell, szinte biztosan más a szakszerű megoldás. Ha úgy döntünk, hogy eme figyelmeztetés ellenére mégis szükségünk van rá, a friend kulcsszóval megtehetjük.

Egy függvényt vagy akár egy teljes osztályt az előző fejezetben megismert String barátjaként deklarálhatunk. Ekkor az látja a String privát adattagjait. Ezt a String belsejében kell jeleznünk, a friend kulcsszóval:

class String {
    // ...

    friend std::ostream& operator<<(std::ostream&, const String&);
}

std::ostream& operator<<(std::ostream& os, const String& s) {
    os << s.str;
    return os;
}

Ha az inserter operátornak – amit tagfüggvényként nem lehet megvalósítani – mindenképpen bele kellene látnia a String belsejébe, jobb egy kiíró tagfüggvényt írni, amit az inserter operátor már tud hívni.

class String {
    // ...
  public:
    void kiir(std::ostream& os) {
        // ...
    }
}

std::ostream& operator<<(std::ostream& os, const String& s) {
    s.kiir(os);
    return os;
}

Nézzünk egy példát egy teljes osztály friend-dé tételére.

class RAM {
    friend class CPU;
    // ...
}

class CPU {
    // ...
}

A processzor teljes hozzáférést kapott a rendszermemória tartalmához, ami más osztálynak nem jár: csak a processzor kezelheti a memória teljes tartalmát. Ha a hozzáférést getterekkel, setterekkel valósítottuk volna meg, bárki hozzáférhetne a memóriához, nem csak a processzor. Ebben az esetben indokolt a friend használata.

A friend tulajdonság nem reciprok (tehát ha A-nak barátja B, attól még B-nek nem lesz barátja A), és nem is tranzitív (tehát barát barátja nem lesz automatikusan barát). Ezen felül, a friend osztály leszármazottai nem lesznek friend-ek, ez a tulajdonság nem örökölhető. (Az öröklésről később lesz csak szó.)

31. Osztályszintű adattagok, függvények

C-ben a static kulcsszó két jelentését ismerhettük meg. Egy globális változó vagy függvény módosítójaként csak az adott fordítási egységben tette láthatóvá. Egy függvény statikus lokális változója pedig globális memóriaterületen elhelyezkedő, de lokális láthatóságú változója, amely két hívás között megőrzi az értékét.

Az utóbbi használathoz hasonló jelentéssel bír egy osztály statikus adattagja: az osztályhoz tartozik, nem a példányokhoz. Tekinthetjük úgy is, hogy ez egy olyan változó, amelyik az osztály objektumai számára közös, ezért kifejezőbb az osztályszintű megnevezés. Ez globális memóriaterületen van, külső elérése ugyanúgy lehet public vagy private. Az osztályszintű adattagot egyrészt az osztály belsejében deklarálnunk kell, másrészt azon kívül, egy fordítási egységben definiálni is, itt kaphat kezdeti értéket.

class String {

    // ...

  public:

    static bool debug;

    String() {
        if (debug)
            std::cerr << "String default ctor" << std::endl;
        // ...
    }

    // ...
}

bool String::debug = false; // statikus adattag "példányosítása"

int main() {

    String s1; // nincs kimenet
    String::debug = true;
    String s2; // van kimenet

    return 0;
}

Ezzel az összes String-et tettük varázsütésre bőbeszédűvé, megkönnyítve a hibakeresést.

Az osztályszintű függvény ehhez hasonlóan viselkedik és hívható. Fontos, hogy nem tartozik hozzá objektum, nincs benne this pointer – úgymond önálló, globális függvény. Viszont a statikus adattagokat, és az osztály példányainak adattagjait is látja (ha kap paraméterként, hoz létre, stb.)

class Komplex {
  private:
    double re, im;

  public:

    Komplex() :
        re(0),
        im(0) {
    }

    static Komplex create_from_re_im(double re = 0, double im = 0) {
        Komplex k;
        k.re = re;
        k.im = im;
        return k;
    }

    static Komplex create_from_r_phi(double r = 0, double phi = 0) {
        Komplex k;
        k.re = r * cos(phi);
        k.im = r * sin(phi);
        return k;
    }
};

int main() {
    Komplex k1 = Komplex::create_from_re_im(1, 0);        // 1 + 0i
    Komplex k2 = Komplex::create_from_r_phi(1, M_PI / 4); // 0 + 1i
    return 0;
}

32. A hármas szabály alkalmazása

Az előző fejezetben megtanultuk, hogy az erőforrásokat érdemes egy objektumra rábízni, ami az élettartama végén fel tudja őket szabadítani. Ez azzal járt, hogy ha a destruktorra szükség van, akkor másoló konstruktorra és értékadó operátorra is.

Akinek a kezében kalapács van, mindent szögnek néz.

A C++ tanulmányok ezen szakaszában gyakran előfordul, hogy egy C kódot, vagy a C-s gondolkodást rosszul C++-osítjuk. Vegyük példának ezt a C-s struktúrát:

struct Vonatjegy {
    char * indulo_allomas;
    char * vegallomas;
};

Jó lenne, ha ezeket az állomásokat konstruktorban tudnánk inicializálni, ráadásul dinamikus memóriába kerülnének a sztringek, sőt a destruktor fel is szabadítaná a dinamikusan tárolt neveket. Na de ha destruktor kell, akkor copy ctor és op= is! Szép menet lesz ezeket mind megírni, de megéri. Vagy mégsem? Annyira rossz lenne az a megoldás, hogy inkább le se írjuk, nehogy véletlenül bárkinek az ragadjon meg a fejében.

Itt szövegek (mondhatni sztringek) dinamikus tárolását szeretnénk megvalósítani. Na de azt a feladatot nemrég oldottuk meg! Pontosan erre írtunk egy osztályt (String), használjuk hát fel! Illetve ne is ezt, hanem a nála sokkal jobb std::string-et.

struct Vonatjegy {
    std::string indulo_allomas;
    std::string vegallomas;
};

Így a fordító által generált "szentháromság" pontosan azt fogja csinálni, ami nekünk kell. Mennyit nyertünk ezzel a megoldással? Alsó hangon harminc sort, hatvan hibalehetőséget, esetleg két álmatlan éjszakát, visszadobott nagyházit, és szerencsére a laborvezető sem fojtotta meg az elkövetőt.

33. Egyértelmű felelősség

Ezt a code smell-t kicsit formalizálva: a hibás megoldás OOP egyik alapelvével ütközne, történetesen a Single Responsibility Principle-lel. Abban az esetben ugyanis a Vonatjegy egyszerre lenne felelős a vonatjegyért mint az utasnak adott információdarabkáért, és a szövegek dinamikus memóriakezeléséért.

A jó megoldás szerint egy osztálynak pontosan egyetlen felelőssége van, és a két felelősségi kört szétválasztja, az std::string osztály csak dinamikusan foglalt szöveget kezel, a Vonatjegy pedig csak az utasnak átadott információért felel.

Ha ezt a gondolatmenetet tovább visszük, lesznek tisztán erőforráskezelő osztályaink, aminek azon kívül szinte semmi dolga, és egyik más osztályunknak sem kell utána erőforrást kezelni.

Egy olyan objektum, aminél a hármas szabály életbe lép, arra való, hogy az erőforráskezelést elrejtsük a felsőbb szintű kód elől. Az őket használóknak már nem kell ezzel szerencsétlenkedni, például String-et tartalmazó osztálynak emiatt nem kell a másoló konstruktort, destruktort, értékadó operátort megírni, hiszen a fordító által generáltak is jók. Érték szerint tudjuk a String-et kezelni, akár csak egy int-et, függetlenül attól, hogy a belsejében éppen dinamikus memória van.

Míg a többi osztálynak nem kell – sőt tilos – erőforrást kezelnie, ott koncentrálhatunk arra, ami a valódi felelősség.

34. Hibakezelés

Emlékezzünk vissza, C-ben mit csinálunk, ha a hibát valamilyen módon jelezni akarjuk a hívónak! Erre gyakorlatilag egyetlen értelmes megoldás van: visszatérési értékben jelezni a hibát. Nézzünk erre egy példát! Olyan függvényt kell írnunk, ami vezetéknév és keresztnév összefűzésével előállítja a teljes nevet.

char * teljes_nev(char const * vezeteknev, char const * keresztnev) {
    int h1 = strlen(vezeteknev);
    int h2 = strlen(keresztnev);

    char * result = (char*)malloc((h1 + h2 + 1 + 1) * sizeof(char)); // szóköz + lezáró nulla

    strcpy(result, vezeteknev);
    strcat(result, " ");
    strcat(result, keresztnev);

    return result;
}

Ezzel a megoldással van egy komoly baj: nem foglalkozik a hibakezeléssel. Akár a paraméter sztringek helyett is kaphatunk NULL-okat, és a malloc is térhet vissza NULL-lal. Ezért ha ilyen hiba történik, mi is térjünk vissza NULL-lal!

char * teljes_nev(char const * vezeteknev, char const * keresztnev) {
    if(vezeteknev == NULL || keresztnev == NULL)
        return NULL;

    int h1 = strlen(vezeteknev);
    int h2 = strlen(keresztnev);

    char * result = (char*)malloc((h1 + 1 + h2 + 1) * sizeof(char)); // szóköz + lezáró nulla

    if(result == NULL)
        return NULL;

    strcpy(result, vezeteknev);
    strcat(result, " ");
    strcat(result, keresztnev);

    return result;
}

Nézzük meg ugyanezt C++-ban, std::string használatával!

std::string teljes_nev(std::string const& vezeteknev, std::string const& keresztnev) {
    return vezeteknev + " " + keresztnev;
}

Mitől lett ennyivel rövidebb, ennyivel egyszerűbb?

Egyrészt az erőforrás-kezelést teljes egészében rábíztuk az std::string-re. Bár az agyunk mélyén tudjuk, hogy belül valamiféle memóriafoglalás van, igazából ez semmit nem zavar. Másrészt az std:string, mint osztály, a maga legjellemzőbb műveleteit (itt: összefűzés) "konyhakészen" adja a használója számára, nem nekünk kell kitalálni az algoritmust, hogyan is kell kezelni. Az operator overloading csak egy plusz nyalánkság a történetben, a szemnek kedves, a lényegen nem változtat. Ezeket a tanulságokat az előző fejezetekben mind részletesen tárgyaltuk, de még mindig van látnivaló ebben a néhány sorban.

Hova tűnt a hibakezelés? Nem butább ez a kód, mint az előző?

Ha megnézzük a két C-s változat közti különbségeket, két helyen kellett hibát kezelni. Egyrészt a bemenetként kapott sztringek lehettek NULL-ok, másrészt a malloc is NULL-lal térhet vissza. A C++-os változatban egy std::string nem lehet érvénytelen, biztosan valid a vezetéknév és a keresztnév. A new pedig kivételt dob NULL helyett. A hibajelzésre nem NULL pointert használunk, hanem kivételeket. Ez a mi függvényünkre is igaz: hiba esetén az std::string kivételt fog dobni, nem kell a visszatérési értéket vizsgálni, (sem memóriaterületeket felszabadítani,) így a használata is egyszerűbb lett:

char * nev = teljes_nev("Neumann", "János");
if(nev == NULL) {
    fprintf(stderr, "Elfogyott a memória. Mégis, ilyenkor mit lehet csinálni?");
    exit(1); // ?
}
printf("%s\n", nev);
free(nev);
std::cout << teljes_nev("Neumann", "János");

Az első esetben muszáj a visszatérési értéket megtartani, mert meg kell vizsgálni, hogy valid-e, és fel kell szabadítani. A C++ kódban a felszabadítás nem a hívó dolga, és a kivétel kezelését is ráhagyhatjuk valami felsőbb szintű kódra.

35. Hibakezelés és erőforráskezelés

Visszatérési értékkel

Az előző C-s példakódban gyakorlatilag egy kritikus művelet volt, a memóriafoglalás. Nézzük meg, mi történik, ha hibakezelés és erőforráskezelés is bőven akad.

A feladat egy mátrix tartalmának beolvasása fájlból, a kód egyelőre félkész. A mátrix egy double** típusú kétdimenziós tömbbe kerül majd, ez lesz a visszatérési érték. Azt még biztosan javítani kell, hogy a hívó megkapja a méretet, de egyelőre ezzel akadnak nagyobb bajok is.

double ** read_matrix_0(char const * filename) {
    FILE * fp = fopen(filename, "rt");

    int w, h;
    fscanf(fp, "%d %d", &w, &h); // méret beolvasása

    double ** ret = (double **) malloc(sizeof(double *) * h);
    for(int y = 0; y < h; ++y)
        ret[y] = (double *) malloc(sizeof(double) * w);

    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            fscanf(fp, "%lf", &ret[y][x]); // számok beolvasása

    fclose(fp);
    return ret;
}

Kifelejtettük a hibakezelést! Az fopen adhat vissza NULL-t, így ha a fájl nem létezik – ami nem olyan ritka, mint a memóriahiány! –, a negyedik sorban egy segmentation fault jöhet szembe. Ugyanígy a malloc és az fscanf is hibába futhat bele, amit szintén ellenőrizni kell. Próbáljuk meg naiv módon kezelni a hibát, adjunk vissza NULL-t, ha bármi hiba történt!

double ** read_matrix_1(char const * filename) {
    FILE * fp = fopen(filename, "rt");
    if(fp == NULL)
        return NULL;

    int w, h;
    if(fscanf(fp, "%d %d", &w, &h) != 2)
        return NULL;

    double ** ret = (double **) malloc(sizeof(double *) * h);
    if(ret == NULL)
        return NULL;
    for(int y = 0; y < h; ++y) {
        ret[y] = (double *) malloc(sizeof(double) * w);
        if(ret[y] == NULL)
            return NULL;
    }

    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            if(fscanf(fp, "%lf", &ret[y][x]) != 1)
                return NULL;

    fclose(fp);
    return ret;
}

Nos, ez nem sokkal jobb. Ha pl. hibás a fájl formátuma, gátlástalanul kilépünk a függvényből, ezzel elszivárogtatunk némi memóriát, és nyitva felejtünk egy fájlt! Így talán még az előző változatnál is rosszabb.

Akkor hát mi a teendő? Hiba esetén mindent, amit addig csináltunk, vissza kell vonni, legtöbbször pont fordított sorrendben. Felszabadítani a kétdimenziós tömböt, és a fájlt becsukni, csak ezután jöhet a return NULL.

double ** read_matrix_2(char const * filename) {
    FILE * fp = fopen(filename, "rt");
    if(fp == NULL)
        return NULL;

    int w, h;
    if(fscanf(fp, "%d %d", &w, &h) != 2) {
        fclose(fp);
        return NULL;
    }
    double ** ret = (double **) malloc(sizeof(double *) * h);
    if(ret == NULL) {
        fclose(fp);
        return NULL;
    }
    for(int y = 0; y < h; ++y) {
        ret[y] = (double *) malloc(sizeof(double) * w);
        if (ret[y] == NULL) {
            for (int yy = 0; yy < y; ++yy)
                free(ret[yy]);
            free(ret);
            fclose(fp);
            return NULL;
        }
    }
    for(int y = 0; y < h; ++y) {
        for(int x = 0; x < w; ++x) {
            if(fscanf(fp, "%lf", &ret[y][x]) != 1) {
                for (int yy = 0; yy < h; ++yy)
                    free(ret[yy]);
                free(ret);
                fclose(fp);
                return NULL;
            }
        }
    }
    fclose(fp);
    return ret;
}

Ez a változat már jól működik. Egy probléma van vele: ránézve elég sok minden látszik rajta, csak az nem, hogy mit csinál. Több a hibakezelés, mint a lényeg. Az eredeti read_matrix_0 függvény 17 sor volt, ez 38 sorra hízott, beolvasás és hibakezelés vegyesen.

Most térjünk vissza arra a problémára, hogyan adjuk vissza a hívónak a méreteket. Nyilvánvalóan egy struktúrába kéne tenni az adattal együtt, valahogy így:

struct Matrix {
    int w, h;
    double ** data;
}

Mi legyen a hibát jelző visszatérési érték? Lehet egyrészt egy olyan Matrix, aminek a data adattagja NULL. Ez a kódot utólag olvasva egyáltalán nem lesz kézenfekvő. Vagy térjünk vissza Matrix *-gal? Ahhoz dinamikusan kell foglalni azt is, plusz hibalehetőség, plusz hibakezelés, plusz indirekció. Már a read_matrix_2 is egy behemót, és a tökéletes működéshez még bonyolítani kell rajta, így a kényelmetlenség is fokozódni fog.

Ugyan igaz, hogy a fájl beolvasását és a memóriafoglalást nem kellett volna egy függvénybe tennünk, de ha a memóriakezelést kiszerveznénk egy külön függvénybe, a hibakezelés akkor is ugyanígy nézne ki. Rengeteg helyen kellene ellenőrizni a visszatérési értékeket, és felszabadítani az erőforrásokat sikeres és sikertelen esetekben is.

Kivételekkel

A legnagyobb kényelmetlenséget az okozza a fenti függvényekben, hogy a lefoglalt erőforrásokat hiba esetén is nekünk kell felszabadítani, fordított sorrendben.

Hiszen erre való a destruktor, használjuk arra, amire kitalálták! Legyen egy Matrix osztályunk, ami elrejti előlünk a memóriakezelést! Az elemek elérését érdemes függvényhívás operátorral megoldani, mert az indexelő operátor nem kaphat két paramétert. Valahogy így:

double& Matrix::operator()(int i, int j) {
    return data[j][i];
}

A FILE* helyett is jobb lenne egy olyan osztályt használni, aminek a destruktora bezárja a fájlt. Ilyenek a szabványos fstream fejlécben definiált, std névtérbeli ifstream és ofstream osztályok. Ezek az std::cin-hez és std::cout-hoz nagyon hasonlóan használhatóak, nem véletlenül.

Matrix read_matrix_3(char const * filename) {
    std::ifstream is(filename);
    if(!is.is_open())
        throw FileError();

    int w, h;
    if(!(is >> w >> h))
        throw FileError();

    Matrix ret(w, h);
    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            if(!(is >> ret(x, y)))
                throw FileError();

    return ret;
}

Figyeljük meg a változásokat a read_matrix_0 változathoz képest!

  • Ha írunk egy FileError osztályt – ami akár egy üres osztály is lehet –, hiba esetén ilyen típusú kivételt dobhatunk. Dobni viszont csak objektumot lehet, osztályt nem, ezért hívjuk a default konstruktorát a throw utasításnál.
  • Ilyen kivételt akkor dobunk, ha a beolvasás során bármi hiba van a fájllal: nem sikerült megnyitni, vagy rossz a formátuma.
  • A memória foglalása eltűnt, legalábbis nem látjuk. Helyette a Matrix konstruktorát hívjuk, ami létrehozza a mátrixot. Sejtjük, hogy ott belül valami new van, ami hiba esetén kivételt dob.
  • Ezt a kivételt ebben a függvényben szándékosan nem kapjuk el. Ha a mátrix nem tud létrejönni, akkor a read_matrix_3 függvény sem tudja visszaadni azt.
  • Bármiféle hiba esetén nem valami furcsán megjelölt mátrixszal térünk vissza, hanem kivétel dobódik. Ez a hívónak is kényelmesebb: dönthet úgy, hogy nem kapja el, míg egy visszatérési értéket muszáj megvizsgálni.
  • A lényeg – ez a sok egyszerűsítés – a sorok között van, illetve ott sem: nincs odaírva. Nem kellett odaírni!
  • Az ifstream destruktora nem csak visszatéréskor zárja be a fájlt, hanem mindig, akárhogy lépünk ki az őt tartalmazó blokkból, – jelen esetben a függvényből, – kivételdobás esetén is!

Ez a kód közelebbről vegyes-büdös, mert a beolvasás sikerességét még mindig visszatérési értéken keresztül figyeljük. Jó lenne, ha már maga a beolvasás dobna kivételt. Szerencsére erre meg tudjuk kérni az std::ifstream-et.

Matrix read_matrix_4(char const * filename) {
    std::ifstream is;
    is.exceptions(std::ifstream::badbit |
                  std::ifstream::eofbit |
                  std::ifstream::failbit);
    is.open(filename);

    int w, h;
    is >> w >> h;

    Matrix ret(w, h);
    for(int y = 0; y < h; ++y)
        for(int x = 0; x < w; ++x)
            is >> ret(x, y);

    return ret;
}

Vessük össze ezt a megoldást a kiindulási, ezer sebből vérző read_matrix_0 függvénnyel! Ahhoz képest csak annyi változott, hogy a foglalás eltűnt, pointerek helyett érték szerint kezelt objektumok vannak. A kód ugyanúgy mentes mindenféle hibakezeléstől, de nem azért, mert kifelejtettük, hanem mert automatikusan történik.

36. A RAII elv

Az erőforrások kezelése során a problémát általában az okozta, hogy kivétel dobása esetén a pointerre rábízott erőforrás felszabadítását nagyon könnyű elfelejteni.

Ezért igazából nem a pointereket kell hibáztatni. A C-ben írt függvénykönyvtárak többsége azt a konvenciót követi, amit a FILE * példáján ismertünk meg. Az fopen veszi fel a konstruktor, az fclose pedig a destruktor szerepét. Az erőforrást itt egy FILE * írja le, ennek a tartalma közvetlenül nekünk érdektelen, csak a függvényeken keresztül tudjuk őket használni. Ezért nem is kell pointernek lennie, ahogy Linux API-nál int, OpenGL-nél GLuint veszi fel ezt a szerepet. Az a lényeg, hogy kicsi és könnyen kezelhető legyen.

Akik a könyvtárakat írják, hogy mernek rábízni egy erőforrást egy primitív típusra, egy pointerre? Miért nem objektumra bízzák rá?

Igazából nem is a pointerre bízzák, hanem ránk. Ehhez kapunk egy névjegykártyát, amit a megfelelő helyen bemutatva elérhetjük a megfelelő hatást. A kártyára nekünk kell vigyázni, ha elveszítjük, nincs többé. C++ objektumot azért nem használnak, mert gondolnak a többi nyelvre is. Egy C-ben írt könyvtárat a legkönnyebb bármilyen más nyelvben felhasználni.

Szóval a mi dolgunk, hogy vigyázzunk az erőforrásokra. Testőrként segít minket egy elv, amit RAII-nak hívnak, és pontosan az olyan helyzetekre találták ki, mint a read_matrix.

A RAII a Resource Acquisition Is Initialization rövidítése. Körülbelül annyit jelent, hogy egy erőforrás lefoglalása egyben egy objektum inicializálása is legyen. Azaz egy objektumra bízzuk az erőforrást, aminek van destruktora, szemben egy "sima" pointerrel. Az objektum élettartamához kötjük hozzá az erőforrást. Amint az objektum megszűnik, automatikusan lefut a destruktor, és felszabadítja a rá bízott erőforrást is.

A RAII-t eddig is alkalmazuk, még ha nem is hívtuk a nevén: a String és a Matrix is így működik. Ezeknél az osztályoknál a foglalás a konstruktor dolga, ezért csak a destruktornak kell tudnia, hogyan kell felszabadítani. Az erőforráskezelés teljes mikéntje a belső reprezentáció "titka" maradhat.

Az ebben a szellemben megírt osztályok erejét az egyszerűség kedvéért egy double tömbön mutatjuk be. Akár lehetne SDL_Surface * vagy GPU-n futó program is. A kezdeti, RAII-t nem alkalmazó kód így néz ki:

void feladat() {
    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    double * tomb = new double[n];

    // sok-sok művelet

    delete[] tomb;
}​

A read_matrix változatai után itt az olvasóknak kórusban kell felkiáltania: na de mi van, ha a sok-sok művelet bármelyike kivételt dob? RAII nélkül valahogy így lehet javítani.

void feladat() {
    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    double * tomb = new double[n];

    try {
        // sok-sok művelet
    }
    catch(...) {
        delete[] tomb;
        throw;
    }

    delete[] tomb;
}​

A RAII azt mondja, hogy az erőforrást – ami itt egy double tömb – bízzuk rá egy objektumra, ami a végén felszabadítja. Írjunk tehát egy olyan osztályt, ami egy akármekkora méretű double tömböt kezel.

class Vektor_double {

    // ...

    Vektor_double(int m) {
        this->meret = m;
        this->data = new double[m];
    }

    ~Vektor_double() {
        delete[] tomb;
    }

    // copy ctor, op=
};

Ha a fenti kódban double * helyett ezt a Vektor_double osztályt használjuk, annyival egyszerűsödik a helyzet, hogy nem nekünk kell felszabadítani az erőforrásokat. Igazából nem is a plusz négy sor miatt csináltuk, hanem azért, mert így nem lehet elfelejteni a felszabadítást.

int main() {

    int n;
    std::cout << "Hany szam lesz?";
    std::cin >> n;

    Vektor_double tomb(n);

    /* sok-sok kód */
}

Ez egy elég primitív példa, egyszerűen kezelhető elemekkel (double). Ha a tárolt elemek is bonyolultabbak, mint például egy std::string, az olvasóra bízzuk annak végiggondolását, mennyi macerával jár egy sztringtömb kezelése C-ben. Még akkor is, ha csak annyi a feladat, hogy rendezni kell az elemeket.

Láthatjuk, hogy a kivételek destruktorok híján semmit nem egyszerűsítenek a helyzeten: akkor ugyanúgy el kell kapni minden kivételt, kézzel kipucolni, majd továbbdobni. Az igazi megváltást a kettő együtt hozta.

template

38. Mitől template a template?

A "template" magyarul sablont jelent. A sablon egy séma, recept, amit felhasználva igény esetén elkészíthető belőle a végleges termék, önmagában viszont életképtelen. A C++ template-je is innen kapta a nevét.

A függvénysablon szó alatt magyarul nem csak a template függvényeket értjük, van OOP tervezéssel kapcsolatos vonatkozása is. Ebben a fejezetben viszont mindvégig a C++ template kulcsszaváról lesz szó.

39. template függvények

Találós kérdés: mi hiányzik ebből, az első fejezetből már ismerős kódrészletből?

inline int min(int a, int b) {
    return a < b ? a : b;
}

Szigorúan nézve semmi nem hiányzik belőle, így is tökéletes. Megmondja, két int közül melyik a nagyobb, bizonyos szituációkban mégis hiányérzetünk támad. Mit ír ki az alábbi kódrészlet?

std::cout << min(2.5, 2.1) << std::endl;

Ez bizony 2-t ír ki. Szóval két szám, 2.5 és 2.1 közül a kisebb egy harmadik (2). Jogos igény, hogy a min függvény működjön valós számokra is. Elvégre azokat is a < operátorral kell összehasonlítani. Írjunk hát egy overload-ot, ami két double-t vesz át, és double-t ad vissza!

inline double min(double a, double b) {
    return a < b ? a : b;
}

Ha eszünkbe jut a Tort, az std::string, vagy ezer másik osztály, amiket mind-mind a < operátorral hasonlítunk össze, elszabadulhat a pokol.

inline Tort min(Tort a, Tort b) {
    return a < b ? a : b;
}

inline std::string min(std::string a, std::string b) {
    return a < b ? a : b;
}

Itt illik érezni, hogy ez így nem mehet tovább. Az overload megírásához akár használhattuk a Search and replace funkciót is. A jóérzésű programozó sormintadetektora ettől elég hamar kiakad.

Tulajdonképp lehetne generálni is ezeket az overload-okat. Ezt a mechanikus munkát el tudja végezni helyettünk a fordító, egy nyelvi elem, a template segítségével:

template<typename T>
T min(T a, T b) {
    return a < b ? a : b;
}

Bár elsőre szokatlan a szintaxisa, később arra is rávilágítunk, miért kell ennek ilyennek lennie. Elemezzük ki apránként, mi mit jelent!

  • A template kulcsszóval kell jeleznünk, hogy itt egy generikus valami fog következni, nem sima függvény.
  • A template után kacsacsőrök között kell megadni, milyen paramétereket adunk a template-nek: ezek a template paraméterek.
  • typename T: template paraméterként ennek a függvénynek egy típust (typename) adunk, amit a függvényben T-nek hívunk. Ennek a T-nek a helyére fog a fordító igény szerint int-et, double-t vagy std::string-et helyettesíteni.

A függvényben a T-t már valóban használhatjuk típusként, két T-t vár paraméternek, és T-t ad vissza. Ha mi int-ekkel használjuk, T helyére képzeljünk int-et, ha double értékekkel hívjuk, akkor pedig double-t. A függvény belsejében gátlástalanul használjuk a < operátorát, ezzel elvárjuk, hogy csak olyan típust adjanak meg neki, aminek tényleg van ilyenje. Ha mégsincs, akkor a kód nem fordul.

A fenti kontextusban, a kacsacsőrök között a typename helyett a class kulcsszó is használható, pontosan ugyanazt jelenti (struct viszont nem használható). Tehát a lenti függvény tökéletesen ekvivalens a fentivel, T ugyanúgy lehet int is.

template<class T>
T min(T a, T b) {
    return a < b ? a : b;
}

A jegyzetben következetesen a typename-et használjuk, valamivel kifejezőbb.

40. template függvények hívása

A fent definiált min függvényt az alábbi módokon hívhatjuk meg:

int main() {
    double a = min(3.1, 0.0);
    int b = min<int>(0, 'b');
    Tort c = min<Tort>(Tort(2), Tort(6));
}

Az egész arra ment ki, hogy az első hívás helyes legyen, és ennek hatására két double-t hasonlítson össze a fordító által generált függvény.

A második és harmadik esetben explicit módon kiírtuk, hogy milyen típussal szeretnénk meghívni a függvényt, ez mindig működni fog. Ezen felül jogosan várjuk el a fordítótól, hogy ha mi pl. double-öket adunk meg paraméterekként, akkor legyen okos, és találja ki, hogy T == double. A fordító képes levezetni a paramétereket, ezért tud működni az első. Ezt természetesen csak akkor tudja megtenni, ha van egyáltalán a template paramétertől, typename T-től függő függvényparaméter: T a, T b (a min függvénynél természetesen van).

Ha explicit módon adjuk meg a template paramétert, akkor az automatikus konverzió is meg van engedve, például az első esetben 'b' konvertálódik int-re. Ha a fordító vezeti le, akkor ettől a lehetőségtől elesünk. Csak akkor vezetheti le a template paramétereket, ha a típusok pontosan passzolnak. Például min(3.1, 0) fordítási hibához vezet, mert 3.1 egy double, a 0 viszont int.

41. Mitől kacsa egy kacsa?

A válasz legalább annyira meghökkentő, mint a kérdés. Az kacsa, amit mi annak tekintünk. Egy séf teljesen más szempont szerint keres madarak között kacsát, mint egy genetikus. Egy kisgyerek szemében minden kacsa, ami úszik és hápog.

Egyáltalán hogy kerül ide a kacsa? Nézzük meg megint a min függvényünket!

template<typename T>
T min(T a, T b) {
    return a < b ? a : b;
}

Milyen típusokon működik ez a függvény? Mindenen, ami lemásolható (mivel érték szerint veszi át a paramétereit) és van < operátora. Ezt a függvényt semmi más nem érdekli.

Ennek a típusszemléletnek duck typing a neve. A szó nagyjából annyit takar, hogy előzetes információ nélkül kipróbáljuk, tud-e hápogni és repülni a kezünkbe adott dög. Ha igen, nekünk éppen eléggé kacsa. Nem kell előre megmondani, hogy mi itt olyan T-ket várunk, ami másolható és van < operátora. Ha nincs neki, mi ugyanúgy ledobjuk a szakadékból, de az alján nagyot csattan. Ez időben kiderül, ugyanis ilyenkor fordítási hiba keletkezik.

Éles szeműek észrevehetik, hogy a kacsát csak szintaktikailag ellenőrizzük le, akár teljesen más szemantika is lehet mögötte. Mit ír ki ez a kód?

template<typename T>
T duplaz(T const& x) {
    return x+x;
}

int main() {
    std::cout << duplaz(3) << std::endl;
    std::cout << duplaz(std::string("ha")) << std::endl;
}

A duplaz(3) visszaadja 3+3-at, ami 6, és a költő valószínűleg erre gondolt a duplaz megírása közben. A duplaz(std::string("ha")) viszont "haha" lesz! Ugyanúgy értelmesek a műveletek, de ugyanaz a függvény (ami itt operator+) az egyik típusnál összead, a másiknál összefűz. Szóval a séf megtalálta a hápogó, úszó valamit, és megfőzte. A vendég a tányérjában egy darab műanyagot talált, ugyanis az egy elemes gumikacsa volt. Persze lehet, hogy pont ez volt a cél.

42. A template specializáció

Mit ír ki az alábbi kódrészlet?

int main() {
    std::cout << min("hello", "vilag") << std::endl;
}

Prog1-ből tudjuk, hogy egy string literal char const-okból álló tömb. Ez függvénynek pointerként adódik át, tehát itt char const* típussal példányosodik a függvény:

char const* fordito_altal_generalt_min(char const* a, char const* b) {
    // a és b pointerek!
    return a < b ? a : b;
}

Tehát a min a pointereket hasonlította össze, nem a sztringek tartalmát!

Itt a költő valószínűleg arra gondolt, hogy annak kéne kiíródnia, amelyik ABC-sorrendben (strcmp) előrébb van. Azt szeretnénk elérni, hogy a min függvény minden típusra a < operátort használja, kivéve char const *-ra, mert ott strcmp kellene nekünk. A kivéve viszony C++-beli megfelelője a template specializáció.

template<typename T>
T min(T a, T b) {
    return a < b ? a : b;
}

template<>
char const* min<const char*>(char const* a, char const* b) {
    return strcmp(a, b) < 0 ? a : b;
}

Az eredeti függvény csak azért került ide is, nehogy félreértse valaki: a template specializáció egy kiegészítés, kell hozzá "alap". Elemezzük ki ezt is!

  • template<>: ezzel jelezzük, hogy ez nem egy függvény-overload, hanem specializáció, tehát egy meglévő, ugyanilyen nevű template-et szeretnénk most kiegészíteni. Az üres kacsacsőr itt kell, nem hagyható el.
  • Mivel ebben a specializációban nincs template paraméter, T sem használható! Ezért került mindenhova char const* a T helyére.
  • A függvény neve után, de a paraméterek előtt meg kell adni, hogy mi a "kivéve" feltétele. Vegyük észre, a függvény neve helyén nem simán min, hanem min<char const*> szerepel. Azaz akkor aktiválódjon ez a specializáció, ha az eredeti függvényt char const* paraméterrel példányosítja valaki. Figyeljük meg, hogy pont ott van, ahol hívásnál is szerepelnie kell!

Ez utóbbit nem mindig muszáj megadni, de érdemes. Ezen kívül specializáció helyett néha használhatunk sima overload-ot is, az viszont kicsit mást jelent. Például az alábbi esetben az overload-trükköt nem tudnánk kihasználni.

template<typename T>
T pi() {
    return 3.14159265358979323846;
}

template<>
Tort pi<Tort>() {
    return Tort(104348, 33215);
}

43. template osztályok

A template-et eredendően nem a template függvények miatt találták fel, hanem a template osztályok miatt. Azon belül is eredendően a tároló osztályok azok, amiknek a típusát már C-ben jó lett volna általánosítani. A jegyzetben eddig alig esett szó tároló osztályokról, pont azért, mert template nélkül sok lenne a sorminta.

Első példaként nézzünk egy dinamikusan nyújtózkodó tömböt, ami double-öket tárol. Ez az osztály a konstruktorában vegye át a kiindulási méretet, ami alapértelmezetten 0! A méretet a size(), az elemek egyenkénti elérését az operator[] fogja biztosítani. A nyújtózkodást pedig – a szabványos könyvtárral összhangban – a push_back függvény váltja ki.

class Vektor_double {
    double *adat;
    size_t meret;

public:

    explicit Vektor_double(size_t meret = 0)
        : adat(new double[meret])
        , meret(meret) {
    }

    // copy ctor, operator=, dtor kellene, házi feladat.

    size_t size() const {
        return meret;
    }

    double& operator[](size_t index) {
        return adat[index];
    }

    double const& operator[](size_t index) const {
        return adat[index];
    }

    void push_back(double uj_ertek) {
        double *uj_adat = new double[meret + 1];
        for(size_t i = 0; i < meret; ++i)
            uj_adat[i] = adat[i];
        uj_adat[meret] = uj_ertek;
        delete[] adat;
        adat = uj_adat;
        ++meret;
    }
};

Ebben a megvalósításban – ahogy a szabványos könyvtár std::vector osztályában is van – az operator[] nem ellenőrzi a határokat, mivel erre a legtöbbször semmi szükség. Ha a használója nem biztos a dolgában, akkor használhatja helyette az at tagfüggvényt.

Melyek azok a részek, amik ebben az osztályban double-specifikusak?

Erre jelen esetben egyszerű válaszolni: ahol a kódban szerepel a double szó. Például mindjárt az osztály nevében. Ha template-esíteni szeretnénk, az osztálydefiníció elejére kell írni, hogy template<typename TIPUS>. Ezen kívül ahol double típus szerepel, mind ki kell cserélni TIPUS-ra.

template<typename TIPUS>
class Vektor {
    TIPUS *adat;
    size_t meret;

public:

    explicit Vektor(size_t meret = 0) :
        adat(new TIPUS[meret]),
        meret(meret) {
    }

    // copy ctor, operator=, dtor mind kell, házi feladat.

    size_t size() const {
        return meret;
    }

    TIPUS& operator[](size_t index) {
        return adat[index];
    }

    TIPUS const& operator[](size_t index) const {
        return adat[index];
    }

    void push_back(TIPUS const& uj_ertek) {
        TIPUS *uj_adat = new TIPUS[meret + 1];
        for(size_t i = 0; i < meret; ++i)
            uj_adat[i] = adat[i];
        uj_adat[meret] = uj_ertek;
        delete[] adat;
        adat = uj_adat;
        ++meret;
    }
};

Nagyon figyelmesen nézve észrevehető, hogy még egy apróságot változtattunk ezen az osztályon. A push_back eddig érték szerint vette át az uj_ertek paramétert, a template-es verzióban pedig már const&-ként. Felelős programozóként gondolnunk kell a jövőre is. Például ha valaki a Vektor osztályunkat std::string paraméterrel szeretné használni, annak a lemásolása ott igen költséges és felesleges lenne.

Hogyan kell használni ezt az osztályt? Ahogy a függvényeknél megtanultuk, itt is meg kell adni, milyen template paraméterekkel szeretnénk példányosítani az osztályt. Fontos különbség, hogy osztályoknál nincs típuslevezetés, csak függvényeknél.

Vektor<double> v(100);
for(int i = 0; i < 100; ++i)
    v[i] = double(i) / double(i+1);

Az osztályt úgy írtuk meg, hogy a Vektor<double> lehetőleg ugyanúgy viselkedjen, mint a Vektor_double, tehát a használata csak a deklarációban különbözik. Annál viszont többet tud, akármilyen típusra cserélhetjük a double-t. Akár std::string-re is.

Vektor<std::string> v;
std::string word;
while(std::cin >> word)
    v.push_back(word);

44. Osztályon kívül definiált template tagfüggvények

Ahogy a "sima" osztályoknál megszokhattuk, a template osztályok tagfüggvényeit is definiálhatjuk az osztályon kívül. A szintaxisa elég körülményes, némi magyarázatra szorul, miért pont úgy kell.

Emlékezzünk vissza, a tagfüggvényeknek van egy implicit paramétere, a this pointer. Mi ennek a típusa egy Vektor<double> esetén? (A const tagfüggvényektől most tekintsünk el.)

Vektor<double> * const this;

Mi a típusa általános esetben, Vektor<TIPUS>-ra?

Vektor<TIPUS> * const this;

Ebből az látszik, hogy egy template osztály tagfüggvénye is template. Tehát deklarálnunk kell a template paramétereket, és a tagfüggvény nevében egyértelműsítenünk kell, hogy ez bizony a Vektor<TIPUS> tagfüggvénye. Például az alábbi kódban az uj_adat[meret] = uj_ertek lehet int értékadás, vagy std::string::operator= hívása is.

template<typename TIPUS>
void Vektor<TIPUS>::push_back(TIPUS const& uj_ertek) {
    TIPUS *uj_adat = new TIPUS[meret + 1];
    for(size_t i = 0; i < meret; ++i)
        uj_adat[i] = adat[i];
    uj_adat[meret] = uj_ertek;
    delete[] adat;
    adat = uj_adat;
    ++meret;
}

Ennek ugyanúgy a header-ben a helye, mint a "sima" template függvényeknek, ennek okára is fény fog derülni.

45. template paraméterek típusai

Az eddigi template kódjainkban a template paraméterek mind típusok (typename) voltak. Ezek azonban lehetnek értékek is, például lehet egy int is. Fontos megkötés a sima függvény- vagy konstruktorparaméterekkel szemben, hogy fordítási időben ismertnek kell lennie. Ez a korlátozás néhány helyen plusz lehetőségeket hordoz, például C-s tömböt csak fordítási időben ismert mérettel deklarálhatunk.

A "nem-típus" (non-type) template paraméter lehet bármilyen beépített típus, egész típus, függvénypointer, valamilyen enumból származó érték, és még pár elvadult dolog, aminek a használata jóval körülményesebb. Lebegőpontos típus (float, double) viszont nem.

Lássunk egy példát erre! Írjunk egy Stack osztályt, ami bármilyen típust tud tárolni! A maximálisan tárolható elemek száma legyen template paraméterben megadható.

template<typename T, std::size_t N>
class Stack {

    T adat[N];
    std::size_t db;

public:

    Stack() : db(0) {
    }

    bool empty() const {
        return db == 0;
    }

    bool full() const {
        return db == N;
    }

    std::size_t size() const {
        return db;
    }

    void push(T const& uj) {
        if(full())
            throw std::overflow_error("Stack");
        adat[db] = uj;
        ++db;
    }

    T pop() {
        db--;
        return adat[db];
    }

};

A nem-típus template paramétert ugyanott kell megadni, ahol az ember várná. Az osztályon belül N ugyanúgy használható, mintha egy sima std::size_t lenne, ráadásul fordítási időben ismert.

Fontos, hogy a különböző template paraméterekkel példányosított Stack-eknek egymáshoz semmi közük! Ahogy egy Vektor<int> és egy Vektor<std::string> is inkompatibilis, ugyanúgy a Stack<int, 100> és a Stack<int, 200> is.

Vegyük észre, hogy ennek az osztálynak nem kell destruktort írni, sem copy ctort, sem op=-t. Ugyanis nincs benne dinamikus memóriakezelés, a statikus méretű tömbök adattagként ugyanúgy érték szerint másolódnak le, ahogy C-ben.

46. Részleges specializáció

A Stack-ünk memóriakezelése egyszerű és gyors: a tömb mint beépített tároló kezelése nem igényel különösebb figyelmet. A tárolt elemek benne vannak az objektumban, tehát egy elem gyorsan és indirekció nélkül elérhető. Egyetlen metaadatot tárolunk, a foglalt darabszámot, amire mindenképpen szükség van.

Van egy különleges eset: amikor a tárolt elem bool. A memóriában egy bool változó nem tud egy bájtnál kevesebb helyet foglalni (hiszen címezhetőnek kell lennie), viszont logikai értelemben egyetlen bitnyi információt tartalmaz. Írjunk egy olyan Stack-et, ami a bool-okat hatékonyabban tárolja! A tömbben tároljunk unsigned char-okból álló tömböt, a push és a pop pedig a megfelelő helyre tologatja a biteket.

Jó lenne, ha a Stack ugyanúgy működne, mint eddig, kivéve ha a T helyére bool kerül. A "kivéve" viszony C++-ban specializációt jelent, ez azonban egy másfajta specializációt igényel, mint a fentebb bemutatott függvényé. A Stack<bool, N> ugyanis szintén egy template osztály, viszont bármilyen N-re ennek a specializációnak kell életbe lépnie.

A specializált osztálynak tehát N továbbra is template paramétere, T viszont rögzítve van bool-ra. Az ilyen neve részleges specializáció.

A Stack<bool, N> ez lett, a specializációt az első két sorban láthatjuk.

template<std::size_t N>
class Stack<bool, N> {

    unsigned char adat[N/8 + 1];
    std::size_t db;

public:

    Stack() : db(0) {
    }

    bool full() const {
        return db == N;
    }

    bool empty() const {
        return db == 0;
    }

    std::size_t size() const {
        return db;
    }

    void push(bool uj) {
        if(full())
            throw std::overflow_error("Stack");

        int bajt = db / 8;
        int bit = db % 8;

        if(uj)
            adat[bajt] |= 1 << bit;
        else
            adat[bajt] &= ~0 ^ (1 << bit);

        ++db;
    }

    T pop() {
        db--;
        return adat[db / 8] & (db % 8);
    }

};

Teljesen korrektek vagyunk itt a 8-as számot illetően? Tudni kellene, hogy egy bájtba (unsigned char-ba) hány bit fér. A climits header-ben definiált CHAR_BIT makró megmondja nekünk, de ezzel most nem foglalkozunk, 8-nak vesszük. A szabvány egyébként garantálja, hogy legalább 8, tehát ezzel nem követünk el hibát.

47. STL-vonatkozások

A fent bemutatott Vektor osztály ihletője az std::vector volt, a vector standard header-ből. Utóbbi jóval okosabb: nem hív feleslegesen TIPUS-konstruktorokat, csak azt, amire kértük. A push_back-je jóval hatékonyabb, a memóriát nem egyesével nyújtogatja, hanem nagyobb darabokban. Az általa lefoglalt méret sokkal nagyobb lehet, mint a ténylegesen feltöltött.

Az std::vector részlegesen specializált a bool-ra, ez adta az ötletet a Stack<bool>-hoz. Az eredeti cél egy memóriahatékonyabb bitset megalkotása volt, azonban ennek súlyos az ára: az std::vector<bool> butább, mint a többi std::vector, és a koncepció megtartásával nem javítható, így a használata ellenjavallott.

Az STL-ben stacket is találunk a stack header-ben, a neve std::stack. Ez szerencsére nincs specializálva bool-ra. Különbség a mi implementációnkhoz képest, hogy megkülönbözteti a legfelső elem kiolvasását és törlését. A pop függvénye nem adja vissza a kivett elemet, csak eldobja. A legfelső elemet a top tagfüggvénnyel érjük el, méghozzá referencia szerint.

48. Haladó template függvények

Írjunk függvényt, ami egy fenti Stack-et megfordít! A függvény bármilyen típusú és méretű Stack-ekre működjön!

A második kitétel azt okozza, hogy a függvénynek két template paraméter kell, a típus és a méret.

template <typename T, std::size_t N>
void megfordit(Stack<T, N> & s) {
    Stack<T, N> ujstack;
    while (!s.empty())
        ujstack.push(s.pop());
    s = ujstack;
}

49. A fordító varázsereje

Arról már esett szó, hogy a fordító generálja le a sablonokból a kész függvényeket. Nézzük meg ismét első template függvényünkön, hogyan történik mindez.

template<typename T>
T min(T a, T b) {
    return a < b ? a : b;
}

Észre kell vennünk azt, hogy ezt a függvényt nem lehet lefordítani, amíg nem tudjuk meg, hogy a T típus pontosan micsoda. Amíg ez az információ nem áll rendelkezésre, addig nem tud kódot generálni.

Például beépített típusok esetén a paramétereket és a visszatérési értéket bitenként kell másolni, az összehasonlítás egyetlen gépi utasítással történik. Ezzel szemben egy objektumnál (pl. egy std::string-nél) a visszatérési érték bemásolásához az std::string másoló konstruktorát kell hívni. Az összehasonlításnál olyan operator<-t kell keresnie a fordítónak, ami két std::string-et tud összehasonlítani, ez lehet tagfüggvény vagy globális függvény is.

Ennyire aprólékosan kifejtve szépen látszik, hogy egy template függvény típusonként nagyon eltérő tartalmú. Mégis, hogyan tud ez egyáltalán működni?

A template kód fordításának menete nem tartozik szigorúan a tárgy anyagához, de a megértést nagyon megkönnyíti, egy csapásra világossá válik az egész. Nem pontos technikailag az alábbi leírás, de nagy vonalakban stimmel.

Először, amikor a fordító elemzi a kódot, csak nagyvonalú szintaktikai ellenőrzést végez. Zárójelek, pontosvesszők, függvényhívások, operátorok szintaxisa, stb. Aztán amikor a kódban ilyet talál:

int a = min(1, 2);

Akkor visszatér a min függvényhez, és legenerál belőle egy példányt. Ekkor már pontosan tudja, hogy itt a min<int> függvény kell, int template paraméterrel. Az így kapott specializációt hívja meg a fenti sorban. (Ugyanabban a fájlban még egy min<int> hívásnál természetesen nem generálja újra ugyanazt, csak beilleszti a megfelelő hívást.)

Ha a kódban van egy min<std::string> hívás is, akkor legenerál egy másik specializációt, ami már std::string paraméterű, és ennek felel meg a törzse is. Ha meg min<char const *>-ot, akkor használja az általunk megadott specializációt.

Mi történik, ha egy template függvény definíciója nem a header-ben (pl. min.h), hanem egy önálló fordítási egységben (min.cpp) van, és egy másik fájlban (main.cpp) használni próbáljuk? Tehát tegyük fel, hogy úgy írjuk a kódot, ahogy C-ből tanultuk, a függvénynek csak a deklarációját írva a fejlécfájlba:

#ifndef MIN_H_INCLUDED
#define MIN_H_INCLUDED

template<typename T>
T min(T a, T b);

#endif

A main.cpp-ben nem tudja a fordító legenerálni a megfelelő min() példányt, hiszen nem ismert a törzs. A min.cpp fordításakor viszont azt sem tudja a fordító, hogy mi a main.cpp-ben egyáltalán használni szeretnénk, tehát nem tudja, milyen típusokkal kellene példányosítania.

Tehát az az egy megoldás marad, hogy a header-be tesszük a definíciót is, függetlenül attól, hogy önálló függvény, osztályon belül vagy kívül definiált tagfüggvény. Az ilyen header-ök a szokványostól eltérően nem csak deklarációkat tartalmaznak, és nem tartozik hozzájuk *.cpp fájl sem. Ezért ezek konvenció szerint hpp kiterjesztést kapnak (pl. min.hpp). Tehát így:

#ifndef MIN_HPP_INCLUDED
#define MIN_HPP_INCLUDED

template<typename T>
T min(T a, T b) {
    return a < b ? a : b;
}

#endif

Trükkös kérdés: ha a min<int> példányt több fájlban is használjuk, a linker hogyhogy engedi? Szokványos függvényeket csak egy helyen definiálhatunk, míg így több fájlban is szerepel a definíciója.

Ezért van biztosítva a C++ szabványban, hogy minden template függvény definíciója inline-nak minősül, akkor is, ha mi nem írjuk ki. Így ugyan többször lefordul a függvény, de erről a linker tudja, hogy normális (és feltételezhető, hogy egyformák is lettek a lefordított függvények), és ezért csak az egyik kerül be végül a programba.

Iterátorok, generikus algoritmusok

Az előző fejezetben láttuk, hogyan tudunk úgy tárolót írni, hogy a tárolt típust tulajdonképpen nem ismerjük, bármi lehet. Ebben a fejezetben azt nézzük meg, hogyan lehetséges, hogy egyes algoritmusaink bármilyen tárolón ugyanúgy tudnak dolgozni.

51. Tárolók bejárása

A bejárás alapproblémája

Első feladat: írjuk ki egy tömb minden elemét!

Ezt naivan egy int i ciklusváltozóval és indexeléssel tennénk meg első nekifutásra. Ehelyett alább egy pointert használunk ciklusváltozónak, a téma szempontjából sokkal tanulságosabb.

int tomb[100]; // valaki feltölti

int * iter;
for (iter = tomb; iter != tomb + 100; ++iter)
    std::cout << *iter << std::endl;

Második feladat: tegyük meg ugyanezt egy egyszeresen láncolt listával! Ezt C-ből jól ismerjük, de a példában már kicsit C++-osítottuk.

Lista lista; // valaki feltölti

ListaElem * iter;
for (iter = lista.eleje; iter != NULL; iter = iter->kov)
    std::cout << iter->adat << std::endl;

Hasonlítsuk össze a két bejárást! Mi a közös a két tároló bejárásában, és miben térnek el?

A két tároló elemeivel ugyanazokat csináljuk, csak teljesen máshogy.

Tevékenység Tömb Lista
1. Léptetjük a következő elemre ++iter iter = iter->kov
2. Elkérjük az általa mutatott elemet*iter iter->adat
3. Indítjuk a tároló elejéről iter = tomb iter = lista.eleje
4. Összehasonlítjuk a tároló végével iter != tomb + 100iter != NULL

A ciklusváltozók – a tömbnél az int * iter, a listánál a ListaElem * iter – ennyit "tudnak". Ezeket léptetni lehet (1), és elérni rajtuk keresztül az aktuális elemet (2).

A tárolókhoz tartozik a (3)-as és a (4)-es művelet. Egyrészt elkérjük az első elemet, hogy el tudjuk kezdeni a bejárást. Ez a listánál szépen látszik, már az elején feltűnt, hogy nem triviális. A tömbnél elsőre nem látszik, hogy mi történik, csak ha alaposan megnézzük: iter = tomb igazából iter = &tomb[0], tehát létrejött egy pointer, ami a tömb elejére mutat. Másrészt elérjük a tároló végét. Konvenció szerint ez az utolsó utáni elemet jelenti. Tömbnél ez tomb + 100, láncolt listánál ebben az esetben konstans NULL.

Vegyük észre, hogy a tömbnél és a listánál logikailag pontosan ugyanaz történt, de gyakorlatilag teljesen máshogy. A ciklusváltozón ugyanazok az elvégezhető műveletek, de más implementációval. Azért, hogy ugyanúgy lehessen használni a különböző tárolók ciklusváltozóit, az implementációs különbségeket osztályok belsejébe szokták elrejteni. Nézzük meg, hogyan.

A fenti műveleteket megvalósító osztályokat hívják iterátoroknak. A műveleteket a pointerek alapján nevezték el. Így egyrészt konzisztens, másrészt kompatibilis a használatuk.

  • Két iterátort össze lehet hasonlítani az == és != operátorokkal.
  • A léptetésre a ++ operátor való.
  • A mutatott elem elérése a * operátorral lehetséges.

Bejárás iterátorokkal

A C++ beépített tárolói mind rendelkeznek iterátorokkal. A szabványos tárolók elejét és végét a begin és end tagfüggvényekkel érhetjük el. Az iterátor típusa konvenció szerint a tároló osztály belsejében definiált típus (angolul nested type). Például egy std::vector<int> bejárását így is lehet írni:

std::vector<int> szamok; // valaki feltölti

std::vector<int>::iterator iter;
for (iter = szamok.begin(); iter != szamok.end(); ++iter)
    std::cout << *iter << std::endl;

Figyeljük meg alaposan az egyes elemeket!

  • Az iterátor típusa std::vector<int>::iterator.
  • A tároló begin és end tagfüggvényei visszaadnak egy-egy iterátort: vec.begin(), vec.end().
  • Az iterátorokat lehet egymással összehasonlítani: iter != vec.end(), egy iterátort lehet léptetni: ++iter és elkérni a tárolt elemet: *iter.

Erősen ajánlott a saját tárolóink és iterátoraink függvényeit pontosan ugyanígy nevezni, a többi fejlesztő lelki nyugalmának és a saját testi épségünknek az érdekében. Ha nem így teszünk, a tárolókat sokkal nehezebb lesz használni, és az STL algoritmusok sem használhatók rajta.

Ha írunk a saját listánknak is iterátort, akkor azt így kell majd bejárni:

Lista<int> szamok; // valaki feltölti

Lista<int>::iterator iter;
for (iter = szamok.begin(); iter != szamok.end(); ++iter)
    std::cout << *iter << std::endl;

A fentihez képest tehát pontosan ugyanúgy kell majd csinálni a bejárást, mint az std::vector-nál. Pusztán a típus nevét kell megváltoztatnia az azt használó kódnak! Hát nem csodálatos? De. Ez teszi majd lehetővé, hogy a tároló típusától független algoritmusokat írhassunk.

C++11 sarok: std::begin

A begin és end tagfüggvények pici foltok a C++ teljes konzisztenciájának illúzióján. Egy std::vector<int>-nél ugyanis így kell hívni őket:

std::vector<int>::iterator iter;
for (iter = vec.begin(); iter != vec.end(); ++iter)
    std::cout << *iter << std::endl;

Egy tömbnek viszont természetesen nincsenek tagfüggvényei, annál marad a tomb és a tomb + 100 forma. Ezért a C++11 szabványba belekerült két globális függvény, az std::begin és az std::end, amik alapesetben a tagfüggvényeket hívják, de specializálva vannak tömbökre, hogy ott is az elvárásnak megfelelően működhessenek. Így már tényleg lehet a saját tárolóinkat és a tömböket teljesen azonos módon kezelni. Egy apróság rontja az összképet, az iterátor deklarációja, a következő C++11 blokkban erre is megoldást lelünk.

for (int *iter = std::begin(tomb); iter != std::end(tomb); ++iter)
    std::cout << *iter << std::endl;

C++11 sarok: auto

Mit ad vissza egy std::vector<std::string> objektum begin tagfüggvénye? Így tudjuk az iterátort deklarálni:

std::vector<std::string>::iterator it = strvec.begin();

Ez ebben a formában kifejezetten kényelmetlen. Nem tudná esetleg a fordító kitalálni, milyen típust ad vissza a begin? Dehogynem, hiszen ismeri a begin függvény deklarációját.

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. Például:

auto x = 5;         // x = int lesz

int i;
auto& j = i;        // int& j = i;

int *foo();
auto k = foo();     // int *k = foo();
auto *l = foo();    // int *l = foo();

Így már jobban néz ki a bejárás:

for (auto iter = vec.begin(); iter != vec.end(); ++iter)
    std::cout << *iter << std::endl;

C++11 sarok: range based for

Az auto kulcsszó és a globális std::begin(), std::end() függvények együtt végtelenül egyszerűvé és hatékonnyá teszik a tárolók bejárását:

for (auto iter = std::begin(tarolo), iter_end = std::end(tarolo); iter != iter_end; ++iter) {
    const auto& elem = *iter;
    std::cout << elem << std::endl;
}

Ez már-már sormintagyanús, ezért ezt a fajta bejárást egy új nyelvi elemmel könnyítették meg, a neve range-based for:

for (const auto& elem : tarolo)
    std::cout << elem << std::endl;

A fejlécben természetesen nem kötelező sem az auto, sem a (konstans) referencia használata, ki is írhatjuk, ha az olvashatóság kedvéért szeretnénk, pl. ha double-öket tartalmaz a tároló:

for (int elem : tarolo)
    std::cout << elem << std::endl;

52. Belső osztályok

Prog1-ből egy teljes listát általában egy ListaElem* típussal írtunk le, az üres lista pedig NULL volt. Ez szemantikailag zavaró lehet, sosem szerencsés egy típusra több feladatot (Lista és Listaelem) bízni. Erre már több példát láttunk (char[] avagy std::string, bool vs. int, NULL és nullptr). Ha a listánál maradunk, akkor is előfordulnak problémák:

ListaElem * Lista_beolvas(char const * fajlnev);
ListaElem * Lista_keres(ListaElem * miben, int mit);

Az első függvény egy teljes listát ad vissza, amire majd meg kell hívnunk a felszabadító függvényt. A második függvény első argumentumának szintén teljes listát vár, de a visszatérési érték nem teljes lista, és nem kell felszabadítanunk, mert a listához tartozik.

A két zöld doboz, a Lista és a ListaElem tehát szemantikailag különbözik, ezért meg kell őket különböztetni. A fenti megoldásban a ListaElem globális típus sem a legszerencsésebb. A Lista használójának nincs szüksége a ListaElem-re, egyedül a bejárásnál használjuk, viszont ott épp most készülünk iterátorral helyettesíteni. Tehát csak zavaró, hiszen miért van ott, ha nincs vele dolgunk. Ráadásul ha valaki más akar írni egy ezektől független ListaElem típust, azt a nevet már elfoglaltuk, teljesen feleslegesen. (Ezt a jelenséget namespace pollution-nek nevezzük.)

Erre találták ki a belső osztály (nested class) nyelvi elemet. Csak annyiban tud többet egy független osztálytól, hogy belelát a tartalmazó osztály (esetünkben a Lista) privát tartalmába, esetünkben látja az eleje pointert.

C++-ban egy belső osztály definiálása a külső osztály definíciójának belsejében történik:

class Lista {
private:
    struct ListaElem { // belső típus definíciója
        ListaElem * kov;
        int adat;
    };

    ListaElem * eleje; // hivatkozás a típusra

public:

    // tagfüggvények

};

Ebben az esetben a definíció az osztály privát láthatóságú tagja, tehát ha kívülről megpróbáljuk elérni, fordítási hibát kapunk. Ezt egyébként a Lista::ListaElem módon tudnánk megtenni, később erre a szintaktikára szükség lesz.

Alakítsuk át ezt a listát úgy, hogy bármilyen típust tudjon tárolni! Ez C++-ra lefordítva azt jelenti, hogy a tárolt típus legyen template paramétere. Mivel a külső template a teljes lista osztályra vonatkozik, az annak belsejében definiált ListaElem típus is automatikusan template lesz. Ezért ott is használhatjuk a TIPUS paramétert:

template<typename TIPUS>
class Lista {
private:
    struct ListaElem {
        ListaElem * kov;
        TIPUS adat;       // <- TIPUS itt is érvényes!
    };

    ListaElem * eleje;

public:

    // tagfüggvények

};

53. Egy iterátor megvalósítása

Alapok

Írjunk a listánknak iterátort! Ez ugyanúgy egy belső osztály, ahogy a ListaElem volt, de ez publikus láthatóságú. A begin és end ilyen iterátorokkal tér vissza. Emlékeztetőképp, ennek a kódnak kell működnie:

Lista<int> szamok; // valaki feltölti

Lista<int>::iterator iter;
for (iter = szamok.begin(); iter != szamok.end(); ++iter)
    std::cout << *iter << std::endl;
template<typename TIPUS>
class Lista {
private:
    struct ListaElem {
        ListaElem * kov;
        TIPUS adat;
    };

    ListaElem * eleje;

public:

    class iterator {
        // ...
    };

    iterator begin(); // a begin és end a tárolóé!
    iterator end();

    // tagfüggvények

};

A Lista iterátorának itt szüksége van a Lista privát tartalmára, hiszen a ListaElem típus is az. Ezt az iterátort arra használjuk, amire C-ben a ListaElem*-ot használtuk, tehát az iterátor műveleteinek a megvalósításához belül elegendő egy ListaElem*-ot tárolni. Valahogy így:

template<typename TIPUS>
class Lista {
private:
    ListaElem * eleje;

public:

    class iterator {

        ListaElem * elem;

    public:
        iterator(ListaElem * elem = NULL)
            : elem(elem) {
        }

        iterator& operator++() {
            if (elem != NULL)
                elem = elem->kov;
            return *this;
        }
        iterator operator++(int) {
            iterator masolat = *this; // !
            ++(*this);
            return masolat;
        }

        TIPUS& operator*() const {
            return elem->adat;
        }

        bool operator==(iterator rhs) const {
            return elem == rhs.elem;
        }
        bool operator!=(iterator rhs) const {
            return elem != rhs.elem;
        }
    };

    iterator begin() {
        return iterator(eleje);
    }

    iterator end() {
        return iterator(NULL);
    }
};

A fenti táblázatunk így egészül ki az iterátorokkal:

Tevékenység Tömb Lista Iterátor
1. Léptetjük a következő elemre ++iter iter = iter->kov ++iter
2. Elkérjük az általa mutatott elemet*iter iter->adat *iter
3. Indítjuk a tároló elejéről iter = tomb iter = lista.eleje iter = lista.begin()
4. Összehasonlítjuk a tároló végével iter != tomb + 100iter != NULL iter != lista.end()

Pre- és posztinkremens operátorok

Nézzük meg kicsit jobban az iterator pre- és posztinkremens operátorait!

iterator& operator++() {
    if (elem != NULL)
        elem = elem->kov;
    return *this;
}

iterator operator++(int) {
    iterator masolat = *this; // !
    ++(*this);
    return masolat;
}

A posztinkremens operátornál itt is új objektummal kell visszatérni, illetve a régi másolatával, éppúgy, ahogy a Tort-nél megtanultuk. Ez a tény viszont egy fontos aprósággal ismertet meg minket. Mi a különbség a kettő között?

Lista<int>::iterator iter;

for (iter = lista.begin(); iter != lista.end(); ++iter)
    // ...

for (iter = lista.begin(); iter != lista.end(); iter++)
    // ...

Ha az iterátor egy egyszerű pointer lenne, beépített típus lévén ugyanazt jelentené a kettő. Itt viszont ez függvényhívás, és a fordító nem csereberélheti a kettőt. A preinkremens operátor csak a következőre lépteti, míg a posztinkremens először lemásolja, majd lépteti, aztán visszaadja a másolatot, és a végén eldobja a másolatot. Ezért lehet a preinkremens operátor hatékonyabb, mint a posztinkremens, így ha nem használjuk fel a visszatérési értéket, érdemes / illik az előbbit használni. (Az inlining és egyéb optimalizálási trükkök után persze eléggé valószínű, hogy a kettő lefordítva ugyanaz lesz.)

Nyíl operátor

Vizsgáljuk meg egy példán keresztül az iterátoroknál használt nyíl operátort is! A feladat: összegeznünk kell egy listában tárolt összes sztring hosszát.

Lista<std::string> strvec; 
// valaki feltölti

Lista<std::string>::iterator it;
int sum = 0;
for (it = strvec.begin(); it != strvec.end(); ++it)
    sum += (*it).size();

Az iterátor (it) egy pointer szemantikájú objektum, pointerként viselkedik, ugyanúgy tudjuk használni. Ezt operator overloading-gal értük el, és egyvalami még hiányzik a listából. A (*it).size() kifejezést még leírni is fáj, jó lenne, ha a nyíl operátorral elérnénk az általa mutatott objektum tagváltozóit, tagfüggvényeit, ahogy egy sima pointernél is: it->size(). Ha egy objektumunk pointerként viselkedik, a csillag operátort overload-oljuk, akkor a konzisztencia érdekében illik a nyíl operátort is.

A nyíl operátor egy érdekes állatfaj, egyedülálló a működése a többi operátorhoz képest. Ugyanis amit mi visszaadunk az operator-> függvényből, arra a fordító ismét alkalmazza a nyíl operátort. Például az alsó két sor ekvivalens:

std::size_t s1 = it->size();
std::size_t s2 = (it.operator->()) ->size();

Tehát a nyíl operátornak egy pointert kell visszaadnia, amire a nyíl operátort még egyszer alkalmazva érhetjük el azt a tagot, amit szeretnénk, itt például std::string osztály size tagfüggvényét.

A Lista<T>::iterator osztályunkat kiegészítve ez így fog kinézni:

template<typename TIPUS>
class Lista {
private:
    // ...
    struct ListaElem {
        ListaElem * kov;
        TIPUS adat;
    };

public:
    class iterator {
        ListaElem * elem;
    public:
        TIPUS* operator->() const {
            return &(elem->adat);
        }

        // minden más marad a régiben
    };

};

Ezután ha std::string-et tárol a lista, akkor it->size() működni fog rajta. Viszont ha valaki egy Lista<int>-et példányosít, akkor a fordítási szabályok miatt egészen addig nem okoz fordítási hibát a nyíl operátor, amíg valaki nem próbálja meg használni, akkor viszont fordítási hibának kell lennie – hiszen egy int-nek nincsenek tagfüggvényei.

const_iterator

Írjunk függvényt, ami egy Lista<int> minden elemét megduplázza!

void Lista_duplaz(Lista<int>& lista) {
    Lista<int>::iterator it;
    for (it = lista.begin(); it != lista.end(); ++it)
        *it *= 2;
}

Írjunk olyan függvényt, ami minden elemet kiír a szabványos kimenetre!

void Lista_kiir(Lista<int> const& lista) {
    Lista<int>::iterator it = lista.begin(); // <- ERROR
    for ( ; it != lista.end(); ++it)
        std::cout << *it << std::endl;
}

Ez a függvény hibás. A listát konstans referencia szerint vettük át, hogy ne kelljen lemásolni, de változtatni nem akarunk rajta. A begin tagfüggvény viszont nem konstans minősítésű, ezért konstans tárolón nem lehet meghívni.

Egy iterator-t visszaadó függvény logikailag nem is lehet const, ugyanis a *it-n keresztül meg kell tudni változtatni a tároló tartalmát, ahogy ezt a Lista_duplaz-nál ki is használtuk. Jó lenne viszont, ha konstans tárolókon is lehetne iterálni és az elemeket olvasni, de a módosítást ilyenkor az iterátor nem engedné. Vagyis iterátor az operator*-jának T const&-et kell visszaadnia. Ez nem lehet ugyanaz a típus, mint az iterator. Ezért a tárolóknak szokott lenni egy const_iterator típusa is, ami csak annyiban különbözik a sima iterator-tól, hogy rajta keresztül nem változtatható meg a tároló tartalma.

template<typename TIPUS>
class Lista {
private:
    // ...

public:

    class iterator { /* ... */ };
    class const_iterator {
    public:

        TIPUS const& operator*() const { // adat;
        }

        TIPUS const* operator->() const { // adat);
        }

        // minden más ugyanolyan, mint az iterator belsejében
    };

    iterator begin() {
        return iterator(eleje);
    }

    iterator end() {
        return iterator(NULL);
    }

    const_iterator begin() const {
        return const_iterator(eleje);
    }

    const_iterator end() const {
        return const_iterator(NULL);
    }
};

Ilyenkor – az indexelő operátornál megismert szabály alapján – ha a tárolót konstansként látjuk, akkor const_iterator-t ad vissza a begin és az end, egyébként iterator-t. Erre sajnos sokszor oda kell figyelni, és C++11-ig nem lehet mindig kikerülni. A helyes függvényünk tehát:

void Lista_kiir(Lista<int> const& lista) {
    Lista<int>::const_iterator it = lista.begin(); // const_iterator!
    for ( ; it != lista.end(); ++it)
        std::cout << *it << std::endl;
}

Iterátorokkal dolgozó függvények

Tarolo_kiir 1.

Írjunk függvényt, ami egy tetszőleges típusú elemeket tartalmazó Lista<TIPUS>-t ki tud írni!

template<typename TIPUS>
void Lista_kiir(Lista<TIPUS> const& lista) {
    typename Lista<TIPUS>::const_iterator it; // typename!
    for (it = lista.begin(); it != lista.end(); ++it)
        std::cout << *it << std::endl;
}

Egyelőre fogadjuk el, hogy a jelölt sor elejére kell egy typename, később lesz magyarázat, hogy pontosan miért.

Ezt így tudjuk meghívni:

Lista<int> lista; // valaki feltölti

Lista_kiir(lista); // <- TIPUS == int

Tarolo_kiir 2.

Az iterátorokat azért találták ki, hogy egymástól teljesen eltérő tárolókat lehessen ugyanúgy kezelni. Az általánosítás előtt nézzük meg a vektort kiíró függvényt:

template<typename TIPUS>
void Vektor_kiir(std::vector<TIPUS> const& vec) {
    typename std::vector<TIPUS>::const_iterator it; // ide is typename!
    for (it = vec.begin(); it != vec.end(); ++it)
        std::cout << *it << std::endl;
}

Itt erősen érezni a sormintaszagot. Annyi történt, hogy ahol eddig Lista volt, kicseréltük std::vector-ra. Tehát a függvényben nemcsak a tárolt típust lehet absztrahálni, hanem magát a tárolót is. A függvény bármilyen TAROLO-t átvehetne:

template<typename TAROLO>
void Tarolo_kiir(TAROLO const& tar) {
    typename TAROLO::const_iterator it; // ide is kell typename!
    for (it = tar.begin(); it != tar.end(); ++it)
        std::cout << *it << std::endl;
}

Tarolo_kiir 3.

Az előző függvénynél már alig van hiányérzetünk, jól működő, kellően általános függvénynek tűnik. Okos emberek rájöttek, hogy még ezen is lehet általánosítani.

  • Ez a függvény nem működik tömbökre.

  • Bejárja a teljes tárolót, nem mondhatunk olyat, hogy pl. a harmadik elemtől kezdve írja ki.

  • Még ha osztályokról van is szó, nem tömbökről, bele van égetve, hogy

    • a begin és end tagfüggvényeket hívja,
    • és az iterator típust használja.

Egy tárolónak nemcsak egyfajta iterátora van. Konstans objektumnál const_iterator-t kellene használni, de léteznek egyéb iterátorok is: például a reverse_iterator hátrafelé járja be a tárolót. Jó lenne, ha a függvényünk ilyen esetben is működne, hiszen a különböző iterátorokat ugyanúgy kell használni.

Viszont ha a függvény nem tárolót, hanem iterátorokat venne át (méghozzá bármilyen típusú iterátorokat), egy csapásra megoldódnak a problémáink. Működni fog különböző tárolók (egyformán használandó) iterátorain, egy tároló különféle iterátorain és tömbökön is.

A bármilyen típusú iterátor azt jelenti C++-ra lefordítva, hogy a függvény template paramétere legyen az iterátor típusa.

template<typename ITER>
void Tarolo_kiir(ITER eleje, ITER vege) {
    for (ITER it = eleje; it != vege; ++it)
        std::cout << *it << ' ';
}

Ez a séma sokkal jobban használhatónak bizonyult, mint az előbbi. A szabványos könyvtár algoritmusai is ilyen paraméterezésűek, és a továbbiakban is ilyen függvényeket fogunk írni. Nézzünk meg egy példát a Tarolo_kiir használatára.

Tarolo_kiir(vec.begin(), vec.end());
Tarolo_kiir(lista.begin(), lista.end());
Tarolo_kiir(tomb, tomb + 100);

Tarolo_eleme_e

Következő feladatként írjunk függvényt, ami egy tetszőleges értékről eldönti, hogy benne van-e egy tárolóban! A keresett érték típusa legyen még egy sablonparaméter, mert az iterátorok típusából macerás lenne kiszedni, és nem is kell. Így lehet vele pl. std::string-et tartalmazó tárolóban char const*-ot keresni.

template<typename ITER, typename T>
bool Tarolo_eleme_e(ITER first, ITER last, T const& value) {
    for (ITER it = first; it != last; ++it)
        if (*it == value)
            return true;
    return false;
}

Tarolo_keres

Alakítsuk át ezt a függvényt úgy, hogy azt is megmondja, hol van a megtalált elem! Egy bool meglehetősen kevés információt tartalmaz. Viszont a return true; sorban már rendelkezésünkre áll a keresett elem helye, az épp a ciklusváltozó.

Mivel térjen vissza a függvény? A megtalált elem indexe nem jó ötlet, láncolt listában lassú az n. elemig elmászni, egy bináris fában pedig abszolút irreleváns lenne a használója számára.

Prog1-ből ilyenkor a megtalált elemre mutató pointerrel tértünk vissza, listában pl. ListaElem*-gal. Ez bármilyen tárolóra általánosítva: az iterátor! Tehát a megtalált elemre mutató iterátorral visszatérve a hívó pontosan tudja, mit jelent az a saját kollekciójában, és mit tud vele kezdeni.

Emlékezzünk vissza, mire mutat az end(): az utolsó utáni elemre, tehát nem egy létező elemre. Ezt vissza lehet adni akkor, ha nem találtuk meg az elemet, és a hívó biztosan nem fogja összekeverni egy érvényes találattal.

template<typename ITER, typename T>
ITER Tarolo_keres(ITER first, ITER last, T const& value) {
    for (ITER it = first; it != last; ++it)
        if (*it == value)
            return it;
    return last;
}

Írjuk ki ennek a függvénynek a segítségével egy std::vector<std::string>-ből a "Tagok:"-tól kezdve az összes elemet!

std::vector<std::string> strvec; // valaki feltölti

std::vector<std::string>::iterator tagok;
tagok = Tarolo_keres(strvec.begin(), strvec.end(), "Tagok:"); // keressük meg a Tagok-at
Tarolo_kiir(tagok, strvec.end()); // és írjuk ki az elemeket Tagok-tól a végéig

54. Funktorok

Predikátum, komparátor

Tarolo_legvalamilyenebb

Az előző bekezdés szemléletmódjával rengeteg nagyon gyakran használt algoritmust lehet előre megírni, hogy a használat helyén ne éktelenkedjenek ciklusok. Ettől jóval olvashatóbb lesz a kód, a megfelelően elnevezett függvények sokat segítenek.

Vannak olyan feladatok, amik csak további általánosítással szervezhetők ki külön függvénybe. Mi a közös az alábbiakban?

  • Keressük meg egy tárolóban a leghosszabb sztringet!
  • Melyik az ábécében legelső szó a tárolóban?
  • Határozzuk meg, melyik tárolóbeli szám van legközelebb a pi-hez!

Mindegyik egy szélsőértékkeresést sugall, ami meghatározza a tárolóban a legvalamilyenebb elemet. A "valamilyen"-t pedig jó lenne paraméterként átadni, hiszen az algoritmusnak ez nem szerves része, ha nem ismeri, akkor is tud működni.

A "valamilyen" milyen típus? Annyit tudunk róla, hogy meg tudja mondani két azonos típusú micsodáról, hogy melyik a valamilyenebb. Tehát valami, ami igazzal vagy hamissal tér vissza, és két T-t vár paraméternek. C-ben ezt függvénypointerrel oldottuk meg, első megoldásunk ezt használja:

template <typename ITER, typename T>
ITER Tarolo_legvalamilyenebb(ITER eleje, ITER vege, bool (*valamilyenebb)(T, T)) {
    if (eleje == vege)
        return vege;

    ITER leg = eleje;
    ITER i = eleje;
    ++i;
    while (i != vege) {
        if (valamilyenebb(*i, *leg))
            leg = i;
        ++i;
    }
    return leg;
}

Az eleje == vege feltétel magyarázatot érdemel. Ha a két iterátor megegyezik, akkor üres tartományt kaptunk, amiben nincs egy elem sem (hiszen a vege az utolsó utáni elemre mutat, tehát eleje is érvénytelen). Az eddigi algoritmusainknál nem kellett erre külön figyelni, hiszen ilyenkor a for ciklus magja egyszer sem futott le, a return vege; adta vissza a megfelelő iterátort. Könnyű átgondolni, hogy ez az algoritmus ennek hiányában elszállna a ciklus előtti ++i sornál.

A fenti három feladatot például így lehet megoldani a Tarolo_legvalamilyenebb felhasználásával:

bool hosszabb(std::string const& a, std::string const& b) {
    return a.size() > b.size();
}

bool abece_elorebb(std::string const& a, std::string const& b) {
    return a < b;
}

bool pihez_kozelebb(double a, double b) {
    return fabs(a - M_PI) < fabs(b - M_PI);
}

std::vector<std::string> strvec; // valaki feltölti

std::cout << *Tarolo_legvalamilyenebb(
    strvec.begin(), strvec.end(), hosszabb) << std::endl;
std::cout << *Tarolo_legvalamilyenebb(
    strvec.begin(), strvec.end(), abece_elorebb) << std::endl;

double tomb[100] = {...};

std::cout << *Tarolo_legvalamilyenebb(
    tomb, tomb+100, pihez_kozelebb) << std::endl;

Az első két függvénynél a fordító T-re std::string const&-et vezet le, a harmadiknál pedig double-t. Nem okoz gondot, hogy az egyik konstans referencia, a másik pedig érték.

Nézzük meg a pihez_kozelebb függvényt! Ebbe bele van égetve, hogy a pi-hez legközelebbi számot keressük. Jó lenne, ha ez is paraméterezhető lenne, és nem kéne az 5-höz közelebb kereséséhez külön függvényt írni.

A globális változókkal való megadás gányolás lenne, valami C++-osabb megoldást kéne keresni. A valamilyenebb paraméternek tehát más, általánosabb típus kell. Hogyan használja az algoritmus a valamilyenebb paramétert, mit kell tudnia a paraméternek?

Az algoritmus átveszi a valamilyenebb-et, és egyetlen dolgot művel vele: meghívja, mint egy függvényt. Mi más lehetne ez, mint egy függvény? A jegyzetben már volt szó a megoldás kulcsáról. A C++ megengedi, hogy az objektumok függvényhívás operátorát overload-oljuk. Legyen valamilyenebb egy objektum, aminek a függvényhívás operátora át tud venni két valamit, és visszaad egy igaz/hamis értéket. Az objektum tagváltozója pedig tárolja, hogy mihez közelebbi számot keresünk.

class valamihez_kozelebb {
private:
    double mihez;
public:
    valamihez_kozelebb(double mihez)
        : mihez(mihez) {
    }
    bool operator() (double a, double b) const {
        return fabs(a-mihez) < fabs(b-mihez);
    }
};

Ehhez a Tarolo_legvalamilyenebb függvényt is át kell írnunk. Nemcsak függvénypointert kell tudnia átvenni, hanem bármit, aminek van megfelelő függvényhívás operátora. Ez lehet egy objektum vagy egy függvénypointer is.

template <typename ITER, typename MILYENEBB>
ITER Tarolo_legvalamilyenebb(ITER eleje, ITER vege, MILYENEBB valamilyenebb) {
    // ...
    for (/*...*/) {
        if (valamilyenebb(*i, *leg)) // <- függvényhívás operátor!
            // ...
    }
    // ...
}

Az ilyen MILYENEBB típusokat hívjuk tág értelemben predikátumnak, ami megmondja valamiről vagy valamikről, hogy milyen. A predikátumok speciális fajtája például a komparátor, ami két valamiről mondja meg, hogy egymáshoz képest milyenek, ahogy a valamihez_kozelebb is.

Tarolo_szamol

Feladat: Számoljuk meg, hogy egy double tömbben hány 5-nél kisebb szám van!

Ez a feladat másfajta predikátumot igényel, olyat, ami egyetlen számról tudja megmondani, hogy olyan-e (5-nél kisebb-e). Az ilyet unáris predikátumnak (unáris == egyoperandusú) hívjuk. Például ez egy unáris predikátum:

bool otnel_kisebb(double d) {
    return d < 5;
}

A feladat tehát arra redukálódott, hogy írjunk függvényt, ami megszámolja, hogy egy tárolóban hány valamilyen elem van. Aztán ezt fel lehet paraméterezni az otnel_kisebb függvénnyel.

template <typename ITER, typename PRED>
std::size_t Tarolo_szamol(ITER eleje, ITER vege, PRED pred) {
    std::size_t db = 0;
    for (ITER i = eleje; i != vege; ++i) {
        if (pred(*i))
            ++db;
    }
    return db;
}

std::size_t darab = Tarolo_szamol(tomb, tomb+100, otnel_kisebb);

55. STL algoritmusok és iterátorok

Generikus STL algoritmusok

A fent bemutatott algoritmusokat, és még sok hasonló algoritmust nagyon sokszor újra lehet használni. Ezért a C++ STL része az <algorithm> fejlécfájl, ami ilyen algoritmusokat tartalmaz.

Az előző fejezetben a kiíró függvénynek négy verzióját mutattuk meg. Ezek közül a legutóbbi bizonyult a legjobban használhatónak, ami két tetszőleges iterátort vesz át érték szerint. Ezért az STL algoritmusok is mind ilyenek.

A fejezet példafüggvényei nem hasra ütés alapján kerültek be, mindegyik megtalálható az STL-ben is, más néven. Sokuknak két, alig különböző verziója van. Az egyik T const&-et vesz át (ha kell), a másik predikátumot.

  • A Tarolo_keres-nek az STL-ben a find felel meg. Keresett értéket átvevő: std::find, predikátumot átvevő: std::find_if
  • A szélsőértékkereső Tarolo_legvalamilyenebb: std::min_element, std::max_element
  • A megszámláló Tarolo_szamol is elérhető értéket átvevő: std::count és predikátumot átvevő: std::count_if változatban.

Ezeket név szerint felesleges megtanulni, viszont ha kódolás során egy hasonlóan általános algoritmus szagát érezzük, érdemes megnézni, valaki megoldotta-e már. Erre a célra a cppreference.com-ot és a cplusplus.com-ot érdemes használni. Előbbi általában egy egyszerű implementációt is mellékel a leírás mellé, algoritmusoknál ez sokat segíthet a megértésben.

STL tárolók iterátorai

A Lista osztályunkhoz hasonló az STL-ben található std::list osztály (<list> fejlécfájl). A fő különbség, hogy az std::list duplán láncolt, és valamivel okosabb a mi listánknál.

A korábban említett std::vector-nak is vannak iterátorai. Hogy mennyivel tud többet egy std::vector iterátora, mint az std::list-é, a következő bekezdésben kiderül. Érdekes módon egyébként az std::string-nek is vannak iterátorai. Úgy érdemes elképzelni, mint egy karaktereket tároló std::vector, kicsit kibővített funkcionalitással. A string egyébként maga is egy template, a generikus osztály neve std::basic_string, és az std::string tulajdonképpen:

typedef basic_string<char> string;

Az iterátorokat eredetileg – ahogy a neve is mutatja – tárolók bejárására találták ki. Azonban ennél sokkal hasznosabbnak bizonyultak, a tárolók bizonyos műveleteit is iterátorokon keresztül érjük el.

erase

Feladat: írjunk – gondolatban – egy olyan tagfüggvényt az std::list-nek, ami egy elemet tud törölni belőle!

Mit vegyen át ez a függvény, mi alapján töröljön? Első gondolatunk lehetne az, hogy adjuk át neki az elemet, és abban keressen == operátorral. Ez általában azonban nem jó megoldás, ugyanis egy listában lehetnek azonos elemek, és így közülük mindig csak az elsőt tudnánk törölni.

Ha a törlendő elem indexét veszi át, az ugyanúgy durván hatékonytalan, mint az előző megoldás. Először meg kell határozni a törlendő elem indexét valahogy, ez általában O(n) komplexitású, majd a törlésnél még egyszer el kell menni a listában addig, ez szintén O(n). Egy duplán láncolt listában viszont a törlés maga O(1) is lehetne, hiszen csak néhány pointer átállítgatásáról van szó.

Ötlet: Adjunk át a törlendő elemre mutató pointert! Ha van az elemre pointerünk, onnan könnyen elérjük a szomszédos elemeket és ki tudjuk fűzni a listából. A pointer általánosítva: iterátor!

insert

Az STL tárolók törlő függvénye (erase) ezért mindig iterátort vesz át, ahogy a beszúró függvény is:

std::list<int> lista;
lista.insert(lista.begin(), 42);

Az insert a pozícióként megadott iterátor elé szúrja be az új elemet. Ha belegondolunk, ennek így kell lennie: ha a begin() elé kerül, akkor az lesz az első elem. Az end() az utolsó utáni elem, tehát ha az utolsó utáni elé szúrunk be, az lesz az utolsó.

Invalidálás

Mi történik, ha ezt a kódot futtatjuk?

std::list<int> lista; 
// valaki feltölti
lista.insert(lista.begin(), 42);

std::list<int>::iterator it = std::find(lista.begin(), lista.end(), 42);
std::cout << *it << std::endl;

lista.erase(it);
std::cout << *it << std::endl; // <- baj van

Ha listából kitöröltük a megtalált elemet – lista.erase(it) –, utána it hova mutat? Sehova, azt az elemet, amire mutatott, épp akkor töröltük. Mivel az erase érték szerint veszi át az iterátort – konvenció szerint mindig érték szerint kezeljük –, az erase nem is tudta megváltoztatni it-t, tehát a jelzett sor hibás!

Amikor az iterátor egy ehhez hasonló művelet hatására érvénytelenné válik, invalidálódik, tehát utána nem használhatjuk. Pontosan ugyanaz történik, mint pointereknél: ha felszabadítjuk a pointer által mutatott területet, a pointert sem használhatjuk (dangling pointer).

Szerencsére a C++ szabvány pontosan leírja, mikor invalidálódnak az iterátorok. Józan ésszel nagyjából ki lehet találni, mit szabad és mit nem: például egy std::list-be való beszúráskor a régi iterátorok megmaradnak, de std::vector-ba beszúrásnál nem feltétlenül. Ha bizonytalanok vagyunk, keressünk rá.

Feladat: egy std::list-ből töröljük az összes 42 értékű elemet!

Nem tűnik könnyűnek a feladat, pedig érezni lehet, hogy ez gyakori probléma. Ha találtunk egy 42-t, és töröljük, a ciklusváltozónk invalidálódik, és nem tudjuk, hol tartottunk, hiszen egy érvénytelen iterátoron az operator++ sem használható. Két lehetőségünk van:

  • Kihasználuk azt, hogy listából törlésnél csak a törlendő elemre mutató iterátor invalidálódik, a többi nem. Elmentjük pl. a következő elemre mutató iterátort, majd töröljük az előzőt.
  • Szerencsére az STL megírásakor erre is gondoltak. Az erase visszaad egy valid iterátort, ami a törölt elem utáni elemre mutat.

A második megoldás jóval általánosabb és egyszerűbben használható, nézzük meg azt kódban!

std::list<int> lista; // valaki feltölti

std::list<int>::iterator it = lista.begin();
while (it != lista.end()) {
    if (*it == 42)
        it = lista.erase(it);
    else
        ++it;
}

Ha az elem törlendő, az erase a következő elemre mutató iterátort adja vissza, ezért csak akkor kell léptetni, ha nem kellett törölni.

56. Egyéb részletek

Bind

Feladat:

  • Számoljuk meg egy std::vector<std::string>-ben az ábécé szerint a "predikátum" után lévő sztringeket!
  • Számoljuk meg egy std::vector<double>-ben azokat a számokat, amik abszolút értékben kisebbek pi-nél!

Ezt a két feladatot naivan meg lehetne úgy oldani, hogy írunk két függvényt vagy predikátumot, amik visszaadják, hogy egy sztring a "predikátum" után van-e, illetve egy szám abszolút értékben kisebb-e, mint pi.

Éles szeműek észrevehetik, hogy nagyjából ugyanazokat a segédfüggvényeket kell megírni, mint az első iterátoros feladatokban, csak most kettő helyett egy paraméterrel. Sőt, akár vissza is vezethetnénk az előző függvényekre, valahogy így:

bool abece_predikatum_utan(std::string const& str) {
    return abece_elorebb("predikátum", str);
}

bool abs_kisebb_pi_nel(double szam) {
    valamihez_kozelebb vk(M_PI);
    return vk(szam, 0.0);
}

std::size_t s1 = Tarolo_szamol(strvec.begin(), strvec.end(), abece_predikatum_utan);
std::size_t s1 = Tarolo_szamol(szamok.begin(), szamok.end(), abs_kisebb_pi_nel);

A rossz hír, hogy a feladat ismét mechanikusnak tűnik, érezzük, hogy lehetne automatizálni. Pontosan mi közük van egymáshoz a régi és az új függvényeknek? Az újak fognak egy bináris predikátumot (komparátor), és rögzítik az egyik paraméterét egy fix értékre. A kapott pararamétert csak továbbítják a komparátor másik paraméterének. Ezt a műveletet nevezzük angolul bind-nak.

A jó hír pedig: ezt nem kell minden egyes ilyen szituációban megcsinálni, van rá egyszerűbb megoldás. Képzeljünk el egy objektumot, aminek tagváltozója egy bármilyen komparátor, és egy bármilyen "fix érték". Ezen kívül van egy egyparaméteres függvényhívás operátora, ami pontosan azt csinálja, amit elvárunk tőle.

C++11 sarok: std::bind

C++98-ban a rögzítésre csak igen korlátozott és kényelmetlen lehetőségeink vannak: std::bind1st és std::bind2nd. Ez a két függvény C++11 óta elavultnak számít, kivezetik a szabványból. Úgyhogy kivételesen nem is ismertetjük őket, csak a C++11-es, modernebb változatukat. Ez pedig az std::bind függvény.

Ez első paramétereként a manipulálandó függvényt kapja. Többi paramétereként pedig annyi értéket, amennyit az eredeti függvény várt. Vagy értékek helyett az std::placeholders::_1, _2... helyőrző szimbólumok valamelyikét, amelyek a megtartandó paramétereket helyettesítik. A sorrend a manipulált függvény paramétersorrendjével kell megegyezzen. A keletkező függvény annyi paraméterű lesz, ahány helyőrzőt használtunk; a többi paraméter a megadott konstansokkal lesz helyettesítve.

using namespace std::placeholders;
std::size_t s1 = Tarolo_szamol(strvec.begin(), strvec.end(),
    std::bind(&abece_elorebb, "predikátum", _1));
std::size_t s2 = Tarolo_szamol(szamok.begin(), szamok.end(),
    std::bind(valamihez_kozelebb(M_PI), _1, 0.0));

A visszatérési értékének típusa unspecified, tehát valami implementációtól függő típus. Ha mi mégis el szeretnénk tárolni, jól jön az auto.

Ezzel akár három paraméteres függvényből is csinálhatunk egyparamétereset, vagy meg is cserélhetjük egy függvény két paraméterének a sorrendjét.

bool f(int a, int b, int c) {
    return a + b <= c;
}

Tarolo_szamol(szamok.begin(), szamok.end(), std::bind(f, 0, _1, 4));
auto rovidebb = std::bind(hosszabb, _2, _1);

A typename kulcsszó

A fejezetben szerepelt néhány függvény, ahol kellett néhány "felesleges" typename kulcsszó, amiknek a magyarázatát későbbre ígértük. Még az iterátoros függvényeknél is ritkán van rá szükség, de ha mégis, csúnya hibaüzeneteket kapunk a fordítótól. A magyarázat ideje most jött el. Az egyik ilyen függvény ez volt:

template<typename TAROLO>
void Tarolo_kiir(TAROLO tar) {
    typename TAROLO::const_iterator it; // <- !
    for (it = tar.begin(); it != tar.end(); ++it)
        std::cout < *it < std::endl;
}

Ez a kód meglepő módon nem fordul typename nélkül. Hogy megértsük, miért, nézzünk meg két fejtörőt. Deklaráljuk A-t illetve B-t úgy, hogy az alábbi kód szintaktikailag helyes legyen!

int main() {
    // deklarációk
    A * B;
}

Itt kétfajta lehetséges megoldás van. Egy triviális megoldás például ez:

class A {};
A * B;

Ilyenkor a B egy olyan pointer, amelyik A típusú objektumra mutat. A másik lehetőség tanulságosabb:

int A, B;
A * B;

Ilyenkor a kódrészlet egy szorzást jelent.

Ebből egy fontos következtetést tudunk levonni. A fordítónak ismernie kell a deklarált típusokat, változókat, hogy el tudja dönteni egyetlen utasításról, hogy szintaktikailag helyes-e. Sőt, a deklarációk ismerete nélkül azt sem lehetne eldönteni, hogy az deklaráció, vagy utasítás! Ez a template kódoknál nagyobb problémát okoz. A következő példánkban lényegében ugyanez a feladat, picit összetettebb verzióban.

// deklarációk
int main() {
    A::B * C;
}

Itt A-tól függően B-re két lehetőség adódik.

  • B lehet egy belső típus, mint például a Lista osztálynál az iterator.
  • B azonban lehet A-nak egy statikus tagváltozója is. Ebben az esetben a kérdéses sor nem a C pointer deklarációja, hanem megint egy szorzás.

A template kódok fordításának ismeretében (előző fejezet) már könnyebben megérthető, mi is a baj ezzel a sorral:

TAROLO::const_iterator it = lista.begin(); // <- ERROR!

A fordító, amikor először találkozik a Lista_kiir függvénnyel, csak szintaktikai ellenőrzést végez, (zárójelek, pontosvesszők, deklarációk, stb.) még konkrét példányosítás nélkül. Tehát TAROLO még ismeretlen, akármi lehet majd. Tehát azt sem tudhatja, hogy a const_iterator neki belső típusa vagy statikus adattagja-e, mert a :: operátor mindkettőre utalhat.

Az ilyen, kérdéses esetekben a fordító azt feltételezi, hogy statikus tagról van szó, és nekünk kell jeleznünk a typename kulcsszóval, ha nested type. Ha elfelejtjük, példányosításkor úgyis kiderül.

reverse_iterator

A const_iterator-ról már esett szó, konstansként látott tárolók bejárásához a "sima" iterator-t nem használhatjuk, mert rajta keresztül meg lehetne változtatni a tárolóban tárolt elemet.

A reverse_iterator annyiban különbözik az iterator-tól, hogy a másik irányban járja be a tárolót. A "hátulról első", azaz utolsó elemre mutató iterátort a tároló rbegin tagfüggvénye adja vissza, a "hátulról utolsó utánit", azaz az első előttit pedig a rend. Ettől eltekintve pontosan ugyanúgy lehet használni.

std::list<int> lista; // valaki feltölti

std::list<int>::iterator it;
for (it = lista.begin(); it != lista.end(); ++it) { // egyik irányban
    std::cout << *it << std::endl;
}

std::list<int>::reverse_iterator rit;
for (rit = lista.rbegin(); rit != lista.rend(); ++rit) { // visszafelé
    std::cout << *rit << std::endl;
}

Figyeljük meg, hogy a reverse_iterator előre léptető operátora, az operator++ a tárolón valójában hátrafelé léptet. Ennek így kell lennie, hogy az iterator-t és a reverse_iterator-t egyformán lehessen használni, például egy STL algoritmusban. Természetesen konstans tárolót is bejárhatunk visszafelé, erre való az ijesztő nevű const_reverse_iterator típus.

Pointeraritmetika?

Tömb elemére mutató pointernél lehetőségünk van aritmetikára. Ha p egy tömb elemére mutat, akkor p+20 a hússzal arrébb lévő elemre.

Számunkra nyilvánvaló, hogy egy láncolt listával ezt nem tudjuk megtenni, a tömb ennyivel "okosabb" a memóriában való elhelyezkedés miatt. De mi a helyzet ezzel az std::vector és az std::list iterátorai esetén? Egyáltalán, van ezeken kívül még többet / kevesebbet "tudó" iterátor?

Az iterátorokat csoportosíthatjuk a rajtuk elvégezhető műveletek alapján, mit lehet velük tenni és mit nem. Ezt a felépítést a "legbutább" iterátorokkal kezdjük, és megnézzük, hogy ahhoz képest mit tudnak az "okosabbak". A leírás nem pontos, leegyszerűsítettük a könnyebb megértés érdekében.

  • Input iterator: kiolvashatjuk a mutatott elem értékét. A Lista iterátora és const_iterator is ilyen.
  • Output iterator: írhatjuk a mutatott elemet. A fenti iterator ilyen, de a const_iterator nem. (Vigyázat, van olyan iterátor, ami output, de nem input! A jegyzetben később lesz is szó ilyenről.) Az olvasást és írást is a * és -> operátorok biztosítják.
  • Forward iterator: olyan iterator, amit előre léptethetünk, és többször is bejárhatjuk vele a tárolót. Például az egyszeresen láncolt Lista osztályunk iterátora. Léptetni a ++ operátorokkal tudjuk.
  • Bidirectional iterator: előre és hátra is léptethető iterátor, például az std::list iterátora. A hátrafelé léptetésre a -- operátorok valók.
  • Random access iterator: "távoli", nem szomszédos elemek is O(1) komplexitással elérhetőek, például az std::vector iterátora. Az iterátorhoz egészeket (int) adhatunk hozzá és vonhatunk ki, pont úgy, ahogy egy pointerrel tennénk.

Az STL algoritmusok ezeket ki is használják, és le van dokumentálva, hogy melyik algoritmus milyen iterátort vár. Például a rendezés, std::sort garantáltan O(n×log n) komplexitású, ennek megfelelően jellemzően quicksort-ot (gyorsrendezést) végez. Ahhoz viszont random access iterator kell, egy láncolt listával nem működik. Az std::count viszont beéri egy forward iterator-ral is, hiszen csak végigmegy az elemeken, nem ugrál összevissza.

Ha rossz, "túl buta" iterátort próbálunk meg átadni egy ilyen algoritmusnak, például std::sort-nak lista-iterátort, a fordító a kezünkre csap, hiszen használni próbálja a random access iterator extra tulajdonságait.

Az iterátorok típusai közötti viszony programozásban gyakran előforduló kapcsolat, az öröklésről szóló fejezeben visszatérünk rá.

Öröklés, heterogén kollekció

58. Az öröklés fogalma

Példafeladat

Feladat: modellezzünk alakzatokat! Legyen a modellben téglalap és kör, és ezeket lehessen egy közös tárolóba tenni! Minden alakzatot lehessen kirajzolni, a saját színével! A tároló kirajzolása rajzolja ki az összes alakzatot, a megfelelő sorrendben! Készüljünk fel más típusú alakzatok tárolására is!

Az öröklés témakörét elsősorban ezen a feladaton keresztül fogjuk bemutatni. Ez a reláció a programozásban gyakran előfordul, és rengeteg modellezési példát old meg. Nézzük meg alaposan a feladat kritériumait, amik rávezetnek minket a kapcsolatok elemeire!

  1. Minden alakzatnak van színe.
  2. Minden alakzatot ki lehet rajzolni, de a különböző típusú alakzatok kirajzolása mást jelent.
  3. Az alakzatok pozícióját nem érdemes egységesen tárolni. Körnél a középpont tárolása tűnik a legalkalmasabbnak, téglalapnál a bal felső sarok. Ha esetleg lesz általános sokszög, annál ezek egyike sem. Egyvalami tűnik biztosnak, hogy minden alakzatnak van egy referenciapontja, amihez képest nyilvántarthatjuk a kiterjedését.
  4. A méretek nyilvánvalóan egyediek a különböző típusú alakzatoknál. Kört a sugár vagy átmérő, téglalapot a két oldalhossz jellemez legjobban.
  5. Az alakzatok közös kirajzolásánál a sorrend az átfedések miatt fontos. Nem mindegy, hogy előbb a kék kört, és utána a piros téglalapot rajzoljuk ki, vagy fordítva, mert a második takarná az elsőt.

Az első fontos következtetésünk, hogy lesznek olyan tulajdonságok és műveletek, amik minden alakzatra ugyanúgy működnek, és lesznek csak bizonyos típusú alakzatokra jellemzőek is. A közös részeket érdemes az egyes alakzatoktól külön kezelni, hogy elkerüljük a kódduplikációt.

Legyen tehát egy típus a közös részeknek (Alakzat), amit majd valamilyen módon felhasznál a Kor és a Teglalap. A közös részeken kívül a téglalapnak és a körnek semmi köze egymáshoz.

class Alakzat {
    uint32_t szin;
public:
    // rajzolás?
};

A szín típusának uint32_t-t választottuk, mert nagyon egyszerűen kezelhető, ugyanakkor a Prog1-ből ismerős SDL rajzolófüggvényei is ezt használják, 0xRRGGBBAA formátumban.

Micsoda?

Mi köze a Kor és Teglalap típusoknak az Alakzat-hoz?

Minden téglalap egy alakzat, és minden kör egy alakzat. Amit meg lehet csinálni egy alakzattal, azt egy körrel is. Kódban: ahova Alakzat típus kell, ott meg kéne tudjunk adni egy kört vagy egy téglalapot is. Azt szeretnénk, ha ilyen kódot lehetne írni:

void alakzat_feldolgoz(Alakzat const& a) {
    a.rajzol();
    std::cout << "az alakzat területe: " << a.terulet();
}

Kor k1(/* ... */);
alakzat_feldolgoz(k1);

Teglalap t1(/* ... */);
alakzat_feldolgoz(t1);

A függvény paraméterként alakzatot vár. Ha a kör az alakzatok egy fajtája, akkor jó kell legyen a kör típusú paraméter is. Ugyanez a helyzet a téglalappal. Általánosságban ez egy „minden micsoda micsoda” jellegű kapcsolat. Angolul úgy mondják, hogy is-a, azaz Teglalap is an Alakzat. A programozásban ezt leszármazásnak vagy öröklésnek nevezzük.

A típusok közötti kapcsolat pontos definícióját pedig a Liskov-féle elv adja meg (LSP, Liskov Substitution Principle), ami a következőt mondja ki: akkor mondhatjuk, hogy B osztály az A osztály leszármazottja, ha bárhol használhatunk B típusú objektumot, ahol használhattunk A típusú objektumot is.

A fenti példában ez teljesül: a függvény alakzatot vár, ezért kaphat kört is és téglalapot is. Persze igaznak kell lennie, hogy a kör is kirajzolható és a téglalap is, illetve hogy a körnek is kiszámolható a területe és a téglalapnak is. Vegyük észre, hogy ha minden téglalap egy alakzat, akkor a téglalap meg kell örökölje az alakzat attribútumait is. Ha az alakzatoknak van színe, akkor a téglalapoknak is kell legyen. Ezért szokták ezt a kapcsolatot jellemezni az öröklés szóval.

Az osztályok közötti leszármazás jellegű kapcsolatra a C++ jelölés ez:

class Teglalap : public Alakzat {
    // ...
};

Mivel a fordítónak ezzel egyértelműen jelezzük, hogy minden téglalap alakzat, ezért megengedi nekünk, hogy átadhassuk a Teglalap-ot Alakzat-ként. Ez technikailag úgy jelenik meg, hogy a leszármazott objektum referenciája (és pointere) automatikusan tud konvertálódni ősosztály referenciájává (és pointerévé). Tehát a Teglalap& → Alakzat&, illetve Teglalap* → Alakzat* konverziók helyesek, ezért implicit módon is elvégezhetők.

Fontos megjegyezni, hogy ez a konverzió csak akkor működik az elvárt módon, ha pointerek vagy referenciák között történik, érték szerinti átadásnál nem. A fenti alakzat_feldolgoz() függvény paramétere nem lehet Alakzat, csak Alakzat& vagy Alakzat*, különben a kód nem fog helyesen működni. Ennek okáról lesz szó a fejezet későbbi részében.

Az öröklési viszonyt a következő fogalmakkal is szokták jellemezni:

  • A Teglalap osztály leszármazik az Alakzat-ból, tehát az Alakzat leszármazottja a Teglalap.
  • A Teglalap ősosztálya az Alakzat.
  • Angolul az ősosztály base class, a leszármazott pedig derived class.
  • A Teglalap egy speciális Alakzat, tehát a Teglalap az Alakzat specializációja.
  • A Teglalap és a Kor általánosítása az Alakzat.
  • Elterjedten használt a típus – altípus terminológia is (type – substype).
  • Több osztály ős-leszármazott viszonyát nevezhetjük hierarchiának.

Kompatibilitás

Feladat: írjunk függvényt, ami egy alakzat színét invertálja! Az Alakzat osztályokhoz ezúttal nem nyúlhatunk.

Jó lenne, ha nem kellene minden alakzattípusra egyesével megírni. Elvégre minden téglalap alakzat, tehát ahogy egy alakzatot lehet invertálni, úgy egy téglalapot is lehet.

void invertal(Alakzat& alakzat) {
    uint32_t regiszin = alakzat.get_szin();
    uint32_t ujszin = szin_invertal(regiszin); // szin_invertal-t valaki megírta
    alakzat.set_szin(ujszin);
}
Teglalap t1(/* ... */);
invertal(t1);

Ez működni fog téglalapokra is, mivel az előbb megmondtuk a fordítónak, hogy a Teglalap leszármazik az Alakzat-ból, az LSP értelmében. Ezért ahol a kódban Alakzat-ot várunk, oda adhatunk paraméternek Teglalap-ot is, ezt a tulajdonságot nevezzük kompatibilitásnak.

A szín és a referenciapont kezelését meg tudtuk oldani az ősosztályban, mert azt minden alakzatra ugyanúgy kell. A fenti alakzat_feldolgoz függvény viszont még mindig nem tud működni, az Alakzat-nak nicsenek rajzol és terulet függvényei.

Az invertáló függvénynek tehát át tudunk adni mindenféle alakzatot, de hogyan fog működni az alakzat_feldolgoz? Valahogy azt is meg kell majd oldani, hogy az alakzatokon a rajzol és a terulet is az elvárt módon működjön, tehát ha Kor objektumot adtunk át neki, akkor kört rajzoljon, ha Teglalap-ot kapott, akkor téglalapot rajzoljon.

59. Az öröklés elemei C++-ban

Eddig is tudtuk:

Még mielőtt rátérnénk az új elemekre, nézzük meg, az eddig bemutatott nyelvi elemekkel mit tudunk megoldani, és mit nem!

Az alakzatok pontjait az egyszerűség kedvéért ebben a primitív struktúrában tároljuk:

struct Pont {
    int x;
    int y;

    Pont(int x, int y)
        : x(x)
        , y(y) {
    }
};

A kör pozícióját és méretét egyértelműen a középpont és a sugár / átmérő jellemzi legjobban, mi a sugár mellett döntöttünk. A téglalapnál ez sokkal kevésbé triviális, még akkor is, ha megkötjük, hogy a téglalapok oldalai párhuzamosak a koordináta-tengelyekkel. Legalább három lehetőségünk adódik:

  • középpont, a és b oldal hossza
  • bal felső sarok, a és b oldal hossza
  • bal felső és jobb alsó sarkok

Bármelyik megoldást is választjuk, a téglalap minden adata matematikailag kiszámítható a meglévő adatokból. Így ez a tervezési döntés a lényeg szempontjából irreleváns, ezért mi önkényesen a másodikat választjuk.

class Alakzat {
    Pont referenciapont;
    uint32_t szin;
public:
    Alakzat(Pont referenciapont, uint32_t szin)
        : referenciapont(referenciapont)
        , szin(szin) {
    }
    void mozgat(Pont mennyivel) {
        referenciapont.x += mennyivel.x;
        referenciapont.y += mennyivel.y;
    }
    uint32_t get_szin() const {
        return szin;
    }
    void set_szin(uint32_t szin) {
        this->szin = szin;
    }
    // rajzolás?
    // terület?
};

class Teglalap : public Alakzat {
    int szeles, magas;
public:
    void rajzol() {
        SDL_valami_teglalap(
            referenciapont.x,          referenciapont.y,
            referenciapont.x + szeles, referenciapont.y + magas,
            szin);
    }
    double terulet() {
        return szeles * magas;
    }
};

class Kor : public Alakzat {
    Pont kozeppont;
    int sugar;
public:
    void rajzol() {
        SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
    }
    double terulet() {
        return sugar * sugar * M_PI;
    }
};

A protected kulcsszó

Nézzük meg alaposan a Kor::rajzol-t!

void Kor::rajzol() {
    SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}

A fordító ebben a függvényben a kezünkre csap. A referenciapont és szin adattagokat a Kor az Alakzat-tól örökölte, ott viszont private elérésűnek nyilvánítottuk őket. Privát adattagokat viszont a tartalmazó osztályon kívülről nem érhetjük el, még a leszármazottak sem érhetik el.

Ebben a konkrét esetben a leszármazottak használhatnák a publikus get_szin függvényt is, viszont sokszor nem tehetjük publikussá azt, amire a leszármazottaknak szüksége van, ezért nézzünk rá más megoldást!

Ha egy adattagot vagy függvényt a leszármazottak számára is elérhetővé szeretnénk tenni, de a nyilvánosság számára nem, használhatjuk a protected módosítót. Ugyanaz a szintaktikája, mint a public-nak és a private-nak.

class Alakzat {
protected:
    Pont referenciapont;
    uint32_t szin;
public:
    // ...
};

Így a leszármazottak már közvetlenül elérik a referenciapont-ot és a szin-t. A láthatósági szintek összefoglalva:

publicprotectedprivate
Saját tagfüggvények
friend függvények
friend osztályok tagfüggvényei
Leszármazottak tagfüggvényei
Többi függvény

Fordító által generált konstruktorok és destruktor

A fordító által generált default konstruktor, mint megtanultuk, az adattagok default konstruktorát hívja az adattagok definiálásának sorrendjében. A leszármazás esetén ez kiegészül az ősosztály konstruktorának meghívásával. Ugyanis az ősosztálynak is vannak adattagjai, azokat is létre kell hozni. Például egy téglalap létrehozása esetén az objektumnak lesz szín adattagja is, annak is kell értéket adni. Ezért ha van ősosztály, akkor még a leszármazott adattagjai konstruktorainak hívása előtt hívja meg az ős konstruktorát. Sorrendben:

  1. Ős konstruktorának hívása, inicializáló lista alapján
  2. Adattagok konstruktorainak hívása, sorrendben, inicializáló lista alapján
  3. Konstruktor törzsében lévő utasítások

A leszármazottak konstruktora miatt a fordító még mindig a kezünkre csap. Az Alakzat-nak csak olyan konstruktora van, ami egy színt vár paraméternek. azonban mivel mi a Teglalap konstruktoránál nem adtuk meg, az ős melyik konstruktorát hívja a leszármazott, a fordító alapból a default-ot keresi, olyan pedig nincs. Ha meg akarjuk adni, hogy az ősosztály melyik konstruktora hívódjon, azt az inicializáló lista elején tehetjük meg.

class Teglalap : public Alakzat {
    int a;
    int b;
public:
    Teglalap(Pont balfelso, int szelesseg, int magassag, uint32_t szin)
        : Alakzat(balfelso, szin) // <- !
        , a(szelesseg)
        , b(magassag) {
    }
    // ...
};

class Kor : public Alakzat {
    int sugar;
public:
    Kor(Pont kozeppont, int sugar, uint32_t szin)
        : Alakzat(kozeppont, szin) // <- !
        , sugar(sugar) {
    }
    // ...
};

A destruktor pontosan ugyanezeket csinálja, csak fordítva:

  1. Destruktor törzsében lévő utasítások
  2. Adattagok destruktorainak hívása, deklarációval ellentétes sorrendben
  3. Ős destruktorának hívása

Ha van virtuális függvény, a destruktor külön figyelmet érdemel, erről is lesz szó nemsokára.

Virtuális tagfüggvények

A hierarchiában hova kerül a rajzol függvény?

Az első következtetésünk szerint az ősosztály a közös viselkedésért is felelős, nem csak az adattagokért, tehát a rajzol-nak meg kell jelennie az Alakzat-ban. Viszont – mivel a különböző típusú alakzatzokat teljesen máshogy kell kirajzolni – ha meghívjuk egy Alakzat objektumon a rajzol függvényt, akkor Kor alakzat esetén a Kor rajzolásának kellene hívódnia.

Pontosan ezt a viselkedést teszi lehetővé a virtuális tagfüggvény:

class Alakzat {
    // ...
public:
    virtual void rajzol() {
        std::cout << "???" << std::endl;
    }
    virtual double terulet() {
        return 0.0; // <- ???
    }
};

class Teglalap : public Alakzat {
    // ...
public:
    virtual void rajzol() {
        SDL_valami_teglalap(
            referenciapont.x,          referenciapont.y,
            referenciapont.x + szeles, referenciapont.y + magas,
            szin);
    }
    virtual double terulet() {
        return szeles * magas;
    }
};

class Kor : public Alakzat {
    // ...
public:
    virtual void rajzol() {
        SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
    }
    virtual double terulet() {
        return sugar * sugar * M_PI;
    }
};

Eszerint az Alakzat-nak van rajzol függvénye, amit a leszármazottak megörökölnek, mint minden függvényt, tehát mindenképp lesz rajzol függvényük. Ha egy virtuális függvény ősosztálybeli implementációja a leszármazottban nem az elvárt módon működik, felülírhatjuk a viselkedését. A Teglalap és a Kor pontosan ezt teszi. Ha a Teglalap-ot Alakzat-ként látjuk, és meghívjuk rajta a rajzol függvényt, akkor is a Teglalap::rajzol hívódik.

Ha egy virtuális függvényt az osztályon kívül szeretnénk definiálni, a definícióba tilos kiírni a virtual kulcsszót:

void Kor::rajzol() {
    SDL_valami_kor(referenciapont.x, referenciapont.y, sugar, szin);
}

Tisztán virtuális függvény, absztrakt osztály

A fenti Alakzat::rajzol függvény implementációjánál érezzük, hogy valami nincs rendben. Az Alakzat nem tud semmit a leszármazottairól, de még a saját pozíciójáról sem, azokat is egyedi módon tárolják az alakzatok. Ezért az Alakzat-ban nem lehet implementálni a rajzol-t. Viszont mivel minden alakzatot ki lehet rajzolni, ezért a leszármazottban kötelező!

Ezt a kötöttséget – hogy a leszármazottnak kötelező implementálnia a rajzol-t – igazából a fordítónak kellene ellenőriznie, és arra is lehetőséget kellene adnia, hogy az ősosztályban ne kelljen. Erre való a tisztán virtuális függvény nyelvi elem. C++-ban a függvénydeklaráció végére írt = 0 a jelölése:

class Alakzat {
    // ...
public:
    virtual void rajzol() = 0;
    virtual double terulet() = 0;
};

Ennek a 0-nak semmi köze a 0 egész számhoz, ez nem NULL pointer, csak egy jelölés. Később lesz róla szó, mire utal.

Vegyük észre, hogy az az osztály, aminek van tisztán virtuális függvénye, egy különleges gyengeséggel bír: önmagában nem létezhet. Hiszen megígértük, hogy van rajzol függvénye, akkor annak valahol kell lennie implementációjának, egy leszármazottban. Tehát Alakzat-ból nem hozhatunk létre példányt, csak Alakzat leszármazottaiból. Az ilyen osztályokat absztrakt osztályoknak nevezzük.

60. Heterogén kollekció

Az eredeti feladat megoldásával szinte készen vagyunk. Egy dolog hiányzik, az 5. részfeladat, amelyik azt mondta, hogy téglalapokat és köröket közös tárolóba kell tudnunk tenni.

A megoldásban a kompatibilitás – mint az öröklés alapvető tulajdonsága – segít. Mivel minden téglalap és kör igazából Alakzat, ha Alakzat&-et vagy Alakzat*-ot látunk, tudjuk, hogy "alatta" akár Kor, akár Teglalap lehet, és a rajzol függvényt meghívva a leszármazott megfelelő rajzol-ja hívódik.

Ötlet: tegyünk a tárolóba Alakzat*-okat! Az ősosztályra cast-olással fedjük el a leszármazottak közti különbségeket, és így, közös típusként már az STL tárolók is elbírnak vele, a példában std::vector:

Teglalap t1(Pont(1, 2), 4, -4, 0xFF0000FF);
Kor k1(Pont(3, 0), 2, 0x00FF00FF);

std::vector<Alakzat*> alakzatok; // <- közös tároló

alakzatok.push_back(&t1); // Teglalap* -> Alakzat*
alakzatok.push_back(&k1); // Kor* -> Alakzat*

std::vector<Alakzat*>::iterator it;
for(it = alakzatok.begin(); it != alakzatok.end(), ++it)
    it->rajzol(); // <- kirajzolás a sorrend megtartásával: a kör a téglalap felett van

Miért épp pointer?

Az Alakzat-okat értelemszerűen érték szerint nem tárolhatjuk, hiszen absztrakt osztály. A referencia sem jó ötlet, azokból nem lehet tömböt építeni. Megállapíthatjuk, hogy a heterogén kollekció mindig pointert tárol. Ez azonban azzal a kényelmetlenséggel fog járni, hogy az alakzatainkat dinamikusan kell foglalni.

Azért hívják heterogén kollekciónak, mert egyetlen tárolóban több különböző típusú valamit – téglalapot, kört – tárol. Legtöbb esetben elmondható, hogy csak azért írunk osztályhierarchiát, mert heterogén kollekcióra van szükség, a feladat többi része megoldható lenne anélkül – például template-tel, duck typing-gal.

Az Alakzat-okat nem csak azért nem tárolhatjuk érték szerint, mert absztrakt, hanem a C / C++ memóriakezelési sajátosságai miatt sem, mindjárt megmagyarázzuk.

61. Memóriakép, szeletelődés

A kompatibilitás kapcsán felmerülhet az a kérdés, hogyan lehet a leszármazott kompatibilis az ősével, hova kerülnek az adattagok a memóriában, és hogyan lehet mindig helyes a leszármazott → ős konverzió.

Ezt úgy oldja meg a fordító, hogy a leszármazott memóriaképének az elejére kerül az ős teljes tartalma, és utána következnek a leszármazott-specifikus adattagok. Így ha van egy pointerünk (vagy referenciánk), ami valami Alakzat elejére mutat, biztos lehet benne a fordító, hogy a mutatott helyen az első adattag a szin, a típusa uint32_t, a következő egy Pont, amit a kódban referenciapont-nak hívunk, stb. A leszármazott többi adattagja pedig ezek után következik.

Mi történne, ha egy Alakzat-ot érték szerint próbálnánk átadni egy függvénynek?

Mivel az érték szerint átadott paraméterek a stackre másolódnak, a fordítónak előre tudnia kell, mennyi hely kell nekik. Egy Alakzat-nál viszont nem lehet előre tudni, hogy a leszármazottai mennyi helyet foglalnak pluszban, ezért a fordító kénytelen csak egy Alakzat-nyit hagyni, és a leszármazottak többi adattagjának nem jut hely. Tehát a memóriában leszeletelődik az Alakzat-ról a leszármazott része, és csak az ősosztály adattagjai maradnak meg.

Ha egy Kor-t próbálunk Alakzat-ként érték szerint átadni, a sugar adattag tehát elveszik. Enélkül viszont a Kor virtuális függvényei sem működhetnek, hiszen azok próbálnák használni a sugar-t, ami már nincs ott! Ezért ha egy objektum leszeletelődik, a fordító nem csak az adattagjait, hanem a viselkedését is megváltoztatja: sima Alakzat-ként viselkedne tovább, virtuális függvényhívásnál az Alakzat-é hívódna!

Ez ebben a konkrét esetben egyébként nem történhetne meg, a fordító a kezünkre csapna, mert az Alakzat absztrakt. Mivel nem létezhet belőle önálló példány, nem lehet érték szerint átadni. Persze, hiszen egy önálló Alakzat-on a rajzol függvényt sem lehetne meghívni.

Mi történne, ha az Alakzat nem lenne absztrakt?

void f(Alakzat a);

Kor k1(Pont(3, 0), 2, 0x00FF00FF);
Alakzat a1 = k1;     // <- !

f(k1);               // <- !

Ez miért fordul le, mi történik itt?

Mindkét esetben az Alakzat copy ctora hívódik: Alakzat(Alakzat const &a). mivel a kompatibilitás miatt a Kor& → Alakzat& konverzió automatikus, ezért az Alakzat copy ctora kaphat kört is. És ő alakzatot fog létrehozni, tehát csak a színt és a referenciapontot fogja lemásolni, mivel nem tudja, hogy ténylegesen milyen alakzatról van szó.

62. Virtuális destruktor

Ha egy dinamikusan foglalt objektumot az ősosztály felől törlünk, előfordulhat váratlan memóriaszivárgás. Ősosztály felőli törlés alatt ezt értjük:

Alakzat *a = new Cimke(Pont(0, 10), "Hello world!", 0xFF0000FF);
delete a;

Ezt szerencsére könnyű kikerülni, de előbb nézzük meg, mi történhet ilyenkor, ez miért baj. Vegyünk fel egy új Alakzat leszármazottat, ami egy sztringet reprezentál (Cimke, label).

class Cimke : public Alakzat {
    std::string felirat;
public:
    // ...
};

Ezután vegyünk fel egy heterogén tárolót, amibe tegyünk dinamikusan foglalt alakzatokat, majd dolgunk végeztével töröljük őket!

std::vector<Alakzat*> alakzatok;

alakzatok.push_back(new Teglalap(Pont(1, 2), 3, 4, 0xFF0000FF));
alakzatok.push_back(new Kor(Pont(3, 0), 2, 0x00FF00FF));
alakzatok.push_back(new Cimke(Pont(0, 10), "Hello world!", 0xFF0000FF));

// ...

for(int i = 0; i < alakzatok.size(); ++i)
    delete alakzatok[i];

Első ránézésre helyesnek tűnhet a programunk, pedig nem az. Hogyan helyezkednek el ezek az objektumok a memóriában?

Mi történik, amikor egy Alakzat*-ként ismert Cimke objektumra hívjuk a delete-et?

Ilyenkor természetesen az Alakzat destruktora hívódik, ami meghívja az adattagjai destruktorát. Arról viszont fogalma sincs, milyen adattagok vannak a leszármazottban, pedig akár ott is lehet dinamikusan foglalt terület. Például a Cimke-nek van egy std::string adattagja, aminek a belsejében biztosan van valami dinamikusan foglalt adat.

Az Alakzat destruktora tehát csak a szin és pont adattagokról tud, a felirat adattag destruktora ezért nem fog meghívódni! Jó lenne, ha Alakzat törlésénél is a leszármazottak megfelelő destruktora hívódna, ahogy Alakzat kirajzolásánál is kör vagy téglalap rajzolódik ki. Az erre való nyelvi elemet már ismerjük, ez a virtuális függvény. Tehát az ősosztály destruktorát virtuálissá kell tennünk! Ezen felül semmi dolgunk nincs vele, tehát a törzse maradhat üres.

class Alakzat {
    // ...
public:
    virtual void rajzol() = 0;
    virtual double terulet() = 0;
    virtual ~Alakzat() {}           // <- !
};

Felmerülhet a kérdés, hogy mégis mikor van szükség virtuális destruktorra, a Pont osztályunknál például feleslegesnek érezhetjük.

Ez a fajta memóriaszivárgás akkor lép fel, ha az ősosztály felől törlünk egy objektumot, tehát heterogén kollekcióban van. Heterogén kollekciónak viszont csak akkor van értelme, ha van virtuális függvény az ősosztályban. Tehát ökölszabályként elmondhatjuk, a destruktort akkor és csak akkor kell virtuálissá tennünk az ősben, ha van legalább egy virtuális függvénye.

63. Virtuális függvénytábla

A virtuális függvények kapcsán jogosan merülhet fel a kérdés, hogyan derül ki futásidőben, melyik rajzol-nak kell hívódnia. A tároló bejárásánál csak Alakzat*-okat látunk, a szin és pont adattagokból nem lehet tudni.

Logikusnak tűnik tehát, hogy az Alakzat-ban kell lennie valami futásidőben rendelkezésre álló információnak, ahonnan a fordító elő tudja varázsolni a megfelelő implementációt, ha szükséges. Első gondolatunk az lehet, hogy függvénypointereket tesz az objektumok belsejébe, ez azonban nem lenne szerencsés:

  • Minden Teglalap példányban ugyanazokat a függvénypointereket kellene tárolni.
  • Ahány virtuális függvényünk van, annyi függvénypointer kellene minden példányba – jelen esetben 3 –, annyival nőne az objektumok mérete, pedig az előző pont miatt ez felesleges.

Ezért osztályonként van egy darab "függvénypointer"-tábla (vtable), ami tárolja egy típus összes virtuális függvényének implementációját. Egy az összes Alakzat-nak, egy a Teglalap-oknak, egy a Kor-öknek, stb. Az egyes példányok már erre a vtable-re mutató pointert tartalmaznak (vptr), tehát csak egyetlen pointernyivel nő a méretük. Mindezt természetesen elrejti előlünk a fordító, az ábrán szürkével jelöltük a rejtett részeket.

A fejezet elején szereplő alakzat_feldolgoz függvény paraméterként kap egy Alakzat const&-et, és azon meghív két virtuális függvényt. A paraméterként kapott alakzat típusa nem ismert fordítási időben, így a virtuális függvények hívásánál a vtable-höz kell fordulni. A terulet hívása például az alábbi módon történik:

  • Az alakzat első – rejtett – adattagját meg kell keresni. Ez a vptr, ami az objektum pontos típusának vtable-ére mutat.
  • A vptr-t dereferálni kell, az általa mutatott területen vannak a virtuális függvények függvénypointerei, mintha egy tömbben lennének.
  • A vtable-ből ki kell venni a meghívandó függvény pointerét. Ez a terulet függvény esetén mondjuk az 1. indexű függvénypointert jelenti.
  • Az így kapott függvénypointert meg kell hívni, ahogy C-ben is történt.

Az Alakzat és a Teglalap vtable-jében tehát ugyanolyan sorrendben kell következniük a függvénypointereknek. Mi a helyzet akkor a tisztán virtuális függvényekkel? Azoknak nincs implementációja, de akkor minek a címét lehet tenni a vtable-be?

A függvényre mutató pointer – mint minden pointer – ugyanúgy lehet NULL, mint bármelyik másik pointer. A tisztán virtuális függvényeknél ezért ilyenkor null pointer kerül a vtable-be, erre utal az =0 jelölésük.

Ez a leírás sem pontos technikailag, sőt, nem is lehet az. A C++ szabvány ugyanis azt sem köti meg, hogy a fordítók vtable segítségével implementálják a virtuális függvényhívás kihívásait.

64. Öröklés gyakori félreértései

Az öröklés, más néven leszármazás, az OOP egyik legfontosabb eszköze. A két osztály közötti, ilyen jellegű kapcsolat "hétköznapi" szóval jelölése néha félrevezető. Lássunk néhány példát, félreértést ezzel kapcsolatban!

Korlátozó "öröklés"

Ha a :public helyén :private vagy :protected áll, az szemantikailag teljesen mást jelent, semmi köze az OOP értelemben vett örökléshez, csak a C++ jelölésük hasonlít kísértetiesen. Ezt hívják "korlátozó öröklésnek" is, de annyira semmi köze az eddig bemutatott örökléshez, hogy külön fejezetben szántunk neki helyet.

Kör és ellipszis viszonya

Az objektumorientált tervezés egyik sarkalatos kérdése: mi a kapcsolat a kör és az ellipszis között?

Az egyik lehetséges megközelítés programozói szemszögből próbálja megfogni. A körnek van a átmérője, az ellipszisnek a nagytengelye és b kistengelye, így az ellipszis kicsit "több" a körnél, tehát az ellipszis a kör leszármazottja.

Ez a megközelítés teljesen hibás. Eszerint minden ellipszis igazából egy kör, pedig az ellipszisre nem igaz minden, ami a körre, például a get_atmero függvény nincs értelmezve rajta.

Matematikus fejjel mondhatnánk, hogy minden kör igazából egy ellipszis, tehát az ellipszis leszármazottja a kör. Ezzel a megközelítéssel egészen addig nincs baj, amíg nem akarunk az ellipszisen olyan műveletet végezni, ami körön nem értelmezhető, mondjuk X tengely szerint kétszeresére nyújtani. Ezért általában úgy tekintjük, hogy a Kor és az Ellipszis között nincs közvetlen kapcsolat, mindkettő egyszerűen az Alakzat-ból származik.

Ha kikötnénk, hogy az alakzatjaink tulajdonságai és állapota egyáltalán ne változhasson (azaz legyen immutable), nem lenne probléma a matematikus megközelítéssel. Az immutable objektumok sok más problémát is megoldanak, a koncepció a C++-tól azonban életidegen.

Biológia

Az OOP öröklés fogalma nem azonos a biológia öröklés fogalmával. Ha a gyerek a szülei minden tulajdonságát OOP értelemben véve örökölné, akkor a gyerek képzett óvónő és vasutas lenne. Meg tudná varrni a zoknit, ki tudná cserélni az autó ablaktörlőlapátjait, és mindezt a születése pillanatától fogva azonnal. Ezért ha programozásban öröklésről van szó, tudnunk kell elvonatkoztatni a szó eme jelentéstől, mert teljesen más jellegű a kapcsolat.

A téglalapnak kell legyen területe, mert minden alakzatnak van területe. De a gyereknek nem kell mozdonyvezetőnek lennie (nincs gyerek.mozdonyt_vezet()), csak azért, mert van apa.mozdonyt_vezet().

A kör-ellipszis-probléma egy kézzelfoghatóbb változata a strucc és a madár esete. A strucc madár, vagy nem madár? Attól függ, hogy a strucc tud-e mindent, amit egy madárnak tudnia kell. Ha a modellünkben a madárnak tudnia kell repülni, azaz van Madar::repul függvény, akkor a strucc nem lehet a madár leszármazottja!

65. Pointerkonverziók

C cast

C-ben a típuskonverzióra egyetlen eszközünk van, a cast. Ezzel tulajdonképpen bármilyen típusról bármilyenre cast-olhatunk, és ez C++-ban is így van. Két nagy veszélye van: egyrészt nem látszik rajta messziről, hogy helyes-e, és mit akarunk vele megoldani, másrészt nem is kereshető. Ez a kettő együtt azt okozza, hogy egy hibás cast-ot nem lehet megtalálni a kódban.

Ezért C++-ban külön operátorok vannak a különböző célú cast-okra, így mindegyiken messziről látszik, miért cast-olunk, és lehet-e belőle baj. A C cast-ok használata pedig erősen ellenjavallott.

C++ függvényszerű cast

Ezt már láttuk a Tort osztály környékén.

std::cout << Tort(3); // 3/1

Elsődlegesen konverziós operátor vagy konstruktor hívásra használjuk, de bármire használható, amire a C-s cast.

Szintaktikai megkötés, hogy a céltípus neve csak egyszavas lehet plusz névterek, azaz nem lehet benne * vagy &.

static_cast

A static_cast segítségével egymással „többé-kevésbé kompatibilis” típusok között konvertálunk. Ez olyan konverziókra való, amelyekről mi tudjuk, hogy jogosak, de amelyeket a fordító magától nem végezne el. Például:

  • Nem karaktert, hanem karakterkódot szeretnénk kiírni (overload kiválasztása):
std::cout << static_cast<unsigned int>('A');
  • Egész osztás elkerülése:
int a = 2, b = 3;
std::cout << static_cast<double>(a) / static_cast<double>(b);
  • Konverziós operátor vagy konstruktor hívása
std::cout << static_cast<Tort>(3); // 3/1
  • void*-ról cast-olásnál
int *p = static_cast<int*>(malloc(sizeof(int)));
  • Upcast osztályhierarchiában, ha tudjuk, hogy a cast helyes (bár erre inkább a dynamic_cast való):
Alakzat* a = new Teglalap(3, 4);
Teglalap* t = static_cast<Teglalap*>(a);

dynamic_cast

A dynamic_cast segítségével osztályhierarchiákban ugrálhatunk. Ez a cast, ahogy a neve is mutatja, futási idejű ellenőrzést is végez, és hibajelzést ad, ha a cast helytelen. Ehhez a futási idejű ellenőrzéshez virtuális függvénytáblára van szüksége, tehát ez csak polimorf osztályokon, azaz legalább egy virtuális függvénnyel rendelkező osztályokon működik. (De ha más nem, egy virtuális destruktor úgyis mindig van.) Ha pointert castolunk, helytelen típus esetén NULL pointert ad:

void valami(Alakzat *a) {   // kapunk egy ismeretlen típusú alakzatot
    Teglalap* t = dynamic_cast<Teglalap*>(a);
    if (t == NULL) {
        std::cout << "Nem téglalap";
    } else {
        std::cout << "Téglalap, " <<
                  << t->szelesseg() << "x" << t->magassag();
    }
}

Referenciák esetén pedig std::bad_cast típusú kivételt dob:

void valami(Alakzat &a) {
    try {
        Teglalap& t = dynamic_cast<Teglalap&>(a);
        std::cout << "Téglalap, " <<
                  << t.szelesseg() << "x" << t.magassag();
    } catch(std::bad_cast) {
        std::cout << "Nem téglalap";
    }
}

Osztályhierarchiák esetén jobb elkerülni a cast-ok használatát. A túl sok (nullánál több? :D) a dynamic_cast egy code smell, ami helytelen tervezésre utal; valószínűleg az ősosztályokból hiányoznak virtuális függvények.

Többszörös, virtuális öröklés (lásd lejjebb) esetén mindenképp dynamic_cast-ot kell használni. Ott ugyanis az objektumrészletek az egész objektumhoz képest nem mindig ugyanazon az offset-en kezdődnek; a pointerek közti eltolást a virtuális táblákból lehet kiolvasni.

const_cast

A const_cast célja, hogy a const minősítőt le tudjuk varázsolni egy pointer vagy egy referencia által mutatott objektumról. Tehát ezt pl. T const * → T * konverzióra használhatjuk, egyéb fajta konverziók fordítási hibához vezetnek.

Ha az eredeti objektum, amit cast-oltunk vele, konstans, akkor futási idejű hibához vezethet (undefined behavior).

Ez az egyetlen egy olyan cast, amelyik a const-ot le tudja venni.

reinterpret_cast

reinterpret_cast-ot használunk minden egyéb esetben, ami nem fér a fenti kategóriák valamelyikébe, vagy nem rakható össze azokból; olyan típusok között, amelyeknek semmi közük egymáshoz. Ezek tipikusan a „pointermágiát” használó kódrészletek, pl. tetszőleges típusú pointer unsigned char*-gá való konvertálása a memória bájtonkénti elérése céljából. Általában elmondható, hogy a reinterpret_cast nem hordozható kódhoz vezet.

Egy int bájtjainak kiírása:

int x = 0x11223344;
unsigned char* p = reinterpret_cast<unsigned char*>(&x);

for (size_t i = 0; i != sizeof(x); ++i)
    printf("%02x ", p[i]);

Ez nem hordozható kód, a futási eredménye architektúrától függ (int mérete, ábrázolási módja, endianness stb.).

C cast pontosítva

Ezek ismeretében jobban látszik, hogy miért veszélyes a C cast. Egy C-cast ugyanis ezek bármelyike lehet, konkrétan sorrendben az első, amelyik lefordul:

  • static_cast
  • static_cast és const_cast kombinációja
  • reinterpret_cast és const_cast kombinációja

Ezek közül csak az első biztonságos, és egy C cast-on messziről nem látszik, hogy tulajdonképpen melyik történik.

66. Többszörös öröklés

A C++ ritka állatfaj az OO-t támogató nyelvek között amiatt, hogy támogatja a többszörös öröklést. A legtöbb nyelvben nem lehet két osztályból leszármazni, mert implementációs nehézségekkel jár; elsősorban a fordító oldaláról, de néha a programozó számára is.

Feladat: nézzük meg a Neptun néhány elképzelt osztályát! Legyen Hallgato, Oktato és Demonstrator!

Alapeset

A probléma lényege, hogy a demonstrátor egyszerre hallgató és oktató. Hallgat órát, és kaphat ösztöndíjat, de tarthat is órát, és kap fizetést (elvileg).

Ez OO modellezési szempontból azt jelenti, hogy a Demonstrator leszármazottja a Hallgato-nak és az Oktato-nak is.

class Hallgato { /* ... */ };
class Oktato { /* ... */ };

class Demonstrator : public Hallgato, public Oktato {
    /* ... */
};

Ezzel semmi baj nincsen, minden ugyanúgy működik, ahogy eddig. A Demonstrator memóriaképe ennek megfelelően két ősosztályt tartalmaz egymás alatt, utána jönnek a saját adattagjai:

A kompatibilitás és a cast-ok ilyenkor ugyanúgy működnek, ahogy eddig, csak a pointerkonverzió során ténylegesen megváltozhat a memóriacím is. Az ábrán az Oktato* típusú pointer máshova mutat, mint a Hallgato* vagy a Demonstrator*, hiszen így kompatibilisek. Ezt a trükköt is elrejti előlünk a fordító.

Gyémánt-öröklés

A problémák akkor kezdődnek, ha rájövünk, hogy a hallgatónak és az oktatónak vannak közös tulajdonságai és viselkedése:

  • Mindkettőnek van neve, neptunkódja, számlaszáma.
  • Mindkettőnek lehet pénzt utalni, oktatónak a fizetést, hallgatónak pedig az ösztöndíjat.

Ebből az következik, hogy van egy közös ősosztályuk, az Ember:

class Ember {
protected:
    std::string nev;
    std::string neptun;
    std::string szamlaszam;
public:
    void utal(int osszeg);
};

class Hallgato : public Ember { /* ... */ };
class Oktato : public Ember { /* ... */ };

class Demonstrator : public Hallgato, public Oktato {
    /* ... */
};

A fenti ábrán bemutatott memóriakép ekkor így módosul:

Azért hívják gyémántnak (diamond), mert az UML osztálydiagramon – ami a jegyzetben csak később fog szerepelni – rombusz alakzatban helyezkednek el.

Ezen már látszik, mi a baj: a demonstrátorban kétszer is megjelenik az Ember, egyszer a hallgatón, egyszer pedig az oktatón keresztül. Ez baj, hiszen ha a Demonstrator osztályból el akarjuk érni mondjuk a számlaszámát, akkor nem egyértelmű, melyik számlaszám adattagról van szó. Ilyenkor a Hallgato ősosztályon hívott tagfüggvények az egyik Ember objektum adattagjait használják, módosítják, az Oktato ősosztályon hívottak pedig a másikat, így inkonzisztencia is kialakulhat.

Ha ilyen probléma merül fel, az az esetek többségében tervezési hiba, és a probléma mélyebben gyökerezik, ez csak tünet. Ettől most tekintsünk el, és nézzük meg, hogyan lehet megoldani a problémát komolyabb újratervezés nélkül.

Erre nyilvánvalóan az lenne a megoldás, hogy egyetlen közös Ember objektum legyen egy Demonstrator objektumban. Ilyenkor viszont a Hallgato vagy az Oktato osztálynak is tudnia kell, hogy hozzájuk képest hol van az Ember objektum, hiszen a számukra eddig megszokott helyen nem lehetnek.

Az erre bevezetett C++ nyelvi elem a virtual öröklés. Ilyenkor – mint a virtual szó minden egyes jelentése esetében – pointeren keresztül érünk el valamit. Jelen esetben az ősosztály adattagjait a Hallgato és az Oktato osztályoknak kell egy pointeren keresztül elérniük, hiszen az lehet akárhol.

Ez kódban pedig így jelenik meg:

class Ember {
protected:
    std::string nev;
    std::string neptun;
    std::string szamlaszam;
public:
    void utal(int osszeg);
};

class Hallgato : public virtual Ember { /* ... */ };
class Oktato : public virtual Ember { /* ... */ };

class Demonstrator : public Hallgato, public Oktato {
    /* ... */
};

Az ilyen öröklésnek az a nehzésége programozói oldalról, hogy a virtuális ősosztály konstruktorát mindig a legalsó leszármazottnak kell meghívnia, itt a Demonstrator-nak. Logikus, hiszen a Hallgato és az Oktato magától azt sem tudja, hozzájuk képest hol lesz az ősosztályuk az objektumon belül, tehát konstruálni sem tudják maguknak.

Ha önálló Hallgato vagy Oktato példányokat hozunk létre, ugyanúgy működik minden, mint az alapesetben, csak az ősosztályt továbbra is egy pointeren keresztül érik el.

Az igazi, mélyen gyökerező probléma ebben a példában az, hogy összekevertük a típus és a szerep fogalmakat. Igazából itt egyetlen típusunk van, az Ember, és három szerepünk: Hallgato, Oktato, Demonstrator. Ezt a három szerepet azért nem lehet az emberek típusába foglalni, mert ugyanaz az ember bármelyik szerepet felveheti vagy leadhatja, márpedig egy objektum nem válthat típust.

Adapterosztályok

Gyakran előforduló probléma, hogy egy meglévő osztályunk interfészén szeretnénk módosítani, esetleg szűkíteni. Úgy is megfogalmazható a kérdés, hogy egy létező implementációt hogyan használhatunk fel legegyszerűbben más célokra, más köntösbe bújtatva.

68. Vektor és Stack

Feladat: olvassunk be a szabványos bemenetről fájl végéig valós számokat, és írjuk vissza fordított sorrendben. Írjunk ehhez egy tárolót, ami ezt minél egyszerűbbé teszi!

A példánkban az egyszerűség kedvéért fixen double-öket fogunk tárolni. Nincs akadálya, hogy bármilyen T-t tároljunk, csak a szintaktika lenne némileg bonyolultabb, számunkra most nem érdekes.

A Prog1-ből ismerős feladat legegyszerűbben verem (Stack) adatszerkezettel oldható meg. A veremnek logikailag két alapművelete van, egy tárolt elem berakása (push), és kivétele (pop). Néhány megvalósításban a pop érték szerint visszaadja a tárolt elemet, ami több okból is kényelmetlen. Egy veremben a felső elemet látjuk anélkül, hogy kivennénk, így ha mi bármit szeretnénk csinálni a veremmel, nem feltétlenül kell módosítanunk a tartalmát. Ezért a legtöbb megvalósításban a top tagfüggvény adja vissza a legfelső elem referenciáját, és a pop nem ad vissza semmit, csak törli a legfelső elemet.

Tetszőleges típust tároló veremnél kivételkezelési okokból sem jó ötlet a két alapűveletes megvalósítás, ennek miértje azonban túlmutat a tárgy keretein.

A feladat megoldása verem használatával tehát a lenti kódra egyszerűsödik. Már csak a Stack-et kell implementálnunk.

Stack stack;
double d;

while (std::cin >> d)
    stack.push(d);

while (!stack.empty()) {
    std::cout << stack.top() << std::endl;
    stack.pop();
}

Az 5. fejezetben megismert std::vector osztálysablon egy dinamikus tömböt valósít meg, amelynek a végére hatékonyan tehetünk be elemeket (push_back), közben csak ritkán foglal újra, és a memóriát sem pazarolja.

Ha egy vermet szeretnénk implementálni, abban is lehetne használni ugyanezt az adatszerkezetet. A vektort nyújtani vagy rövidíteni természeténél fogva csak az egyik végén hatékony, a veremnél viszont a másik végéhez hozzá se szabad nyúlni. Kézenfekvőnek tűnik az ötlet, hogy a Stack osztályunk megvalósításához használjunk std::vector-t.

Kompozíció és továbbítás

Az első helyes megközelítés szerint a Stack tartalmaz egy std::vector-t, és a három alapművelet igazából a vektor műveleteire való "lefordítás".

class Stack {
    std::vector<double> adat;
public:
    void push(double uj) {
        adat.push_back(uj); // végére beszúr
    }
    void pop() {
        adat.pop_back();    // végéről levesz
    }
    double& top() {
        return adat.back(); // utolsó elem
    }
    double const& top() const {
        return adat.back();
    }
};

Az egyszerű használathoz ezeken kívül szeretnénk tudni lekérdezni, hogy üres-e a verem, illetve mekkora a mérete. Ezt az std::vector-tól az empty és size tagfüggvényekkel kérdezhetjük le, és a Stack-nek ugyanezen a néven illene létrehozni őket, a konzisztencia érdekében.

class Stack {
    std::vector<double> adat;
public:
    // ...

    bool empty() const {
        return adat.empty();
    }
    int size() const {
        return adat.size();
    }
};

Ez így eléggé sormintagyanús. Ha további függvényeket szeretnénk delegálni az std::vector-ból, a vektor tagfüggvényeinek a deklarációját le kell másolnunk. A tartalmuk sima paramétertovábbítás, és a visszatérési értéket is változatlanul kell visszaadnunk. Ráadásul ha esetleg változna az std::vector bármely tagfüggvényének a fejléce, a Stack-ünket hozzá kell igazítanunk, csak azért, hogy a továbbítás továbbra is helyes maradjon.

Privát öröklés és delegálás

Sok esetben a fenti megoldás túl kényelmetlen, jó lenne a delegálást egyszerűbben megoldani. A vektor és a verem közötti kapcsolat különbözik az eddig bemutatottaktól. A Stack belső működése lehet olyan is, mint egy std::vector. Annyiban, hogy vermet építhetünk egy dinamikus tömb felhasználásával. Ilyenkor a Stack csak a lelke mélyén egy vektor, nem köti mindenkinek az orrára, erről más nem tudhat. Például nem enged meg mindent, amit egy vektor megenged magának: a közepét nem módosíthatjuk, csak a tetejét látjuk.

Bizonyos műveleteket ő maga "lerendez" a vektorral (push, pop, top), másokat egyszerűen rábíz a vektorra (size, empty), és semmi mást nem enged másoknak, nehogy elszemtelenedjenek. Úgy is lehetne mondani, hogy megörököl a vektortól néhány, gondosan kiválasztott funkciót.

Erre a kapcsolatra ez a C++ jelölés:

class Stack : private std::vector<double> {
};

Ezzel az eszközzel a Stack-ünk leírása némileg egyszerűsödik. A Stack az ősosztály minden tagfüggvényét örökli, private láthatósággal, ezért a push, pop és top függvényeinkben sajátként használhatjuk őket:

class Stack : private std::vector<double> {
    std::vector<double> adat;
public:
    void push(double const& uj) {
        push_back(uj); // végére beszúr
    }
    void pop() {
        pop_back();    // végéről levesz
    }
    double& top() {
        return back(); // utolsó elem
    }
    double const& top() const {
        return back();
    }
};

Ha az így privátként megörökölt függvényeinket publikussá szeretnénk tenni (pl. size, empty), a using kulcsszó egy számunkra új használatával érhetjük el:

class Stack : private std::vector<double> {
    // ...
public:
    using std::vector<double>::empty;
    using std::vector<double>::size;
};

Ezt hívják privát öröklésnek vagy korlátozó öröklésnek is. Fontos megjegyezni, hogy semmi köze az OOP értelemben vett örökléshez, teljesen más a célja. Gyakorlatilag a tartalmazás + továbbítás egyszerűbb leírási formája.

Miért nem publikus öröklés?

Sokakban felmerül az ötlet, hogy az adapterosztályt publikus öröklés segítségével implementálják. Ez a megközelítés azonban a legtöbbször hibás.

Egy adapterosztály az esetek túlnyomó többségében szűkít vagy módosít az implementáláshoz segítségül hívott osztály interfészén. Nem engedi használni minden tagfüggvényét, esetleg egy létező tagfüggvény viselkedését módosítja. Utóbbira lehet példa egy olyan vektor, aminek az indexelő operátora szükség esetén megnyújtja a tárolót. Ha ehhez publikus öröklést használ, annak a legfontosabb alapelvét sérti, a Liskov Substitution Principle-t. Az LSP szerint ami az ősosztályon megengedett, annak a leszármazotton is helyesnek és azonos viselkedésűnek kell lennie. Márpedig egy std::vector-nak szabad a közepére elemet beszúrni, a Stack-nek viszont nem, ez tehát mindenképpen sértené az LSP-t.

A minden micsoda micsoda viszony az indexelésre nyújtózkodó vektorra sem áll fenn. Az std::vector indexelő operátora garantálja, hogy a tároló méretét nem változtatja meg, így ezt a leszármazottól is jogosan várnánk el.

Ráadásul a publikus öröklés azért publikus, mert mindenki ismeri az öröklési viszonyt. Tehát a leszármazott → ős konverzió helyes: csendben, gond nélkül átadhatnánk a Stack-et std::vector&-et váró függvénynek, ami bármit bárhol módosíthat rajta, és erre a fordítótól még figyelmezetést sem kapnánk. Persze, hiszen mi azt hazudtuk a fordítónak, hogy minden Stack egy vektor, amit kénytelen elhinni.

Egy osztály (pl. az std::vector) kódjának ilyen felhasználására nem a publikus öröklés való, hanem a tartalmazás és a privát öröklés. Ezért is írtuk, hogy a privát öröklés nem is öröklés.

composition-inheritance

69. Adapterosztályok az STL-ben

Az alaposztály: std::deque

Az std::deque egy mindkét végén módosítható várakozási sor.

huehuehue

A neve a double-ended queue rövidítése, és ugyanúgy kell ejteni, mint a deck-et.

A jellemző műveletei: push_back, pop_back, push_front és pop_front, amelyek a sor végére illetve elejére tesznek be új elemet, illetve vesznek ki elemet. A végét és az elejét a back és a front tagfüggvényei adják vissza.

Ezen felül rendes sorozattárolóként is viselkedik, mint mondjuk egy std::vector: indexelhető, iterálható, lekérdezhető a mérete, de elsősorban nem erre lett kitalálva, hanem az elejének és a végének a gyors módosítására.

Ha egy tárolónak mindig csak az egyik végére teszünk elemet, és csak az egyik végéből veszünk el elemet, jellegzetes tárolókat kapunk, amik szemantikailag különlegesek. Ezért az STL-ben definiáltak két adaptert, ami szemantikailag egyértelművé teszi, hogyan használjuk a tárolót, a nem jellemző műveletek elrejtésével.

std::stack

Ha a tárolónak ugyanarról a végéről veszünk el, ahova teszünk, az egy LIFO (last in, first out) tároló, azaz egy verem (stack). Az std::stack template osztály alapértelmezetten egy std::deque<T> felhasználásával biztosítja a felhasználója számára a LIFO műveleteket: push, pop, top. A felhasznált tároló egyébként a stack második sablonparaméterével adható meg igény esetén.

Az alábbi kódrészlet például a standard inputon kapott szavakat kiírja a standard outputra, fordított sorrendben.

std::stack<string> verem;
std::string elem;

while (std::cin >> elem)
    verem.push(elem);

while (!verem.empty()) {
    std::cout << verem.back();
    verem.pop();
}

std::queue

Ha a tároló a másik végéről veszünk el, akkor egy FIFO (first in, first out) tárolót, azaz egy várakozási sort (queue) kapunk. Ugyanúgy paraméterezhető, mint a std::stack, a jellemző műveletei pedig push, pop, front és back. Mindig a tároló végére teszi az új elemet, és az elejéről veszi ki.

Az alábbi kódrészlet például a standard inputon kapott szavakat kiírja a standard outputra, ugyanabban a sorrendben.

std::queue<string> sor;
std::string elem;

while (std::cin >> elem)
    sor.push(elem);

while (!verem.empty()) {
    std::cout << sor.front();
    sor.pop();
}

70. A Matrix osztály a 4. fejezetből

A RAII-s fejezetben használtunk egy elképzelt Matrix osztályt, ami egy kétdimenziós double-tömböt jelentett. Az biztos, hogy a belsejében valahogyan dinamikus memóriát kezel, mert akármekkora méretű mátrixot beletehettünk. Mivel a másoló konstruktor és társai jól működtek, ezért vissza lehetett adni érték szerint. A konstruktorban adtuk meg a dimenziókat, és függvényhívás operátort használtunk az indexelésre. Valahogy így:

Matrix m(3, 4);         // 3 magas, 4 széles, 0-kkal feltöltve
m(1, 2) = 1.0;          // 1. sor 2. oszlopában lévő elem értéke legyen 1
std::cout << m(1, 2);   // 1.0

A dinamikus memóriakezelés egy lehetséges, hatékony módjára az 5. gyakorlaton láttunk példát. A trükk lényege az, hogy az elemeket sorfolytonosan, kilapítva tároltuk. Így egyetlen dinamikusan foglalt memóriaterülete van a mátrixunknak:

ketdimenzios

A dinamikus memóriakezelést a gyakorlaton "kézzel" csináltuk, most nézzünk meg egy egyszerűbb megoldást. A dinamikusan foglalt tömbre már létezik az STL-ben egy osztály, az std::vector, használjuk fel!

Az egyes elemek indexeléséhez tudnunk kell (azaz el kell tárolnunk), hogy milyen hosszú egy sor, a példánkban 4. Így ha az (1, 2) elemet akarjuk elérni, az kilapítva az 1 * 4 + 2 = 6 indexű elemet kell visszaadnunk. A vektor hossza lekérdezhető, így a magasságot egy osztással megkaphatjuk.

class Matrix {

    int sz;
    std::vector<double> szamok;

public:
    Matrix() 
        : sz(0)
        , szamok() // üres vektor
    { }

    Matrix(int magassag, int szelesseg)
        : sz(szelesseg)
        , szamok(magassag * szelesseg) // a vector konstruktora 0-kkal tölti fel
    { }

    int szelesseg() const {
        return sz;
    }
    int magassag() const {
        return szamok.size() / sz;
    }

    double& operator()(int sor, int oszlop) {
        return szamok[sor * sz + oszlop];
    }
    const double& operator()(int sor, int oszlop) const {
        return szamok[sor * sz + oszlop];
    }
};

Így bár ugyanolyan a memóriaképe az osztálynak, megúsztuk a memóriakezelést. Az osztály interfésze pedig a gyakorlaton előállthoz képest nem változott – azaz a globális függvényként megírt operátorokat – összeadás, szorzás, stb. – átemelhetjük változtatás nélkül. Ez az egyik előnye, hogy globálisként valósítottuk meg őket.

Az egységbe zárás elvét ezzel nem sértettük meg. Azt és csak azt tettük az osztály belsejébe, aminek feltétlenül ismernie kell a belső implementációt, de az operátoroknak nem kell.

UML, osztályhierarchiák tervezése

Az UML osztálydiagram

Egy nagy objektumorientált program tervezése nem egyszerű feladat. A Prog1 tárgyban megismert tervezési módok - funkcionális dekompozíció, top-down tervezés - ad-hoc alkalmazása nem elég. Amint osztályaink vannak, azoknak vannak felelősségi körei és viselkedésük, amiket a tervezés elején tisztázni kell. Ha nem tesszük meg időben, az OOP elvek miatt (pl. adatrejtés) ezek utólagos megváltoztatása nehézkessé válik.

A tervezést emiatt célszerű (vagy inkább kell) a kódírás előtt elkezdeni. Milyen osztályaink legyenek, melyik miért legyen felelős? Ez a két legfontosabb kérdés. Ezek megválaszolása, az elképzelések részletezése közben új kérdések merülnek fel, és sokszor derül ki egy korábbi ötletünkről, hogy rossz. Na de hogyan lássuk át sok-sok osztály egymáshoz való viszonyát azelőtt, hogy leírnánk egy sor kódot?

Ha a kód elkészült, akkor sem sokkal jobb a helyzet. Ha van 15 osztályunk, - ami nem számít soknak, - a konvenciók szerint az 15 header és 15 cpp fájl, összesen 30 fájl. Ennyit kell megnézni ahhoz, hogy lássuk, mely osztályok vannak egymással kapcsolatban. A lehetséges kapcsolatok száma pedig O(n2), hiszen bármely osztály bármelyik másikkal kapcsolatban állhat.

Mindkét probléma azt igényli, hogy legyen egy egyszerűsített leírási módja - ha úgy tetszik, modellje - az osztályok viselkedésének és a közöttük lévő kapcsolatoknak. Az UML (Unified Modeling Language) szabvány osztálydiagramjai éppen erre valók.

Az objektumorientált programok tervezése jó esetben UML osztálydiagram rajzolásával, tehát papírral, ceruzával és radírral kezdődik. A radír is fontos eleme a folyamatnak, ugyanis a legritkább esetben sikerül elsőre a tervezés, sokszor kell javítani, módosítani.

Ha a tervezési fázison túljutottunk, és demonstrációs vagy dokumentációs célból szeretnénk kulturáltabb formába önteni a diagramunkat, ebben számos diagramszerkesztő áll rendelkezésünkre. A szerzők tapasztalata szerint ezeket az eszközöket érdemes használni C++ projekt esetén:

Az UML ezen kívül számos hasznos diagramtípust definiál, mi azonban csak az osztálydiagramokat, azoknak is csak a számunkra lényeges elemeit mutatjuk be: előbb az osztályok közötti kapcsolatokat, utána az egyes osztályok leírását. Azért pont ebben a sorrendben, mert tervezni is így érdemes. Először csak egy-egy dobozt rajzolunk az osztályoknak, amik közé különféle nyilakat húzunk. Ilyenkor hanyagságból lehagyjuk az adattagokat és a tagfüggvényeket, így egy osztálynak csak egy téglalapot rajzolunk. Ha ezzel nagyjából készen vagyunk, utána érdemes a tagfüggvényekkel és az adattagokkal foglalkozni.

Az UML jelölésben egy osztály egy három részre osztott téglalap. A felső részbe kerül a neve, a középsőbe az adattagjai, az alsóba pedig a tagfüggvényei. Először tekintsünk el az adattagoktól és a tagfüggvényektől, és nézzük meg, hogy az osztályok téglalapjai - a dobozok - között milyen kapcsolatokat definiál az UML.

73. Asszociáció, aggregáció, kompozíció

Fogalmuk

Mindhárom fogalom annak egy formáját takarja, hogy az egyik osztály példányainak köze van a másik osztály bizonyos példányaihoz (ismeri, használja). Ez az UML-beli jelölésük:

Asszociáció, aggregáció, kompozíció

A jelentésük közel áll egymáshoz, sokszor nem könnyű eldönteni, miről is van szó. A két szélső eset - kompozíció és asszociáció - tipikus esetei könnyen felismerhetőek:

Kompozíció: rész-egész viszonyt fejez ki, A objektumok tartalmaznak B-t. Onnan ismerhető fel, hogy az objektumok élettartama szorosan egymáshoz kötődik: ha megszűnik a tartalmazó objektum, vele együtt megszűnik a tartalmazott objektum is. UML-beli jelölése a teli rombusz a tartalmazó oldalán. Ilyen például:

  • Az autó és a kerék viszonya: ha az autó a roncstelepre megy (vagy beleszakad a Balatonba), a kerekei is mennek vele.
  • Cimke és std::string.
  • String és a karakterek viszonya: a sztring megszűnésével a karakterei is megszűnnek.

Asszociáció: gyenge ismeretség, azaz az objektum ismeri a másik létezését, de az élettartamára semmiféle hatással nincs. UML-beli jelölése az ismert objektum felé mutató nyíl (fontos, hogy nem zárt a feje, csak két vonalból áll). Például:

  • Egy autó és a tulajdonosa: az autó bezúzása után a tulajdonos tovább él.
  • Egy könyv és a szerzője: a szerző halálával a könyve tovább él.

Aggregáció: bármi, ami a kettő között van: az A objektum élettartamához kötődik a B-k élettartama, de nem annyira szorosan, mint kompozíció esetén. Jele az üres fejű rombusz a tartalmazó oldalán. Például:

  • Egy autó és a rendszámtáblája: az autó rendszámtábla nélkül is autó, csak nem mehet közútra. Nem feltétlenül hal együtt az autóval, például az autó bezúzásakor átkerülhet másik autóra. A Nemzeti Közlekedési Hatóságnak is van hatása a rendszámtábla élettartamára, pl. bevonathatja, így az NKH és a rendszámtábla között is aggregáció van.
  • Szoba és a falai: a szoba ledózerolása nem feltétlenül vonja maga után minden falának a lebontását, mert a fal több szobához is tartozhat.

Mint a példákból is látszik, az aggregáció "gumi-fogalom", sokféle különböző kapcsolatot takarhat: közös, megoszott objektumot, lazán kapcsolódó, de összefüggő élettartamot, stb.

Megjelenésük a kódban

Fontos az elején tisztázni, hogy ezek a fogalmak logikai kapcsolatokat takarnak, a kódban lehetnek ettől teljesen eltérőek. Mégis, a C++ egyes nyelvi elemei ezekhez jól igazodnak, így a tipikus esetek könnyen felismerhetőek.

Kompozíció: érték szerinti tartalmazás. A C++ nyelv biztosítja, hogy a tartalmazó objektum élettartamával megegyezik a tartalmazott objektumok élettartama. Erre valók a 3. fejezetben megismert speciális "tagfüggvények": konstruktor, másoló konstruktor, operator=, destruktor.

class Cimke /* ... */ {
    std::string felirat;
    // ...
};

Asszociáció: pointer az ismert objektumra. A C++ logikája szerint a pointer által mutatott terület érvényességére nekünk kell figyelni, arra a pointernek mint objektumnak semmi ráhatása nincs.

Ilyenkor fennáll a veszélye, hogy a pointer alól felszabadul a memóriaterület, azaz dangling pointer lesz belőle. Ez ellen általában felsőbb szintű kódnak kell védekeznie.

Egy példa a félrevezető nyelvi megjelenésre: a String egy char* pointert tartalmaz, tehát messziről asszociációnak tűnik. Logikailag azonban ez kompozíció, és a String biztosítja, hogy a karakterek élettartama is feleljen meg ennek.

Aggregáció: mivel sokféle kapcsolatot takarhat, elég változatos módokon jelenhet meg a kódban.

Az egyik leggyakoribb speciális esetére, a megosztott, közös erőforrásra van bevett megoldás, a shared_ptr. C++11 óta a szabványos könyvtár - <memory> - része, de C++98-ban is megvalósítható, így tipikusan minden közepes vagy nagyobb projekt C++11 előtt is használt ehhez hasonlót, tipikusan a boost::shared_ptr-t.

Érdekesség, hogy az UML leginkább a Java nyelv fogalmaihoz igazodik, ezért néhány C++-specifikus dolognak - mint látni fogjuk - nincs rendes jelölése. Ezzel szemben Java-ban nincs megkülönböztetett nyelvi eleme a "sima" asszociációnak és a kompozíciónak, technikailag minden aggregációként viselkedik.

C++11 sarok: shared_ptr, weak_ptr

A megosztott közös erőforrás legegyszerűbb, automatikus memóriakezelést biztosító módja a shared_ptr. Ez egy olyan okos pointer, ami számlálja, hány ugyanilyen okos pointer mutat az adott objektumra. Amint megszűnik az utolsó okos pointer, ami hivatkozik az objektumra, azaz a referenciaszámláló 0-ra csökken, felszabadítja az objektumot.

Ilyen esetekben előfordulhatnak körkörös hivatkozások: pl. ha két objektum egymásra mutat, és mindkettő referenciaszámlálója 1. Tehát egyik sem szabadul fel, de egyik sem érhető el máshonnan a programból: tehát memóriaszivárgás történt. Ilyenkor az egyik irányban weak_ptr-t érdemes használni, ami engedi, hogy felszabaduljon az objektum. A weak_ptr annyival tud többet egy sima pointernél, hogy tudjuk ellenőrizni, hogy a hivatkozott objektum létezik-e még (ha nem, NULL pointert kapunk). Ennek használatával az aggregációt átalakíthatjuk asszociációra, így szüntetve meg a körkörös hivatkozást.

A shared_ptr-ről még lesz szó bővebben az STL-es fejezetben.

Multiplicitás, szerep

Ezek a kapcsolatok nem feltétlenül 1-1 objektum között állnak fenn. Egy tartalmazó objektum több másikat is tartalmazhat, és bármely objektumot ismerhet több másik. Ha ennek van jelentősége, a nyilakra írva jelezhetjük.

  • Lehet konkrét szám: 2
  • Lehet intervallum: 3..5, szigorúan két darab ponttal elválasztva, szóköz nélkül.
  • A számok helyén állhat *, ami akárhányat jelent: pl. simán * vagy 3..*

Ezeket a nyíl bármelyik végére írhatjuk, és fontos, hogy melyik oldalra. Ebben a példában az autónak 3..* kereke lehet, egy kerék azonban csak egy autón lehet rajta:

Multiplicitás

A nyilakra azt is ráírhatjuk, hogy a két objektum kapcsolatában melyik objektumnak mi a szerepe, ha nem egyértelmű:

Szerep

Feladat: milyen kapcsolatban áll egymással az autó, a motorkerékpár, az oldalkocsi és a kerék?

  • A motorkerékpárnak része 2 db kerék, ez kompozíció.
  • Az oldalkocsinak 1 db kerék része, ez is kompozíció.
  • A motorkerékpár élettartama néha kötődik az oldalkocsiéhoz (például ha összekapcsolva a Dunába hajtanak), de nem feltétlenül (pl. könnyű róla leszerelni, külön értékesíteni). Ez aggregáció.
  • Az autónak legalább 3 kereke van (pl. Reliant Robin), de lehet akármennyi, az emberi hülyeségnek nem szabhat határt (American Dream).
Szerep

74. Öröklés

Két osztály közötti ősosztály-leszármazott viszonyt üres fejű nyíllal jelöljük, ami az ősosztály felé mutat. Fontos, hogy ez csak az OOP értelemben vett (publikus, is-a) öröklésre vonatkozik, a "privát öröklés" objektumorientált szempontból kompozíciónak számít!

Öröklés

A virtuális öröklés jelölésére az UML-ben nincs szabványos mód, ilyenkor a nyílra szokás ráírni, hogy virtual, esetleg stereotype-ként, két kacsacsőr közé: <<virtual>>. Az örökléssel kapcsolatban semmi további tudnivaló nincs.

Virtuális öröklés

75. Függőség

Akkor mondjuk, hogy A osztály függ B osztálytól, ha a B osztály interfészének a módosítása az A osztályban is módosítást igényelhet.

A fenti relációk – kompozíció, aggregáció, asszociáció, öröklés – mind ilyenek. Az ezeken túli függőségeket jelöljük UML-ben függőségnek. Ilyenek lehetnek például:

  • A valamilyen függvénye paraméterként B objektumot kap
  • A példányosítja B-t
  • A meghívja B statikus függvényét
  • A valamilyen függvénye B objektummal tér vissza

Jelölése a sima, két vonalból álló fejű, szaggatott nyíl:

Függőség

Ezek általában látszanak UML diagramon is, az adattagoknál és a tagfüggvényeknél. Vigyázz, nem minden esetben, például az "A példányosítja B-t" esetben nem!

76. Template osztályok

A template osztályok jobb felső sarkában van egy szaggatott téglalap, amibe a template paramétereket írhatjuk. Ha a template paraméter typename, akkor kész is vagyunk, ha viszont változó a template paraméter, odaírhatjuk a típusát.

Ahol használjuk az osztályt, a nyílra rá kell írnunk, hogy milyen template paraméterekkel, a <<bind>> stereotype használatával.

Template osztály

77. Adattagok, tagfüggvények

A fejezet elején említettük, hogy az osztályok dobozából a tervezés elején lehagyjuk az adattagokat és a tagfüggvényeket, és csak egy téglalapot rajzolunk nekik. A szabványos UML osztálydiagramon egy osztálynak igazából egy három részre osztott téglalap jár: a felső részbe kerül a neve, a középsőbe az adattagjai, az alsóba pedig a tagfüggvényei:

Adattagok

Az első furcsaság, hogy az adattagok neve után, kettősponttal elválasztva következik a típusa. Függvényeknél ugyanez a helyzet: a paraméterlistája után, kettősponttal elválasztva szerepel a visszatérési értéke, és a paraméterek is ugyanígy, fordított sorrendben szerepelnek. A konstruktornak természetesen itt sincs típusa.

Sorrend

A sorok elején lévő karakterek a láthatóságot befolyásolják: kötőjel a private, pluszjel a public, és kettőskereszt a protected. A statikus függvényeket, adattagokat aláhúzással kell jelölni.

Láthatóságok

Az absztrakt osztályok neve dőlt betűvel írandó. A virtuális függvények jelölésére nincs bevett mód, szokás dőlt betűvel írni, vagy a visszatérési érték végére tenni, hogy virtual.

Ez a hiányosság is abból ered, hogy a Java nyelvhez igazodik az UML, ott ugyanis minden függvény alapból virtuális.

Virtuális, absztrakt

Összetett példa: Vonatjegy

Ne felejtsük: az UML csak egy módszert ad arra, hogyan ábrázoljuk az osztályaink közti kapcsolatokat és kommunikációt. Azt viszont nekünk kell eldönteni, hogyan határozzuk meg ezeket. Ez nem egy egzakt folyamat, mindig sok emberi döntésre van szükség, így sok hasonló modell lehet jó. Szerencsére segít minket néhány elv és módszer, amivel el tudunk indulni.

  • Érdemes úgy kezdeni a modellalkotást, hogy nyelvtanilag elemezzük a feladatot vagy specifikációt. A főnevekből lesznek az osztályok, az igékből pedig a tagfüggvények. Természetesen ezzel nem kapunk meg minden osztályt (pl. az absztakt osztályokat), és nem minden főnévből lesz osztály.
  • A folytatásban érdemes követni azt az elvet, hogy próbáljuk a valóságot modellezni. Az osztályok felelőssége és tagfüggvények működése lehetőleg egyezzen azzal, ami a valóságban létezik és történik.
  • Ezután keressünk egyszerűsítéseket: felesleges, esetleg máshonnan átemelhető osztályokat tudunk kihúzni, vagy a modellben máshogy megjeleníteni.

Ezek természetesen nem szükségszerűen ilyen sorrendben követik egymást, sőt, egy-egy ilyet sokszor újra előveszünk, és átgondoljuk még egyszer. Kreatívan kell tudni felhasználni azokat a gondolatokat, amik itt megjelennek.

Jelen leírás célja, hogy bemutassunk néhány fontos gondolatot, ugyanakkor demonstráljuk, hogy a modellek felépülése tudatos döntések eredménye, és a főbb gondolatok logikailag levezethetők.

79. Feladat

Nézzünk meg egy konkrét példát, amin ezeket be tudjuk mutatni. A példafeladat a hivatalos nagyházi-listából származik, így az itt leírtakat a plágiumgyanú elkerülése érdekében lehetőleg ne vegye senki a nagyházija kiindulási alapjának.

Tervezze meg egy vonatjegy eladó rendszer egyszerűsített objektummodelljét, majd valósítsa azt meg! A vonatjegy a feladatban mindig jegyet és helyjegyet jelent együtt. Így egy jegyen minimum a következőket kell feltüntetni:

  • vonatszám, kocsiszám, hely
  • indulási állomás, indulási idő
  • érkezési állomás, érkezési idő

A rendszerrel minimum a következő műveleteket kívánjuk elvégezni:

  • vonatok felvétele
  • jegy kiadása

A rendszer később lehet bővebb funkcionalitású (pl. késések kezelése, vonat törlése, menetrend, stb.), ezért nagyon fontos, hogy jól határozza meg az objektumokat és azok felelősségét.

80. Értelmezés

Gyűjtsük ki az osztályokat (főneveket) a szövegből!

  • vonatjegy
  • vonat
  • kocsi
  • hely
  • állomás
  • idő

Vegyük észre, hogy a feladat néhol pongyolán fogalmaz. A legfontosabb kérdés, amit tisztázni kell, hogy mit jelent az, hogy veszünk egy jegyet.

Mivel a valóságot modellezzük, képzeljük el, mi történik! Ha mi veszünk egy (hely)jegyet Pécs - Budapest között egy vonatra, akkor azt is meg kell adnunk, hogy melyik járatra: 7:14-kor indulóra, vagy 9:14-kor indulóra, esetleg valamelyik későbbire. Ugyanaz a vonat többször is közlekedhet ugyanazon a vonalon, oda-vissza, napjában akár többször is, de amikor jegyet veszünk, akkor csak az adott járatra szól a jegyünk, nem utazhatunk egyetlen jeggyel oda-vissza. Ebből az következik, hogy fel kell vennünk egy új osztályt a járatnak.

A vasúttársaságnak a jegy eladása után arra is figyelnie kell, hogy ugyanazt a helyet nem adhatja el másnak, tehát nyilván kell tartania a foglalásokat az adott járatra. A vonatjegy igazából egy információ és igazolás arról, hogy azt a helyet nekünk foglalták le, tehát a foglalásnak is kell egy új osztály.

81. Definíciók, kapcsolatok

Definiáljuk kicsit pontosabban a fogalmakat, hogy meg tudjuk határozni a felelősségi köröket! Ez elengedhetetlen ahhoz, hogy az osztályok közötti kapcsolatokat kialakítsuk.

  • Az állomásról semmit nem kell tudnunk a nevén kívül. A pontos helye sem érdekes, az utasoknak kell utánajárni.
  • A hely a jegyeladás szempontjából csak egy szám. Az utazást magát nem kell modelleznünk, tehát nem kell figyelembe vennünk az utasokat, stb.
  • Egy kocsiban sok hely van, és van száma, ezen felül semmi teendőnk vele.
  • A vonatnak a feladat szerint száma van, a valóságban inkább neve (pl. IC 802 Tubes). A vonat, azaz fizikai valójában a szerelvény, kocsikból áll.
  • A járat az, amikor egy vonatot elküldünk A-ból B-be egy adott időpontban. Egy járatra minden helyet csak egyszer adhatunk el, így a járatnak nyilván kell tartania a foglalásokat.
  • A foglalás egy bejegyzés egy járatban, hogy a vonatán egy kocsiban lévő egyik hely már nem adható ki.
  • A vonatjegy pedig egy papírdarab, ami igazolja, hogy az adott foglalás a miénk.

Ebből már látszik, hogy mely osztályok melyik másik osztályokat ismerik. Nézzük meg közelebbről, hogy az egyes relációk a három "ismerik" – kompozíció, aggregáció, asszociáció – közül melyikek lehetnek! Ha kicsit máshogy gondolkodunk, ebben komoly eltérések lehetnek, mert ez az erőforrás-kezelést fogja meghatározni.

  • A kocsi helyekből áll: kompozíció.
  • A vonat kocsikból áll: kompozíció.
  • A járat ismeri a vonatát, de nincs hatással az élettartamára (a vasúttársaság felelőssége, hogy működő vonatot küldjenek a járatra): asszociáció.
  • A járat tudja, hogy melyik állomások között közlekedik, de az állomások élettartamára nincs hatással: asszociáció.
  • A járat része az összes foglalás (járat törlésekor a foglalások is törlődnek): kompozíció.
  • A foglalás egy adott járatra, adott kocsira és adott helyre vonatkozik: ezek asszociációk.
  • A foglaláshoz hozzá tartozik, hogy melyik vonatra szól (mert pl. nem minden vonatban van 18-as kocsi), de a foglalás a vonatot el tudja érni a járaton keresztül is.
  • A vonatjegy egy foglalásra hivatkozik: asszociáció.

Ennyiből már tudunk rajzolni egy kezdetleges osztálydiagramot.

class1

Egy fontos dolog azonban hiányzik. Azt mondtuk, hogy a vasúttársaság felelőssége, hogy a járatok létező vonattal, létező állomások között közlekedjenek. Szintén a vasúttársaság feladata nyilvántartani a járatokat, hogy a felhasználó azok közül választhasson, és vehessen rájuk jegyet. Tehát kell még egy osztály a vasúttársaságnak, ami tartalmazza a vonatokat, járatokat és vasútállomásokat.

Ezzel kiegészítve így néz ki az osztálydiagram:

class2

A fenti diagramot kiegészítettük a további, nyilakkal nem jelölt adattagokkal, és a multiplicitással, hogy a későbbi egyszerűsítési lépések jól követhetők legyenek.

class3

82. Egyszerűsítések

Mivel a modellünknek kizárólag a jegyeladásra kell koncentrálnia, néhány osztálynak nincs felelőssége. Ezeket akár el is hagyhatjuk, és a kódban máshogy jelenítjük meg.

  • A feladat nem követeli meg, hogy egy járaton legyenek köztes állomások, így könnyítsük meg a dolgunkat azzal, hogy a vonatok csak a két végállomásnál állnak meg. A valóságban nem egészen így van, de mivel a feladat nem kérte, ezért elegendő ezzel foglalkoznunk. A valóságtól egyébként nem áll messze, pl. a repülőjáratok így közlekednek. Ezzel sokat fog egyszerűsödni a helyfoglalás algoritmusa is.
  • Az állomás számunkra csak egy sztring. Akár helyettesíthetjük is egy sztringgel. Egyszerű, ismert típusokat pedig nem szoktunk a osztálydiagramon feltüntetni, így ezt inkább sima adattagként ábrázoljuk.
  • A hely nem más, mint egy szám, használhatunk helyette inteket is. Sőt, ha feltételezzük, hogy egy kocsiban 1-n-ig vannak számozva a helyek, akkor elég azt tárolnunk, hogy hány hely van a kocsiban.
  • A foglalás a vasúttársaság rendszerében van rögzítve, a vonatjegy így valójában egy foglalás adatai kinyomtatva. Mondhatnánk azt is, hogy a vonatjegy egy pointer a foglalásra, amivel egy egyszerű, konzolos programban semmit nem kell csinálnunk azon kívül, hogy kiírjuk. Így ezt egyszerűen elhagyhatjuk.
class4

83. Műveletek

Keressük meg a feladat szövegében a műveleteket, azaz az igéket!

  • jegyen feltüntetni adatokat: azaz kiírni egy foglalást
  • jegyet kiadni: azaz létrehozni, és kiírni egy foglalást
  • vonatot felvenni: a feladatkiírás nem különbözteti meg a vonatot és a járatot, így valószínűsíthetően arra gondolt, hogy járatot is kell tudni felvenni

További, józan ész elvárásai szerinti funkciókat is meghatározhatunk, ami ahhoz kell, hogy működőképes legyen a program:

  • vonathoz kocsit felvenni: így kezdetben lehet kocsi nélküli a vonat
  • állomást felvenni

Az osztálydiagram kiegészítve ezekkel a műveletekkel:

class5

84. Adatszerkezet

Eltöprenghetünk azon, hogy a kompozíciókat - asszociációkat hogyan jelenítsük meg a kódban, ez azonban közel sem egyszerű.

Az UML-es részben írtak szerint a kompozíciót érték szerinti tartalmazással, az asszociációt pedig sima pointerrel érdemes megvalósítani. Ahol n objektumot kell tartalmazni, a kézenfekvő megoldás az std::vector, amit pont az ilyen esetekre találtak ki. Esetünkben azonban ez több problémával is járna.

Az std::vector egy dinamikus tömb, ami képes dinamikusan "megnyúlni", azaz ha betelik a memóriaterülete, újrafoglalni és az újat használni. Az újrafoglalás során a memóriában máshova kerülhetnek az objektumok. Így ha van egy pointerünk, ami a vektor egyik elemére mutat, akkor az újrafoglalás során kikerülhet alóla a memóriaterület, így a pointer érvénytelenné válik. Ezt hívják invalidálódásnak (angolul invalidation), ahogy az iterátoros fejezetben részletesen kifejtettük. Az érvénytelen pointer neve angolul dangling pointer. A mi esetünkben például vonat hozzáadásánál fordulhat elő (az ábrán pirossal):

invalidal1

Így ha új vonatot szúrunk be, a korábban hozzáadott járatok Vonat-ra mutató pointere invalidálódhat.

Szintén sorszám szerint indexelhető tároló, azaz sorozattároló az std::deque. A deque garantálja, hogy a végeire beszúrásnál nem invalidálódnak a létező elemekre mutató iterátorok (vagy pointerek), azonban ezt két okból sem érdemes használnunk. Egyrészt, mert nem erre való, hanem egy két végű sor (double-ended queue). Másrészt pedig a feladat kiköti, hogy későbbi funkciókra – köztük a vonat törlésére – készen kell állnia a modellnek. Márpedig egy deque közepéről való törlés már sajnos invalidál.

Ez a probléma – mint minden probléma, ahogy a "programozás alaptétele" kimondja – megoldható az indirekció egy új szintjének bevezetésével. A mi esetünkben érdemes a vektorba dinamikusan foglalt elemeket tenni, így az elemek újrafoglalásnál csak a tényleges vonatokra mutató pointer helyét foglaljuk újra, az egyes vonatokat nem:

invalidal2

A dinamikusan foglalt vonatoknál azonban ránk hárulna a dinamikus memóriakezelés feladata, ami a kódot feleslegesen bonyolíthatja. Ennek elkerülése végett érdemes sima pointer helyett valamilyen okos pointert használni, ami megoldja a dinamikus memóriakezelést.

C++11 sarok: std::unique_ptr

Ehhez a C++11-es std::unique_ptr illik, ami a dinamikusan foglalt vonatok élettartamát menedzseli. A konstruktorában dinamikusan foglalt pointert vesz át, amire a destruktorában delete-et hív, és gondoskodik arról, hogy véletlenül ne tudjuk elszivárogtatni a memóriát. Ez az okos pointer egyébként nem másolható, de pl. függvényből érték szerint visszaadható. Ennek a mikéntje a Prog2 kereteibe sajnos nem fér bele. A részletes magyarázathoz egyes C++11 nyelvi elemek ismeretére lenne szükség. A használat szempontjából azonban ez nem lényeges.

Ugyanez a helyzet áll elő, ha egy vonatot másolunk, akkor a foglalásokban lévő Kocsi* mutathat rossz helyre. Ezt úgy tudjuk kikerülni, hogy a foglalások a kocsi számát ismerik (ahogy a helynek is csak a számát tartalmazza, hiszen Hely osztály nincs is).

A vonatokat másolni egyébként valószínűleg nem kell (azonos névvel két különböző vonat amúgy sem létezhet), ahogy járatokat sem. Ráadásul járatokat nem is szabad másolni, ott ugyanis a Foglalas objektumoknak van pointere a tartalmazó Jarat-okra. Így ha egy járatot átmásolunk, majd a régit megszüntetjük, a benne lévő foglalások Jarat pointerei mind érvénytelenné válnának.

Ez a probléma kikerülhető úgy, hogy a Foglalas-ok nem ismerik a Jarat-okat, mert egyedül a kiírás miatt kell. A kiírást pedig úgyis a Jarat-on keresztül intézzük, tehát ha nincs más ilyen műveletre szükség, és a foglalásokra nincs máshol nyilvántartás, ezt elhagyhatjuk.

A Jarat-ok tárolásánál érdemes megfontolnunk azt, hogy a felhasználó a járatok között valamilyen szempont (idő, állomás) szerint keresni szeretne. Ehhez kapóra jönne egy rendezett tároló, ami a keresést egyszerűsítené és gyorsítaná. Érdemes lenne az indulási idő szerint rendezni, hiszen a felhasználónak azt úgyis meg kell adnia, és úgy könnyebb pl. átszállásos útvonalakat keresni.

Ilyen, valamilyen kulcs szerint rendezett tároló az std::map (és az std::multimap), amelynek az alapszintű használatát az STL-es fejezet mutatja be. Az STL-es extra írásban is olvashattok róla. A számunkra legfontosabb tulajdonsága, hogy a map-be való beszúrás nem invalidálja a korábban beszúrt elemeket. Ezt is használhatnánk dinamikusan foglalt járatok helyett.

Ezek olyan tervezési megfontolások, amihez hasonlókkal ki-ki a saját nagyházijában szembesülni fog. Elképzeltünk egy lehetséges "felállást", az ebből kiinduló megoldás kódban nagyjából így jelenik meg:

class Kocsi
{
    int szam;
    int max_hely;
};

class Vonat
{
    std::string nev;
    std::vector<Kocsi> kocsik;
};

class Foglalas
{
    int kocsi;
    int hely;
};

class Jarat
{
    Vonat * vonat;
    std::vector<Foglalas> foglalasok;

    DatumIdo indulasi_ido;
    DatumIdo erkezesi_ido;
    std::string indulo_allomas;
    std::string erkezo_allomas;
};

class MAV
{
    std::vector<std::string> allomasok;
    std::vector<std::unique_ptr<Vonat>> vonatok;
    std::multimap<DatumIdo, Jarat> jaratok; // indulasi_ido szerint kulcsolva
};

Javasoljuk az STL tárolók használatát az ilyen esetekre. Ha a feladat és a laborvezető nem engedélyezi, akkor is érdemes a tervezésnél ezeket figyelembe venni, majd az implementáció során a felhasználandó STL tárolóhoz hasonlót magunknak írni. Ezekről a tárolókról a következő fejezetben lesz részletesebben szó.

STL eszközök és használatuk

A C++ tartalmaz csomó olyan adatszerkezetet és osztályt, amire minden programnak szüksége lehet. Ilyenek a sztring, a duplán láncolt lista, a dinamikus tömb és még sok egyéb. Ezt az osztály gyűjteményt STL-nek, "Standard Template Library"-nek hívják. A programozást nagyban megkönnyítik, mert ezek a C++-ban szabványosak, bármikor használhatóak, nem kell őket újra megírni.

Ezeknek az osztályoknak a részletes megértéséhez szükség van az eddig bemutatott C++ nyelvi elemek ismeretére is (pl. osztály sablonok, iterátorok), ezért általában a félév végén szoktak szerepelni a tananyagban. Az egyszerűbb felhasználáshoz azonban – amilyen például a nagy házi feladatokhoz is elegendő – ez nem olyan lényeges még.

A házik közül a legtöbb feladat (és az összes olyan feladat, amit érdemes választani) nem valamilyen alapvető osztály létrehozására koncentrál. Ilyenkor igazából nem éri meg gürcölni a saját, kézzel megvalósított dinamikus sztringgel (char* rémálom), saját dinamikus tömbbel stb. Hogy az energia ne ezeknek a megvalósítására menjen el, röviden leírjuk, milyen beépített tároló osztályokat ad a C++. Ennek az irománynak az áttanulmányozása után sokkal könnyebb lesz a házit elkezdeni.

Ahogy az OOP tervezésről szóló fejezet is említi, a házi tervezésekor és az írásának az elején érdemes feltételezni, hogy ezeket a tárolókat mind használhatjuk. Ha a feladat és a laborvezető máshogy rendelkezik, akkor persze muszáj egy hasonlót írni magunknak.

Az STL részletes dokumentációja elérhető itt:

Az osztályok mind az std névtérben vannak, és érdemes is kiírni. Az using namespace std; erősen ellenjavallott, a legváltozatosabb, furcsa hibákat tudja okozni.

Eddig megismert STL eszközök

87. std::string

Dinamikusan átméreteződő, okos string, az összes értelmes operátora átdefiniálva. Hasonló a memóriakezeléses fejezetben bemutatotthoz, de annál jóval bővebb funkcionalitású, és sokkal ügyesebben kezel dinamikus memóriát. Például nem minden karakter hozzáfűzésénél foglal és másol memóriát, csak ritkán.

#include <string>

int main() {
    std::string s;                  // Üres stringet hozunk létre.
                                    // A méretével nem kell foglalkozni.
    std::cout << s.size();          // Tudja a méretét, le lehet kérdezni,
    std::cout << s.length();        // És hívhatjuk hossz-nak is.
    std::string m("Hello vilag!");  // 0-val lezárt sztringet is átvehet a konstruktora.
    std::string a = "alma";         // Ez a konstruktor implicit.
    std::string k = "korte";

    s = "Hello";                    // Beállítjuk valamilyen értékre.
    s += " vilag";                  // Hozzátoldunk egy másik sztringet.
    s += '!';                       // Hozzátoldunk egy karaktert.
    s.push_back('!');               // Hozzátoldunk egy karaktert, mint egy vector-hoz
    s.pop_back();                   // Ledobunk a végéről egy karaktert, mint a vector
    std::cout << s;                 // Kiírjuk a konzolra.
    std::cin >> s;                  // Beolvassuk valamilyen szóközig (azaz egy szót).
    std::getline(std::cin, s);      // Beolvassuk enterig.
    std::getline(std::cin, s, '\t');// Beolvassuk tablulátorig.

    if (s == m)                     // Összehasonlítjuk.
        std::cout << "egyformák!";
    if (a < k)
        std::cout << "a < k";       // a (kvázi) előbb van az ABC-ben.

    s[0] = 'h';                     // hello vilag!
    s.erase(2, 3);                  // Töröl a belsejéből: "he vilag!"
    s = m.substr(2, 3);             // m belsejéből egy darab, "llo"
    int i = a.find("lm");           // Keresés: 1, mert "alma" szóban "lm" pozíciója.
    a.insert(3, "er");              // Beszúrás: "alma"-ból almera lesz.
    std::cout << a.c_str();         // A tartalmáról C-s sztringet, azaz
                                    // 0-val lezárt const char*-ot is kérhetünk.
}

Ezen kívül van iterátora, pont, mint az std::vector-nak. Például így is bejárhatunk egy sztringet, és kiírhatjuk karakterenként külön sorba:

std::string s = "hello";
for (auto it = s.begin(); it != s.end(); ++it)
    std::cout << *it << std::endl;

Az iterátora random access, tehát akár rendezhetjük is (erről később is lesz szó).

std::string s = "hello";
std::sort(s.begin(), s.end()); // ehllo

Az egyetlen lényeges különbség az előadáson / jegyzetben / laboron látott sztringekhez képest, hogy a belsejében is lehet lezáró 0, akár csupa '\0'-kból is állhat:

std::string s(10, '\0'); // 10 db lezáró nulla

88. std::vector

A template-es fejezetben volt szó egy egyszerű vektor osztálysablonról, ami bármilyen elemeket tartalmazhat. Emlékeztetőül: a vektor egy átméretezhető, egydimenziós tömb, a tartalmazott típust <> között kell megadni. Például egész számokat tartalmazó tömb: std::vector<int>.

Az ott bemutatottnál jóval bővebb funkcionalitású az std::vector, és a dinamikus memóriakezelést sokkal profibban csinálja: nem hív felesleges konstruktort, és csak "ritkán" foglal újra.

A használata pont olyan, mint egy tömbnek, de a hosszát is le tudjuk kérdezni a .size() tagfüggvénnyel. Ezen kívül specialitása, hogy úgy "varázsol" a dinamikus memóriával, hogy ha a végére szúrunk be vagy veszünk el elemet, az konstans idejűnek tekinthető (de természetesen dinamikus memóriaterületre kerül).

#include <vector>

int main() {
    std::vector<int> t;                 // Tömb, ami egyelőre üres.
    std::vector<double> d(50);          // 50 elemű double tömb.

    d[23] = 3.14;
    t.push_back(1);                     // A tömb végére rak egy új számot,
    t.push_back(2);                     // és meg is növeli a tömb méretét.

    for (int i = 0; i < t.size(); ++i)  // Kiírjuk a tömb tartalmát,
        std::cout << t[i] << ' ';       // az aktuális méretét a size() adja meg.
    std::cout << std::endl;

    t.clear();                          // Kitörli a tömböt, újra üres lesz.

    d[60] = 10.3;                       // NEM SZABAD!!! Továbbra is 0..49 között
                                        // indexelődik az 50 elemű tömb.

    d.at(120) = 0.012;                  // Ugyanaz, mint az indexelő, csak
                                        // ellenőrzi, hogy nem hivatkozunk-e
                                        // nem létező indexre. Ha igen, kivételt dob.
}

Szintén van iterátora, ami random access, tehát ugyanúgy járhatjuk be, vagy rendezhetjük, mint pl. a sztringet:

#include <vector>
#include <algorithm>                // rendezéshez, std::sort
#include <cstdlib>                  // rand() véletlenszámokhoz

int main() {

    std::vector<int> t;
    t.resize(100);                      // Méretezzük át a tömböt 100 eleműre.
    srand(time(0));                     // Véletlenszám-generátor indítása

    for (int i = 0; i < t.size(); ++i)
        t[i] = rand() % 200;            // Minden elem: véletlenszám 0..199 között

    std::sort(t.begin(), t.end());      // Növekvő sorrendbe az egész
                                        // tömb, az elejétől a végéig.

    for (auto it = t.begin(); it != t.end(); ++it)
        std::cout << *it << ' ';        // Bejárjuk, és szóközzel elválasztva kiírjuk.
    std::cout << std::endl;
}

Érdemes abba belegondolni, hogy az egyszerű másolás, mint lehetőség, illetve a méret az adatokkal történő egységbe zárása miatt már sokszor megéri std::vector-t használni sima tömb helyett. Például nem kell átadnunk egy függvénynek a tömb méretét, mert az már tartalmazza azt:

double atlag(const double *tomb, int meret);
double atlag(const std::vector<double> &tomb);

Egy függvény, amely valahány (nem kell tudni előre, mennyi) stringgel tér vissza:

std::vector<std::string> beolvas();

89. std::deque, std::stack, std::queue

Az std::deque egy mindkét végén módosítható várakozási sor. Az std::stack és az std::queue pedig std::deque-et használó adapterosztályok. Bővebben az adapteres fejezetben volt róluk szó.

90. std::list

Az iterátoros fejezetben bemutattunk egy egyszerű, egyszeresen láncolt listát. Az std::list is ilyen, csak kétszeresen láncolt, és a lista mellett a méretet is nyilvántartja.

#include <list>
#include <cctype>

int main() {

    std::list<std::string> szavak;      // stringekből álló lista (!)

    szavak.push_back("alma");           // alma  (... került az üres lista végére)
    szavak.push_back("korte");          // alma, korte
    szavak.push_front("malna");         // malna, alma, korte   (elejére teszi!)
    szavak.push_front("villa");         // villa, malna, alma, korte

    std::cout << szavak.size() << " szót tartalmaz.\n";
    szavak.remove("villa");             // kitöröljük a kakukktojást

    std::cout << "Az első elem: " << szavak.front() << std::endl;
    std::cout << "Az utolsó elem: " << szavak.back() << std::endl;

    std::list<std::string> masolat;
    masolat = szavak;                   // Lemásoljuk a listát: értékadás!

    while (!masolat.empty()) {              // Amíg nem üres a lista:
        std::cout << masolat.back() << ' '; // Kiírjuk az utolsó elemet...
        masolat.pop_back();                 // és kitöröljük.
    }
}

A legtöbb tároló osztályokhoz létezik iterátor osztály, amely legegyszerűbb esetben arra jó, hogy az összes adaton egy ciklussal végig tudjunk menni. A láncolt listánál az adatszerkezet miatt az iterátor tényleg erre és csak erre jó.

Összevissza nem ugrálhatunk, mint mondjuk egy vektornál, mert mindig csak a következő és az előző elemet ismerjük. Épp ezért sehogy máshogy nem érjük el a lista belsejét, csak iterátorokkal; egy lista igazából iterátor nélkül nem sok mindenre jó.

std::list<std::string>::iterator it;    // Sztringekből álló lista iterátora.

// Indulunk a lista elejéről (begin). Addig megyünk, amíg
// el nem érjük a lista végét (end). Amikor egy adott elemmel
// elvégeztük a dolgunkat, ugrunk a következőre (++).

for (it = szavak.begin(); it != szavak.end(); ++it) {
    char & c = (*it)[0];        // Az iterátor által mutatott sztring
                                // kezdő karakterére referencia.
    c = toupper(c);             // nagybetűsítjük az első karakterét
    std::cout << *it << ' ';    // kiírjuk a szót.
}

Figyeljük meg, hogy a lista belső felépítéséről semmit nem tudunk, ez el van rejtve előlünk. Az iterátor viszont együtt dolgozik a listával; tudja, hogy a lista belsejében mi hogyan van megoldva. De azt nekünk nem kell tudni, legyen az az iterátor dolga.

A belső adatszerkezet miatt az iterátor elég buta, viszont néhány műveletet meg lehet oldani a vektornál sokkal egyszerűbben, az elemek átláncolásával. Például a rendezést:

szavak.sort();                          // rendezzük ábécésorrendbe!

std::list<std::string>::iterator it;
for (it = szavak.begin(); it != szavak.end(); ++it)
    std::cout << "*it << ' ';

Szintén az adatszerkezet sajátossága, hogy lassú. Ha sorban szeretnénk belepakolni az elemeket, majd bejárni, a vektor gyorsabb. Ha az elejére és a végére, akkor a deque gyorsabb. Ha a közepére, sok esetben akkor is gyorsabb a vektor, még az átméretezéssel együtt is. Bizonyos speciális problémák megoldására vagy megkerülésére viszont jól használható, pl. iterátor invalidálódás, vagy költségesen mozgatható elemek.

91. C++11 sarok: inicializáló lista

A tárolók kapcsán az egyik legfontosabb hiányosság C++11 előtt az volt, hogyan inicializáljuk őket beégetett adatokkal. A tömböt ugyanis lehet egy beépített nyelvi elemmel:

int arr[] = {5, 2, 3, 4, 1}; // 5 elemű, intekből álló tömb, előre feltöltve:

C++11-ben bevezettek egy új típust a jobb oldalon álló, kapcsos zárójeles kifejezésre. Ez lett az std::initalizer_list. Arra jó, hogy a tárolóknak így már lehet írni olyan konstruktort, ami inicializáló listát vesz át. Az STL tárolókhoz meg is írták őket:

std::vector<int> t = {1, 2};
std::list<std::string> szavak = {"villa", "malna", "alma", "korte"};
std::deque<int> sor = {1, 2, 3, 4, 5};

C++11-ben a kapcsos zárójeleket még néhány új dologra felhasználták, amivel néha ütközhet az inicializáló lista szintaxisa, de ebbe nem megyünk bele.

92. std::fstream

A C++ fájlműveleteket megvalósító osztályai. Jól kombinálhatók az std::string-gel.

#include <fstream>
#include <iomanip>      // manipulátorok, lásd lent

int main() {

    std::ifstream is;               // input file stream, vagyis olvasunk egy fájlból

    is.open("fajl.txt");            // megnyitjuk a fájlt
    if (!is) {
        std::cerr << "Nincs ilyen fájl!";
        // ... meg csinálunk is valami értelmeset ilyenkor
    }

    std::ofstream os("out.txt");    // a párja az std::ofstream. a konstruktoruk megnyitja

    std::string s;                  // A string szükség szerint átméreteződik!
    int i = 0;
    while (std::getline(is, s)) {   // Beolvasunk egy teljes sort.

        // Számozva kiírjuk a sorokat. std::setw manipulátor: az utána
        // következő kiírt dolgot 5 karakter szélességűre veszi.
        // Mintha printf %5d lenne.
        os << std::setw(5) << ++i << ". sor: " << s << std::endl;
    }

    is.close();                     // kézzel is bezárhatjuk, de a destruktor
    // os dtor                      // automatikusan bezárja a fájlt, ha még nyitva van

}

93. <algorithm>

A generikus algoritmusokról szóló fejezetben volt szó arról, hogy az STL-ben találhatók ilyenek.

A legfontosabb közös jellemzőjük, hogy iterátorokon vagy iterátorokkal megadott tartományokon dolgoznak. Számos algoritmusnak, amelyik objektumot vesz át, van predikátumot átvevő változta is.

Eddig ezekről volt szó az iterátoros fejezetben:

  • std::find: megtalál egy tartományban egy elemet
  • std::find_if: megtalál egy tartományban egy valamilyen feltételnek eleget tevő elemet
  • std::min_element: megtalálja a legkisebb, vagy a predikátum szerinti legkisebb elemet
  • std::max_element: ugyanez, csak a legnagyobb elemet
  • std::count: megszámolja, hányszor szerepel a tartományban az elem
  • std::count_if: megszámolja, hány elem van, ami a feltételnek eleget tesz

Korábban említettünk még néhány STL algoritmust, pl. a rendezés:

#include <algorithm>
#include <vector>
#include <cstdlib>

bool kozelebb_0_hoz(int a, int b)
{
    return std::abs(a) < std::abs(b);
}

int main() {
    std::vector<int> t = {-3, 2, 5, 1, 4};
    std::sort(t.begin(), t.end());                      // növekvő sorrendben
    std::sort(t.begin(), t.end(), std::greater<int>()); // csökkenő sorrendben
    std::sort(t.begin(), t.end(), kozelebb_0_hoz);      // abszolútérték szerint

    // ha rendezve sorrendben vannak, lehet benne binárisan keresni,
    bool benne_van = std::binary_search(t.begin(), t.end(), kozelebb_0_hoz, 4);

    // de az elemet pl. a lower_bound adja vissza:
    auto it = std::lower_bound(t.begin(), t.end(), kozelebb_0_hoz, 4);
    if (it != t.end())
        std::cout << *it << std::endl;
}

Ezeket név szerint felesleges megtanulni, viszont ha kódolás során egy hasonlóan általános algoritmus szagát érezzük, érdemes megnézni, valaki megoldotta-e már. Erre a célra a cppreference.com-ot és a cplusplus.com-ot érdemes használni. Előbbi általában egy egyszerű implementációt is mellékel a leírás mellé, algoritmusoknál ez sokat segíthet a megértésben.

94. C++11 sarok: std::unique_ptr

Előfordul, hogy 1-1 objektumot kell dinamikusan foglalnunk, hogy ezzel valamilyen problémát megoldjunk. Például heterogén kollekciónál, vagy a vektorba tett elemek invalidálódásánál. Ez viszont mindig hozza magával azt a problémát, hogy a dinamikusan foglalt memória kezeléséért mi vagyunk a felelősök.

Természetesen nem akarjuk magunk kezelni a memóriát minden ilyen esetben. A RAII elv értelmében egy erre való osztályt kell használnunk. Az ilyen, 1 db objektum élettartamáért felelős osztályokat okos pointereknek hívjuk.

Az eredeti C++98 szabvány is magában foglalt egy okos pointert, ez volt az std::auto_ptr. Ez azonban nagyon nehézkesen használható (pl. nem szabad STL tárolóba tenni), így C++11-ben gyorsan elavulttá nyilvánították.

A helyébe az std::unique_ptr lépett. Attól unique, hogy az általa menedzselt objektumnak ő az egyetlen, kizárólagos tulajdonosa. Nem másolható, de a tartalma átadható másik unique_ptr-nek.

#include <memory>       // unique_ptr
#include <utility>      // move, lásd lent

class Ember {
    // ...
};

std::unique_ptr<Ember> f() {
    // a konstruktora egy dinamikusan foglalt Ember*-ot vár
    std::unique_ptr<Ember> valaki(new Ember("Bjarne"));

    // érték szerint visszaadható
    return valaki;
}

int main() {

    std::unique_ptr<Ember> bjarne = f();    // temporálist el lehet tárolni belőle
    std::unique_ptr<Ember> masik;           // most még NULL

    masik = bjarne;                         // ERROR, nem másolható!
    masik = std::move(bjarne);              // mozgatni szabad

    if (bjarne == NULL) {                   // NULL is lehet
        std::cout << "bjarne most kiürült." << std::endl;
    }
    if (masik) {                            // nyíl operátor
        std::cout << "masik-ba átkerült " << masik->nev() << std::endl;
    }

    Ember *ptr = masik.get();           // sima C-s pointer, nem szabad felszabadítanunk,
                                        // az a unique_ptr destruktorának a dolga!
    Ember& ref = *masik;                // operator*

    // destruktora felszabadít
}

Ha heterogén kollekcióra van szükségünk, ezt érdemes a tárolóba tennünk.

#include <memory>
#include <utility>

// Alakzat, Negyzet, Teglalap, Kor

int main() {

    std::vector<std::unique_ptr<Alakzat>> alakzatok;

    std::unique_ptr<Alakzat> kor(new Kor(3.0));
    alakzatok.push_back(kor);               // ERROR: nem másolható
    alakzatok.push_back(std::move(kor));    // viszont mozgatható

    // csinálhatunk egy temporális unique_ptr-t, azt gond nélkül betehetjük
    alakzatok.push_back(std::unique_ptr<Alakzat>(new Negyzet(1.2)));

    // vagy helyben konstruálhatjuk a unique_ptr-t egy Alakzat*-ból
    alakzatok.emplace_back(new Teglalap(3.0, 4.0));
}

További hasznos STL eszközök

Eddig nem, vagy csak érintőlegesen volt szó néhány olyan STL tárolóról, amik nagyháziban vagy bármilyen nagyobb projektnél jól jöhetnek.

96. std::set

Ez egy halmaz osztály, bármilyen típusokat képes tárolni. Az elemeket rendezett struktúrában tárolja, így összehasonlíthatónak kell lenniük (operator< és operator==). Adott értékről meg tudja mondani, szerepel-e benne. Az iterátorok itt is használhatóak.

#include <set>

int main() {

    std::set<int> s;            // Egész számok halmaza

    std::cout << "Írj be számokat, 0=vége!" << std::endl;
    int i;
    std::cin >> i;
    while (i != 0) {
        if (s.find(i) != s.end()) // Így jelzi, ha nincs benne
            std::cout << "Ez már benne volt!\n";
        else                    // Berakjuk a halmazba a kapott számot.
            s.insert(i);        // Természetesen, ha már benne volt,
                                // amúgy se történne semmi.
        std::cout << "Most " << s.size() << " szám van a halmazban.\n";

        std::cout << "Kérem a következőt!\n";
        std::cin >> i;
    }

    std::cout << "A halmaz elemei: ";
    for (std::set<int>::iterator it = s.begin(); it != s.end(); ++it)
        std::cout << *it << ' ';
}

97. std::map

Asszociatív tömb, kulcsokhoz értékeket rendel hozzá. Mintha egy tömb lenne, amit nem számmal kell indexelni.

A kulcs szerint rendezett, ezért gyors benne a keresés. Ehhez a kulcsokat kell tudni összehasonlítani, pont úgy, mint a halmaznál.

#include <map>

int main() {

    // Sztringeket képezünk le egész számokra;
    // vagyis minden sztringhez rendelünk egy egész számot.
    std::map<std::string, int> m = {
        {"Ernőke", 4},
        {"Pistike", 7},
    };

    m["Orsika"] = 4;        // Mintha a tömböt indexelnénk, csak épp sztringgel.

    std::cout << "Ernőke, még csak " << m["Ernőke"] << " éves vagy." << std::endl;

    // Vigyázni kell, hogy ha olyan névvel indexeljük a map-et, ami még
    // nem szerepel benne, akkor létrejön az az elem! És kap kezdeti
    // értéket is, ami intek esetén nulla.
    //      std::cout<<m["NincsIlyen"]<<std::endl;
    // Ez a sor nullát írna ki, és létrehozna egy NincsIlyen nevű embert!!!
    // Ha ellenőrizni szeretnénk, hogy van-e benne, akkor a find
    // függvény használható.
    std::cout << "Kinek szeretnéd tudni a korát? ";
    std::string s;
    std::cin >> s;

    auto talalat = m.find(s);
    if (talalat != m.end())
        std::cout << s << ' ' << *talalat << " éves." << std::endl;
    else
        std::cout << "Nincs ilyen név, hogy " << s << "!" << std::endl;

    // Persze itt is lehet iterátorunk, viszont nem csak az értéket, hanem
    // a kulcsot is látjuk. it->first a kulcs, it->second pedig az érték,
    // mert az std::map belül std::pair elemeket tárol.
    // Sztringeket egészekre leképező map iterátora:
    std::map<std::string, int>::iterator it;
    for (it = m.begin(); it != m.end(); ++it)
        std::cout << it->first << " még csak " << it->second << " éves." << std::endl;
}

A map belül kulcs-érték-párokat tárol, azaz std::pair<Kulcs, Ertek>-eket. Az std::pair csak annyit tud, hogy van first és second adattagja.

98. std::multiset és std::multimap

Pont, mint a set és a map, csak adott kulccsal több érték is szerepelhet.

Adott kulcs szerinti tartományt az equal_range függvénnyel kereshetünk. A count függvénnyel kérhetjük le az elemek számát.

Érdekesség, hogy a sima std::map-nek és std::set-nek sincs contains függvénye, ahelyett is a count használható.

99. std::shared_ptr

Az UML-es fejezetben volt szó az aggregációról, aminek egyik speciális esete a megosztott, közös erőforrás. Azaz több objektum is használhatja ugyanazt a (mondjuk Negyzet) objektumot. Mindaddig szükség van a négyzetre, amíg legalább egyvalakinek kell. Ha már nem kell senkinek, akkor lehet felszabadítani.

Például ha szeretnénk egy alakzatokból álló heterogén kollekciót, azt csinálhatjuk úgy is, hogy ne csak a rajztábla érje el az alakzatokat, hanem az is, aki beletette. Így mindketten tulajdonosai lesznek.

Aki használja a dinamikusan foglalt négyzetet, annak van egy shared_ptr-e a négyzetre, a négyzet maga pedig dinamikusan foglalt. Ha a shared_ptr-t lemásoljuk, akkor keletkezett egy új "használója" a négyzetnek. Tehát a négyzet mellett nyilván kell tartani, hogy hány darab shared_ptr hivatkozik ugyanarra a négyzetre, és ha már egy sem, akkor lehet felszabadítani:

Emellett a shared_ptr úgy viselkedik, mint minden rendes okos pointer: konstruktora dinamikusan foglalt objektumra mutató pointert vár, van nyíl és csillag operátora, és a destrukora akkor szabadítja fel az objektumot, amikor kell.

#include <memory>

// class Alakzat
// class Negyzet : public Alakzat

int main() {

    std::shared_ptr<Negyzet> n(
        // (120; 140), a=10, szin=kek
        new Negyzet(Point(120,140), 10, BLUE));

    // n referenciaszámlálója 1

    {
        std::vector<std::shared_ptr<Alakzat>> rajztabla;
        rajztabla.push_back(n); // n referenciaszámlálója 2 lett

        n->set_a(20);   // a=20 lett a rajztáblában lévő négyzet is,
                        // hiszen ugyanaz az objektum!

        // blokk végén megszűnik a rajztabla,
        // a referenciaszámláló lecsökken 1-re
    }

    // a main végén n megszűnik, a referenciaszámláló lecsökken 0-ra,
    // tehát maga a négyzet is felszabadul
}

Figyeljük meg, hogy a példában n típusa std::shared_ptr<Negyzet>, a vektorban viszont std::shared_ptr<Alakzat>-ok vannak. Ezt azért tehetjük meg, mert a shared_ptr-ek ugyanúgy kompatibilisek, ahogy a sima pointerek, az öröklés miatt.

Egyszerű példák

Az STL lényege az, hogy egyszerű feladatokat egyszerűen tudjunk megoldani.

Ez azért lehetséges, mert - a jegyzetben is bemutatott - objektumorientált elvek alapján készültek.

  • A RAII elv értelmében a memóriakezelés a tárolók feladata, és nem a mi kódunké.
  • A legalapvetőbb OOP elv, az SRP szerint a tárolóknak csak a tárolt elemek kezelése a feladata, más pedig nem.

Így a memóriakezelés, és a tárolók kezelése - végére beszúrás, rendezve beszúrás, törlés, stb. - nem a mi kódunkban van. A mi kódunkban tehát kizárásos alapon pusztán a feladat megoldása, lekódolása.

101. stdin leghosszabb szava

A feladat egyszerű: olvassunk be szavakat a standard bemenetről, és mondjuk meg, melyik volt közülük a leghosszabb!

Prog1-ből tanultuk, hogy elég mindig a leghosszabbat megjegyezni, a többit el is dobhatjuk.

Azt is tanultuk, hogy egy szó bármilyen hosszú lehet, és ezért dinamikusan kell foglalni - de C++-ban már nem a mi dolgunk. Az std::string beolvasó operátora épp szavanként olvassa be a bemenetet:

std::string leghosszabb;
std::string szo;

while (std::cin >> szo) // addig megy, amíg sikerült szót beolvasni
    if (szo.length() > leghosszabb.length())
        leghosszabb = szo;

std::cout << leghosszabb << std::endl;

102. stdin leghosszabb szavai

Csavarjunk rajta egyet: írjuk ki az összes leghosszabbat! Ehhez már nem elég egyetlen szót megjegyezni, hanem akármennyi lehet belőle: tegyük őket tömbbe! A dinamikus tömb pedig a vektor.

Ha találunk hosszabbat, mint a mostaniak, akkor dobjuk ki az eddigieket, és tegyük bele az újat. Ha ugyanolyan hosszút találunk, elég csak elrakni azt is.

// tegyünk bele egy üres szót, hogy ne legyen túlindexelés
std::vector<std::string> leghosszabbak = {""};
std::string szo;

while (std::cin >> szo) { // addig megy, amíg sikerült szót beolvasni
    if (szo.length() > leghosszabbak[0].length()) {
        leghosszabbak.clear();
        leghosszabbak.push_back(szo);
    } else if (szo.length() == leghosszabbak[0].length()) {
        leghosszabbak.push_back(szo);
    }
}

for (std::string const& leghosszabb : leghosszabbak)
    std::cout << leghosszabb << std::endl;

Érdemes belegondolni, hogy C-ben mennyire bonyolult lenne ehhez egy dinamikus sztringtömböt csinálni. Ezzel szemben C++-ban ebből semmit nem látunk.

103. stdin leghosszabb szavai, egyszer

A fenti megoldás minden leghosszabb szót annyiszor ír ki, ahányszor szerepeltek a bemenetben. Ha csak egyszer szabad kiírni minden leghosszabbat, akkor nem írhatjuk ki a vektor tartalmát egészben.

Az egyik megoldás, hogy nem vektorba szúrjuk be a szavakat, hanem egy olyan tárolóba, ami biztosítja, hogy nem lehet benne ugyanaz az elem többször: ez a halmaz, az std::set. Ehhez a vector-t kell kicserélni setre, a leghosszabbak[0]-t *leghosszabbak.begin()-re, a push_back-et pedig insert-re, és működne is.

Ezzel csak az a probléma, hogy a set-be beszúrás sokkal lassabb a vektorba beszúrásnál: O(log n) az átlagosan O(1) helyett. Valószínűleg kevés hosszú és sok rövid szó lesz, azaz rengeteg elemet szúrunk be, majd dobunk el, ha találunk hosszabbat.

Ehelyett érdemes megtartani a vektort, és csak akkor szüntetjük meg a duplikációkat, mielőtt kiírjuk. Ekkor már csak viszonylag kevés elemünk van, így annyira nem fáj a halmazba beszúrás.

Szinte minden STL tárolónak van olyan konstruktora, ami egy másik tároló elemeiből képes inicializálni magát, értelemszerűen a másik tárolóra iterátorokat (begin, end) kap. A set-nek is van ilyen:

std::vector<std::string> leghosszabbak; // beolvassuk

// átmásoljuk
std::set<std::string> egyszer(leghosszabbak.begin(), leghosszabbak.end());

for (std::string const& leghosszabb : egyszer)
    std::cout << leghosszabb << std::endl;

104. stdin leghosszabb sorai

Csavarjunk rajta még egyet: ne a leghosszabb szavakat, hanem a leghosszabb sorokat keressük meg!

Ehhez a beolvasás módján kell változtatni, egész sort kell beolvasni a sztringünkbe. Szerencsére létezik erre egy függvény, az std::getline, erre kell lecserélnünk a beolvasó operátort:

while (std::getline(std::cin, sor)) { // addig megy, amíg sikerült sort beolvasni
    // ugyanaz, mint eddig
}

105. Stroustrup kedvenc feladata

Bjarne Stroustrup a C++ nyelv atyja. Számos C++-ról szóló könyvet írt, és szinte mindben benne van ez a feladat:

Számoljuk meg, egy szövegfájlban melyik szó hányszor szerepel!

Például ha ez van a fájlban:

alma barack körte barack alma barack

Ezt kell kiírnunk:

2x szerepelt ez: alma
3x szerepelt ez: barack
1x szerepelt ez: körte

Elmélkedjünk az adatszerkezetről! Nekünk nyilván kell tartani minden szóhoz az eddigi előfordulásainak számát. Ha találunk egy új szót, akkor meg kell keresni az eddigi előfordulásait, és azt növelni eggyel.

Ide egy asszociatív tömb kell: sztringeket képezünk le egész számokra. Az STL-ben van ilyen, ez az std::map.

#include <iostream>
#include <map>
#include <string>
#include <fstream>

int main() {

    std::ifstream is("fajl.txt");   // konstruktor egyből meg is nyitja
    if (!is) {
        std::cerr << "Nem lehet megnyitni!" << std::endl;
        return 1;
    }

    std::string s;
    std::map<std::string, int> m;   // sztringeket képzünk le egészekre

    // Beolvasunk egy szót...
    while (is >> s) {
        // A map megfelelő számát növeljük eggyel. Ha még
        // nem volt olyan, létrejön 0 értékkel, és az növelődik.
        m[s]++;
    }

    std::map<std::string, int>::iterator it;
    for (it = m.begin(); it != m.end(); ++it) {
        std::cout << it->second << "x szerepelt ez: "
                  << it->first << std::endl;
    }
}

106. Stroustrup feladata rendezve

Figyeljük meg, hogy a fenti megoldás szavak szerint rendezve írja ki a gyakoriságokat. Csavarjunk rajta egyet: írjuk ki a szavakat gyakoriság szerint rendezve!

Tehát ha ez van a fájlban:

alma barack körte barack alma barack

Ezt kell kiírnunk:

3x szerepelt ez: barack
2x szerepelt ez: alma
1x szerepelt ez: körte

Ehhez a jelenlegi std::map<std::string, int> alkalmatlan, mert mindig a sztring szerint fogja rendezve tartani az értékeket. Egy másik adatszerkezetet kell találni, amiben lehet gyakoriság szerint rendezve tárolni.

Az eredeti map-et viszont meg kell tartani, mert a gyakoriságok számolásához muszáj a sztring szerint rendezett tárolót használni, tehát a map tartalmát kell átalakítani valami más formátumba.

Az első ötlet, hogy másoljuk át egy vektorba, és rendezzük. Az std::vector-ba szintén std::string, int párokat kell tenni. Ha a sorrendjét megfordítjuk, és std::pair<int, std::string>-eket tartalmaz, akkor a sima rendezés elsősorban az int, és másodsorban std::string szerint fog rendezni.

std::map<std::string, int> m; // beolvassuk

std::vector<std::pair<int, std::string>> r;

for (auto it = m.begin(); it != m.end(); ++it)
    // fordított sorrendben
    r.push_back(std::pair<int, std::string>(it->second, it->first));

std::sort(r.begin(), r.end());

for (auto it = r.begin(); it != r.end(); ++it) {
    // itt is fordított sorrendben
    std::cout << it->first << "x szerepelt ez: "
              << it->second << std::endl;       
}

A második ötlet, hogy használjunk egy tárolót, ami alapból rendezett: az std::map-et. A probléma csak annyi, hogy most több szó is szerepelhet azonos gyakorisággal, nem csak egy. Tehát az értékek nem lehetnek sima std::string-ek, hanem akárhány std::string: legyen std::vector<std::string>!

std::map<std::string, int> m; // beolvassuk

std::map<int, std::vector<std::string>> r;

for (auto it = m.begin(); it != m.end(); ++it)
    // ha nem létezik még a vektor, létrehozza üresen
    r[it->second].push_back(it->first); 

for (auto it = v.begin(); it != v.end(); ++it) {
    auto& szavak = it->second;
    for (auto szoit = szavak.begin(); szoit != szavak.end(); ++szoit)
        std::cout << it->first << "x szerepelt ez: "
                  << *szoit << std::endl;
}

A harmadik ötlet, hogy már létezik olyan tároló, ami egy kulcshoz akárhány értéket tud tárolni: ez az std::multimap. Tegyük multimapbe az értékeket!

std::map<std::string, int> m; // beolvassuk

std::multimap<int, std::string> r;

for (auto it = m.begin(); it != m.end(); ++it)
    // fordított sorrendben
    r.insert(std::pair<int, std::string>(it->second, it->first));

for (auto it = r.begin(); it != r.end(); ++it) {
    // itt is fordított sorrendben
    std::cout << it->first << "x szerepelt ez: "
              << it->second << std::endl;        
}

Érdekesség: az eredeti feladat megoldásának a komplexitása O(n*log n): n darab szót szúrunk be a vektorba, egy beszúrás költsége O(log n). A gyakoriság szerint rendezett megoldások is mind ugyanilyen komplexitásúak: az std::sort is, az std::map-be és az std::multimap-be beszúrások is ezt adják. A többi művelet költsége ehhez képest elhanyagolható.

Összetett példa: telefonkönyv

hamarosan