Table of Contents

Laborator 09 - Clase abstracte și interfețe

Autor: Răzvan Cristea

Obiective Specifice

Studentul va fi capabil la finalul acestui laborator să:

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

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

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

Metode virtuale

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.

Late binding-ul în cazul metodelor virtuale apare doar atunci când obiectele sunt accesate prin intermediul unui pointer sau al unei referințe la clasa de bază. Dacă un obiect derivat este manipulat direct prin valoare, are loc fenomenul object slicing, iar mecanismul de legare dinamică nu mai funcționează, apelul fiind rezolvat folosind tipul bazei.

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 C++ există două tipuri de metode virtuale și anume: metode virtuale și metode virtual pure. Diferența între cele două tipuri constă în faptul că o metodă virtual pură nu are implementare în clasa de bază.

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

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 metodelor ș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 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ă 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 din clasa Animal fără a-l apela și pe cel al clasei Caine.

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.

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

Î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++
Overloading vs Overriding
Clase Abstracte și Metode Virtual Pure
Interfețele în C++

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.