Clase (II)

Operatori de supraîncărcare

În esență, clasele definesc noi tipuri de date ce pot fi folosite în programe C++. Dar tipurile de date în C++ nu interacționează numai prin intermediul construcțiilor și atribuirilor, ci și prin operatori. De exemplu, să luăm următoarea operație cu tipuri de date fundamentale:

1
2
int a, b, c;
a = b + c;

Mai multor variabile de tipul (int) li se aplică operatorul de adunare și apoi operatorul de atribuire. Pentru un tip de dată aritmetic fundamental, sensul acestor operații este evident și neambiguu, dar se poate întâmpla ca pentru o anumită clasă sa nu fie așa. De exemplu:

1
2
3
4
5
struct clasa_mea {
  string produs;
  float pret;
} a, b, c;
a = b + c;

Aici nu este evident rezultatul operației de adunare dintre b și c. De fapt, doar această secvență generează eroare la compilare, pentru tipul de dată clasa_mea nu s-a definit operația de adunare. Totuși, C++ permite celor mai multor operatori să fie supraîncărcați, astfel încât comportamentul lor să poată fi definit pentru orice alt tip, inclusiv clase. Iată o listă a tuturor operatorilor care pot fi supraîncărcați:

Operatori ce pot fi supraîncărcați
+    -    *    /    =    
    >    +=   -=   *=   /=   

   >>
= >>= == != = >= ++ -- % & ^ ! |
~ &= ^= |= && || %= [] () , ->* -> new
delete new[] delete[]

Operatorii pot fi supraîncărcați cu ajutorul funcțiilor operator care sunt funcții obișnuite dar cu nume speciale: numele lor încep cu cuvântul cheie operator urmat de simbolul operatorului ce trebuie supraîncărcat. Sintaxa este:

tip_dată operator simbol (parameteri) { /*... corp ...*/ }
De exemplu, vectorii cartezieni sunt perechi de două coordonate: x și y. Operația de adunare a doi vectori cartezieni este definită prin adunarea celor douâ coordonate x , respectiv a celor două coordonate y. De exemplu, prin adunarea vectorilor cartezieni (3,1) și (1,2) se obține rezultatul (3+1,1+2) = (4,3). Acesta se poate implementa în C++ cu următoarea secvență:

// exemplu supraîncărcare operatori
#include <iostream>
using namespace std;

class CVector {
  public:
    int x,y;
    CVector () {};
    CVector (int a,int b) : x(a), y(b) {}
    CVector operator + (const CVector&);
};

CVector CVector::operator+ (const CVector& param) {
  CVector temp;
  temp.x = x + param.x;
  temp.y = y + param.y;
  return temp;
}

int main () {
  CVector foo (3,1);
  CVector bar (1,2);
  CVector result;
  result = foo + bar;
  cout << result.x << ',' << result.y << '\n';
  return 0;
}
4,3
Dacă atât de multe apariții ale lui CVector par să creeze confuzie, să ne gândim că unele dintre ele se referă la numele clasei (adica tipul de dată) CVector, iar altele sunt funcții cu acest nume (reprezentând constructori, care trebuie să aibă același nume cu clasă). De exemplu:

1
2
CVector (int, int) : x(a), y(b) {}  // nume funcție CVector (constructor)
CVector operator+ (const CVector&); // funcție care returnează un obiect CVector  

Funcția operator+ a clasei CVector supraîncarcă operatorul de adunare (+) pentru acel tip de dată. Odată declarată, această funcție poate fi apelată fie implicit folosind operatorul, fie explicit folosind numele funcției:

1
2
c = a + b;
c = a.operator+ (b);

Cele două expresii sunt echivalente.

Într-un capitol anterior, funcția atribuire prin copiere a fost prezentată ca o funcție membru specială definită implicit, chiar și atunci când nu este declaratâ explicit în clasă. Comportamentul implicit al acestei funcții constă în copierea tuturor informațiilor obiectului transmis ca argument (cel din partea dreaptă a simbolului egal) în obiectului din partea stângă a simbolului:

1
2
3
CVector d (2,3);
CVector e;
e = d;           // operatorul de atribuire prin copiere  

Membrul atribuire prin copiere este singurul operator definit implicit pentru toate clasele. Desigur, el poate fi redefinit pentru a-i da o altă funcționalitate ca, de exemplu, copierea numai a anumitor membri sau efectuarea unor operații de inițializare suplimentare.

Supraîncărcările operatorilor sunt doar niște funcții obișnuite care pot avea orice fel de comportament; nu există nici o restricție care sa impună vreo relație matematică sau legată de sensul operatorului, deși ar fi recomandat să se întâmple așa. De exemplu, o clasă care supraîncarcă operator+ prin scădere sau cea care supraîncarcă operator== prin completarea cu zerouri a obiectului, sunt perfect valide, deși folosirea unei asemenea clasei reprezintă într-adevăr o sfidare a sensului firesc.

