Operátorok

Czirkos Zoltán · 2019.02.27.

Operator overloading

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

1. Mire jó?

A C++ lehetőséget ad arra, hogy a saját típusú objektumainkra megadjuk, az egyes operátorok, pl. összeadás, kivonás stb. mit jelentsenek. Ez nagyon kényelmes lehet az osztály használói számára. Ha írunk egy sztring osztályt, akkor a sima = értékadó operátort használhatjuk a sztringek másolására, az == összehasonlító operátort pedig két sztring összehasonlítására. Nem kell majd egyik művelet helyett sem egy hosszan részletezett, a függvény nevét megadó írásmódot használni. Az egész számoknál is elég kényelmetlen lenne, ha egy i=5*6+7 helyett azt kellene írni, hogy beallit(i, osszead(szoroz(5, 6), 7)). Ha helyesen használjuk, akkor nagyon leegyszerűsítheti a megírt osztályok használatát.

(Kép: IFLScience)

box& operator<<(box&, cat const&);
box& operator<<(box&, box const&);

box1 << cat1 << (box2 << cat2);

operator<<(operator<<(box1, cat1), operator<<(box2, cat2));

2. A helyes használatról

Saját osztályhoz egy operátort akkor és csak akkor érdemes átdefiniálni, ha az átdefiniált jelentés intuitív és magától értetődő. Amiatt, hogy kevesebbet kelljen gépelni, semmiképpen nem szabad. Az csak arra lenne jó, hogy megtévesztő legyen a leírt programrész.

  • Helyes használat például az operator+ átdefiniálása sztringek esetén. Ha az s1 sztring az alma szót tárolja, az s2 tartalma pedig fa, akkor elég egyértelmű, hogy s1+s2 értéke almafa kell legyen. És ilyenkor kényelmes azt írni, hogy s1+s2, az osszefuz(s1,s2) vagy esetleg az s1.osszefuz(s2) helyett.
  • Nincs értelme viszont például tömbök esetén átdefiniálni a + operátort. Mit jelent két double tömb összege? Hogy az egyes elemeket külön összeadjuk, vagy hogy egymás után írjuk a két tömböt? Ha egyesével adjuk őket össze, mi van akkor, ha nem egyforma méretűek? Értelmetlen.
  • Megint csak értelmes akkor, ha egy Vektor3D osztályt írunk, amelynél két vektor összeadása ugyanazt fogja jelenteni, amit matematikából is jelent. Igen, a Vektor3D belül egy háromdimenziós double tömb. De a célja teljesen más, mint az előző példa általános double tömbje, amelyben a számok jelentéséről nem tudunk semmit.
  • Általában véve a józan ész diktálta utat érdemes követni. Helyes lehet megadni egy kivonás műveletet két dátumhoz; nov.11-nov.2 nyilvánvalóan 9-re kell kiértékelődjön, hiszen 9 nap telik el közöttük. Esetleg dátum+int-et lehet értelme írni; mert nov.2+9=nov.11. Ezzel ellentétben összeadni két dátumot már értelmetlen, ezért a dátum osztály ne adjon meg dátum+dátum operátort!

A helyesen átdefiniált operátorok szemantikája is teljesen szokványos kell legyen. Ehhez jó iránymutató az, hogy egy adott operátor hatására a sima, egyszerű, beépített int számok hogyan működnek. A * összeszoroz két számot, létrehoz egy harmadikat, amely a szorzat. De a két szám nem változik meg. A *= viszont megváltoztatja a bal oldalán álló változót:

int a, b, c;
a = 2; b = 3;
c = a*b;
std::cout << a << b;        /* még mindig 2 és 3! */
a *= b;
std::cout << a << b;        /* "a" most 6! b marad 3. */

A helyes szemantikához tartozik az is, hogy – ha úgy értelmes – jelentse a+b és b+a ugyanazt; vagyis ha a művelet, amit modellezni szeretnénk, kommutatív, akkor a programban is írhassuk felcserélve a tagokat. (Sztringekre ez nyilván pont nem igaz; alma+fa nem ugyanaz, mint fa+alma.) Az is, hogy a=a+b-nek legyen ugyanaz a hatása, mint a+=b-nek; ezt nekünk kell megoldanunk. Ha írunk ++ vagy -- operátort, akkor azok prefix és postfix alakban is működjenek a szokásos módon. És még lehetne sorolni.

3. A függvényhívás forma

Minden operátort használó műveletet fel lehet írni függvényhívás formájában. Ezt objektumok esetén engedi is a fordító, csak ritkán szokás használni; inkább azért érdemes megvizsgálni, mert ebből látszik az, hogy az átdefiniált operátorokat megvalósító függvények hogyan kapják meg a műveletek operandusait paraméterként. Legyen a példa egy racionális számokat (törtet) megvalósító osztály.

