Tömb – egy egyszerű tároló osztály létrehozása

Czirkos Zoltán · 2019.02.27.

Tömb – egy egyszerű tároló osztály létrehozása

Ez az írás jelentős átfedéseket tartalmaz a jegyzet 4. és 5. fejezetével. Ha úgy érzed, maradtak olyan pontok, amiket nem sikerült tökéletesen megértened, érdemes lehet ennek ellenére elolvasnod.

Az alábbi szösszenetben egy dinamikus tömb osztályt boncolgatok. Minden függvényben az egyszerűségre törekedtem, nem a trükkös rövidítési lehetőségek kihasználására.

1. Dinamikus tömb

A feladat így szól:

Csináljunk egy tömb osztályt, amely double számokat tárol. Lehessen a tömböt létrehozni adott mérettel; a számok induláskor legyenek mind nullák. Lehessen indexelni, mint egy sima, statikus C-s tömböt; lehessen másolni és értékadni (egyik tömböt a másiknak). Lehessen továbbá utólag megváltoztatni a méretét. Ha nagyobb lett, akkor az új elemek legyenek nullák, ha kisebb lett, akkor a hátsó elemek elveszhetnek. A tömb elején viszont a számok ne vesszenek el ennek hatására.

2. Deklaráció

A fenti feladatkiírást nagyrészt megadhatták volna úgy is, hogy adott egy egyszerű programrész, amelyben látjuk, milyen tagfüggvényei hívódnak ennek a tömbnek. Ezekből következtetni tudunk arra, hogy milyen publikus tagfüggvényei kell legyenek az osztálynak. Ez csak a publikus rész: a privát tagváltozók és függvények ránk vannak bízva, azt az objektumok úgysem mutatják a külvilág felé. Nézzük tehát sorról sorra végig a kódot, hogy melyik rész mit csinál a tömbbel!

int main() {
    Tomb t(30);         // 1
    std::cout << t[20];     // 2
    t[22] = 10.2;       // 3
    t.atmeretez(50);        // 4
    Tomb masolat(t), harmadik;   // 5
    harmadik = t;           // 6
}                       // 7

Az (1) helyen egy új tömböt hozunk létre, ez egy konstruktor hívása. A paramétere a 30, az egész szám, amely a feladatkiírás szerint a tömb mérete. Vagyis lesz egy Tomb::Tomb(int) konstruktor.

A (2) helyen indexeljük a tömböt: a t objektumon használjuk a szögletes zárójel [] indexelő operátort. Ez a tömbnek egy átdefiniált indexelő operátora lesz. A szokásos módon, a tömb húszas indexű elemét kérjük el, ez lesz az indexelő operátor paramétere; vissza pedig a számot adja, amit utána kiírunk a kimenetre. Vigyázat: a kiíráshoz a tömbnek már semmi köze! A tömb csak megadja, hogy mi a húszas indexű szám. Egyelőre úgy okoskodunk, hogy lesz egy double Tomb::operator[](int) alakú tagfüggvény.

A (3) helyen megint az indexelő operátort használjuk, most viszont – miután a tömb megmondta, hogy melyik az ő 22-es indexű eleme – bele is írunk ebbe az elembe. Az értékadásnak természetesen már itt sincs köze a tömbhöz! A tömb csak megadta, hogy melyik a 22-es elem. Hogy oda írni is tudjunk, ahhoz nem lesz jó, ha az indexelő operátor a számmal tér vissza, amit az adott helyen talált; azt kell megadnia, hogy hol van az a szám, mert csak akkor tudjuk módosítani. Tehát nem értékkel, hanem referenciával kell visszatérnie. Ezért az előző ötletünket úgy módosítjuk, hogy az indexelő operátor egy referenciával térjen vissza: double& Tomb::operator[](int) kell legyen a függvény alakja.

A (4)-gyel jelölt sor triviális, egész szám paraméterű átméretező függvény: void Tomb::atmeretez(int).

