Membri speciali

[NOTĂ: Pentru acest capitol sunt necesare cunoștințe solide privind memoria alocată dinamic]

Funcțiile membru speciale sunt funcții membru definite implicit ca membri ai clasei în anumite circumstanțe. Sunt în număr de șase:

Funcția membruForma generală pentru clasa C:
Constructor implicitC::C();
DestructorC::~C();
Constructor de copiereC::C (const C&);
Copiere prin atribuireC& operator= (const C&);
Constructor de mutareC::C (C&&);
Atribuire prin mutareC& operator= (C&&);

Să o examinâm pe fiecare:

Constructor implicit

Constructorul implicit este constructorul care se apelează atunci când se declară obiecte ale clasei, dar nu sunt inițializate cu niciun argument.

Dacă o definiție de clasă nu contine niciun constructor, compilatorul presupune că a fost definitconstructorul implicit. De aceea, dupa declararea unei clase precum:

1
2
3
4
5
class Exemplu {
  public:
    int total;
    void adauga (int x) { total += x; }
};

compilatorul presupune că Exemplu are un constructor implicit. De aceea, obiectele acestei clase pot fi construite doar declarându-le, fără alte argumente:

1
Exemplu ex;

Dar, atunci când o clasă conține cel puțin un constructor cu parametri declarat explicit, compilatorul nu mai ia în considerare un constructor implicit și nu mai permite declararea fârâ argumente de noi obiecte ale clasei. De exemplu, în următoarea clasâ:

1
2
3
4
5
6
class Exemplu2 {
  public:
    int total;
    Exemplu2 (int valoare_initiala) : total(valoare_initiala) { };
    void adauga (int x) { total += x; };
};

am declarat un constructor având un parametru de tip int. De aceea urmâtoarea declarație este corectă:

1
Exemplu2 ex (100);   // ok: se apeleaza constructorul 

dar aceasta:
1
Exemplu2 ex;         // nu este valida: nu exista constructor implicit 

nu poate fi validată, deoarece clasa a fost definită cu un constructor implicit care să aibă un parametru și astfel a fost eliminat constructorul implicit fără parametri.

Așa încât, dacă avem nevoie să declară obiecte ale acestei clase fără argumente, ar trebui să declarăm pentru clasă și un constructor implicit. De exemplu:

// clase și constructori impliciți
#include <iostream>
#include <string>
using namespace std;

class Exemplu3 {
    string data;
  public:
    Exemplu3 (const string& str) : data(str) {}
    Exemplu3() {}
    const string& continut() const {return data;}
};

int main () {
  Exemplu3 foo;
  Exemplu3 bar ("Exemplu");

  cout << "continutul lui bar: " << bar.continut() << '\n';
  return 0;
}
continutul lui bar: Exemplu

Aici, Exemplu3 are un constructor implicit (adică, un constructor fără parametri) definit ca un bloc vid:

1
Exemplu3() {};

Acesta permite construcția de obiecte ale clasei Exemplu3 fără parametri (așa cum a fost declarat foo în acest exemplu). În mod normal, un asemenea constructor implicit este definit pentru toate clasele care nu au alți constructori, motiv pentru care nu este necesară definirea explicită. Dar în acest caz, Exemplu3 are deja un alt constructor:

1
Exemplu3 (const string& str);

și când există declarat explicit un constructor pentru o clasă, nu mai este prevăzut automat un constructor implicit.

Destructor

Destructorii îndeplinesc functionalitatea inversă a constructorilor: eliberează resursele alocate printr-o clasă atunci când aceasta nu mai este necesară. Clasele pe care le-am definit în capitolele anterioare nu alocă niciun fel de resurse și de aceea nu avem ce să eliberăm.

Acum, să ne imaginăm că în clasa din ultimul exemplu se alocă dinamic memorie care să rețină string-ul conținut ca dată membru; în acest caz, ar fi foarte utilă o funcție care să fie apelată automat la sfârșitul ciclului de viață al obiectului și care să elibereze memoria alocată. Pentru a face aceasta, folosim un destructor. Un destructor este o funcție membru foarte asemănătoare unui constructor implicit: nu are argumente și nu returnează nimic, nici chiar void. De asemenea, are ca nume chiar numele clasei, dar precedat de un simbol tilda (~):

// destructori
#include <iostream>
#include <string>
using namespace std;

class Exemplu4 {
    string* ptr;
  public:
    // constructori:
    Exemplu4() : ptr(new string) {}
    Exemplu4 (const string& str) : ptr(new string(str)) {}
    // destructor:
    ~Exemplu4 () {delete ptr;}
    // accesarea continutului:
    const string& continut() const {return *ptr;}
};