Tort t1, t2;

// kétoperandusú
t1 - t2                     /* kivonás */
operator-(t1, t2)           /* mínusz operátor két paraméterrel */
t1.operator-(t2)            /* tagfüggvényként ugyanaz */

// egyoperandusú
-t1                         /* negálás */
operator-(t1)               /* globális függvénnyel */
t1.operator-()              /* tagfüggvényként */

// értékadó
t1 = t2                     /* értékadás */
t1.operator=(t2)            /* tagfüggvényként */

// kiíró, beolvasó
std::cout << t1             /* kiírás */
operator<<(std::cout, t1)   /* globális függvényként */

Az operátorokhoz megadott függvényeket többféleképpen is megadhatjuk. A normál, kétoperandusú operátorokat megírhatjuk globális függvényként, pl. a t1-t2 kifejezés ekvivalens lesz a operator-(t1,t2) függvényhívással, ha ezt írjuk meg. Ilyenkor a bal oldali operandusból lesz az első paraméter, a jobb oldaliból pedig a második. De megírhatjuk tagfüggvényként is: a Tort osztály metódusa lehet az operator-. Ilyenkor a t1 objektumon fog futni a tagfüggvény, vagyis a this a t1-re, a bal oldali operandusra mutat; a jobb oldali operandust, t2-t pedig paraméterként kapja.

Az egyoperandusú operátoroknál hasonló a helyzet. Ha globális függvényként szerepelnek, akkor egy paraméterük van. Ha tagfüggvényként implementáljuk, akkor pedig egy sem, mert az egyetlen operandus a this-en keresztül fog látszani. Fontos a paraméterek száma, mert esetleg ez különböztetheti meg az operátorokat! Az a-b kifejezés esetén a két operandusú operator- az, amiről szó van, vagyis egy kivonásról. A -b kifejezés esetén pedig az egy operandusú, ellentett operátorról.

Az értékadó operátor, bár technikailag két paraméterű, csakis tagfüggvényként valósítható meg. Ennek az az oka, hogy a fordító ilyet ír magától is, ha nincs deklarálva. Erről eleget van szó a másoló konstruktor, destruktor témakörében máshol, úgyhogy itt nem részletezem. (Az indexelő operátor is egy kivétel; azt is kötelező tagfüggvényként megírni.)

Az std::ostream-re kiíró és az std::istream-ről beolvasó operátor, bár technikailag úgyszint két paraméterű, csak globálisan valósítható meg. Ennek az az oka, hogy tagfüggvényként az ostream vagy az istream osztály tagja kellene legyen, amelynek a definícióját viszont nem mi adjuk meg. Vagyis nem tehetünk hozzá új tagfüggvényt.

4. Példák

A várva várt példa kód. Adott egy tört osztály, amely egyelőre a lenti módon néz ki. Írjunk hozzá néhány operátort!

class Tort {
  private:
    int szaml, nev;
  public:
    Tort() {}
    Tort(int sz, int n): szaml(sz), nev(n) {}
};

int main() {
    Tort t1;                    /* inicializálatlan */
    Tort t2(1, 2), t3(3, 4);    /* 1/2 és 3/4 */
}

Nézzük meg először a szorzást, az könnyű művelet a törteknél. Ha megszorzunk egy törtet egy másikkal, akkor a számlálót a számlálóval, a nevezőt a nevezővel kell szorozni. Például ha az 1/2 értéket tároló t2 törtet megszorozzuk t3-mal, az eredmény 3/8 lesz. Kérdés az, hogy ez a 3/8 hova kerül. Ha azt írjuk, hogy t2*=t3, akkor t2 meg kell változzon, az 1/2-t elfelejtve a 3/8 értéket kell felvegye. t3 nyilván változatlan marad. Ha azt írjuk, hogy t2*t3, akkor se t2, se t3 nem változik. Ilyenkor keletkeznie egy új objektumnak, amely a 3/8 értéket tárolja, hiszen semelyik addigi objektum nem tárolt ilyen értéket.

Az operator* ezért, bárhogyan valósítjuk is meg, egy Tort-tel kell visszatérjen. Csak érték lehet a visszatérés típusa, referencia nem, mert egy új objektum keletkezik! A paraméterét érdemes referenciával átvegye; ahhoz, hogy egy törtet megvizsgáljunk, kinézzük belőle a számlálót és a nevezőt a szorzás kedvéért, felesleges másolatot készíteni róla. A lenti példákban van globális és tagfüggvényes operator* megvalósítás is. Nyilvánvalóan elég csak az egyiket használni; több nem lehet, mert akkor a fordító nem tudna választani közülük.

