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. O altă formă importantă de polimorfism timpuriu este reprezentată de template-uri (funcții și clase generice). În acest caz, compilatorul generează automat instanțele necesare ale funcțiilor și/sau claselor, pe baza tipurilor transmise ca parametri. 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 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 metode 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ă, același namespace, sau același fișier | Apare în ierarhiile de clase (moștenire) |
| Funcțiile/metodele au același nume, dar diferă prin numărul, tipul sau ordinea parametrilor | O metodă dintr-o clasă derivată suprascrie comportamentul unei metode virtuale din clasa de bază |
| Alegerea funcției/metodei este făcută de către compilator pe baza semnăturii acesteia | Alegerea metodei care va fi apelată este făcută la runtime, în funcție de tipul dinamic al obiectului |
| Nu necesită metode virtuale/virtual pure | Necesită utilizarea metodelor 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 metodei – 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 metodă 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/metode cu același nume fie în cadrul aceleiași clase sau în cadrul aceluiași namespace sau în cadrul aceluiași fișier, dar care diferă prin numărul, tipul sau chiar ordinea parametrilor. Deși semnăturile sunt distincte, logica generală a funcțiilor/metodelor 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ă.
Metodele 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 metodă 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 metodei 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.
Object slicing reprezintă situația în care, la copierea unui obiect derivat într-o variabilă de tipul clasei de bază, se păstrează doar partea de bază a obiectului, iar atributele și comportamentele specifice clasei derivate sunt „tăiate” (eliminate), obiectul comportându-se ca unul de tipul clasei de bază.
Pentru a putea înțelege mai bine cum se poate realiza legătura întârziată (late binding) și cum apare fenomenul de Object Slicing vom propune un exemplu simplu de cod. Pentru a putea declara o metodă virtuală în C++ se folosește cuvântul cheie virtual care anunță compilatorul că metoda poate fi suprascrisă în clasele derivate și că aceasta trebuie să fie gestionată prin intermediul vtable.
#include <iostream> class Baza { public: virtual void afisare() const // metoda virtuala { std::cout << "Sunt un obiect de tipul clasei Baza\n"; } }; class Derivata : public Baza { public: void afisare() const // aceasta functie membra suprascrie comportamentul metodei afisare din clasa parinte { std::cout << "Sunt un obiect de tipul clasei Derivata\n"; } }; int main() { Derivata obj; // 1. FARA slicing — prin pointer Baza* ptr = &obj; ptr->afisare(); // se apeleaza metoda din clasa Derivata // 2. FARA slicing — prin referinta Baza& ref = obj; ref.afisare(); // se apeleaza metoda din clasa Derivata // 3. CU slicing — prin copiere (valoare) Baza b = obj; // aici se produce fenomenul de Object Slicing b.afisare(); // se apeleaza metoda din Baza (partea din clasa Derivata este "taiata") delete ptr; return 0; }
În continuare propunem ca și exemplu practic clasele Animal și Caine, unde ne dorim să punem în evidență conceputul de late binding și să adăugăm informații noi cu privire la acestă noțiune nouă pe care o învățăm.
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, totuși vom observa că 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 membră 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 ca metodă 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 metodă 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 | Are cel puțin o metodă virtuală pură și poate include metode virtuale ș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 de clase, 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 | Destructorul virtual pur este obligatoriu atunci când în clasele derivate există membri de tip pointer alocați dinamic | Destructorul virtual trebuie implementat atunci când există pointeri alocați dinamic în clasele derivate |
| 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.