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.
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));
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 azs1
sztring az alma szót tárolja, azs2
tartalma pedig fa, akkor elég egyértelmű, hogys1+s2
értéke almafa kell legyen. És ilyenkor kényelmes azt írni, hogys1+s2
, azosszefuz(s1,s2)
vagy esetleg azs1.osszefuz(s2)
helyett. - Nincs értelme viszont például tömbök esetén átdefiniálni a
+
operátort. Mit jelent kétdouble
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ánosdouble
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.
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.
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 */
}
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;
}
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;
}
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;
}