A függvények paramétereit az operátoroknál egyébként lhs-nek és rhs-nek szokás nevezni. lhs, a left hand side rövidítéseként, a bal oldali operandus; rhs pedig a right hand side rövidítése, a jobb oldali operandus.

// Szorzás operátor: a három megvalósítás közül csak az egyik kell.

/* Globális függvényes megvalósítás.
 * Mivel ez nem tagfüggvény, baráttá kell tenni a tört
 * osztályban: friend Tort operator*(Tort const& lhs, Tort const& rhs); */
Tort operator*(Tort const& lhs, Tort const& rhs) {
    Tort uj;
    uj = Tort(lhs.szaml*rhs.szaml, lhs.nev*rhs.nev);
    return uj;
}

/* Ez ugyanaz, mint a fenti, csak rövidebb. A fentit érdemes
 * lenne így írni, mert a fordító jobban tudja optimalizálni. */
Tort operator*(Tort const& lhs, Tort const& rhs) {
    return Tort(lhs.szaml*rhs.szaml, lhs.nev*rhs.nev);
}

/* Ez pedig tagfüggvény, amelyet értelemszerűen az
   osztályban deklarálni kell. */
Tort Tort::operator*(Tort const& rhs) {
    return Tort(szaml*rhs.szaml, nev*rhs.nev);
}

Talán a harmadik a legjobb. Viszont az operator*=-t most szándékosan tagfüggvényként írom. Annak az a sajátossága, az összes hasonló operátorral (/=, += stb.) és az értékadó operátorral együtt, hogy a bal oldali operandussal kell visszatérjenek. Emiatt csinálja például a t1=t2*=t3 kifejezés azt, hogy t2 és t3 értékét összeszorozza, az eredményt t2-be bemásolja (t2*=t3); ezután ez az eredmény bekerül t1-be is, mivel t1=t2. Vagyis a t2*=t3 részkifejezés visszatérési értéke t2 kell legyen, amely, ha tagfüggvényként írtuk meg, a *this. Ez az összes értékadás jellegű operátorra így van: return *this a végük. Másolni most sem érdemes, ezért itt viszont referencia a visszatérés típusa.

t1=t2*=t3
   \____/    ←  t2*=t3 → t2.operator*=(t3) → vissza: t2
     t2
\_____/      ←  t1=t2  → t1.operator=(t1)  → vissza: t1
 t1=t2       

Lent két megoldás van; a másodikban a sima operator*-ra vezetem vissza az operator*=-t. Általában ezt egyébként fordítva szokás csinálni. Próbáljátok ki, miért. Tudni kell azt viszont, hogy ha írunk * operátort, a fordító nem generál automatikusan *= operátort is, hanem ezt nekünk kell megtennünk.

// *= operátor: itt is elég a kettő közül az egyik.

/* Első lehetséges megoldás. A lényeg, hogy tagfüggvény esetén
 * a bal oldali paraméter a *this. */
Tort& Tort::operator*=(Tort const& rhs) {
    szaml *= rhs.szaml;
    nev *= rhs.nev;
    return *this;
}

/* Második lehetséges megoldás: visszavezetjük a sima
 * operator*-ra, ha már úgyis azt megírtuk. */
Tort& Tort::operator*=(Tort const& rhs) {
    (*this) = (*this)*rhs;
    return *this;
}

Érdekes az ellentettet visszaadó operator-. Ennek használatához ugyanis ugyanazt a mínusz jelet kell használni, mint a kivonáshoz. A paraméterek számából látszik az, hogy melyikről van szó. Álljon itt egymás mellett a megvalósításuk az összehasonlítás kedvéért! Mindkettő tagfüggvényes változat. Ilyen egyébként a * operátor is, amelynek van egy operandusú változata. Csak azt pointerekre használjuk *p, és itt nincs értelme. Egy operandusú a ! tagadás operátor is, ami itt úgyszint felesleges lenne.

// operator-: kivonás és ellentett.
// a paraméterek számából látja a különbséget a fordító!

/* kivonás operátor: a this-ből vonjuk ki a másikat,
 * és az eredménnyel térünk vissza */
Tort Tort::operator-(Tort const& rhs) {
    int ujnev = this->nev*rhs.nev;
    int ujszaml = this->szaml*rhs.nev - rhs.szaml*this->nev;
    return Tort(ujszaml, ujnev);
}

/* ellentett operátor: van a this, és
 * nincs több paraméter! */
Tort Tort::operator-() {
    return Tort(-szaml, nev);
}