Az (5) helyen két új tömböt is létrehozunk, vagyis megint konstruktorokról van szó. Egyrészt a masolat nevűt; a konstruktor paraméterben t-t kapja, amelynek a típusa Tomb. Ez a másoló konstruktor. A tanult dolgok alapján Tomb::Tomb(Tomb const&) a keresett tagfüggvényünk. A harmadik nevű tömbnek pedig nem adunk semmilyen paramétert. Ez a paraméter nélküli, alapértelmezett konstruktort hívja. A feladat specifikációja erről nem beszélt; intuíció alapján hozzunk létre ilyenkor üres tömböt. (Aminek van is értelme, mert később a mérete megváltoztatható.) Rájöhetünk, hogy az (1)-es sorhoz írt konstruktor végül is jó lesz erre a célra is; alapértelmezett paraméterként a méretnek nullát tekintünk, vagyis az előbbi okoskodásunkat is módosítjuk: Tomb::Tomb(int=0) a megvalósítandó függvény.

A (6) helyen az értékadó operátort hívjuk; a tanultak alapján ez Tomb& Tomb::operator=(Tomb const&). Mivel dinamikus adattagja lesz az osztálynak, ezt amúgy is meg kell írni, a másoló konstruktort beleértve. Akkor is, ha nincs ilyen egyértelműen kifejezve, mint itt az (5)-ös sorban; tudjuk, hogy egy ilyenhez muszáj. Ahogyan a (7)-es sorban (nem elírás) meghívódik a tömbök destruktora is; mivel a tömb foglal majd magának memóriát külön, muszáj destruktort írni, amely felszabadítja azt: Tomb::~Tomb().

Amit eddig tudunk:

class Tomb {
    /* privát adattagok helye - lásd lent */
  public:
    Tomb(int ekkora=0);
    ~Tomb();
    Tomb(Tomb const &);             /* másoló ctor */
    Tomb& operator=(Tomb const &);  /* értékadó op. */

    double& operator[](int);
    void atmeretez(int);
};

A privát adattagok jelen esetben adják magukat. Lesz egy double *adat dinamikus tömb, amelyben tároljuk a számokat, new double[valamennyi], így fogunk memóriát foglalni. Mivel egy pointer nem tudja megmondani, mekkora lefoglalt memóriaterületre mutat, csinálunk egy int darab tagváltozót is. A kettő összefügg! Ahol new[] van, ott a méret beállításának is szerepelnie kell, és fordítva. Természetesen a darab is privát adattag, nehogy valaki kívülről megváltoztassa, mert akkor megzavarodhat pl. az atmeretez() függvényünk.

class Tomb {
    int darab;
    double *adat;
  public:
    /* publikus interfész - lásd fent */
}

3. Definíció

Írjuk meg a függvényeket!

A konstruktor: Tomb::Tomb(int ekkora)

Ez könnyű. Mit kell csinálnia? Kap egy számot, hogy mekkora legyen a tömb (1). Megjegyzi, hogy mekkora (2); utána foglal memóriát annyi számnak (3), és kinullázza az elemeket (4).

Tomb::Tomb(int ekkora) { // 1
    darab = ekkora;          // 2
    adat = new double[darab];    // 3
    for (int i = 0; i < darab; ++i)
        adat[i] = 0;         // 4
}

Nem aggódunk az ekkora=0 eset miatt külön! A new double[0] teljesen elfogadott dolog. De az sem baj, ha valaki külön kezeli azt az esetet.

Buktatók:

  • A konstruktornak nincs visszatérési értéke, se void, se semmi.
  • A default paramétert (ekkora=0) a definíciónál nem kell megismételni, vagyis itt már nem írjuk oda az (1) sorban, hogy =0.
  • Viszont amit paraméterben kaptunk méretet, meg kell jegyezni mindenképp. new double[x] és darab=x összetartozik!

A destruktor: Tomb::~Tomb()

Még egyszerűbb:

Tomb::~Tomb() {
    delete[] adat;
}

A tömb foglalta magának a memóriát, az ő dolga felszabadítani is. Mivel senki más nem látja a pointert (privát adattag), más nem is lehet ezért felelős!