int main () {
  Exemplu4 foo;
  Exemplu4 bar ("Exemplu");

  cout << "continutul lui bar: " << bar.continut() << '\n';
  return 0;
}
continutul lui bar: Exemplu

La construcție, Exemplu4 alocă spațiu pentru un string. Acest spațiu va fi eliberat ulterior de către destructor.

Destructorul unui obiect este apelat la sfârșitul ciclului de viață al obiectului; în cazul lui foo și bar momentul acesta are loc la sfârșitul lui main.

Constructor de copiere

Când unui obiect i se transmite ca parametru un nume de obiect de același tip, se apelează un constructor de copiere cae realizează o copie.

Un constructor de copiere este un constructor al cărui prim parametru este de tip referință la acea clasă (poate fi calificat const) și care poate fi apelat cu un singur argument de acel tip. De exemplu, pentru o clasă Clasa_mea, constructorul de copiere poate avea semnătura:

1
Clasa_mea::Clasa_mea (const Clasa_mea&);

Dacă o clasă nu are definiți nici constructori de copiere nici constructori de mutare (sau atribuire), atunci este furnizat automat unconstructor de copiere implicit. Acesta execută pur și simplu o copie a propriilor membri. De exemplu, pentru clasa:

1
2
3
4
class Clasa_mea {
  public:
    int a, b; string c;
};

este definit automat un constructor de copiere implicit. Definiția pentru această funcție execută o copie simplă, aproximativ echivalentă cu:

1
Clasa_mea::Clasa_mea(const Clasa_mea& x) : a(x.a), b(x.b), c(x.c) {}

Constructorul de copiere implicit face față cerințelor pentru multe clase. Dar copiile simple copiază doar membrii însuși ai clasei, ceea ce probabil nu este rezultatul la care ne așteptăm de la clase precum Exemplu4 definită mai sus, deoarece conține pointeri pentru gestionarea memoriei. Pentru acea clasă, realizarea unei copii simple înseamnă copierea valorii pointerului, nu a conținutului său; adică ambele obiecte (copia și originalul) partajează un singur obiect string (cele doua pointează spre același obiect), iar la un moment dat (la apelul constructorului) ambele obiecte vor încerca să șteargă același bloc de memorie, ceea ce foarte probabil va genera eșuarea programului în momentul execuției. Această problemă poate fi rezolvată definind următorul constructor de copiere care realizează o copie complexă:

// constructor de copiere: copie complexa
#include <iostream>
#include <string>
using namespace std;

class Exemplu5 {
    string* ptr;
  public:
    Exemplu5 (const string& str) : ptr(new string(str)) {}
    ~Exemplu5 () {delete ptr;}
    // copy constructor:
    Exemplu5 (const Exemplu5& x) : ptr(new string(x.continut())) {}
    // accesare continut:
    const string& continut() const {return *ptr;}
};

int main () {
  Exemplu5 foo ("Exemplu");
  Exemplu5 bar = foo;

  cout << "continutul lui bar: " << bar.continut() << '\n';
  return 0;
}
continutul lui bar: Exemplu

Copia complexă realizată de acest constructor de copiere alocă spațiu pentru un nou string, care este inițializat astfel încăt să conțină o copie a obiectului original. În acest fel, cele două obiecte (copia și originalul) conțin exemplare distincte ale conținutului, stocate în locații diferite.

Copiere prin atribuire

Obiectele pot fi copiate nu numai la momentul construcției, când sunt inițializate; ele pot fi copiate și printr-o operație de atribuire. Să vedem diferența:

1
2
3
4
Clasa_mea foo;
Clasa_mea bar (foo);       // inițializare obiect: se apelează constructorul de copiere
Clasa_mea baz = foo;       // inițializare obiect: se apelează constructorul de copiere
foo = bar;               // obiectul a fost deja inițializat: se apelează copierea prin atribuire 

Să observăm că baz este inițializat la construcție folosind un simbol equal, dar aceasta nu este o operație de atribuire! (deși seamănă cu așa ceva): declarația unui obiect nu este o operație de atribuire, este doar o altă formă pentru sintaxa de apelare a unui constructor cu un singur argument.

Atribuirea pentru foo este o operație de atribuire. Aici nu este declarat niciun obiect, ci operația se execută cu un obiect existent - foo.

Operatorul de copiere prin atribuire este o supraîncărcare pentru operator= care ia ca parametru o valoare sau o referință a clasei. Valoarea returnată este, în general, o referință la *this (deși nu este obligatoriu). De exemplu, pentru o clasă Clasa_mea, copierea prin atribuire ar putea avea următoarea semnătură:

