Autor: Răzvan Cristea
Studentul va fi capabil la finalul acestui laborator să:
În acest laborator vom extinde lucrul cu clase, introducând conceptul de relații între clase. Vom construi și analiza legături între două clase, folosind relațiile de tip “is-a” și “has-a”, dar vom pune accentul mai mult pe relația de “is-a”. Relația de tip “is-a” reprezintă o formă de moștenire, un principiu fundamental al POO, prin care o clasă derivată (subclasă) preia proprietățile și comportamentele unei clase de bază (superclasă).
Acest tip de relație ne permite să definim ierarhii de clase, să reutilizăm codul și să extindem funcționalitățile claselor, oferind un model de organizare flexibil și scalabil. Moștenirea funcționează similar cu cea din viața reală: clasa derivată preia proprietățile și comportamentele clasei de bază, dar poate adăuga și comportamente noi sau modifica pe cele existente. Astfel, moștenirea facilitează extensibilitatea și întreținerea codului, reducând duplicarea și oferind un mod eficient de a gestiona complexitatea în proiecte de mari dimensiuni.
Așa cum am menționat anterior, moștenirea este un principiu fundamental al POO care permite unei clase derivate să preia atât proprietățile (atributele) cât și comportamentele (funcțiile membre) unei clase părinte. Prin acest mecanism, clasa derivată poate să extindă funcționalitatea moștenită prin adăugarea de noi atribute și metode sau prin redefinirea celor existente. Scopul principal al moștenirii este de a promova reutilizarea codului și de a permite o extensie naturală a funcționalităților inițiale, astfel încât să se creeze o structură mai flexibilă și mai ușor de întreținut în cadrul aplicațiilor.
Înainte de a explica moștenirea între două clase vom face o scurtă recapitulare a noțiunilor deja învățate în cadrul laboratoarelor anterioare. Pentru acest laborator propunem clasa Locuinta care are ca și membri pret (de tip float) și adresa (șir de caractere alocat dinamic).
#pragma once #include <cstring> #include <iostream> class Locuinta { float pret; char* adresa; public: Locuinta(); Locuinta(const float& pret, const char* adresa); Locuinta(const Locuinta& locuinta); Locuinta& operator=(const Locuinta& locuinta); ~Locuinta(); float getPret() const; char* getAdresa() const; void setPret(const float& pret); void setAdresa(const char* adresa); friend std::ostream& operator<<(std::ostream& out, const Locuinta& locuinta); };
#pragma once
. Aceasta este o instrucțiune specifică compilatorului care indică faptul că fișierul respectiv trebuie inclus și compilat o singură dată, chiar dacă este referit în mod repetat în alte fișiere prin intermediul directivelor #include
. Astfel, se previn multiplele incluziuni ale aceluiași fișier header, care ar putea duce la erori de compilare, cum ar fi redefinirea claselor sau funcțiilor. Directiva #pragma once
este o alternativă modernă și mai simplă la gardienii clasici ai fișierelor header, adică acele secvențe de cod cu #ifndef
, #define
și #endif
care au același scop.
Dacă voiam să folosim varianta tradițională de scriere a unui fișier header am fi procedat în maniera următoare.
#ifndef LOCUINTA_H #define LOCUINTA_H #include <cstring> #include <iostream> class Locuinta { float pret; char* adresa; public: Locuinta(); Locuinta(const float& pret, const char* adresa); Locuinta(const Locuinta& locuinta); Locuinta& operator=(const Locuinta& locuinta); ~Locuinta(); float getPret() const; char* getAdresa() const; void setPret(const float& pret); void setAdresa(const char* adresa); friend std::ostream& operator<<(std::ostream& out, const Locuinta& locuinta); }; #endif // LOCUINTA_H
Acest tip de relație ne permite să implementăm moștenirea între clase. În acest context, când discutăm despre moștenire, întâlnim următorii termeni esențiali: clasă părinte (denumită și clasă de bază sau superclasă) și clasă derivată (denumită și clasă copil sau subclasă). Clasa părinte reprezintă clasa de la care dorim să preluăm atribute și metode, având posibilitatea să reutilizăm codul existent, în timp ce clasa derivată extinde această funcționalitate, adăugând noi comportamente și caracteristici.
În continuare vom căuta o clasă care poate să extindă și să respecte relația de “is-a” cu clasa Locuinta. Vom propune clasa Apartament care respectă regulile descrise mai sus, deoarece orice apartament este o locuință. Clasa Apartament are ca și atribute numarCamere (de tip întreg) și numeProprietar (șir de caractere alocat dinamic).
În limbajul C++ pentru ca o clasă să moștenească o altă clasă se folosește următoarea sintaxă:
class NumeClasaDerivata : specificator de acces pentru moștenire NumeClasaParinte
și apoi corpul clasei derivate.
Să urmărim în cod cum putem face clasa Apartament să moștenească clasa Locuință.
#pragma once #include "Locuinta.h" class Apartament : public Locuinta // clasa Apartament mosteneste clasa Locuinta { int numarCamere; char* numeProprietar; public: Apartament(); Apartament(const float& pret, const char* adresa, const int& numarCamere, const char* numeProprietar); Apartament(const Apartament& apartament); Apartament& operator=(const Apartament& apartament); ~Apartament(); int getNumarCamere() const; char* getNumeProprietar() const; void setNumarCamere(const int& numarCamere); void setNumarCamere(const char* numeProprietar); friend std::ostream& operator<<(std::ostream& out, const Apartament& apartament); };
Pentru a putea face câmpurile clasei Locuinta să fie vizibile în clasa copil, dar să nu poată fi în continuare accesate din exterior de oricine le vom marca cu protected după cum urmează în codul de mai jos.
#pragma once #include <cstring> #include <iostream> class Locuinta { protected: // specificatorul de acces protected float pret; char* adresa; public: Locuinta(); Locuinta(const float& pret, const char* adresa); Locuinta(const Locuinta& locuinta); Locuinta& operator=(const Locuinta& locuinta); ~Locuinta(); float getPret() const; char* getAdresa() const; void setPret(const float& pret); void setAdresa(const char* adresa); friend std::ostream& operator<<(std::ostream& out, const Locuinta& locuinta); };
Astfel acum câmpurile pret și adresa vor fi vizibile și în clasa derivată, dar vor rămâne inaccesibile în funcția main sau în orice altă clasă care nu moștenește clasa Locuinta.
În continuare vom prezenta modul în care trebuiesc implementate toate funcționalitățile clasei Apartament astfel încât relația de “is-a” să fie satisfăcută și să reutilizăm codul din clasa Locuinta.
Atunci când dorim să construim un obiect de tipul clasei derivate, trebuie să ținem cont de faptul că, mai întâi, trebuie inițializate și gestionate toate datele și resursele moștenite din clasa părinte. Constructorul clasei derivate va apela constructorul clasei părinte pentru a asigura corectitudinea inițializării componentelor moștenite. Astfel, acest proces garantează că toate proprietățile părintelui sunt gestionate corespunzător înainte de a se trece la inițializarea specifică clasei derivate.
Să urmărim cu atenție mai jos implemetarea constructorului fără parametri pentru clasa Apartament.
Apartament::Apartament() : Locuinta() { numarCamere = 0; numeProprietar = nullptr; }
În continuare vom implementa constructorul cu parametri pentru clasa copil urmând același principiu ca la constructorul fără parametri.
Apartament::Apartament(const float& pret, const char* adresa, const int& numarCamere, const char* numeProprietar) : Locuinta(pret, adresa) { this->numarCamere = numarCamere; if (numeProprietar != nullptr) { this->numeProprietar = new char[strlen(numeProprietar) + 1]; strcpy(this->numeProprietar, numeProprietar); } else { this->numeProprietar = nullptr; } }
Se poate observa din implementarea anterioară că am deschis lista de inițializare pentru acest constructor unde am chemat constructorul cu parametri al clasei părinte (clasa Locuinta).
În manieră similară se implementează și constructorul de copiere al clasei derivate.
Apartament::Apartament(const Apartament& apartament) : Locuinta(apartament) { numarCamere = apartament.numarCamere; if (apartament.numeProprietar != nullptr) { numeProprietar = new char[strlen(apartament.numeProprietar) + 1]; strcpy(numeProprietar, apartament.numeProprietar); } else { numeProprietar = nullptr; } }
Astfel, am evidențiat reutilizarea codului prin faptul că, în momentul în care construim un obiect de tipul clasei derivate, nu este necesar să redefinim sau să duplicăm funcționalitățile și datele din clasa părinte. Prin simplul apel al constructorului clasei părinte în lista de inițializare a constructorului clasei derivate, putem păstra claritatea și concizia codului sursă. Aceasta reprezintă un avantaj major al moștenirii în POO, permițând extinderea funcționalității fără a compromite principiile de reutilizare și organizare.
Destructorul clasei derivate se implementează la fel ca un destructor obișnuit, adică vom elibera memoria alocată dinamic pentru membrii care sunt de tip pointer.
Apartament::~Apartament() { if (numeProprietar != nullptr) { delete[] numeProprietar; } }
La fel ca în cazul constructorilor va trebui să găsim o modalitate prin care mai întâi ne ocupăm de atributele clasei părinte și pe urmă prelucram datele clasei copil. Operatorul de asignare nefiind un constructor nu are listă de inițializare și va trebui să îl apelăm explicit pe cel din clasa părinte pentru a respecta ordinea pașilor exact la fel ca în cazul constructorilor.
Apartament& Apartament::operator=(const Apartament& apartament) { if (this == &apartament) { return *this; } this->Locuinta::operator=(apartament); // se apeleaza operatorul de asignare din clasa parinte /*(Locuinta&)(*this) = apartament; // este echivalent cu linia de mai sus doar ca este o alta forma de apel*/ numarCamere = apartament.numarCamere; if (apartament.numeProprietar != nullptr) { numeProprietar = new char[strlen(apartament.numeProprietar) + 1]; strcpy(numeProprietar, apartament.numeProprietar); } else { numeProprietar = nullptr; } return *this; }
Vom prezenta în continuare modul de implementare a operatorului de afișare pentru obiectele de tip Apartament respectând în continuare relația de “is-a”.
std::ostream& operator<<(std::ostream& out, const Apartament& apartament) { operator<<(out, (Locuinta&)apartament); // chemam operatorul << din clasa parinte out << "Numarul de camere din apartament este: " << apartament.numarCamere << " ron\n"; if (apartament.numeProprietar != nullptr) { out << "Numele proprietarului este: " << apartament.numeProprietar << "\n"; } else { out << "Numele proprietarului este: N/A\n"; } return out; }
Acum că am înțeles conceptul de moștenire între două clase, vom putea avansa către implementarea unor ierarhii mai complexe începând cu următorul laborator. Moștenirea ne permite să construim structuri ierarhice, în care clasele pot extinde și reutiliza funcționalități din clasele părinte. Astfel, vom fi capabili să dezvoltăm sisteme mai robuste, eficiente și ușor de întreținut, în care fiecare clasă va adăuga comportamente și atribute specifice, păstrând în același timp funcționalitatea de bază moștenită. Aceste ierarhii de clase vor facilita gestionarea mai bună a codului și îmbunătățirea scalabilității aplicațiilor noastre.
În cadrul acestui laborator, am învățat și aprofundat conceptul de moștenire și am văzut cum poate fi implementată între două clase. Am înțeles că moștenirea este o metodă esențială pentru a reutiliza și extinde codul existent, oferind un cadru flexibil și scalabil pentru dezvoltarea aplicațiilor OOP. Prin utilizarea moștenirii, o clasă derivată poate prelua proprietățile unei clase părinte, oferind astfel posibilitatea de a adăuga sau modifica funcționalități specifice.
Un aspect important pe care l-am discutat este faptul că funcțiile friend nu se moștenesc. Aceste funcții, deși pot accesa membri privați sau protejați ai unei clase, nu sunt automat apelate în clasa derivată. Pentru a înțelege acest comportament, am făcut o analogie simplă: prietenii părinților voștri nu sunt în mod automat și prietenii voștri direcți. Astfel, în cazul în care dorim să accesăm funcționalitățile unei funcții friend dintr-o clasă părinte, va trebui să o apelăm explicit în clasa derivată.
De asemenea, am explorat rolul specificatorului de acces protected, care permite membrilor clasei să fie accesibili în cadrul claselor derivate, dar să rămână inaccesibili din exterior. Această abordare oferă un echilibru între încapsulare și moștenire, protejând datele interne ale clasei părinte, dar permițând totuși clasei copil să le utilizeze.
Un alt concept esențial a fost utilizarea listei de inițializare a constructorului în clasele derivate. În momentul în care instanțiem un obiect din clasa derivată, trebuie să avem grijă să inițializăm corect și membrii clasei părinte. Aceasta se realizează prin apelarea explicită a constructorului părinte în lista de inițializare a constructorului clasei derivate. Am subliniat importanța acestui mecanism, deoarece doar constructorii pot fi apelați în această manieră.
În plus, pentru a accesa metode sau funcții din clasa părinte care nu sunt constructori, trebuie să apelăm explicit funcția dorită folosind sintaxa: numeClasăPărinte::numeMetodă()
. Acest apel este necesar pentru a ne asigura că executăm corect comportamentul definit în clasa părinte asupra obiectelor din clasa fiu.
Prin toate aceste concepte și tehnici, am făcut un pas important în utilizarea eficientă a moștenirii în limbajul C++, și suntem pregătiți să explorăm ierarhii mai complexe de clase în laboratoarele viitoare.