Autor: Răzvan Cristea
Studentul va fi capabil la finalul acestui laborator să:
În cadrul laboratorului anterior ne-am axat pe scrierea corectă a unei clase respectând principiul încapsulării datelor. În acest laborator atenția ne este îndreptată tot asupra modului în care o clasă este scrisă în așa fel încât să respecte principiile OOP, dar vom introduce noi tipuri de membri și de asemenea vom prezenta câteva din funcțiile membre care sunt specifice clasei.
Pentru a înțelege despre ce vom vorbi pe parcursul laboratorului propunem ca și exemplu clasa Conifer, căreia îi vom adăuga noutățile rând pe rând. De asemenea, pentru această clasă vom pune la dispoziție constructori și accesori de tip get și set pentru fiecare membru.
#include <iostream> class Conifer { double pret; float inaltime; public: Conifer(); Conifer(const double& pret, const float& inaltime); double getPret() const; float getInaltime() const; void setPret(const double& pret); void setInaltime(const float& inaltime); };
Iar implementările metodelor le vom regăsi în fișierul “Conifer.cpp” după cum urmează.
#include "Conifer.h" Conifer::Conifer() { pret = 0.0; inaltime = 0.0f; } Conifer::Conifer(const double& pret, const float& inaltime) { this->pret = pret; this->inaltime = inaltime; } double Conifer::getPret() const { return pret; } float Conifer::getInaltime() const { return inaltime; } void Conifer::setPret(const double& pret) { if (pret <= 0) { return; } this->pret = pret; } void Conifer::setInaltime(const float& inaltime) { if (inaltime <= 0) { return; } this->inaltime = inaltime; }
Până în acest punct am făcut doar o scurtă recapitulare a ceea ce am învățat în laboratorul precedent.
Să presupunem că suntem administratorii unui site care aparține unei firme care se ocupă de comercializarea coniferelor. Firma respectivă are mai multe tipuri de conifere care sunt caracterizate prin denumire, spre exemplu: brad, pin, molid, zadă, ienupăr, etc. După cum se poate observa denumirea fiecărui tip de conifer variază. Noi ca și administratori ai site-ului ar trebui să concepem un mecanism prin care să putem stoca în baza de date aceste denumiri indiferent de varietatea lor.
Dacă ne uităm acum mai atent la denumirile enumerate anterior putem observa că din punct de vedere al programării acestea nu sunt altceva decât șiruri de caractere care au lungimi diferite. Pentru a putea accepta orice fel de denumire de conifer în cod ar trebui ca în clasa noastră să avem un membru în care putem stoca șiruri de caractere.
Să adăugăm în clasa noastră un atribut care ne va ajuta să stocăm denumirea pentru fiecare conifer în parte.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; // membru de tip pointer la char public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); // se aduga un nou parametru la acest constructor double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); };
Am adăugat un atribut denumire, de tip pointer la char, pentru a putea gestiona denumirile coniferelor într-un mod mai flexibil. Am optat pentru un pointer deoarece intenționăm să alocăm memoria dinamic. De ce ar trebui să alegem această abordare? Pentru că lungimea fiecărei denumiri poate varia, iar folosirea unei dimensiuni fixe ar putea fi ori insuficientă, ori ar putea risipi spațiu.
Copia superficială (Shallow Copy) apare atunci când un obiect este copiat fără a se crea duplicatul complet al datelor sale. În cazul în care obiectul conține pointeri, o copie superficială va reține doar adresa de memorie stocată în pointeri, nu și datele efective la care aceștia pointează. Astfel, atât obiectul original, cât și copia vor partaja aceeași zonă de memorie pentru acele date.
Acest tip de copiere poate duce la probleme neașteptate, cum ar fi modificarea datelor originale prin intermediul copiei sau dezalocarea incorectă a memoriei, deoarece două obiecte diferite vor încerca să gestioneze aceeași resursă. De aceea, în astfel de situații este recomandată utilizarea copiei profunde (Deep Copy), care creează o duplicare completă a datelor, inclusiv a zonelor de memorie la care pointerii fac referire.
Pentru a înțelege conceptul de copie superficială vom urmări exemplul de cod de mai jos.
#include <iostream> #include <cstring> int main() { char* sir1 = new char[strlen("Ionescu") + 1]; strcpy(sir1, "Ionescu"); char* sir2 = sir1; // shallow copy (sir2 partajeaza aceeasi zona de memorie ca sir1) sir2[0] = 'J'; std::cout << "Primul sir de caractere are valoarea: " << sir1 << '\n'; std::cout << "Al doilea sir de caractere are valoarea: " << sir2 << '\n'; delete[] sir1; /*delete[] sir2; // incorect deoarece deja am eliberat zona de memorie cu o linie mai sus*/ return 0; }
Pentru a fi și mai clar ce s-a întâmplat pe linia char* sir2 = sir1;
vom ilustra grafic după cum urmează.
După cum se poate observa în imaginea de mai sus variabila sir1 conține adresa primului element din vectorul de caractere (adresa caracterului 'I'). Când am facut atribuirea char* sir2 = sir1;
am făcut ca pointerul sir2 să pointeze către adresa de pointare a lui sir1. Astfel orice modificare pe care o facem asupra lui sir1 se va răsfrânge asupra lui sir2, valabil și invers după cum se poate observa pe linia unde am schimbat valoarea primului caracter prin intermediul lui sir2. Cu alte cuvinte pointerii sir1 și respectiv sir2 partajează aceeași zonă de memorie.
Spre deosebire de shallow copy, unde doar adresele de memorie sunt copiate, în deep copy se creează o replică completă a datelor. Deși pare mai costisitor și mai greu de realizat, acest tip de copiere este soluția cea mai bună atunci când ne dorim ca originalul și copia sa să nu partajeze aceeași zonă de memorie. Practic prin copiere în profunzime realizăm o clonă pentru original în adevăratul sens al cuvântului.
Pentru a înțelege cum putem realiza deep copy vom modifica exemplul anterior în așa fel încât variabilele sir1 și sir2 să fie independente, adică să pointeze către adrese diferite în memoria heap.
#include <iostream> #include <cstring> int main() { char* sir1 = new char[strlen("Ionescu") + 1]; strcpy(sir1, "Ionescu"); char* sir2 = new char[strlen(sir1) + 1]; strcpy(sir2, sir1); sir2[0] = 'J'; std::cout << "Primul sir de caractere are valoarea: " << sir1 << '\n'; std::cout << "Al doilea sir de caractere are valoarea: " << sir2 << '\n'; delete[] sir1; delete[] sir2; // corect deoarece sir1 si sir2 pointeaza catre doua blocuri de memorie diferite return 0; }
Iar ca și ilustrare grafică lucrurile arată în felul următor.
Pașii pe care i-am aplicat pentru a realiza deep copy au fost alocarea unui nou spațiu de memorie pentru sir2 și copierea caracterelor lui sir1 în sir2 cu ajutorul funcției strcpy. Astfel atunci când am schimbat valoarea primului caracter din sir2 modificarea nu s-a propagat și pe sir1, deoarece acum cei doi pointeri nu mai partajează aceeași zonă de memorie.
Având acum cele două noțiuni clarificate putem reveni la clasa Conifer pentru a putea implementa constructorii și metodele accesor. Vom face deep copy, deoarece vrem ca fiecare obiect să aibă pentru membrul său denumire câte o zonă separată în memorie pe care să nu o partajeze cu nimeni altcineva.
Să urmărim în cod implementarea metodelor știind că avem un membru de tip pointer în clasă. Vom prezenta doar implementarile metodelor care conțin atributul denumire, deoarece restul vor rămâne neschimbate.
Conifer::Conifer() { pret = 0.0; inaltime = 0.0f; denumire = nullptr; } Conifer::Conifer(const double& pret, const float& inaltime, const char* denumire) { this->pret = pret; this->inaltime = inaltime; if (denumire != nullptr) { this->denumire = new char[strlen(denumire) + 1]; strcpy(this->denumire, denumire); } else { this->denumire = nullptr; } } char* Conifer::getDenumire() const { return denumire; } void Conifer::setDenumire(const char* denumire) { if (denumire == nullptr || strlen(denumire) < 3) { return; } if (this->denumire != nullptr) { delete[] this->denumire; } this->denumire = new char[strlen(denumire) + 1]; strcpy(this->denumire, denumire); }
Se poate observa că atât în constructorul cu toți parametrii cât și în setter-ul pentru atributul denumire am făcut extra validări pentru a preveni atribuirea de denumiri eronate care în realitate nu ar avea sens.
Așa cum îi spune și numele această metodă se ocupă de distrugerea obiectelor atunci când aceastora li se încheie durata de viață în program. La fel ca și constructorii, destructorul are o serie de trăsături ce îl fac ușor de recunoscut într-o clasă după cum urmează:
În continuare vom prezenta cum putem declara destructorul pentru clasa Conifer.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); ~Conifer(); // destructorul clasei Conifer double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); };
După cum putem vedea în exemplul de mai jos destructorul clasei Conifer se va ocupa de eliberarea memoriei pentru atributul denumire.
Conifer::~Conifer() { if (denumire != nullptr) { delete[] denumire; } }
Constructorul de copiere (Copy Constructor) este o metodă specială a clasei, deoarece cu ajutorul lui putem copia conținutul unui obiect existent într-unul nou. Acest constructor este unic în clasă și respectă toate caracteristicile unui constructor obișnuit. Dacă nu este declarat și implementat de către programator, compilatorul se va ocupa el de generarea unui copy constructor default.
În codul de mai jos vom putea observa modul în care putem declara constructorul de copiere pentru clasa Conifer.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); Conifer(const Conifer& conifer); // constructorul de copiere pentru clasa Conifer ~Conifer(); double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); };
Iar mai jos am implementat acest constructor în așa fel încât să realizeze deep copy.
Conifer::Conifer(const Conifer& conifer) { this->pret = conifer.pret; this->inaltime = conifer.inaltime; if (conifer.denumire != nullptr) { this->denumire = new char[strlen(conifer.denumire) + 1]; strcpy(this->denumire, conifer.denumire); } else { this->denumire = nullptr; } }
Astfel constructorul de copiere este acum specializat pentru crearea de clone reale pentru obiectele de tip Conifer.
Operatorul de atribuire (Operatorul = sau Operatorul de asignare) este folosit pentru a copia conținutul unui obiect existent într-un alt obiect existent. În esență, operatorul de atribuire (asignare) și constructorul de copiere au un comportament similar, deoarece ambele sunt responsabile de copierea valorilor dintr-un obiect în altul. Principala diferență între aceste două funcții membre constă în momentul și contextul în care sunt utilizate.
Constructorul de copiere este folosit atunci când un nou obiect este creat pe baza unui alt obiect existent. Pe de altă parte, operatorul de atribuire este utilizat atunci când un obiect deja existent primește valori din alt obiect, adică obiectul în care se copiază conținutul trebuie să aibă deja alocată memorie și să fi fost inițializat anterior.
Ceea ce urmează a fi prezentat constituie o variantă de supraîncarcare (overloading) a operatorului de atribuire. Vom discuta mai pe larg ce presupune supraîncărcarea operatorilor în laboratorul următor.
Pentru a putea realiza supraîncărcarea operatorului de asignare trebuie să folosim cuvântul cheie operator urmat de simbolul operatorului (adică ”=“) după cum urmează în codul de mai jos.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); Conifer(const Conifer& conifer); Conifer& operator=(const Conifer& conifer); // operatorul de asignare ~Conifer(); double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); };
Iar implementarea acestuia se poate observa mai jos după cum urmează.
Conifer& Conifer::operator=(const Conifer& conifer) { if (this->denumire != nullptr) { delete[] this->denumire; } this->pret = conifer.pret; this->inaltime = conifer.inaltime; if (conifer.denumire != nullptr) { this->denumire = new char[strlen(conifer.denumire) + 1]; strcpy(this->denumire, conifer.denumire); } else { this->denumire = nullptr; } return *this; }
Conifer& Conifer::operator=(const Conifer& conifer) { if (this == &conifer) // verificarea de auto-asignare { return *this; } if (this->denumire != nullptr) { delete[] this->denumire; } this->pret = conifer.pret; this->inaltime = conifer.inaltime; if (conifer.denumire != nullptr) { this->denumire = new char[strlen(conifer.denumire) + 1]; strcpy(this->denumire, conifer.denumire); } else { this->denumire = nullptr; } return *this; }
Cuvântul cheie static provine din limbajul C și și-a păstrat funcționalitatea în C++, dar a dobândit și noi utilizări datorită paradigmei Orientate Obiect. În ambele limbaje de programare, static are rolul de a controla durata de viață și vizibilitatea (accesibilitatea) unor variabile sau funcții.
O variabilă statică are un comportament similar cu cel al unei variabile globale, în sensul că trăiește pe toată durata de execuție a programului. În C++, cuvântul cheie static a fost extins pentru a permite declararea membrilor statici într-o clasă. Un membru static aparține clasei în sine, și nu unei instanțe a acesteia, fiind partajat între toate obiectele clasei.
Un câmp static în clasă poate fi util de exemplu atunci când ne dorim să numărăm câte instanțe ale clasei respective avem la un anumit moment de timp în program. Pentru a evidenția utilitatea unui membru static vom crea un contor de instanțe pentru clasa Conifer după cum urmează.
#include <iostream> class Conifer { double pret; // membru non-static float inaltime; // membru non-static char* denumire; // membru non-static static int numarConifere; // membru static -> in acest exemplu este folosit pe post de contor de instante pentru clasa Conifer public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); Conifer(const Conifer& conifer); Conifer& operator=(const Conifer& conifer); // operatorul de asignare ~Conifer(); double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); };
Inițializarea și prelucrarea unui membru static se poate face după cum urmează.
int Conifer::numarConifere = 0; Conifer::Conifer() { pret = 0.0; inaltime = 0.0f; denumire = nullptr; numarConifere++; } Conifer::Conifer(const double& pret, const float& inaltime, const char* denumire) { this->pret = pret; this->inaltime = inaltime; if (denumire != nullptr) { this->denumire = new char[strlen(denumire) + 1]; strcpy(this->denumire, denumire); } else { this->denumire = nullptr; } numarConifere++; } Conifer::Conifer(const Conifer& conifer) { this->pret = conifer.pret; this->inaltime = conifer.inaltime; if (conifer.denumire != nullptr) { this->denumire = new char[strlen(conifer.denumire) + 1]; strcpy(this->denumire, conifer.denumire); } else { this->denumire = nullptr; } numarConifere++; } Conifer::~Conifer() { if (denumire != nullptr) { delete[] denumire; } numarConifere--; }
Deși membrul static numarConifere este privat noi totuși ne propunem să avem cumva acces la el. Soluția cea mai simplă este să implementăm un getter pentru acesta, astfel încât să respectăm și principiul încapsulării datelor.
La fel ca variabila statică, funcția statică există pe întreaga durată de viață a programului. În POO o funcție statică la nivel de clasă este foarte asemănătoare cu o metodă. Totuși o metodă primește ca parametru pe prima poziție în lista de parametri pointerul this. În C++ chiar dacă acest parametru nu este vizibil în lista de parametri, la compilare există (acest procedeu ne este ascuns, dar în spate compilatorul adaugă acest parametru la toate funcțiile membre).
În exemplul de mai jos am declarat un getter pentru a obține numărul de conifere.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; static int numarConifere; public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); Conifer(const Conifer& conifer); Conifer& operator=(const Conifer& conifer); ~Conifer(); double getPret() const; float getInaltime() const; char* getDenumire() const; void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); static int getNumarConifere(); // functie statica };
Iar implementarea o putem observa mai jos.
int Conifer::getNumarConifere() { return numarConifere; }
Această funcție statică poate fi apelată fie prin numele clasei fie prin intermediul unui obiect după cum urmează în codul sursă de mai jos.
#include "Conifer.h" int main() { std::cout << "Numarul de conifere este: " << Conifer::getNumarConifere() << '\n'; Conifer c1; std::cout << "Numarul de conifere este: " << Conifer::getNumarConifere() << '\n'; std::cout << "Numarul de conifere este: " << c1.getNumarConifere() << '\n'; Conifer c2 = c1; std::cout << "Numarul de conifere este: " << c2.getNumarConifere() << '\n'; return 0; }
Astfel am putut observa și înțelege cum putem folosi membrii statici și fucțiile statice la nivel de clasă.
Evident într-o clasă pot exista și membri constanți pentru a permite distincția între obiecte. De obicei un membru constant are un scop bine definit spre exemplu poate fi un cod unic de identificare a obiectului. În lumea reală o persoană este identificată unic după CNP-ul (codul numeric personal) acesteia care știm foarte bine că nu mai poate fi modificat sau atribuit altei persoane.
În cazul exemplului nostru putem să asigurăm unicitatea membrilor constanți folosindu-ne de membrul static. Să urmărim modul în care am declarat în clasa Conifer un atribut constant denumit codUnic.
#include <iostream> class Conifer { double pret; float inaltime; char* denumire; const int codUnic; // membru constant static int numarConifere; public: Conifer(); Conifer(const double& pret, const float& inaltime, const char* denumire); Conifer(const Conifer& conifer); Conifer& operator=(const Conifer& conifer); ~Conifer(); double getPret() const; float getInaltime() const; char* getDenumire() const; const int getCodUnic() const; // accesor pentru membrul constant void setPret(const double& pret); void setInaltime(const float& inaltime); void setDenumire(const char* denumire); static int getNumarConifere(); };
În exemplul de cod de mai jos am pus în lumină modul în care este inițializat un membru constant.
Conifer::Conifer() : codUnic(++numarConifere) { pret = 0.0; inaltime = 0.0f; denumire = nullptr; } Conifer::Conifer(const double& pret, const float& inaltime, const char* denumire) : codUnic(++numarConifere) { this->pret = pret; this->inaltime = inaltime; if (denumire != nullptr) { this->denumire = new char[strlen(denumire) + 1]; strcpy(this->denumire, denumire); } else { this->denumire = nullptr; } } Conifer::Conifer(const Conifer& conifer) : codUnic(++numarConifere) { this->pret = conifer.pret; this->inaltime = conifer.inaltime; if (conifer.denumire != nullptr) { this->denumire = new char[strlen(conifer.denumire) + 1]; strcpy(this->denumire, conifer.denumire); } else { this->denumire = nullptr; } } Conifer::~Conifer() { if (denumire != nullptr) { delete[] denumire; } /* numarConifere--; // nu facem acest lucru deoarece putem avea id-uri duplicate ceea ce ar strica principiul unicitatii*/ } const int Conifer::getCodUnic() const { return codUnic; }
Implementarea completă a clasei Conifer poate fi descărcată de aici.
În cadrul acestui laborator, am aprofundat conceptul de metode specifice unei clase (constructor de copiere, operator de asignare și destructor) și am clarificat diferențele fundamentale dintre funcții și metode. Funcțiile sunt entități independente, care pot exista în afara unei clase, în timp ce metodele sunt funcții membre asociate direct cu o clasă și care operează asupra instanțelor acesteia.
Un alt aspect important pe care l-am explorat este utilizarea membrilor statici și constanți pentru a implementa mecanisme de generare automată a codurilor unice. Am demonstrat cum membrii statici permit partajarea unei valori comune între toate instanțele unei clase, iar membrii constanți oferă garanția că anumite valori rămân neschimbate pe toată durata de viață a obiectelor. Aceste mecanisme sunt esențiale pentru gestionarea eficientă a resurselor și pentru menținerea consistenței și integrității datelor într-o aplicație OOP.
De asemenea am putut vedea în linii mici ce înseamnă supraîncărcarea unui operator și ce presupune aceasta, mai multe detalii vom da în laboratorul următor când vom studia și alți operatori nu doar pe cel de asignare.