1
Clasa_mea& operator= (const Clasa_mea&);

Operatorul de copiere prin atribuire este, totodată, o funcție specială definită implicit dacă o clasă nu are definite nici copiere, nici mutare prin atribuiri (nici constructor de mutare).

Repetăm, versiunea implicită execută o copie simplă potrivită pentru multe clase, dar nu pentru cele care gestionează spațiul de memorie prin pointeri, precum în Exemplu5. În acest caz, nu numai că apare riscul încercării de ștergere dublă a unui obiect, dar copierea prin atribuire creează probleme căci nu se poate șterge obiectul spre care se pointa înainte de atribuire. Asemenea situații-problemă se pot rezolva cu copierea prin atribuire care poate șterge obiectul anterior și executa o copie complexă:

1
2
3
4
5
6
Exemplu5& operator= (const Exemplu5& x) {
  delete ptr;                      // șterge string-ul curent spre care se pointează
  ptr = new string (x.continut());  // alocă spațiu pentru un nou string și copiază
  return *this;
}

Sau, chiar mai bine, pentru ca membrul string nu este constant, s-ar putea reutiliza acelasi obiect string:

1
2
3
4
Exemplu5& operator= (const Exemplu5& x) {
  *ptr = x.continut();
  return *this;
}


Constructor de mutare și atribuire

Asemănător copierii, mutarea folosește și ea valoarea unui obiect pentru setarea valorii altui obiect. Dar, spre deosebire de copiere, continutul este transferat de la un obiect (sursa) la celălalt (destinația): sursa își pierde conținutul, care este preluat de către destinație. Mutarea se produce numai când sursa valorii este un obiect anonim.

Obiectele anonime sunt obiecte de natură temporară, motiv pentru care nici nu li se dau nume. Exemple tipice de obiecte anonime sunt valorile returnate de funcții sau de conversii de tip.

Folosirea valorii unui obiect temporar pentru inițializarea altui obiect sau într-o operație de atribuire nu necesită o realizarea unei copii: obiectul nu va fi utilizat altfel, deci valoarea sa poate fi mutată în obiectul destinație. Asemenea situații declanșează constructorul de copiere si mutarea prin atribuire:

Constructorul de mutare este apelat când se inițializează un obiect la construcție folosind un temporar anonim. De asemenea, mutarea prin atribuire este apelată când unui obiect i se atribuie valoarea unui anonim temporar:

1
2
3
4
5
6
Clasa_mea fn();            // funcție care returnează un obiect de tip Clasa_mea
Clasa_mea foo;             // constructor implicit
Clasa_mea bar = foo;       // constructor de copiere
Clasa_mea baz = fn();      // constructor de mutare
foo = bar;               // copiere prin atribuire
baz = Clasa_mea();         // mutare prin atribuire 

Atât valoarea returnată de fn cât și valoarea construită cu Clasa_mea sunt temporare anonime. În aceste situații, nu este necesară crearea unor copii, deoarece obiectul anonim are durata de viață foarte scurtă iar operația este mai eficientă astfel.

Constructorul de mutare și mutarea prin atribuire sunt membri care au un parametru de tipul referință rvalue la acea clasă:

1
2
Clasa_mea (Clasa_mea&&);             // constructor de mutare
Clasa_mea& operator= (Clasa_mea&&);  // mutare prin atribuire 

O referință rvalue se precizează prin plasarea după tipul de dată a două simboluri ampersand (&&). Ca parametru, o referință rvalue trebuie să aibă același tip cu argumentele temporare.

Cea mai importantă utilitate a conceptului de mutare o regăsim la obiectele care își gestionează spațiul de memorie, precum obiectele care folosesc operatorii new și delete. Pentru asemenea obiecte, copierea și mutarea reprezintă operații complet diferite:
- Copierea de la A la B înseamnă că se alocă spațiu în B și întregul conținut al lui A este copiat în noua locație de memorie alocată pentru B.
- Mutarea de la A la B înseamnă că memoria alocată deja pentru A este transferată în B, fără alocarea unui nou spațiu. Aceasta implică doar copierea pointerului.

De exemplu:
// constructor de mutare/mutare prin atribuire
#include <iostream>
#include <string>
using namespace std;

