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

Dobra Gábor · 2019.02.27.

Jegyzet 2. fejezet: 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.

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

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

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

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

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

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