Parametrul care se așteaptă pentru o funcție membru care supraîncarcă operații ca operator+ este operandul din partea dreaptă a operatorului. La fel se întâmplă pentru toți operatorii binari (cei care au un operand în stânga și unul în dreapta lor). Dar există operatori cu forme diverse. Dăm în continuare un tabel cu sinteza parametrilor necesari pentru fiecare dintre operatorii ce pot fi supraîncărcați (atenție, @ ar trebui înlocuit cu un operator din fiecare caz):

ExpresieOperatorFuncție membruFuncție care nu e membru
@a+ - * & ! ~ ++ --A::operator@()operator@(A)
a@++ --A::operator@(int)operator@(A,int)
a@b+ - * / % ^ & | < > == != <= >= << >> && || ,A::operator@(B)operator@(A,B)
a@b= += -= *= /= %= ^= &= |= <<= >>= []A::operator@(B)-
a(b,c...)()A::operator()(B,C...)-
a->b->A::operator->()-
(TYPE) aTYPEA::operator TYPE()-
unde a este un obiect al clasei A, b este un obiect al clasei B și c este un obiect al clasei C. TYPE este orice tip de dată (pe care operatorii îl supraîncarcă prin conversia la TYPE).

Să observăm că unii operatori pot fi supraîncărcați în două forme: fie ca funcție membru, fie printr-o funcție care nu este membru. Primul caz a fot demonstrat în exemplul de mai sus cu operator+. Dar unii operatori pot fi supraîncărcați și prin funcții care nu sunt membri; în acest caz, funcția operator are ca prim argument un obiect al clasei respective.

De exemplu:
//supraîncărcarea unui operator prin funcții care nu sunt membri
#include <iostream>
using namespace std;

class CVector {
  public:
    int x,y;
    CVector () {}
    CVector (int a, int b) : x(a), y(b) {}
};


CVector operator+ (const CVector& lhs, const CVector& rhs) {
  CVector temp;
  temp.x = lhs.x + rhs.x;
  temp.y = lhs.y + rhs.y;
  return temp;
}

int main () {
  CVector foo (3,1);
  CVector bar (1,2);
  CVector result;
  result = foo + bar;
  cout << result.x << ',' << result.y << '\n';
  return 0;
}
4,3

Cuvântul cheie this

Cuvântul cheie this reprezintă un pointer câtre obiectul al cărei funcție membru este executată. Se folosește în interiorul unei funcții membru a unei clase pentru a se face referirea chiar la obiectul respectiv.

Una dintre utilități constă în verificarea transmiterii chiar a acelui obiect ca parametru pentru o funcție membru. De exemplu:

// exemplu cu this
#include <iostream>
using namespace std;

class Tantalau {
  public:
    bool sunt_chiar_eu (Tantalau& param);
};

bool Tantalau::sunt_chiar_eu (Tantalau& param)
{
  if (&param == this) return true;
  else return false;
}

int main () {
  Tantalau a;
  Tantalau* b = &a;
  if ( b->sunt_chiar_eu(a) )
    cout << "da, &a este b\n";
  return 0;
}
da, &a este b

De asemenea, este frecvent folosit în funcțiile membru operator= care returnează obiecte prin referință. Revenind la exemplul vectorului cartezian de mai înainte, funcția sa operator= s-ar fi putut defini astfel:

1
2
3
4
5
6
CVector& CVector::operator= (const CVector& param)
{
  x=param.x;
  y=param.y;
  return *this;
}

De fapt, această funcție este foarte asemănătoare cu secvența generată implicit de către compilator în cazul acestei clase pentru operator=.

Membrii statici

O clasă poate conține membri statici, atât date cât și funcții.

O dată membru statică se mai numește și "variabilă de clasă", deoarece ea reprezintă o variabilă comună pentru toate obiectele din aceeași clasă, distribuind aceeași valoare: adică valoarea ei nu diferă de la un obiect la altul al acelei clase.

De exemplu, poate fi folosită pentru o variabilă dintr-o clasă ce conține un contor cu numărul obiectelor curente alocate ale clasei, ca în exemplul următor:

// membrii statici în clase
#include <iostream>
using namespace std;

class Tantalau {
  public:
    static int n;
    Tantalau () { n++; };
    ~Tantalau () { n--; };
};

int Tantalau::n=0;

int main () {
  Tantalau a;
  Tantalau b[5];
  Tantalau * c = new Tantalau;
  cout << a.n << '\n';
  delete c;
  cout << Tantalau::n << '\n';
  return 0;
}
7
6

De fapt, membrii statici au aceleași proprietăți ca variabilele care nu sunt membri dar intră în comeniul clasei. Din acest motiv, precum și pentru a evita declararea lor de mai multe ori, nu pot fi inițializați direct în interiorul clasei, dar trebuie inițializați undeva în exteriorul ei. Ca în exemplul anterior:

1
int Tantalau::n=0;

Deoarece este o valoare comună tuturor obiectelor din aceeași clasă, ea poate fi referită ca membru al oricărui obiect din acea clasă sau chiar direct prin numele clasei (desigur, acest lucru este valabil doar pentru membrii statici):

1
2
cout << a.n;
cout << Tantalau::n;

Cele două apeluri de mai sus se referă la aceeași variabilă: variabila statică n din clasa Tantalau distribuită tuturor obiectelor acestei clase.

Repetăm: este exact ca o variabilă ne-membru, dar cu un nume care necesită să fie accesată ca un membru al unei clase (sau obiect).

Clasele pot avea și funcții ca membri statici. Ele reprezintă același lucru: membrii unei clase care sunt comuni tuturor obiectelor din acea clasă, acționând exact la fel ca funcțiile care nu sunt membri dar se accesează ca membri ai clasei. Deoarece sunt ca funcțiile ne-membri, ele nu pot accesa membri nestatici ai clasei (nic variabile, nici funcții). De asemenea, nu pot folosi cuvântul cheie this.

Funcții membru const

Când un obiect al unei clase este calificat ca obiect const:

1
const Clasa_mea obiectul_meu;

accesul la datele membru ale sale din afara clasei se poate face doar pentru citire, ca și cum toate datele membru ar fi const pentru tot ceea ce le apelează din afara clasei. Să observăm, totuși, apelul constructorului și permiterea inițializării și modificării datelor membru:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// constructor cu obiect const
#include <iostream>
using namespace std;

class Clasa_mea {
  public:
    int x;
    Clasa_mea(int val) : x(val) {}
    int get() {return x;}
};

int main() {
  const Clasa_mea foo(10);
// foo.x = 20;            // nu este valid: x nu poate fi modificat
  cout << foo.x << '\n';  // ok: data membru x poate fi citită
  return 0;
}
10

Funcțiile membru ale unui obiect const pot fi apelate numai dacă ele însele sunt marcate ca membri const; în exemplul de mai sus, membrul get (care nu este marcat cu const) nu poate fi apelat din foo. Pentru a marca un membru ca fiind const, cuvântul cheie const ar trebui să urmeze prototipului funcției, după închiderea parantezelor cu parametri:

1
int get() const {return x;}

Observăm cum const poate fi folosit pentru a califica tipul returnat de o funcție membru. Acest const nu este același cu cel care precizează că un membru este const. Cele două sunt independente și se poziționează în locuri diferite în prototipul funcției:

1
2
3
int get() const {return x;}        // funcție membru const
const int& get() {return x;}       // funcție membru care returnează const&
const int& get() const {return x;} // funcție membru const care returnează const& 

Funcțiile membru precizate ca fiind const nu pot modifica date membru nestaticeși nici nu pot apela alte funcții membru care nu sunt const. În esență, membrilor const nu li se permite să modifice starea unui obiect.

Obiectele const sunt restricționate la accesarea numai a membrilor marcați cu const, dar obiectele non-const nu sunt restricționate și pot accesa atât membri const cât și non-const.

V-ați putea gândi că oricum rar este nevoie să declarăm obiecte const, deci nu merită efortul de a declara const toți membrii care să nu modifice obiectul, însă obiectele const sunt chiar foarte întâlnite. Majoritatea funcțiilor care au ca parametri clase o fac prin referințe const, deci aceste funcții pot să acceseze doar membrii const:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// obiecte const
#include <iostream>
using namespace std;

class Clasa_mea {
    int x;
  public:
    Clasa_mea(int val) : x(val) {}
    const int& get() const {return x;}
};

void print (const Clasa_mea& arg) {
  cout << arg.get() << '\n';
}

int main() {
  Clasa_mea foo (10);
  print(foo);

  return 0;
}
10

Dacă în acest exemplu get nu ar fi fost marcat ca membru const, nu ar fi fost posibil apelul lui arg.get() din funcția print, deoarece obiectele const au acces numai la funcțiile membru const.

Funcțiile membru pot fi supraîncârcate pe proprietatea const, adică: o clasă poate avea două funcții membru cu aceeași semnătură, dar una să fie const iar cealaltă nu; în acest caz, versiunea const este apelată numai când obiectul însuși este const, iar cea non-const este apelată când obiectul este non-const.

// supraîncărcarea membrilor în raport cu const
#include <iostream>
using namespace std;

class Clasa_mea {
    int x;
  public:
    Clasa_mea(int val) : x(val) {}
    const int& get() const {return x;}
    int& get() {return x;}
};