Bár, mint tudjuk, a ++ operátor azt jelenti, hogy következő, a racionális számoknál pedig ilyen nincsen... Írjuk azért meg úgy, hogy ugyanaz történjen rájuk, mint az egész számokra: megnőnek eggyel! A prefixes forma egyértelmű. Megnöveli az értéket, és utána a megnövelt értékkel tér vissza. A régi értékre nincsen szükség, csak a megnöveltre. Ezért ha tagfüggvényként írjuk, simán ez is végződhet egy return *this-szel, a visszatérés pedig történhet referenciával. Nem ez a helyzet a postfixes alaknál. A postfixes működésnél a tört értéke meg kell változzon, de a kifejezés értéke még a régi állapotát kell tükrözze, vagyis a növelés előtti értékét! Ezt úgy lehet megvalósítani, ha a növelés előtt elmentjük egy segédváltozóba az értéket, és azzal térünk vissza. Ebből az következik, hogy két objektum kell legyen; a régi, amelyikhez egyet adtunk, és az új, amelyik még a növelés előtti értéket tárolja. Az új objektum a függvényben jön létre, vagyis a postfixes ++ operátor értékkel tér vissza, nem referenciával! Furcsán különbözteti amúgy meg a C++ ezt a prefixes alaktól: mivel az operandusok számában nem térnek el, a postfixes alak egy jelképes int adattagot kap.

// Preincrement és postincrement. Mind a kettő tagfüggvényként
// szerepel itt, szóval az osztályban deklarálni kell őket.

/* Preincrement. A this-en kívül nincs más paraméter. */
Tort& Tort::operator++() {
    szaml += nev;
    return *this;
}

/* Postincrement. Jelképes int paraméter, ez különbözteti
 * meg a prefixestől. */
Tort Tort::operator++(int) {
    Tort regi(*this);       /* lemásolom a növelés előtt */
    ++(*this);              /* meghívom a prefixest */
    return regi;            /* növelés előtti állapot másolata */
}

5. A kiíró, beolvasó operátorok

Ezek, mint fent azt látni lehetett, két operandusú operátorok ugyan, de nem lehet őket tagfüggvényként megvalósítani. Egy cout<<t hívásnál, ha tagfüggvénynek szeretnénk az operator<<-t, akkor az a cout-ot tartalmazó osztály, vagyis az std::ostream tagfüggvénye kellene legyen. Ehhez viszont nem írhatunk továbbiakat (hiszen akkor az láthatná a privát adattagokat, és akkor az egész private/public védelemnek semmi értelme nem lenne), ezért marad a globális függvényes megvalósítás.

A cout<<t kifejezés függvény alakban így néz ki: operator<<(cout, t). A bal oldali paraméter, hogy hova írjuk ki a számot, a jobb oldali pedig maga a szám. A számot itt is érdemes konstans referenciaként átvenni. A bal oldaliról pedig fejből kell tudni, hogy annak a típusa std::ostream. Fontos az is, hogy azt kötelező referenciaként átvenni, hiszen értelmetlen lemásolni! A visszatérési érték a láncolhatóság miatt ugyanaz kell legyen, mint a bal oldali paraméter; a típusa is ennek megfelelően ugyanaz.

std::cout<<t1<<t2;
\___________/           std::cout<<t1 → visszaadja: std::cout
  std::cout  <<t2;      std::cout<<t2

Jelen esetben ez a függvény az osztály barátja kell legyen, mivel eléri az adattagjait. Ha a kiírandó dolgok látszanának a publikus felületen keresztül (pl. lenne a számlálót és a nevezőt lekérdező tagfüggvény), akkor nem lenne erre szükség. A beolvasó operátort ugyanígy kell megvalósítani; az std::cin típusa std::istream. És persze referencia. És ugyanúgy bal oldali paraméter, hiszen ezt írjuk: cin>>t!

std::ostream& operator<<(std::ostream& os, Tort const& t) {
    os << t.szaml << '/' << t.nev;
    return os;
}

6. Konverziós operátorok

Gyakran szeretnénk azt, ha egy függvény, amelyik törtet vár paraméterként, simán kaphatna egy egész számot is. Hiszen az egész számok is racionális számok; a nevezőjük 1. A C++-ban az egy paraméterű konstruktorok egyben konverziót is jelentenek. Vagyis a lenti konstruktor egy ilyen konverziót valósít meg. Ez sokszor kényelmes; például ha létezik ilyen, akkor egyből elfogadható lesz a t+1 kifejezés; a fordító számára ez azt fogja jelenteni, hogy t+Tort(1), amely két tört összeadása; amelyre pedig már megvan a függvény.