Az átméretezés: void Tomb::atmeretez(int uj_meret)

Tudjuk, hogy a new[] operátorral lefoglalt memóriaterületet átméretezni nem lehet. Ezért újat kell foglalni, az új méret alapján (1), és abba átmásolni mindent (2). A régit meg felszabadítani (3), persze csak azután, miután az adatokat kimásoltuk belőle. És csak ezután lehet a pointert átállítani (4).

A darabszámot is csak a végén állítjuk át (5); a függvény belsejében ugyanis mindvégig szükség van a régi és az új méretre is. Például amikor el kell dönteni, hogy nőtt-e a tömb (6), mert akkor ugyebár a régieket másolni kell és az újakat nullázni (7). Vagy ha épp hogy csökkent, akkor csak másolni kell a régieket, és már azáltal kap az új tömb minden eleme értéket. Látható, hogy az előbb a buktatóknál említett szabályt, hogy new[]-oláskor darabot állítunk, itt is betartjuk (1, 4-5).

void Tomb::atmeretez(int uj_meret) {
    if (darab == uj_meret)
        return;
    double *uj_adat = new double[uj_meret];         // 1
    if (uj_meret > darab) {   /* ha megnő a tömb */     // 6
        for (int i = 0; i<darab; ++i)               // 2
            uj_adat[i] = adat[i];
        for (int i = darab; i < uj_meret; ++i)
            uj_adat[i] = 0;                         // 7
    } else {
        for (int i = 0; i<uj_meret; ++i)            // 2
            uj_adat[i] = adat[i];
    }
    delete[] adat;                                  // 3
    adat = uj_adat;                                     // 4
    darab = uj_meret;                               // 5
}

Buktatók:

  • Sem a kódot, sem a magyarázatot nem érdemes megtanulni. (Ezt ugye mondtuk Prog1-ből?)
  • Aki megérti az új tömb létrehozását, utána nem fogja elrontani, hogy mikor milyen tagváltozót állítgasson át a kód.

Az indexelő operátor: double& Tomb::operator[](int i)

Egyszerű:

double& Tomb::operator[](int i) {
  return adat[i];
}

Ezen semmi érdekes nincs, lehetne hívni index()-nek is, de jobb operator[]-nek nevezni a függvényt, mert akkor t.index(5) helyett t[5] írható. Mennyivel jobban mutat! Meg persze kifelé valahogy elérhetővé kell tenni a privátként tárolt számokat. A buktató itt csak annyi lehet, ha nem referenciaként adjuk vissza az értéket (double&), hanem simán értékként (double), mert akkor megváltoztatni nem lehet az elemeket, hanem csak lekérdezni őket, és nem működne a main() (3) jelű sora.

Másoló konstruktor: Tomb::Tomb(Tomb const &eredeti)

Nem azért írjuk meg csak most, mert félünk tőle, hanem mert a fentiekből össze lehet ollózni. Mit kell csinálni? Létre kell hozni egy új tömböt, mintaként tekintve a paraméterként kapott másik tömbre. Vagyis itt is foglalunk egy új adag memóriát (1), csak most nem nullázzuk a számokat, hanem a másik tömbből egyesével átmásoljuk őket (2). Persze nem feledkezünk meg a méret megjegyzéséről sem, a szokásos módon (3). Ezeket a sorokat a konstruktorból és az átméretező függvényből össze lehet puskázni.

Tomb::Tomb(Tomb const& eredeti) {  // 4
    darab = eredeti.darab;             // 3
    adat = new double[darab];      // 1
    for (int i = 0; i<darab; ++i) 
        adat[i] = eredeti.adat[i]; // 2
}