class Exemplu6 {
    string* ptr;
  public:
    Exemplu6 (const string& str) : ptr(new string(str)) {}
    ~Exemplu6 () {delete ptr;}
    // constructor de mutare
    Exemplu6 (Exemplu6&& x) : ptr(x.ptr) {x.ptr=nullptr;}
    // mutare prin atribuire
    Exemplu6& operator= (Exemplu6&& x) {
      delete ptr; 
      ptr = x.ptr;
      x.ptr=nullptr;
      return *this;
    }
    // accesare conținut:
    const string& continut() const {return *ptr;}
    // adunare:
    Exemplu6 operator+(const Exemplu6& rhs) {
      return Exemplu6(continut()+rhs.continut());
    }
};


int main () {
  Exemplu6 foo ("Exem");
  Exemplu6 bar = Exemplu6("plu");   // mutare prin constructor
  
  foo = foo + bar;                  // mutare prin atribuire

  cout << "continutul lui foo: " << foo.continut() << '\n';
  return 0;
}
continutul lui foo: Exemplu

Compilatoarele deja optimizează multe cazuri care necesită formal apelul unui constructor de mutare, prin ceea ce se numește Optimizarea valorii returnate. Menționăm cel mai important caz: când valoarea returnată de o funcție este folosită pentru inițializarea unui obiect. În aceste cazuri, constructorul de mutare este posibil să nu fie apelat deloc.

Chiar dacă referințele rvalue pot fi folosite pentru tipul oricărui parametru al funcției, rareori se întâmplă pentru altceva decât constructorul de mutare. Referințele rvalue sunt înșelătoare, iar folosirea lor inutilă poate conduce la erori destul de greu de urmărit.

Membri impliciți

Cele șase funcții membru speciale descrise mai sus sunt membri declarați implicit în clase când se îndeplinesc anumite condiții:

Funcție membrudefinitâ implicit:definiție implicită:
Constructor implicitdacă nu a fost definit alt constructornu face nimic
Destructordacă nu există deja destructornu face nimic
Constructor de copieredacă nu există constructor de mutare și nici mutare prin atribuirecopiază toți membrii
Copiere prin atribuiredacă nu există constructor de mutare și nici mutare prin atribuirecopiază toți membrii
Constructor de mutaredacă nu există destructor, nici constructor de copiere, nici copiere sau mutare prin atribuiremută toți membrii
Mutare prin atribuiredacă nu există destructor, nici constructor de copiere, nici copiere sau mutare prin atribuiremută toți membrii

Să remarcăm că nu toate funcțiile membru speciale sunt definite implicit în aceleași cazuri. Aceasta se datorează contextului de compatibilitate cu structurile C și primele versiuni de C++, iar unele, de fapt, sunt cazuri depreciate. Din fericire, pentru fiecare clasă ses pot selecta explicit funcțiile membru care să fie incluse în forma lor implicită sau care dintre ele să fie eliminate, folosing cuvintele cheie default, respectiv delete. Sintaxa poate fi una dintre următoarele:


function_declaration = default;
function_declaration = delete;


De exemplu:
// folosirea lui default și delete pentru membrii impliciți
#include <iostream>
using namespace std;

class Dreptunghi {
    int latime, inaltime;
  public:
    Dreptunghi (int x, int y) : latime(x), inaltime(y) {}
    Dreptunghi() = default;
    Dreptunghi (const Dreptunghi& altul) = delete;
    int aria() {return latime*inaltime;}
};

int main () {
  Dreptunghi foo;
  Dreptunghi bar (10,20);

  cout << "aria lui bar: " << bar.aria() << '\n';
  return 0;
}
aria lui bar: 200

Aici, Dreptunghi poate fi construit fie cu două argumente int fie cu construcția implicită (fără argumente). Totuși, nu poate fi construit prin copiere din alt obiect Dreptunghi, deoarece a fost ștearsă această funcție. De aceea, considerând obiectele din ultimul exemplu, urmatoarele instrucțiuni nu sunt valide:

1
Dreptunghi baz (foo);

Totuși, ar putea fi validate explicit prin definirea constructorului de copiere astfel:

1
Dreptunghi::Dreptunghi (const Dreptunghi& altul) = default;

ceea ce ar fi echivalent cu:

1
Dreptunghi::Dreptunghi (const Dreptunghi& altul) : latime(altul.latime), inaltime(altul.inaltime) {}

Remarcăm că, prin cuvântul cheie default, nu se definește o funcție membru echivalentă cu constructorul implicit (adică, unde default constructor să însemne constructor fără parametri), dar echivalentă cu constructorul care ar fi fost definit implicit dacă nu s-ar fi șters.

În general, pentru compatibilitatea ulterioară, încurajăm ca in clasele în care se definește un constructor de copiere/mutare sau o copiere/mutare prin atribuire, dar nu ambele simultan, să se precizeze fie delete fie default între funcțiile membru speciale care nu sunt definite explicit.
Index
Index