Laborator 09 - Clase abstracte și interfețe

Autor: Răzvan Cristea

Obiective Specifice

Studentul va fi capabil la finalul acestui laborator să:

  • recunoască și să definească o funcție virtuală
  • recunoască și să definească o funcție virtual pură
  • înțeleagă importanța utilizării claselor abstracte și a interfețelor în diferite contexte ale POO
  • înțeleagă conceptul de “late binding” corelat cu run time polymorphism
  • înțeleagă diferența dintre supraîncărcare și suprascriere

Introducere

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++.

Overloading vs Overriding

Î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ă.

Astfel suprascrierea permite modificarea profundă a comportamentului unei funcții în cadrul unei ierarhii de clase, în timp ce supraîncărcarea oferă posibilitatea reutilizării aceluiași nume de funcție pentru a gestiona scenarii variate, păstrând însă consistența logicii.

Funcții virtuale

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.

Stabilirea tipului de date în cazul funcțiilor virtuale se aplică exclusiv pointerilor la obiecte, nu și obiectelor utilizate direct (prin valoare). Acest lucru se datorează faptului că legarea dinamică (late binding) funcționează doar atunci când accesul la funcțiile virtuale se face prin intermediul unui pointer.

În C++ există două tipuri de funcții virtuale și anume: funcții virtuale și funcții virtual pure. Diferența între cele două tipuri constă în faptul că o funcție virtual pură nu are implementare în clasa de bază.

Î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
};

Cuvântul cheie override în C++ este folosit pentru a specifica în mod explicit că o funcție membră dintr-o clasă derivată suprascrie o metodă virtuală din clasa de bază. Acest mecanism oferă mai multă siguranță în ceea ce privește suprascrierea funcțiilor și ajută la prevenirea erorilor de programare.

Clase abstracte

Î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ă nu poate fi folosită pentru a crea obiecte. Este concepută să fie doar o bază pentru alte clase care o vor moșteni. În schimb se pot instanția pointeri de tipul acestei clase care să fie inițializati cu ajutorul claselor derivate. Un alt aspect ce trebuie menționat este faptul că orice clasă derivată dintr-o clasă abstractă trebuie să implementeze toate metodele virtual pure, altfel va deveni ea însăși o clasă abstractă.

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;
}

În momentul de față, codul de test din funcția main produce un memory leak deoarece, în mod normal, la eliberarea memoriei, destructorii ar trebui să se apeleze în ordinea inversă apelării constructorilor. Dacă, în cazul nostru, mai întâi s-a apelat constructorul fără parametri pentru clasa Animal și apoi cel din clasa Caine, atunci, la distrugerea obiectului, se va apela destructorul Animal fără a-l apela și pe cel al clasei Caine.

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.

Interfețe

Î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;
}

În cazul clasei FiguraGeometrica nu este necesară implementarea unui destructor virtual pur deoarece în clasele derivate nu există membri de tip pointer alocați dinamic. Prin urmare ordinea de apel a destructorilor este cea corectă, adică sunt apelați mai întâi destructorii claselor derivate (Cerc și Patrat) și la final va fi apelat destructorul superclasei.

Concluzii

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.

Virtualizarea în C++
  • Metodele virtuale oferă flexibilitatea necesară pentru implementarea polimorfismului dinamic, unde comportamentul funcțiilor este stabilit în timpul execuției (late binding).
  • Metodele virtual pure obligă clasele derivate să implementeze funcționalitățile esențiale, asigurând astfel că fiecare clasă derivată își definește comportamentul specific.
Overloading vs Overriding
  • Overriding este asociat cu polimorfismul dinamic (run-time polymorphism) și presupune redefinirea comportamentului unei metode virtuale din clasa de bază în clasele derivate.
  • Overloading este asociat cu polimorfismul timpuriu (compile-time polymorphism) și permite mai multe metode cu același nume, dar semnături diferite, în cadrul aceleiași clase.
  • Este important să folosim cuvântul cheie override pentru a face codul mai sigur și mai lizibil, prevenind erorile legate de semnături greșite sau lipsa suprascrierii.
Clase Abstracte și Metode Virtual Pure
  • Clasele abstracte oferă un punct de plecare pentru proiectarea ierarhiilor complexe, combinând funcționalitățile comune și abstractizarea.
  • O metodă virtual pură este o metodă fără implementare în clasa de bază, iar clasa devine abstractă dacă conține cel puțin o astfel de metodă.
  • Destructorii virtuali sunt obligatorii în clasele abstracte pentru a asigura o eliberare corectă a resurselor în ierarhiile de moștenire.
Interfețele în C++
  • Interfețele sunt un caz particular de clase abstracte care conțin doar metode virtual pure.
  • Ele definesc contracte stricte între clase și sunt utilizate pentru a implementa moștenirea multiplă într-un mod clar și organizat, fără ambiguități.

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.

poo-is-ab/laboratoare/08.txt · Last modified: 2025/01/19 22:30 by razvan.cristea0106
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0