Buktatók:

  • Itt sem érdemes megtanulni a kódot. Csak megérteni, hogy ez egy új tömböt hoz létre (konstruktor), egy másik tömb mintájára (másoló). A többi adódik.
  • Az új tömbbe másolunk mindent az eredetiből, nem pedig fordítva. Ez egy konstruktor, az új objektum a *this, nem pedig a paraméterként kapott eredeti!
  • Legfontosabb észben tartani, hogy ez egy konstruktor! (Volt már erről szó?) Mivel egy vadiúj tömböt hozunk most létre, biztosan hülyeség bármit is kezdeni a darab változóval vagy az adat pointerrel, leszámítva azt persze, hogy értelmes értéket kapnak. Mert teljesen biztos, hogy nincs bennük értelmes dolog, amíg be nem állítjuk azokat!
  • Hogy ez egy konstruktor, az emlékeztessen arra is, hogy ennek nagyon kell hasonlítania a sima konstruktorra. A darabszám beállítása ugyanaz. Az memória foglalás ugyanaz. A nullázás helyett pedig másolás – vagyis a lefoglalt memóriát értelmes értékekkel töltjük ki: ugyanaz.
  • És még egyszer: ez egy konstruktor, vagyis nincs visszatérési értéke; nincs a (4) sorban se void, se semmi. A függvényben pedig se return *this, se semmi.

A függvény fejlécében az osztály neve háromszor szerepel. Tomb::, azaz a tömb osztálynak, Tomb, az a fajta konstruktora, amelyik paraméterként egy másik tömböt kap: Tomb const &eredeti. A const még muszáj is (itt nem tárgyalt okok miatt). A referencia úgyszint, mert ha nem lenne, akkor másolatként kérné a másoló konstruktor a tömböt, amit le kell másolnia – és ez pont a saját feladata, vagyis végtelenül hivatkozna saját magára.

Az értékadó operátor: Tomb& Tomb::operator=(const Tomb& eredeti)

Az értékadó operátor a végére maradt, de ez is azért, mert csak a megfelelő részeket fentről össze kell szedni, újdonság a működésében már nincs. Az értékadó operátor feladata, hogy a már meglévő tömb állapotát módosítsa úgy, hogy az egy másiknak a tökéletes másolata legyen. Nagyon fontos az a gondolat, hogy egy már meglévő tömb állapotát kell módosítani! A tömb mindent elfelejt, és utána újból felépíti magát, egy másik tömböt tekintve mintának. Az „elfelejt” szó utal arra, hogy puskázunk a destruktorból (4. sor); az „újból felépíti magát” pedig utal arra, hogy puskázunk a másoló konstruktorból (5-8. sor).

Tomb& Tomb::operator=(Tomb const &eredeti) { // 1
    if (this != &eredeti) {                      // 3
        delete[] this->adat;        /* destruktorból puska */
        this->darab = eredeti.darab;  /* innentől: másolóból puska. */
        this->adat = new double[this->darab];
        for (int i = 0; i<this->darab; ++i)
            this->adat[i] = eredeti.adat[i];
    }
    return *this; // 2
}

A biztonság kedvéért itt is háromszor szerepel a függvény fejlécében a típus (Tomb) neve. (1): Tomb& visszatérési típusú a Tomb:: osztály operator= nevű tagfüggvénye, amely paraméterként egy Tomb const &eredeti-t kap. A visszatérési típus egy Tomb referencia (1), az objektum saját magára hivatkozik a visszatérési értékben (2). Ez azért van, hogy az értékadás láncolható legyen: ahogy sima számokra írhattuk, hogy a=b=5, úgy tömbökre is írhassuk, hogy t=t2=t3. Minden kulturált operator= úgy végződik, hogy return *this, ebből pedig adódik a Tomb& visszatérési típus.

A (3)-as sor pedig az önértékadás miatt fontos, ha netán az osztály használója ilyet találna írni: t=t. Józan ésszel is belátható, ha az objektum saját magát kéne megváltoztassa úgy, hogy ugyanazokat az adatokat tárola, amiket már addig is, akkor nem kell csinálnia semmit. Az egész procedúra menjen tehát az if()-be, ahogy az átméretezésnél is megtettük ezt a szívességet magunknak. Fontos viszont az önértékadás ellenőrzésre azért, mert ha az objektum, amelyik épp meg akarja változtatni magát (vagyis a *this), ugyanaz, mint a paraméterként kapott (vagyis az eredeti) – nem csak ugyanolyanok, hanem konkrétan ugyanazok! – akkor ha azt írjuk, hogy this->adat, akkor ugyanarról a memóriaterületről beszélünk, mintha azt írnánk, hogy eredeti.adat. Azt gondoljuk, hogy az egy másik memóriaterület, de mivel this==&eredeti, az objektum megkapta a tagfüggvénye paraméterben saját magát, igazából a delete[] this->adat által az eredeti.adat is delete[]-elődik!