int main() {
  Clasa_mea foo (10);
  const Clasa_mea bar (20);
  foo.get() = 15;         // ok: get() returnează int&
// bar.get() = 25;        // invalid: get() returnează const int&
  cout << foo.get() << '\n';
  cout << bar.get() << '\n';

  return 0;
}
15
20

Șabloane de clasă

Exact așa cum se pot crea șabloane de funcții, putem crea și șabloane de clasă, permițând claselor să aibă membrii care folosesc șabloane pentru tipurile de dată. De exemplu:

1
2
3
4
5
6
7
8
9
template <class T>
class pereche {
    T valori [2];
  public:
    pereche (T primul, T al_doilea)
    {
      valori[0]=primul; valori[1]=al_doilea;
    }
};

Clasa pe care tocmai am definit-o poate fi folosită pentru a stoca două elemente de orice tip de dată valid. De exemplu, dacă am fi dorit să declarăm un obiect al acestei clase pentru a stoca două numere întregi de tip int cu valorile 115, respectiv 36, am fi scris:

1
pereche<int> obiectul_meu (115, 36);

Aceeași clasă ar fi putut fi folosită pentru a crea un obiect care să poată memora orice alt tip de dată, precum:

1
pereche<double> reale (3.0, 2.18);

Singura funcție membru a șablonului de clasă este constructorul, care a fost definit inline, chiar în definiția clasei. În cazul în care se definește o funcție membru în afara definiției șablonului de clasă, ea ar trebui precedată de prefixul template <...>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// șabloane de clasă
#include <iostream>
using namespace std;

template <class T>
class pereche {
    T a, b;
  public:
    pereche (T primul, T al_doilea)
      {a=primul; b=al_doilea;}
    T getmax ();
};

template <class T>
T pereche<T>::getmax ()
{
  T retval;
  retval = a>b? a : b;
  return retval;
}

int main () {
  pereche <int> obiectul_meu (100, 75);
  cout << obiectul_meu.getmax();
  return 0;
}
100
Să observăm sintaxa din definiția funcției membru getmax:

1
2
template <class T>
T pereche<T>::getmax ()

Vă simțiți încurcați cu atât de multe T-uri? Sunt trei T-uri în această declarație: primul este parametrul șablonului. Al doilea T se referă la tipul de dată returnat de funcție. Iar ce de-al treilea T (cel dintre parantezele unghiulare) este și el necesar: el precizează faptul că parametrul acestui șablon de funcție este și parametrul șablonului de clasă.

Specializare șablon

Este posibil să definim o implementare diferită a unui șablon atunci când i se transmite ca argument un anumit tip de dată. Această functionalitate se numește specializarea șablonului.

De exemplu, să presupunem că avem o clasă foarte simplă numită containerul_meu, care poate stoca un element de orice tip si care are doar o funcție membru numită marire, care îi crește valoarea. Dar credem că este mai potrivit ca atunci când memorează un element de tip char ar fi mult mai convenabil să avem o implementare cu o funcție majuscula, așa încât decidem să declarăm o specializare a unui șablon de clasă pentru acel tip de dată:

// specializare șablon
#include <iostream>
using namespace std;

// șablon clasă:
template <class T>
class containerul_meu {
    T element;
  public:
    containerul_meu (T arg) {element=arg;}
    T marire () {return ++element;}
};

// specializarea șablonului de clasă:
template <>
class containerul_meu <char> {
    char element;
  public:
    containerul_meu (char arg) {element=arg;}
    char majuscula ()
    {
      if ((element>='a')&&(element<='z'))
      element+='A'-'a';
      return element;
    }
};

int main () {
  containerul_meu<int> intreg (7);
  containerul_meu<char> caracter ('j');
  cout << intreg.marire() << endl;
  cout << caracter.majuscula() << endl;
  return 0;
}
8
J
Sintaxa folosită pentru specializarea șablonului de clasă este:

1
template <> class containerul_meu <char> { ... };

În primul rând, să observăm că punem înaintea numelui clasei cuvântul template<> , incluzând și o listă vidă de parametri. Și facem acest lucru pentru că sunt recunoscute toate tipurile de dată și nu sunt necesare argumente pentru șablon în această specializare, dar este o specializare a unui șablon de clasă, iar acest fapt se semnalează astfel.

Dar mult mai important decât acest prefix este parametrul de specializare <char> de după numele șablonului de clasă. Chiar acest parametru de specializare identifică tipul de dată pentru care se face specializarea șablonului (char). Să observăm diferența dintre șablonul generic de clasă și specializare:

1
2
template <class T> class containerul_meu { ... };
template <> class containerul_meu <char> { ... };

Prima linie este șablonul generic, iar a doua este specializarea.

Când declarăm specializări pentru un șablon de clasă, trebuie să-i definim toți membrii, chiar pe cei identici cu șablonul generic al clasei, deoarece nu există "moștenire" a membrilor de la un șablon generic la specializare.
Index
Index