Ez persze egy ideiglenes objektum létrejöttét is jelenti. Bonyolultabb konstruktoroknál ez hátrány lehet. A C++ beépített sztring típusa például tartalmaz char*→string konstruktort, de az összeadás műveletet külön megvalósították a string+string és a string+char* esetre is, hogy ne másolódjon feleslegesen le a jobb oldali karaktertömb csak az összeadás miatt.

(Fontos megemlíteni, hogy mivel a fordító a kódunkat nem érti, az összes egy paraméterű konstruktort konverzió lehetőségének tekint – hacsak nem mondjuk neki azt, hogy ne tegye. Erre való az explicit kulcsszó. Például egy dinamikus tömb méretét megadó, egyedüli int paraméter a konstruktorban nem konverziót jelent, ezért oda kell írni elé, hogy explicit! Ott nem az egész számot akarjuk tömbbé konvertálni. Itt viszont az egész számot törtté, ami egy értelmes művelet.)

Tort::Tort(int sz)
    : szaml(sz), nev(1) {
}

Az egyparaméterű konstruktor formailag nem operátor. Szükségünk lehet azonban pl. egy olyan konverzióra, amelyben egy törtet valós számmá, double típusú értékké alakítunk. Ezt is meg lehet oldani; ez csak tagfüggvény lehet, és a neve operator double(). Az érdekessége az, hogy a konstruktorokhoz hasonlóan nem kell és nem is szabad semmilyen visszatérési típust megadni. Ami azonban nyilvánvaló, hiszen az operator double() csak double típusa konvertálhat, semmi másra.

Tort::operator double() {
    return (double) szaml/(double) nev;
}

7. A teljes kód

Itt a teljes kód, kipróbálható program. Duplaklikk kijelöli!

#include <iostream>

class Tort {
  private:
    int szaml, nev;
  public:
    Tort() {}       /* inicializálatlan */
    /* egy paraméterű ctor: konverzió */
    Tort(int sz): szaml(sz), nev(1) {}
    Tort(int sz, int n): szaml(sz), nev(n) {}

    Tort& operator*=(Tort const& rhs);

    Tort operator-();
    Tort operator-(Tort const& rhs);

    Tort& operator++();
    Tort operator++(int);

    operator double();
    
    friend Tort operator*(Tort const& lhs, Tort const& rhs);

    friend std::ostream& operator<<(std::ostream& os, Tort const& t);
};

/* szorzás: itt most globális fv, de lehetne tag is */
Tort operator*(Tort const& lhs, Tort const& rhs) {
    return Tort(lhs.szaml*rhs.szaml, lhs.nev*rhs.nev);
}

/* *= tagfüggvényként */
Tort& Tort::operator*=(Tort const& rhs) {
    szaml *= rhs.szaml;
    nev *= rhs.nev;
    return *this;
}

/* ellentett operátor */
Tort Tort::operator-() {
    return Tort(-szaml, nev);
}

/* kivonás operátor */
Tort Tort::operator-(Tort const& rhs) {
    int ujnev = this->nev*rhs.nev;
    int ujszaml = this->szaml*rhs.nev - rhs.szaml*this->nev;
    return Tort(ujszaml, ujnev);
}

/* Preincrement. A this-en kívül nincs más paraméter. */
Tort& Tort::operator++() {
    szaml += nev;
    return *this;
}

/* Postincrement. Jelképes int adattag, ez különbözteti
 * meg a prefixestől. */
Tort Tort::operator++(int) {
    Tort regi(*this);       /* lemásolom a növelés előtt */
    ++(*this);              /* meghívom a prefixest */
    return regi;            /* növelés előtti állapot másolata */
}

std::ostream& operator<<(std::ostream& os, Tort const& t) {
    os << t.szaml << '/' << t.nev;
    return os;
}

Tort::operator double() {
    /* egész/egész elkerülésére konvertálok */
    return szaml/(double)nev;
}

int main() {
    Tort t1;                    /* inicializálatlan */
    Tort t2(1,2), t3(3,4);      /* 1/2 és 3/4 */
    
    std::cout << t2 << std::endl;
    std::cout << t3 << std::endl;

    t1=t2*t3;
    t1*=t2;
    std::cout << t1 << std::endl;
    std::cout << t2 << std::endl;
    std::cout << -t2 << std::endl;
    std::cout << t2++ << std::endl;
    std::cout << ++t2 << std::endl;
    std::cout << (double)t2 << std::endl;   /* konverzió */

    std::cout << t3 << std::endl;
    std::cout << t3+1 << std::endl;         /* automatikus konverzió! */

    return 0;
}