Hogy ne vágjuk magunk alatt a fát, a (3)-as sorban pont azt nézzük, hogy a this, azaz a ránk mutató pointer ugyanannyi-e, mint a paraméterben kapott objektumra mutató pointer (&eredeti). Vagyis hogy ugyanott vagyunk-e a memóriában, mint a paraméterként kapott objektum, mert ha igen, akkor saját magunkat kaptuk paraméterként, és nem hogy nem kell, de nem is szabad elvégezni a műveleteket.

Ebben a tagfüggvényben szándékosan ki van emelve mindenhol, hogy melyik objektumról van szó, mégpedig azáltal, hogy a this-> mindenhova ki van írva, bár általában nem szokás. A this-t amúgy sok programozási nyelvben nem this-nek hívják, hanem self-nek vagy Me-nek. Azok sokkal jobban mutatják, hogy mit kell érteni alatta!

Buktatók:

  • Az (1), (2) és (3) sorokat érteni kell, hogy miért vannak így. A magyarázatuk fent. Ezen kívül, nem az objektumok változóit kell összehasonlítani (pl. this->adat és eredeti.adat), hanem magukra az objektumokra mutató pointereket, this és &eredeti!
  • Érdemes itt arra gondolni, hogy a tömb elfelejt mindent, és utána lemásolja a paraméterként kapottat. A függvény lényegi működése a destruktorból és a másoló konstruktorból puskázható össze és érthető meg. Érdemes ilyen sorrendben kidolgozni őket.
  • A függvény neve operator= kell legyen, semmi más nem lehet. A prototípusa úgyszint kötött. Erről fogja tudni a gép, hogy mi most arról beszélünk, t1=t2 esetben mi a teendő. Lemásolni itt is a *this-be kell az eredetit, és nem fordítva!

Ennyi.

4. A láncolhatóságról

Akinek nem tiszta az operator= láncolhatóság a return *this-szel, nézze meg a következő példát. Ez egy vicces C++ programozási stílus, amit néhányan szeretnek használni. Legyen egy téglalap osztályunk, amelynek minden tagfüggvénye térjen vissza saját magának a referenciájával:

class Teglalap {
    int x, y;
    int szel, mag;
    int szin;
  public:
    Teglalap& poz_megad(int x_, int y_) { x = x_; y = y_; return *this; }
    Teglalap& meret_megad(int sz, int m) { szel = sz; mag = m; return *this; }
    Teglalap& szin_megad(int sz) { szin = sz; return *this; }
};

Teglalap tegl;
tegl.poz_megad(100, 20).meret_megad(12, 8).szin_megad(7);

Minden tagfüggvény magával a téglalappal tér vissza. Referenciával, nem másolattal. Vagyis nem egy ugyanolyan téglalappal, hanem ugyanazzal a téglalappal. tegl.poz_megad(100, 20) visszatérési értéke ezért tegl saját maga; amire meghívjuk a meret_megad(12, 8) függvényt, amelynek a visszatérési értéke megint tegl; amire meghívjuk a szin_megad(7) függvényt, ami úgyszint tegl-lel tér vissza, de többet már nem foglalkozunk vele. Mintha mindegyik előtt kiírtuk volna, hogy tegl., csak láncoltuk a függvényhívásokat. Ugyanilyen a láncolt értékadás is, amely zárójelezve még egyértelműbb:

a=b=c;   →   a=(b=c);   →   b=c; a=b;

Először b=c hajtódik végre, annak visszatérési értéke b; utána a=b hajtódik végre.