Autor: Răzvan Cristea
Studentul va fi capabil la finalul acestui laborator să:
Până în prezent, am explorat conceptul de polimorfism timpuriu (early polymorphism sau compile-time polymorphism), care se manifestă atunci când funcții/metode cu același nume sunt diferențiate prin numărul sau tipul parametrilor. Acest lucru stă la baza conceptului de supraîncărcare a funcțiilor (overloading), care permite definirea mai multor variante ale unei funcții în cadrul aceleiași clase, fiecare având un comportament specific în funcție de semnătura sa. Acest tip de polimorfism este decis la compilare, ceea ce înseamnă că alegerea funcției care va fi apelată se face de către compilator pe baza tipului argumentelor oferite.
De exemplu, dacă avem o funcție adunare
supraîncărcată pentru a lucra cu numere întregi și cu numere reale, compilatorul determină automat care versiune a funcției urmează să fie apelată, în funcție de tipul datelor primite ca argumente. Avantajul acestui tip de polimorfism este viteza de execuție, deoarece decizia a fost deja luată înainte ca programul să ruleze.
Cu toate acestea, există cazuri în care decizia pentru funcția/metoda ce trebuie apelată nu poate fi luată de către compilator, ci doar în timpul execuției. Acest procedeu este cunoscut sub denumirea de polimorfism întârziat (late binding sau run-time polymorphism). Acest concept este strâns legat de suprascrierea funcțiilor (overriding) și de utilizarea mecanismelor de moștenire și funcții virtuale în C++.
În continuare vom prezenta un tabel care pune în evidență diferențele clare între cele două forme de polimorfism.
Overloading (Compile-Time Polymorphism) | Overriding (Run-Time Polymorphism) |
---|---|
Se aplică funcțiilor/metodelor din aceeași clasă | Apare în ierarhiile de clase (moștenire) |
Funcțiile/metodele au același nume, dar diferă prin numărul sau tipul parametrilor | O funcție/metodă dintr-o clasă derivată suprascrie comportamentul unei funcții virtuale din clasa de bază |
Alegerea funcției este făcută de către compilator pe baza semnăturii acesteia | Alegerea funcției care va fi apelată este făcută la momentul execuției, în funcție de tipul dinamic al obiectului |
Nu necesită funcții virtuale/virtual pure. | Necesită utilizarea funcțiilor virtuale/virtual pure în clasa de bază. |
Este o formă de polimorfism timpuriu (early binding) | Este o formă de polimorfism întârziat (late binding) |
Are un impact redus asupra performanței, deoarece decizia se ia la compilare | Are un impact ușor mai mare asupra performanței, deoarece decizia se ia în timpul execuției |
Suprascrierea (overriding) implică păstrarea aceluiași antent al funcției – adică același tip de return și aceeași semnătura (numele și lista de parametri) – dar permite redefinirea completă a comportamentului acesteia într-o clasă derivată. Prin această abordare, o funcție virtuală din clasa de bază poate fi adaptată pentru a răspunde nevoilor specifice ale clasei derivate. Acest proces este esențial pentru polimorfismul la timp de execuție, oferind flexibilitate și posibilitatea de a extinde funcționalitățile într-un mod dinamic.
Pe de altă parte, supraîncărcarea (overloading) presupune existența mai multor funcții cu același nume în cadrul aceleiași clase, dar care diferă prin numărul sau tipul parametrilor. Deși semnăturile sunt distincte, logica generală a funcțiilor rămâne similară, acestea fiind utilizate pentru a oferi funcționalități variate în contexte diferite. Alegerea variantei corespunzătoare este realizată la timpul de compilare, ceea ce asigură o execuție rapidă.
Funcțiile virtuale reprezintă principalul mecanism prin care putem evidenția conceptul de suprascriere (override) în programarea orientată pe obiecte. Atunci când o clasă conține cel puțin o metodă virtuală, pentru acea clasă este generată o structură denumită tabelă de pointeri la funcții virtuale (vtable). Această tabelă stochează adresele funcțiilor virtuale definite în clasa de bază, respectiv în clasele derivate.
Fiecare obiect al unei clase care derivă din clasa de bază cu metode virtuale va primi un pointer la vtable, ceea ce permite legarea dinamică a funcțiilor. Prin urmare, în momentul în care se apelează o funcție virtuală, sistemul determină în momentul execuției care implementare specifică (din clasa de bază sau din clasa derivată) trebuie utilizată, în funcție de tipul dinamic al obiectului.
Acest mecanism stă la baza conceptului de late binding sau legare dinamică, care presupune că alegerea funcției ce urmează să fie executată nu este stabilită în timpul compilării, ci în timpul execuției, oferind astfel o flexibilitate sporită și suport pentru polimorfismul la timp de execuție.
În limbajul C++ pentru a declara o funcție virtuală într-o clasă se utilizeaza cuvântul cheie virtual. Dacă am spus funcție virtuală asta înseamnă că în clasa de bază acea funcție va avea implementare. Ca și exemplu vom propune clasele Animal și Caine.
class Animal { public: void afisare() const; }; void Animal::afisare() const { std::cout << "Sunt un animal!\n"; }
Și respectiv clasa Caine.
class Caine : public Animal { public: void afisare() const; }; void Caine::afisare() const { std::cout << "Sunt un caine!\n"; }
În funcția main vom demostra faptul că deși câinele este un animal se vor apela metodele specifice tipurilor de date din cauza faptului că în clasa Animal metoda afisare
nu este declarată ca fiind virtuală.
int main() { Animal animal; animal.afisare(); // se apeleaza afisarea din Animal Caine caine; caine.afisare(); // se apeleaza afisarea din Caine animal = caine; animal.afisare(); // se apeleaza afisarea din Animal Animal* pAnimal = new Animal(); pAnimal->afisare(); // se apeleaza afisarea din Animal Caine* pCaine = new Caine(); pCaine->afisare(); // se apeleaza afisarea din Caine Animal* pa = pCaine; pa->afisare(); // se apeleaza afisarea din Animal desi ar fi trebui sa se apeleze cea din Caine delete pAnimal; delete pCaine; return 0; }
Soluția este să marcăm metoda de afisare
ca fiind virtuală pentru a putea permite legături întârziate.
class Animal { public: virtual void afisare() const; // metoda afisare este acum virtuala ceea ce inseamna ca vom putea avea legari dinamice };
Iar dacă vom testa acum codul din funcția main vom vedea că se va produce o legare dinamică atunci când vom chema metoda afisare
prin intermediul pointerului pa
.
int main() { Animal animal; animal.afisare(); // se apeleaza afisarea din Animal Caine caine; caine.afisare(); // se apeleaza afisarea din Caine animal = caine; animal.afisare(); // se apeleaza afisarea din Animal deoarece animal nu este pointer Animal* pAnimal = new Animal(); pAnimal->afisare(); // se apeleaza afisarea din Animal Caine* pCaine = new Caine(); pCaine->afisare(); // se apeleaza afisarea din Caine Animal* pa = pCaine; pa->afisare(); // se apeleaza afisarea din Caine delete pAnimal; delete pCaine; return 0; }
Pentru a fi și mai riguroși putem marca în clasa derivată metoda de afisare
cu override pentru a anunța compilatorul că metoda care provine din superclasă urmează să fie suprascrisă.
class Caine : public Animal { public: void afisare() const override; // acum este mentionat explicit faptul ca metoda din Caine o va suprascrie pe cea din Animal };
În limbajul C++, o clasă abstractă este o clasă care conține cel puțin o metodă virtuală pură. Metoda virtuală pură este o funcție declarată în clasa de bază, dar care nu are o implementare în această clasă, obligând astfel clasele derivate să o suprascrie. O clasă abstractă este utilizată pentru a defini un comportament general care trebuie să fie specificat în mod detaliat în clasele derivate.
O clasă abstractă poate avea membri și metode precum constructori, getteri, setteri și destructor care vor fi apelate în clasele derivate. În continuare vom prezenta modul în care putem pune în evidență conceptul de late binding transformând clasa Animal într-o clasă abstractă.
class Animal { char* nume; int varsta; public: Animal(); Animal(const char* nume, const int& varsta); Animal(const Animal& animal); ~Animal(); char* getNume() const; int getVarsta() const; virtual void afisare() const = 0; // metoda virtual pura };
Iar clasa Caine moștenește clasa Animal și implementează metoda virtual pură din clasa de bază.
class Caine : public Animal { char* rasa; public: Caine(); Caine(const char* nume, const int& varsta, const char* rasa); Caine(const Caine& animal); ~Caine(); void afisare() const override; // implementam metoda din clasa de baza };
Iar implementările metodelor din clasa Caine se pot observa mai jos.
Caine::Caine() : Animal() { rasa = nullptr; } Caine::Caine(const char* nume, const int& varsta, const char* rasa) : Animal(nume, varsta) { if (rasa != nullptr) { this->rasa = new char[strlen(rasa) + 1]; strcpy(this->rasa, rasa); } else { this->rasa = nullptr; } } Caine::Caine(const Caine& caine) : Animal(caine) { if (caine.rasa != nullptr) { rasa = new char[strlen(caine.rasa) + 1]; strcpy(rasa, caine.rasa); } else { rasa = nullptr; } } Caine::~Caine() { if (rasa != nullptr) { delete[] rasa; } } void Caine::afisare() const { std::cout << "Nume: " << (getNume() ? getNume() : "Anonim") << '\n'; std::cout << "Varsta: " << getVarsta() << " ani\n"; std::cout << "Rasa: " << (rasa ? rasa : "Necunoscuta") << "\n\n"; }
Iar mai jos propunem un exemplu de testare a acestor clase.
int main() { Caine caine("Zeus", 3, "labrador"); caine.afisare(); // valid Animal* pAnimal = new Caine(); pAnimal->afisare(); // valid deoarece metoda afisare este virtuala delete pAnimal; // incorect nu se cheama si destructorul din Caine, memory leak pAnimal = &caine; // valid datorita relatiei de tip "is-a" pAnimal->afisare(); // valid return 0; }
Acest comportament apare deoarece destructorul din clasa de bază nu este declarat virtual. În cazul ierarhiilor de clase care folosesc metode virtuale sau metode virtual pure, este absolut necesar ca destructorul clasei de bază să fie declarat virtual, astfel încât să asigure eliberarea corectă a resurselor. Când destructorul este declarat virtual, el va garanta apelarea în mod corect a destructorilor pentru toate clasele derivate implicate.
Așadar pentru a elibera memoria corect vom declara destructorul clasei Animal ca funcție virtuală.
class Animal { char* nume; int varsta; public: Animal(); Animal(const char* nume, const int& varsta); Animal(const Animal& animal); virtual ~Animal(); // destructor virtual char* getNume() const; int getVarsta() const; virtual void afisare() const = 0; // metoda virtual pura };
Iar acum codul de test din funcția main nu va mai genera scurgeri de memorie fiind apelați destructorii în ordinea inversă apelării constructorilor. Codul complet cu implementările celor două clase poate fi descărcat de aici.
În limbajul C++, o interfață este o formă specializată de clasă abstractă care conține exclusiv metode virtual pure și, de regulă, un destructor virtual pur. Fiind o clasă destinată exclusiv definirii de funcționalități, o interfață nu conține membri și nici constructori, deoarece scopul său nu este să stocheze starea unui obiect, ci să specifice un set de comportamente pe care clasele derivate le vor implementa.
În continuare vom prezenta un tabel cu caracteristicile interfețelor și ale claselor abstracte pentru a putea identifica eventuale asemănări și deosebiri și pentru a putea înțelege când avem nevoie de fiecare în parte în practică.
Caracteristică | Interfață | Clasă abstractă |
---|---|---|
Metode | Conține doar metode virtual pure | Poate conține metode virtual pure și metode concrete |
Membri de date | Nu poate avea membri | Poate avea membri |
Constructori | Nu poate avea constructori | Poate avea constructori |
Moștenire multiplă | Este utilizată pentru moștenirea multiplă, mai ales pentru a defini contracte comune | Este utilizată în ierarhii simple sau complexe, dar poate genera ambiguități în moștenirea multiplă |
Scop | Definește un contract strict pentru clasele derivate | Definește un comportament parțial și oferă reutilizarea codului |
Destructor | Necesită destructor virtual pur atunci când în clasele derivate există membri de tip pointer | Destructorul virtual care trebuie implementat când există pointeri în clasă |
Instanțiere | Nu poate fi instanțiată, dar se pot utiliza pointeri la tipul acesteia | Nu poate fi instanțiată, dar poate avea constructori pentru clase derivate |
Ca și exemplu propunem clasa FiguraGeometrica care va fi o interfață ce conține metodele getArie
și getPerimetru
, iar ca și clase derivate vom lucra cu Cerc și Patrat.
class FiguraGeomertica // interfata { public: virtual float getArie() const = 0; virtual float getPerimetru() const = 0; };
Iar în continuare vom prezenta clasele Cerc și Patrat.
class Cerc : public FiguraGeomertica { int raza; public: Cerc(const int& raza = 0); float getArie() const override; float getPerimetru() const override; };
Iar mai jos sunt prezentate implementările metodelor aferente clasei Cerc.
Cerc::Cerc(const int& raza) { this->raza = raza; } float Cerc::getArie() const { return 3.14f * raza * raza; } float Cerc::getPerimetru() const { return 2 * 3.14f * raza; }
Declarația clasei Patrat se poate observa mai jos.
class Patrat : public FiguraGeomertica { int latura; public: Patrat(const int& latura = 0); float getArie() const override; float getPerimetru() const override; };
Iar implementările metodelor sunt disponibile mai jos.
Patrat::Patrat(const int& latura) { this->latura = latura; } float Patrat::getArie() const { return(float) latura * latura; } float Patrat::getPerimetru() const { return(float) 4 * latura; }
Iar ca și exemplu de testare a funcționalităților în funcția main avem:
int main() { FiguraGeomertica* pfg1 = new Cerc(4); std::cout << "Aria cercului este: " << pfg1->getArie() << '\n'; std::cout << "Perimetrul cercului este: " << pfg1->getPerimetru() << '\n'; FiguraGeomertica* pfg2 = new Patrat(5); std::cout << "\nAria patratului este: " << pfg2->getArie() << '\n'; std::cout << "Perimetrul patratului este: " << pfg2->getPerimetru() << '\n'; delete pfg1; delete pfg2; return 0; }
Acest laborator a abordat concepte avansate legate de POO în limbajul C++, punând accent pe mecanismele de virtualizare, diferențele dintre overloading și overriding, și utilizarea claselor abstracte și a interfețelor.
Acest laborator a evidențiat rolul important al mecanismului de virtualizare, al claselor abstracte și al interfețelor în proiectarea sistemelor software flexibile și extensibile. Am înțeles diferențele esențiale dintre overloading și overriding și importanța implementării corecte a metodelor virtuale, pentru a asigura funcționarea corectă și eficientă a aplicațiilor orientate obiect.