Supraincarcarea Functiilor
si a Operatorilor
Supraincarcarea
functiilor si a operatorilor reprezinta un mecanism esential al programarii
in C++ intrucat acesta face posibil polimorfismul in timpul compilarii,
conferind limbajului flexibilitate si extensibilitate.
Supraincarcarea
Functiilor
De
regula, intr-un program, 2 functii diferite au nume diferite. Insa daca
functiile executa operatii similare asupra unor tipuri diferite de
obiecte, de
multe ori e mai bine ca aceste functii sa aiba acelasi nume. Compilatorul
poate selecta functia adecvata in cazul unui apel, bazandu-se pe
componenta listelor de parametri. De exemplu,
se pot defini 2 functii de ridicare la putere, una pentru numere intregi
si alta pentru numere reale:
int power(int,int);
double power(double,double);
int x=power(2,10); //aici se apeleaza prima varianta
double y=power(3.14,2.0); //aici se apeleaza a 2-a varianta
Utilizarea aceluiasi nume pentru mai multe functii, in acelasi domeniu
de vizibilitate, se numeste supraincarcarea numelor de
functii sau,
pe scurt, supraincarcare. In lucrarea
2 ne-am intalnit deja cu acest concept, in contextul constructorilor
unei clase.
Tehnica supraincarcarii este aplicata implicit in C++ pentru
operatiile primare.
Astfel, pentru operatia de adunare se utilizeaza un singur "nume", adica
+,
care se aplica si la numere intregi, si la numere reale, si la pointeri.
Pentru a face posibila distinctia la nivelul compilatorului intre
functii omonime (supraincarcate) trebuie ca, fie numarul, fie tipurile
parametrilor
sa difere. Asadar, NU pot sa fie supraincarcate doua functii
care
difera doar prin tipul returnat.
Atentie:
Doua declaratii de functii pot parea uneori
diferite, fara ca ele sa fie in realitate distincte, ca in exemplul de
mai jos:
void
f(int *p);
void
f(int p[]); |
Cele doua
functii declarate mai sus NU sunt diferite intrucat pentru
compilator *p
este acelasi lucru cu p[ ].
Ambiguitati
la Supraincarcarea Functiilor
Situatia
in care la un apel compilatorul nu poate alege intre doua sau mai multe
functii supraincarcate se numeste ambiguitate. Instructiunile
ambigui
sunt tratate ca erori, iar programul nu va fi compilat. Exista in principal
doua cai prin care se poate ajunge la ambiguitate:
-
Conversiile
automate.
Desi conversiile automate sunt foarte utile ele reprezinta
principala cauza a ambiguitatilor:
| #include
<iostream.h>
float
myFunction(float i) { return i; }
double
myFunction(double i) { return i*i; }
main()
{
cout
<< myFunction(10.1) << endl; // OK!
cout
<< myFunction(10) << endl; // ambiguu!!!
} |
In linia
"OK" nu apare nici o ambiguitate intrucat, daca nu sunt specificate explicit
altfel, constantele in virgula mobila in C++ sunt automat de tipul
double.
Ambiguitate apare insa atunci cand functia este apelata cu un parametru
de tip intreg (10 de exemplu) intrucat compilatorul nu are de unde sa stie
daca parametrul
trebuie convertit in
float
sau in double.
Observam
asadar ca NU supraincarcarea functiilor in sine genereaza
ambiguitatea,
ci apelul functiei cu un argument determinat !
(compilatorul emite mesaj de eroare cand intalneste
apelul in cauza, nu cand intalneste declaratia
functiilor omonime).
-
Parametrii cu valori
implicite.
| #include
<iostream.h>
int
myFunction(int i) { return i; }
int
myFunction(int i, int j=1) { return i*j; }
main()
{
cout
<< myFunction(10,2) << endl; //
OK!
cout
<< myFunction(10) << endl; //
ambiguu!!!
} |
La
primul apel al functiei myFunction()
sunt
specificate
doua argumente, deci este clar care dintre functii va fi apelata. In cel
de-al doilea caz insa apare o ambiguitate, deoarece compilatorul nu stie
daca sa apeleze varianta cu un parametru a functiei, sau pe cea cu doi
parametri, dintre care unul este
implicit.
-
Parametrii difera doar prin
modul de transmisie (valoare si
referinta).
| #include
<iostream.h>
int
myFunction(int i) { return i; }
int
myFunction(int &i) { return i*10;
}
main()
{
int a=2;
cout<<myFunction(5)<<endl; //OK! se apeleaza prima
varianta
cout
<< myFunction(a) << endl; //
ambiguu!!!
} |
La
primul apel al functiei myFunction()
este
specificat ca argument o constanta care nu poate fi transmisa prin
referinta, deci este clar care dintre functii va fi apelata. In cel
de-al doilea caz insa apare o ambiguitate, deoarece compilatorul nu stie
daca sa apeleze varianta cu transmitere prin valoare, sau pe cea cu
referinta. Daca varianta cu referinta ar fi avut semnatura myFunction(const int&), atunci si
primul apel al functiei ar fi fost raportat ca
ambiguu.
Functiile
Operator
Intre
tipurile de date abstracte si cele predefinite exista unele asemanari in
modul de declarare si de initializare, dar din ceea ce stim pana acum exista
diferente destul de mari in ceea ce priveste modul de utilizare a lor.
Ar fi ideal ca tipurile de date abstracte sa se comporte ca si cele predefinite.
Ceea ce deosebeste radical cele doua tipuri de date este utlizarea operatorilor.
De exemplu, in lucrarea trecuta am
schitat declaratia clasei
Complex prin care se
implementeaza TDA "numere complexe". Ar fi de dorit
sa putem aduna doua numere complexe in acelasi mod in care adunam si doua
numere intregi. Pentru ca acest lucru sa fie posibil ar trebui ca
operatorul "+" sa poata lucra si cu operanzi de tip Complex.
Pentru
extinde utlizarea operatorilor si asupra tipurilor de date definite de
utilizator, limbajul C++ modeleaza operatorii prin intermediul unor functii
speciale numite functii operator. Ceea ce le face pe aceste
functii sa fie "speciale" sunt urmatoarele:
- [a] Numele lor, care se formeaza cu ajutorul
cuvantului-cheie operator,
urmat de simbolul unui operator sau de numele unui tip (operatori de
conversie).
- [b] Modul de apel; apelul operatorilor se poate
face specificand doar simbolul operatorului, plasat fata de operanzi
ca si in cazul operatiilor cu tipurile primitive (de exemplu,
operatorul "+" binar este de tip infix, operatorul "!" este prefix,
iar "++" poate fi si prefix si postfix). Operanzii asupra carora se
aplica operatorii sunt de fapt parametrii functiilor operator, in
timp ce valoarea returnata corespunde rezultatului operatiei.
Functiile operator pot fi functii membru sau functii globale (cu statut de
friend cel mai adesea) fata de clasa (clasele) de care apartin parametrii
lor.
Exemplu: daca avem o definitie a operatorului
"+" de forma:
AClass operator+(const AClass&, const
AClass&);
el va putea fi invocat pentru 2 obiecte de tip AClass
astfel:
a+b sau operator+(a,b)
Daca acelasi operator ar fi definit ca functie membru a clasei
AClass:
AClass AClass::operator+(const
AClass&);
atunci apelarea sa s-ar putea face astfel:
a+b sau a.operator+(b)
Un operator de conversie are rolul de a converti valori ale unui tip
T1 definit de utilizator, la un alt tip T2,
care poate fi un tip primitiv
sau tot unul definit de utilizator. Apelul operatorilor de conversie se
poate
face:
- implicit, atunci cand un obiect de tip T1 apare
intr-un
loc
in care se asteapta un obiect de tip T2, sau
- explicit, prin casting.
Exemplu:
int AClass::operator int( ); //operator de conversie AClass -> int
AClass a;
int x = a; //apel implicit
int y = (int)a; //apel explicit
Intrucat
tratam operatorii ca functii si intrucat toti operatorii din limbajul C++
sunt deja definiti pentru unul sau multe tipuri predefinite, atunci cand
definim comportarea unui operator pentru un tip de date abstract, spunem
ca supraincarcam operatorul respectiv.
Restrictii
La
supraincarcarea operatorilor in C++ exista urmatoarele restrictii:
-
Nu
pot fi utilizate ca simboluri de operatori alte caractere decat
cele recunoscute ca atare de limbajul C++; de exemplu putem defini
o functie operator*, dar nu putem defini
operator@.
-
Urmatorii
operatori nu pot fi supraincarcati:
.
:: .*
?: sizeof
-
Nu se
poate modifica prin supraincarcare precedenta unui operator (nu
exista posibilitatea de a determina, de exemplu, ca "+" sa fie
prioritar fata de "/").
-
Nu se
poate modifica numarul operanzilor preluati de un operator (dar se poate
ignora unul din parametri). De asemenea nu se
poate modifica sintaxa unui operator, adica nu se poate folosi, de
exemplu, in mod
postfix un operator pe care limbajul il accepta in mod prefix.
-
Un
operator trebuie ori sa fie membru al unei clase, ori sa aiba cel putin un
parametru de tip clasa. De la aceasta regula fac exceptie doar operatorii
new si delete; in
felul acesta este impiedicata denaturarea semanticii unei expresii care
contine doar operanzi de tipuri predefinite ale limbajului (utilizatorul
nu are libertatea sa redefineasca de exemplu operatorul "+" pentru
operanzi intregi astfel incat 1+1 sa nu mai faca 2!!).
-
Nu este
posibila definirea unui operator care sa ia ca parametri exclusiv pointeri
(exceptand operatorii: = & ,).
-
Un
operator care trebuie sa accepte ca prim operand si valori ale unui tip
primitiv NU poate fi functie
membru (vezi discutia de mai jos).
-
Operatorii = (atribuire),
[ ] (indexare), ( ) (apel de functie) si -> (referirea unui camp de structura prin
intermediul unui pointer) nu pot fi functii globale, ci doar membri
non-statici ai claselor; astfel este asigurata indeplinirea conditiei ca
operandul prim al acestor operatori sa fie o expresie lvalue.
Regula
de "buna purtare": Desi puteti efectua orice in corpul unei functii
operator ramaneti aproape de semnificatia implicita a operatorului. Aceasta
va face ca programele pe care le scrieti sa fie inteligibile si pentru
altii. Uneori s-ar putea totusi sa doriti disocierea unui operator de semnficatia
sa initiala. Desigur, puteti face acest lucru, dar asigurati-va in prealabil
ca aveti un motiv temeinic pentru aceasta.
Supraincarcarea
Operatorilor Aritmetici
Pentru
a ilustra caracteristicile supraincarcarii operatorilor aritmetici vom
relua definitia clasei Complex,
implementand operatorul de adunare +
si pe cel de inmultire * .
class
Complex {
public:
Complex()
{re=0.0; im = 0.0;}
Complex(double
i) {re=i;}
Complex(double
i, double j) {re=i; im=j;}
Complex
operator+(const Complex&);
Complex
operator*(const Complex&);
bool
operator==(const Complex&);
private:
double re;
double im;
};
Complex
Complex::operator+(const Complex& z) {
Complex tmp;
tmp.re = re + z.re;
tmp.im = im + z.im;
return tmp;
}
Complex
Complex::operator*(const Complex& z) {
Complex tmp;
tmp.re = re*z.re - im*z.im;
tmp.im = re*z.im + im*z.re;
return tmp;
}
bool
Complex::operator==(const Complex& z){
return ((re == z.re) && (im == z.im));
}
main()
{
Complex z1(2.0, -3), z2(1, 0.5), z3;
z3 = z1 * z2;
z2 = z1 + 0.5;
if (z1 == z2) { ... }
else { ... }
}
|
Am
afirmat mai sus ca parametrii operatorilor corespund operanzilor. Atunci
de ce operatorii binari din exemplul prezentat au un singur parametru?
Motivul este
acela ca functiile care implementeaza operatorii sunt functii membru, ceea
ce inseamna ca pe langa parametrul transmis explicit functiei, se mai transmite
implicit pointer-ul this.
Instructiunea
se
traduce prin:
De aici
desprindem urmatoarea regula:
daca un operator este implementat printr-o
functie membru atunci numarul de parametri este cu 1 mai mic decat
aritatea
op
eratorului.
Sa
analizam acum ce se intampla la apelul
In acest
caz, cel de-al doilea parametru nu este un numar complex, ci un numar real.
Pentru a putea aplica operatorul de adunare definit pentru aceasta
clasa, trebuie facuta conversia spre tipul Complex
a parametrului. Acest lucru este asigurat prin constructorul
Complex(double) care
creaza un obiect temporar a carui referinta va fi apoi pasata operatorului
de adunare. Daca acest constructor nu ar fi fost definit, operatia de mai
sus ar fi fost considerata ilegala!
Intrebare:
In absenta constructorului care asigura conversia double-Complex, cum s-ar
fi putut asigura legalitatea adunarii unui numar complex cu un numar real?
Definirea
operatorilor ca functii friend
Una
dintre proprietatile operatiei de adunare este
comutativitatea.
Astfel ne-am astepta ca operatiile de mai jos sa fie echivalente:
Sa analizam
ce se intampla in cel de-al doilea caz: compilatorul incearca sa descopere
o functie-operator avand semnatura operator+(double,
Complex).
Intrucat o astfel de functie nu exista compilatorul va raporta o eroare
de compilare. In acest caz NU se mai efectueaza o conversie a
parametrului
double spre tipul Complex (desi in clasa
Complex ar exista un constructor corespunzator).
Compilatorul
incearca de fapt urmatoarele variante:
|
z2 = (0.5).operator+(z1); |
adica
fie o functie-operator membra a "clasei" primului operand (double) care sa aiba parametrul
de tip
Complex, fie o functie-operator
globala cu parametrii double si Complex. Intrucat
nu putem interveni asupra tipului predefinit double pentru
a-i adauga un
operator de adunare cu parametru Complex, devine clar ca daca dorim sa
asiguram comutativitatea operatiei de adunare
pentru clasa Complex in orice situatie, va
trebui sa definim o functie-operator globala
cu parametrii indicati mai sus. Iar pentru ca aceasta functie va trebui sa
aiba acces la definitia clasei, vom declara functia-operator ca friend in
clasa Complex.
Observatie: de
fapt statutul de friend pentru operatorii definiti ca functii globale nu
este obligatoriu. El se acorda insa din motive de eficienta a accesului la
membrii operanzilor. Daca operatorii globali nu ar avea statut de friend,
ei ar fi obligati sa acceseze datele membru ale operanzilor via functii de
genul GetMember/SetMember, ceea ce ar reduce mult din viteza
de executie.
Pentru exemplul nostru adaugirile vor arata astfel:
class
Complex {
public:
...
friend
Complex operator+(double, const Complex&);
friend
Complex operator*(double, const Complex&);
...
};
Complex
operator+(double r, const Complex& z) {
Complex tmp;
tmp.re = r + z.re;
tmp.im = z.im;
return tmp;
}
Complex
operator*(double r, const Complex& z)
{
Complex tmp;
tmp.re = r*z.re;
tmp.im = r*z.im;
return tmp;
}
|
Se
observa ca in acest caz functia care implementeaza operatorii, fiind
globala
are 2 parametri. De unde regula:
atunci
cand un operator este implementat printr-o functie friend, numarul parametrilor
este egal cu aritatea operatorului.
Supraincarcarea
Operatorilor de Comparare
Alaturi
de operatorii aritmetici +
si * am implementat
si operatorul de test la egalitate ==
pentru clasa Complex.
Toate aspectele discutate pentru operatorii aritmetici raman valabile si
in cazul operatorilor de comparare, incluzand si discutia despre cazurile
in care este necesara utilizarea unei functii friend pentru a pastra
proprietatile operatorului.
Supraincarcarea
Operatorilor de Incrementare si Decrementare ++
si --
Operatorii
++ si --
sunt operatori unari, iar supraincarcarea acestora se poate face
utilizand atat functii membru non-statice, cat si functii friend. Pentru
a putea distinge intre forma prefix si cea postfix a acestor operatori
se aplica urmatoarea regula:
O functie-membru operator++
care nu primeste nici un parametru (cu exceptia parametrului implicit
this)
defineste operatorul ++ postfix, in timp ce functia
operator++
cu un parametru de tip int
defineste operatorul ++ postfix. La apel, in cazul
formei postfix, utilizatorul nu este obligat sa specifice nici un
argument, valoarea transmisa implicit fiind 0.
Aceeasi
regula se aplica si pentru operatorul de decrementare. Astfel:
class
X {
public:
X operator++() { ... }
X operator++(int) { ... }
};
void
f(X a) {
++a;
// la compilare se traduce ca a.operator++();
a++; // la compilare se
traduce ca a.operator++(0);
} |
In
cazul in care se implementeaza operatorii ++ si -- folosind
functii friend,
regula de deosebire prefix/postfix enuntata mai sus ramane valabila,
singura diferenta reprezentand-o transmiterea explicita a referintei
obiectului caruia i se aplica operatia:
class
Y {
public:
friend y operator--(Y&);
friend Y operator--(Y&, int);
};
void
g(Y a) {
--a; //se traduce ca operator--(a);
a--; //se traduce ca operator--(a, 0);
} |
daca pentru o clasa se
defineste numai varianta prefix a operatorilor ++/--, atunci la apel se
vor putea aplica ambele forme, adica si a++
(a--) si ++a (--a), efectul
fiind ca
amandoua vor fi traduse de compilator ca apeluri la forma prefix; pentru
a++ (a--) se va emite, in plus, un avertisment
in acest sens. Daca insa
este definita doar forma postfix, atunci constructia ++a
(--a) va fi
considerata eroare.
Supraincarcarea
Operatorului de Atribuire =
In
limbajul C++ se pot face atribuiri de obiecte care sunt instantieri ale
aceleiasi clase. Daca programatorul nu defineste in mod explicit un
operator de atribuire pentru o clasa, compilatorul genereaza unul
implicit. Comportarea operatorului de atribuire implicit presupune
ca datele membru ale obiectului din dreapta operatorului = se atribuie la datele membru
corespunzatoare ale obiectului din partea stanga a operatorului, dupa
care se returneaza o referinta la obiectul modificat. Aceasta
copiere este de tip "membru la membru" asa cum se intampla si in
cazul
constructorului de copiere
generat de compilator.
Putem descrie deci functionarea operatorului de atribuire generat de
compilator, sub forma:
MyClass& MyClass::operator=(const MyClass& ob_sursa){
member_1 = ob_sursa.member_1;
member_2 = ob_sursa.member_2;
//. . .
member_n = ob_sursa.member_n;
return *this;
}
unde member_i sunt date membru non-statice ale clasei
MyClass.
O observatie foarte importanta care trebuie
facuta
aici este ca daca una dintre datele membru este o instanta a unei alte
clase, care are operator de atribuire explicit, atunci pentru data membru
respectiva se va apela
acel operator de atribuire.
Operatorul
de Atribuire si Constructorul de Copiere
La
fel ca si constructorul de copiere implicit, si operatorul de atribuire
implicit este, pentru majoritatea claselor nebanale, insuficient. Motivele
sunt acelasi: in cazul unor date membru alocate dinamic in memoria heap,
prin copiere membru la membru se vor obtine doi pointeri spre aceeasi zona
de memorie,
si nu o copiere a datelor aflate la adresa indicata de pointer
intr-o alta zona de memorie distincta. Rezulta ca operatorul
de atribuire se va implementa intr-un mod similar cu constructorul de
copiere.
Utilizare:
Operatorul de atribuire se va defini pentru toate clasele care au date-membru
alocate dinamic pe heap. Intotdeauna cand definim un constructor de copiere
trebuie sa definim si un operator de atribuire si
reciproc.
Intre cele doua functii exista cateva diferente:
-
Operatorul de atribuire este lansat in executie numai in cazul intructiunilor
de atribuire de forma:
a=expr
unde
expr da ca
rezultat fie un obiect din
aceeasi clasa cu a, fie o valoare care poate fi convertita
la clasa
respectiva (vezi conversiile obiectelor); o asemenea instructiune este
interpretata ca un apel de forma:
a.operator=(expr);
In timp
ce constructorul de copiere nu returneaza nici o valoare, operatorul de
atribuire returneaza "ceva". Desi teoretic programatorul
poate specifica orice pe post de tip returnat
(inclusiv void), in mod normal
operatorul de atribuire va returna o referinta
la obiectul curent.
Copy-constructorul actioneaza asupra
unui obiect nou, proaspat creat si
neinitializat, deci programatorul nu trebuie
sa-si faca griji privind posibilele valori anterioare
ale datelor obiectului respectiv. Operatorul de
atribuire, in schimb, actioneaza asupra unui
obiect deja initializat, ceea ce, de multe ori,
presupune executia unor teste si/sau operatii de
"curatenie" inaintea copierii propriu-zise (vezi
paragraful urmator).
Parametrul
operatorului de atribuire poate fi si un obiect propriu-zis (transmis
prin valoare adica), desi aproape
intotdeauna el va fi o referinta la obiectul din dreapta
atribuirii.
Semnatura
si Caracteristicile Operatorului de Atribuire
Se
recomanda urmatoarea semnatura pentru supraincarcarea operatorului de
atribuire:
nume_clasa&
nume_clasa::operator=(const nume_clasa &operand_dreapta);
Obiectul
curent este operandul stang al atribuirii. Urmatoarele lucruri sunt
importante atunci cand definim un operator de atribuire:
-
Supraincarcarea
operatorului de atribuire se poate realiza numai printr-o functie membru
nestatica.
-
Operandul
drept al
operatorului se poate transfera prin valoare sau prin
referinta.
-
Componentele
obiectului din stanga operatorului de atribuire, care sunt alocate dinamic
(pe heap) vor trebui eliberate inainte de a aloca zone noi
in vederea copierii elementelor corespunzaotarea obiectului din dreapta.
Spunem ca obiectul din stanga trebuie "curatat" inainte de a se atribui
valorile obiectului din dreapta.
-
Atribuirile
de forma ob1
= ob1
nu au nici un efect. De aceea, in corpul functiei pentru supraincarcarea
operatorului de atribuire trebuie testat daca obiectul curent este
diferit
fata de cel transmis ca parametru, si numai in acest caz se executa corpul
functiei. In caz contrar, se returneaza pur si simplu o referinta la
obiectul curent.
Vom exemplifica
supraincarcarea operatorului de atribuire, adaugand acest operator
pentru clasa Stack definita in
lucrarea 2.
class
Stack {
int
dim; // dimensiunea alocata pt. stiva
public
:
...
Stack&
operator=(const Stack &);
};
Stack&
Stack::operator=(const Stack &st) {
if(this != &st) { //nu avem situatia ob=ob
delete []items; // dezaloca
datele alocate
//inainte de
atribuire
items = new int[st.dim];
sp = s.sp;
for(int i = 0;i<=sp;items[i++] = st.items[i]);
}
return *this;
} |
Tipuri
de Date Concrete (TDC). Forma Ortodox-Canonica a unei Clase
Tipurile
de date concrete se deosebesc fata de restul claselor prin aceea ca un
TDC se comporta corect in toate situatiile in care tipurile predefinite
din C se comporta corect. Clasele care definesc TDC au o anumita morfologie
care extinde sistemul de tipuri al compilatorului, astfel incat acesta
din urma sa poata genera un cod eficient si sigur pentru abstractiuni oricat
de complexe. Numim aceasta forma a unei clase forma ortodox-canonica
[Cop91]. Forma se numeste canonica intrucat ofera
compilatorului
un sablon de reguli pe care acesta sa le utilizeze la generarea de cod,
si se numeste ortodoxa intrucat este forma cea mai bine
inteleasa
si direct sustinuta de limbaj. Utilizarea acestei "retete" la definirea
claselor ne asigura ca obiectele instantiate din aceasta clasa pot fi atribuite,
declarate si transmise ca argumente functiilor exact ca si orica alta variabila
din C.
Forma
ortodox-canonica a unei clase X este caracterizata prin prezenta urmatoarelor
elemente:
-
Constructor
Implicit:
X::X()
-
Constructor
de Copiere X::X(const
X&)
-
Operator
de Atribuire X&
X::operator=(const X&)
-
Destructor
X::~X()
Utilizare:
Trebuie sa utilizam forma canonic-ortodoxa
pentru o clasa daca:
-
dorim
sa asiguram atribuirea obiectelor clasei, sau transmiterea lor ca argumente
(prin valoare) unor functii
-
obiectele
clasei contin pointeri sau destructorul clasei foloseste delete pe date-membru ale
clasei
Se
recomanda utilizarea formei ortodox-canonice pentru orice clasa ne-triviala,
pentru a asigura unformitatea la nivelul claselor si pentru a gestiona
mai bine cresterea complexitatii fiecarei clase pe parcursul evolutiei
programului.
Supraincarcarea
Operatorilor de Atribuire Compusi (+=
, -=,
*=
etc)
Operatorii
compusi se supraincarca folosind functii membru nestatice care au un prototip
similar cu functiile pentru supraincarcarea operatorului de atribuire.
In cazul in care pentru o clasa avem deja implementat operatorul de atribuire
si unul dintre operatorii aritmetici, atunci operatorul compus format din
cei doi va fi deobicei supraincarcat folosind "alogritmica" operatiei de
la operatorul aritmetic, si tehnica modificarii obiectului curent implementata
in operatorul de atribuire.
Operatori cu Semantica
Predefinita
In cazul functiilor operator definiti de utilizator exista putine
prezumtii in ceea ce priveste semantica. Aceasta afirmatie implica 2
aspecte:
- (1)Pe de o parte, data fiind o clasa X, cu un membru non-static m
de tip int, se poate defini functia operator+ pentru ea, astfel incat a+b sa
insemne de fapt scaderea a.m-b.m (e
adevarat, insa, ca asa ceva ar fi total nerecomandat!).
(2)Pe de alta parte, exista operatori predefiniti ai limbajului care
au rezultat ca o combinatie de alti operatori. De exemplu: daca x este de tip int, expresia x++ este <=> cu x+=1 si <=> cu x=x+1. Pentru operatorii omologi definiti
de utilizator asemenea reguli predefinite nu se mai aplica automat. De exemplu,
daca utilizatorul a definit operatorii + si
= pentru o clasa X, compilatorul nu va interpreta automat o
expresie a+=b ca fiind a=a+b (a si b de tip X), ci este necesara definirea explicita a
operatorului +=. Este adevarat ca
definitia acestui operator se poate baza pe apelurile la = si +, dar ea
trebuie sa existe.
Operatorii = (atribuire), &(adresa) si ,(secventializare) sunt o categorie aparte, in
sensul ca ei sunt singurii care au o semantica predefinita
si atunci cand sunt aplicati obiectelor unei clase. Aceasta inseamna ca,
daca utilizatorul nu a definit explicit acesti operatori pentru o anumita
clasa, ei pot totusi sa fie utilizati, dar vor avea un comportament
prestabilit.
Supraincarcarea
Operatorului de Indexare [ ]
Operatorul
predefinit [ ] se utilizeaza pentru a face acces la elementele unui tablou,
in constructii de forma
tablou[expr].
Aceasta constructie poate fi privita ca o expresie formata din operanzii
tablou
si expr,
carora li se aplica operatorul [ ].
Putem supraincarca acest operator pentru a da sens constructiilor de indexare
si pentru cazul in care operanzii sunt obiecte.
Intrucat
expresiile tablou[expr]
sunt expresii lvalue (se pot utiliza ca parte stanga intr-o atribuire)
, la supraincarcare operatorului [ ]
pentru tipuri abstracte, ca si la supraincarcarea operatorului de atribuire,
se va utiliza o functie membru nestatica care sa returneze o referinta
la elementul selectat pentru functia respectiva. Forma generala a functiei
de supraincarcare a operatorului [ ]
are forma generala:
tip&
nume_clasa::operator[](tip_indice)
Utilizare:
Acest operator va fi supraincarcat in mod special pentru a exprima operatia
de selectare a unui element dintr-o colectie de astfel de elemente.
Vom
prezenta in continuare modul in care acest operator este supraincarcat
in cazul unei liste simplu-inlantuite:
| typedef
int InfoType;
struct
Element {
InfoType info;
Element *next;
Element(InfoType i = 0, Element *el = NULL) : info(i), next(el) { }
Element& operator= (const Element &el) { info = el.info;
return *this; }
...
};
class
Container {
private:
Element *head;
public:
Element& operator[](int index);
void add(InfoType);
};
void
Container::add(InfoType i) {
if(!head)
head = new Element(i);
else
head = new Element(i, head);
}
Element&
Container::operator[] (int index) {
Element *tmp;
for(tmp = head; tmp && (index > 1); tmp = tmp->next, index--);
return *tmp;
}
...
//modul de utilizare
main()
{
Container
list;
Element
*q = new Element(77);
list.add(1);
list.add(2); list.add(3);
//...
cout
<< list[2]->info
<< endl; //
2
list[2]
= *q;
cout
<< list[2]->info
<< endl; //
77
} |
Pe
langa observatiile deja facute trebuie remarcat rolul pe care il joaca
supraincarcarea operatorului de atribuire pentru executia corecta a instructiunii
cont[2]
= *q;
In cazul in care nu ar fi fost supraincarcat acest operator, atribuirea
s-ar fi facut folosind operatorul de atribuire implicit, adica s-ar fi
produs pe langa copiere campului info
si copierea campului next
(care este NULL) ceea ce ar fi distrus legatura acestui element cu restul
listei.
Supraincarcarea
Operatorului de Apel Functie ()
Stim
ca o functie se apeleaza printr-o constructie de forma: nume_functie(lista_parametrilor_de_apel).
Pana
acum am privit aceasta constructie ca pe un tot unitar reprezentand un
operator pentru o anumita operatie. In realitate, aceasta constructie este
formata din doi operanzi nume_functie
si
lista_parametrilor_de_apel
la care se aplica operatorul binar ().
Specific
pentru aceasta constructie este ca spre deosebire de ceilalti operatori
binari, in acest caz al doilea parametru poate fi si vid (corespunzand
unui apel de functie fara parametri).
Operatorul
() se poate supraincarca asa incat primul operand sa fie un obiect, ceea
ce rezulta intr-o constructie de forma:
obiect(lista_parametrilor_efectivi)
O astfel
de expresie este echivalenta cu:
obiect.operator()(lista_parametrilor_efectivi)
Functia
care supraincarca operatorul() trebuie sa fie o functie membru nestatica.
Utilizare:
Principala utilizare a supraincarcarii acestui operator o constituie
constructia iteratorilor.
Clase
Iterator
Iteratorii
se utilizeaza in legatura cu TDA care contin colectii de elemente. Aceste
TDA-uri sunt numite adese containere. (de ex: listele, arborii,
tabelele de hash etc.) Problema cautarii elementelor dintr-o colectie de
elemente protejate se rezolva cel mai simplu cu ajutorul iteratorilor.
Acestia asigura pe de o parte protectia datelor si pe de alta
parte acces la elementele colectiei fara a intra in detalii legate de implementarea
acesteia. Astfel se realizeaza o independenta ridicata a utilizarii colectiei
de implementarea acesteia.
In
principiu un iterator se realizeaza printr-o clasa speciala atasata unui
TDA-container. Vom schita mai jos o clasa iterator atasata unei listei
simplu prezentata in sectiunea precedenta:
struct
Element {
TipInfo info;
Element *next;
};
class
Container {
private:
Element *head;
...
public:
Element* operator[](int index);
friend class Iterator;
};
class
Iterator {
Container *theContainer;
Element *crtElement;
public:
Iterator(Container &cont, int start = 0) {
theContainer = &cont;
crtElement = &cont[start];
}
...
Element* operator() ();
};
Element*
Iterator::operator() (){
Element *tmp;
tmp = crtElement;
// se retine elementul curent
if(crtElement)
crtElement = crtElement->next; // se trece la urmatorul element
return tmp;
// se returneaza elementul curent
}
...
// utilizarea iteratorului
main()
{
Container
cont;
Iterator
iterate(cont, 0);
Element
*p;
while((p
= iterate()) != NULL) {
...
}
} |
Supraincarcarea
Operatorului ->
Supraincarcarea
operatorului ->
se face printr-o functie membru nestatica. La supraincarcare acest operator
este considerat ca fiind un operator unar care se aplica la operandul
care il precede.
Fie
expresia:
A
obj_a;
...
a->expresie; |
Aceasta
expresie este echivalenta cu
| (a.operator->())->expresie; |
Daca apelul
operataorului ->
returneaza un pointer la o clasa(structura) care contine un membru
public cu numele expresie
atunci
se
apeleaza
operatorul predefinit ->
selectandu-se componenta definita de. Daca apelul operatorului ->
returneaza un obiect atunci se aplica operatorul ->
supraincarcat pentru clasa obiectului returnat.
Utilizare:
Se recomanda utilizarea acestui operator in principal pentru a inlocui
folosirea in cascada a operatorilor de selectie ("."
si "->")
printr-o selectare aparent directa.
In
exemplul de mai jos
struct
Info {
char nume[80];
int varsta;
};
class
Catalog {
public:
Info *persoane; //un tablou de pointeri la informatii
...
Info *operator->() { return persoane; }
...
};
...
main()
{
Catalog unCatalog;
...
strcpy(unCatalog->nume,
"Yetti");
unCatalog->varsta
= 1001;
...
} |
Se observa
asadar ca prin implementare operatorului ->
operam cu campurile structurii Info
ca si cum unCatalog
ar fi declarat ca pointer la Info!
In lipsa supraincarcarii operatorului ->
ar fi trebuit sa efectuam operatiile din main() astfel:
main()
{
strcpy((unCatalog.persoane)->nume, "Yetti");
(unCatalog.persoane)->varsta = 1001;
} |
Supraincarcarea
Operatorului de Conversie (cast)
Am
vazut deja cum pentru o clasa conversiile de la un tip de date oarecare
la acea clasa se rezolva prin intermediul constructorilor. Problema pe
care o rezolva supraincarcarea operatorului de conversie este problema
inversa, si anume conversia unui tip declarat prin intermediul unei clase
date spre un tip de date predefinit. Supraincarcarea operatorului de conversie
se realizeaza printr-o functie membru nestatica avand forma: nume_clasa::operator
nume_tip_predefinit().
Acest
operator are cateva particularitati:
-
Fiind
un operator unar implementat printr-o functie membru, nu are parametrii
-
Aceasta
functie nu are specificat un tip returnat intrucat acesta este dat
automat de nume_tip_predefinit.
-
Acest
operator se apeleaza pentru instantele clasei atat implicit (acolo unde
contextul impune acest lucru) cat si explicit prin constructii de
tipul (nume_tip_predefinit)obiect
sau nume_tip_predefinit(obiect)
Supraincarcarea
Operatorilor new
si delete
In
C++ prin intermediul operatorilor new si delete sunt alocate resp. dezalocate
dinamic datele in si resp. din memoria heap. Acesti operatori
pot fi utilizati atat pentru a gestiona date de tipuri predefinite cat
si obiecte.
Caracteristici
ale Supraincarcarii operatorului new.
-
Operatorul
new se supraincarca
utilizand o functie membru statica. Specificarea explicita a faptului
ca functia este statica nu este necesara, ea fiind implicita asimilata
de catre compilator astfel.
-
Antetul
functiei-operator new este:
void *operator new(size_t
lung);
Functia
returneaza un pointer universal a carui valoare este adresa de inceput
a zonei de memorie heap rezervata obiectului creat.
-
La aplicarea
operatorului new nu se indica in mod normal nici o valoarea a parametrului,
intrucat compilatorul calculeaza in mod automat dimensiunea obiectului
pentru care se rezerva zona de memorie si aceasta dimensiune se atribuie
parametrului lung.
-
Aplicarea
operatorului new supraincarcat apeleaza in mod automat constructorul corespunzator
clasei, sau pe cel implicit daca clasa nu are constructori. De aceea, la
aplicarea operatorului new,
pot sa fie prezenti parametrii pentru constructorii clasei.
Caracteristici
ale Supraincarcarii operatorului delete.
-
La fel
ca si operatorul new, si operatroul delete se supraincarca utilizand o
functie membru statica. Specificarea explicita a faptului ca functia
este statica nu este necesara, ea fiind implicita asimilata de catre compilator
astfel.
-
Antetul
functiei-operator delete este:
void operator delete(void*
p);
Operatorul
ia ca parametru un pointer catre obiectul pe care il dezaloca.
-
La aplicarea
operatorului delete se apeleaza automat destructorul clasei definit pentru
aceea clasa, sau in cazul in care acesta nu a fost definit explicit, se
va apela destructorul definit implicit de catre compilator.
Operatorii
new si delete definiti de utilizator au un avantaj fata de cei standard
si anume sunt mai economici dpdv al timpului de executie si al memoriei
consumate pentru gestiunea obiectelor dinamice.
Atentie:
La definirea operatorului delete de catre utilizator trebuie avut mare
grija, mai ales daca in clasa respectiva este definit si destructorul,
deoarece foarte usor se poate ajunge la situatia de apel in lant infinit
a acestor 2 functii.
Exemplu
- Clasa Singleton [Gamma95]
class
Singleton {
public:
void* operator new(size_t n=0);
void operator delete(void*);
// ... restul interfetei
private:
static Singleton* ob;
Singleton(); // initializarea datelor
~Singleton();
// ... alte date
};
void*
Singleton::operator new(size_t n = 0) {
if(ob == NULL)
ob= ::new Singleton;
return ob;
}
void
Singleton::operator delete(void *p) {
if(ob != NULL) {
::delete ob;
ob=NULL;
}
Singleton*
Singleton::ob=NULL;
void
main() {
Singleton *ob1=Single::operator new();
Singleton *ob2=Single::operator new();
//...
delete ob1; //se sterge efectiv obiectul
delete ob2; //nu se face nimic
} |
Observatii:
1. Constructorul clasei Single a fost pus in sectiunea 'private' si in
felul acesta, nicaieri in program nu se pot crea obiecte Single prin simpla
declarare si nici prin alocare dinamica, folosind operatorul new global;
orice tentativa de acest gen va fi sanctionata de compilator; singura cale
de a crea obiecte Single este prin intermediul lui Single::operator
new;
2. Operatorul 'new' pentru clasa Single returneaza de fiecare data adresa
aceluiasi obiect care este creat ca obiect static local; oricate apeluri
ale operatorului new ar aparea in program, nu se creaza noi obiecte;
Probleme
1.
Sa se implementeze TDC String care ascunde detaliile de reprezentarea ale
sirurilor din C (char*). Cerinte specifice:
- Clasa String va respecta forma ortodox-canonica;
- urmatoarea secventa de cod sa fie valida sintactic si semantic:
main()
{
String s1 = "Hello world";
String s2 = s1(6,6);
//
vezi explicatia mai jos.
String s3;
s3 = s1(1,5) + ("fascinating" + s2) + "of C++ !";
// concatenare
cout << "Al treilea carcater din s1 este " << s1[3] <<
endl;
s2[3] = 'e';
s2[5] = 'f'; // s2 devine "woolf"
if(s3 == s1) cout << "Sirurile sunt identice\n";
//comparare de siruri
else cout << "Sirurile nu sunt identice\n"
strcmp(s2, "woolf");
} |
Expresia
s(i,j) are ca efect extragerea din sirul s a subsirului care incepe de
la pozitia i si are lungimea de j caractere. Primul caracter din sir se
considera a fi pe pozitia 1. Daca i < 1 se considera ca subsirul incepe
de la primul caracter. Daca i este mai mare decat ultima pozitie din sir
se va returna sirul vid. Daca i+j depasesc lungimea sirului se va returna
subsirul incepand la pozitia i si care tine pana la sfarsitul sirului.
2.
Cum credeti ca se realizeza in mod normal conversiile de la un TDA la alt
TDA?
3*.
Una din criticile severe aduse limbajului C/C++ este lucrul prea "descoperit" cu
pointerii. Implementati o lista dublu-inlantuita (DoubleList) in care sa
"dispara" din implementarea clasei DoubleList operarea directa cu
pointerii la clasa Nod. Acestia (adica, pointerii) sa fie incapsulati
intr-o clasa NodP, iar DoubleList sa opereze cu instante ale acest tip.
Folositi cu incredere supraincarcarea operatorillor!
Bibliografie
[Cop91]
J. Coplien - "Advanced C++ Programming Styles and Idioms",
Addison-Wesley, 1991
M.Ellis, B.Stroustrup - "The Annotated C++ Reference Manual, Addison-Wesley,
1990
[Gam95]
E.Gamma, R.Helm, R.Johnson, J.Vlissides - Design Patterns, Addison-Wesley,
1995
L. Negrescu - "Limbajele C/C++ pentru Incepatori vol. II", Editura Microinformatica
Cluj, 1994
H. Shildt - "C++. Manual complet", Editura TEORA, 1997