Autor: Răzvan Cristea
Studentul va fi capabil la finalul acestui laborator să:
Până acum, erați obișnuiți să vă organizați codul folosind funcții, fiecare subprogram având un rol bine definit. Paradigma Orientată Obiect (OO) presupune că știți deja cum se scrie o funcție, însă diferența constă în modul în care va fi structurat codul. Pe parcursul acestui laborator, vom observa și înțelege cum funcționează această organizare.
De-a lungul anului întâi ați “simulat” paradigma OO prin folosirea structurii. Vom vedea însă câteva diferențe semnificative între ce știți deja și ce veți învăța în cadrul laboratorului curent.
Structura este un tip de date special definit de către programator. Prin intermediul ei programatorul poate stoca sub același nume mai multe proprietăți de care are nevoie atunci când lucrează la o aplicație. Pentru a crea o structura de obicei se asociază obiecte din viața reală în cod.
Să urmărim cu atenție exemplul de cod de mai jos unde am alcătuit structura Avion.
#include <iostream> struct Avion { int numarLocuri; int anFabricatie; float capacitateRezervor; }; void afisareAvion(const Avion& avion) { std::cout << "Numarul de locuri din avion este: " << avion.numarLocuri << '\n'; std::cout << "Anul de fabricatie al avionului este: " << avion.anFabricatie << '\n'; std::cout << "Capacitatea rezervorului avionului este: " << avion.capacitateRezervor << " litri\n"; } int main() { Avion avion; // in limbajul C era eroare de compilare daca nu foloseam typedef la definirea structurii /* struct Avion avion; // ar fi fost declaratia corecta pentru o variabila de tip Avion in C */ avion.numarLocuri = 230; avion.anFabricatie = 2000; avion.capacitateRezervor = 755.5f; afisareAvion(avion); return 0; }
Am creat o funcție auxiliară, care primește ca parametru o referință constantă de tip Avion, pentru a afișa datele parametrului avion. Am transmis parametrul prin referință pentru a evita copierea inutilă a variabilei avion din funcția main. Referința este constantă, deoarece nu dorim să modificăm câmpurile avionului în funcția de afișare.
În limbajul C pentru a putea avea programul identic cu cel de mai sus trebuie să procedăm astfel.
#include <cstdio> typedef struct Avion Avion; struct Avion { int numarLocuri; int anFabricatie; float capacitateRezervor; }; void afisareAvion(Avion avion) { printf("Numarul de locuri din avion este: %d\n", avion.numarLocuri); printf("Anul de fabricatie al avionului este: %d\n", avion.anFabricatie); printf("Capacitatea rezervorului avionului este: %.2f litri\n", avion.capacitateRezervor); } int main() { Avion avion; avion.numarLocuri = 230; avion.anFabricatie = 2000; avion.capacitateRezervor = 755.5f; afisareAvion(avion); return 0; }
De acum înainte când vom discuta despre structuri ne vom referi doar la cele din limbajul C++. Variabilele numarLocuri, anFabricatie și capacitateRezervor poartă denumirea de câmpuri sau membri, deoarece se găsesc în interiorul declarației structurii Avion. Prin intermediul operatorului ”.” am avut acces direct la acești membri pe care am putut să îi modificăm și mai apoi i-am folosit la afișarea avionului în funcția main.
Până aici nu este nimic nou față de ce știați deja de la disciplinele PCLP și respectiv PA. Cu toate acestea care este diferența fundamentală între structura din C și cea din C++? Răspunsul îl vom afla mai târziu după ce vom înțelege ce este Programarea Orientată Obiect.
Programarea Orientată Obiect, tradusă din englezescul Object-Oriented Programming (OOP), este un nou mod de a scrie și a organiza codul sursă, practic o nouă paradigmă de programare așa cum am mai menționat anterior. POO oferă o modalitate mai intuitivă și mai naturală de a învăța programarea, deoarece reflectă într-o anumită măsură modul în care percepem lumea reală. În POO, conceptele din viața de zi cu zi, cum ar fi obiectele și relațiile dintre ele, sunt transpuse în cod prin clase și obiecte. Astfel, programatorii pot modela entități reale, fiecare având atribute (proprietăți/câmpuri/membri) și comportamente (funcții membre/metode), aceasta fiind o modalitate prin care programarea a devenit mai ușor de înțeles și de gestionat. Această abordare modulară simplifică dezvoltarea de aplicații complexe și încurajează reutilizarea codului.
Ca orice paradigmă de programare aceasta trebuie să aibă și un set de reguli care trebuiesc aplicate în momentul în care se dorește dezvoltarea unei aplicații OOP. Aceste principii fundamentale sunt 4 la număr și le vom discuta pe fiecare separat.
Cele 4 principii ale POO sunt:
Fiind primul principiu OOP pe care îl învățăm trebuie să știm că acesta este utilizat cu scopul de a defini clase care să imite obiectele din realitate. Practic abstractizarea este procesul de transpunere în codul sursă a caracteristicilor unui obiect din realitate.
În C++ o clasă se declară folosind cuvântul cheie (keyword) class. Să urmărim exemplul de mai jos.
class Brad { int vechime; float inaltime; double pret; };
Se poate observa că am declarat o clasă denumită Brad, care conține ca și proprietăți (membri) 3 variabile și anume: vechime, inaltime și pret. Astfel am aplicat primul principul OOP prin crearea unei clase care imită un obiect din realitate. La prima vedere putem spune că structura și clasa sunt similare diferența fiind doar keyword-ul folosit însă vom vedea mai târziu care este diferența clară între structură și clasă în limbajul C++.
Încapsularea se referă la modul în care o clasă își protejează datele, ascunzându-le de mediul exterior. Cu alte cuvinte nu putem modifica membrii unei clase direct, deoarece avem nevoie de acces la aceștia.
Pentru a înțelege ce presupune încapsularea trebuie să menționăm mai întâi ce sunt specificatorii de acces ai unei clase în C++. Specificatorii de acces, în C++, sunt cuvinte cheie prin intermediul cărora se poate decide dacă membrul unei clase poate fi vizibil în exteriorul acesteia sau nu.
În limbajul C++ există 3 specificatori de acces și anume: private, protected, public.
Specificatorul de acces private
Acest specificator presupune ca membrii (câmpurile) unei clase să nu poată fi accesați direct din exterior, singura care poate avea acces direct la propriile câmpuri fiind clasa însăși.
Specificatorul de acces protected
Similar cu specificatorul private, datele marcate cu protected nu pot fi accesate direct din exteriorul clasei. Cu toate acestea, spre deosebire de specificatorul private, clasele derivate (moștenitoare) au acces la aceste date.
Specificatorul de acces public
Acest specificator anunță faptul că datele membre ale unei clase sunt publice și oricine din exterior le poate accesa și modifica fără probleme.
Vom rescrie declarația clasei Brad punând explicit specificatorul private chiar dacă știm deja că cei 3 membri sunt privați din start conform a ceea ce am menționat anterior.
class Brad { private: // specificatorul de acces private, nu era necesar sa il punem deoarece membrii deja erau privati int vechime; float inaltime; double pret; };
Dacă dorim să instanțiem clasa Brad va trebui să declarăm un obiect de tipul acesteia după cum vom putea observa în codul de mai jos.
#include <iostream> class Brad { private: int vechime; float inaltime; double pret; }; int main() { Brad brad; // obiect de tipul clasei Brad (instanta a clasei Brad) return 0; }
Dacă vom dori să accesăm membrii clasei Brad direct din funcția main vom primi o eroare de compilare, deoarece membrii sunt inaccesibili.
#include <iostream> class Brad { int vechime; float inaltime; double pret; }; int main() { Brad brad; brad.vechime = 2; // eroare de compilare, membrul vechime este privat brad.inaltime = 5.25f; // gresit, membrul inaltime este inaccesibil brad.pret = 299.99; // membrul pret este marcat ca private return 0; }
O primă soluție pentru a putea face programul să nu mai aibă eroare de compilare ar fi ca membrii clasei Brad să fie publici.
#include <iostream> class Brad { public: // in acest moment cei 3 membri ai clasei sunt vizibili in exteriorul acesteia int vechime; float inaltime; double pret; }; int main() { Brad brad; brad.vechime = 2; brad.inaltime = 5.25f; brad.pret = 299.99; return 0; }
Moștenirea (Inheritance) permite unei clase să preia proprietățile și comportamentele unei alte clase, oferind astfel posibilitatea reutilizării codului. În același timp, clasa derivată (clasa moștenitoare) poate adăuga funcționalități specifice pe care clasa de bază nu le are, extinzând astfel comportamentul acesteia și adaptându-l la nevoile proprii.
Cuvântul polimorfism provine din limba greacă, de la polys (multe) și morphos (formă). Polimorfismul în POO este întâlnit în două forme și anume: early polymorphism sau compile time polymorphism care apare atunci când realizăm un procedeu numit supraîncărcare (overloading) și run time polymorphism care apare când este realizat procedeul de suprascriere (overriding). Despre conceptul de overriding vom discuta peste câteva laboratoare.
În C++ conceptul de overloading este aplicat pe funcții. Acest lucru presupune faptul că putem avea mai multe funcții cu același nume, dar acestea să difere prin numărul și tipul parametrilor.
Să urmărim exemplul de cod de mai jos.
#include <iostream> void interschimbare(int* a, int* b) { if (a == nullptr || b == nullptr) { return; } int auxiliar = *a; *a = *b; *b = auxiliar; } void interschimbare(int& a, int& b) { int auxiliar = a; a = b; b = auxiliar; } int main() { int x = 22; int y = 8; interschimbare(&x, &y); std::cout << "Valoarea lui x este: " << x << '\n'; std::cout << "Valoarea lui y este: " << y << '\n'; interschimbare(x, y); std::cout << "Valoarea lui x este: " << x << '\n'; std::cout << "Valoarea lui y este: " << y << '\n'; return 0; }
Observăm că există două funcții numite interschimbare, care diferă doar prin tipul parametrilor. Acesta este un exemplu de supraîncărcare a funcțiilor (function overloading), un concept ce demonstrează polimorfismul în C++.
În această secțiune vom descoperi modul în care putem controla accesul la membrii unei clase. Pentru a permite accesul la membrii unei clase din exterior, este necesar să folosim funcții speciale definite în zona publică a clasei, cunoscute sub denumirea de metode sau funcții membre. În continuare, vom prezenta câteva categorii de funcții membre care facilitează interacțiunea controlată cu membrii clasei din exteriorul acesteia.
Așa cum le spune și numele această categorie de metode se ocupă de construirea (inițializarea) obiectelor.
Constructorii au o serie de trăsături care îi fac pe aceștia foarte ușor de recunoscut după cum urmează:
Constructorii pot fi de mai multe tipuri și anume: fără parametri și cu parametri. Să urmărim exemplul de cod de mai jos pentru clasa Brad.
#include <iostream> class Brad { int vechime; float inaltime; double pret; public: Brad() { vechime = 0; inaltime = 0.0f; pret = 0.0; } }; int main() { Brad brad; // echivalent cu a scrie direct Brad brad = Brad(); return 0; }
Se poate observa că am definit și implementat constructorul fără parametri pentru clasa Brad. Constructorul fără parametri se mai numește și constructor default și dacă nu îl implementăm explicit în cazul exemplului de mai sus compilatorul se va ocupa el de generarea unui constructor default în care va popula membrii clasei Brad cu valori aleatoare.
Ca să nu creștem foarte mult numărul de linii din clasă în practică se dorește implementarea metodelor în afara clasei după cum putem observa mai jos.
#include <iostream> class Brad // declaratia clasei se va scrie intr-un fisier cu extensia .h { int vechime; float inaltime; double pret; public: Brad(); }; Brad::Brad() // aceasta bucata de cod va exista intr-un fisier cu extensia .cpp { vechime = 0; inaltime = 0.0f; pret = 0.0; } int main() { Brad brad; // echivalent cu a scrie direct Brad brad = Brad(); return 0; }
Să parcurgem după cum urmează exemplul de cod de mai jos.
#include <iostream> class Brad { int vechime; float inaltime; double pret; public: // Brad(); Brad(const float& inaltime); // constructor cu un parametru Brad(const int& vechime, const float& inaltime, const double& pret); }; int main() { Brad brad; // eroare de compilare, nu exista constructor default return 0; }
Se poate observa că am comentat constructorul fără parametri și am definit alți doi constructori pentru clasa Brad, lucru care pune în lumină polimorfismul, însă ne alegem cu o eroare de compilare atunci când declarăm un obiect de tip Brad.
Prin urmare ori de câte ori definim constructori cu parametri trebuie să avem în vedere că tot noi va trebui să definim și să implementăm constructorul default. În continuare vom implementa constructorul cu un parametru după cum urmează.
Brad::Brad(const float& inaltime) { vechime = 0; inaltime = inaltime; // eroare de compilare pret = 0.0; }
La prima vedere suntem mirați de faptul că avem o eroare de compilare, deoarece în mod normal ne-am aștepta ca variabila inaltime din partea stângă a egalului să fie de fapt membrul inaltime din zona privată a clasei Brad, însă din păcate nu este vorba despre membrul din zona privată ci despre însuși parametrul constructorului. Practic noi am încercat să îi atribuim parametrului propria valoare, dar compilatorul ne-a salvat prin atenționarea că nu putem modifica valoarea constantă a unui parametru.
În cazul de față este vorba despre o situație de ambiguitate în care compilatorul nu știe că de fapt noi vrem să stocăm valoarea parametrului constructorului în membrul clasei Brad. Cum putem totuși să rezolvăm această problemă pentru a ne putea îndeplini obiectivul și anume acela de a stoca valoarea trimisă ca parametru în câmpul clasei?
Cuvântul cheie this
Pentru a putea soluționa problema de mai sus trebuie să introducem un nou keyword specific POO pe care îl vom întâlni în aproape toate limbajele de programare care suportă paradigma OO și anume cuvântul cheie this.
În C++ this este un pointer pe care toate funcțiile membre îl primesc ca parametru pe prima poziție. În C++ acest pointer nu este vizibil, dar asta nu înseamnă că nu există, în lista de parametri a metodei, dar în limbajul Python acesta este prezent și poartă denumirea de self și obligatoriu trebuie pus de către programator pe prima poziție în lista de parametri a fiecărei funcții membre dintr-o clasă.
Pointerul this indică adresa obiectului curent și are rolul de a rezolva problemele de ambiguitate între membrii clasei și parametrii metodelor. Acesta este utilizat pentru a diferenția în mod clar variabilele locale de câmpurile obiectului, permițând atribuirea corectă a valorilor parametrilor metodelor membrilor interni ai obiectului.
Să soluționăm problema de mai sus utilizând pointerul this.
// Brad::Brad(Brad* this, const float& inaltime) -> asa arata de fapt aceasta functie la compilare Brad::Brad(const float& inaltime) { vechime = 0; // nu este nevoie de this deoarece compilatorul stie ca este vorba despre campul vechime al obiectului curent this->inaltime = inaltime; pret = 0.0; // aceeasi explicatie ca la vechime }
În acest moment compilatorul știe foarte clar ce vrem să facem și anume că ne dorim să stocăm în membrul inaltime valoarea parametrului.
Așadar acum putem să implementăm și al treilea constructor al clasei Brad și să îl utilizăm fără probleme după cum urmează.
#include <iostream> class Brad { int vechime; float inaltime; double pret; public: Brad(); // constructor default Brad(const float& inaltime); // constructor cu un parametru Brad(const int& vechime, const float& inaltime, const double& pret); // constructor cu 3 parametri }; // Brad::Brad(Brad* this) - asa arata la compilare Brad::Brad() { vechime = 0; // nu este nevoie de this, dar nu este gresit nici daca scriem this->vechime = 0; inaltime = 0.0f; pret = 0.0; } // Brad::Brad(Brad* this, const float& inaltime) - asa arata la compilare Brad::Brad(const float& inaltime) { vechime = 0; this->inaltime = inaltime; pret = 0.0; } // Brad::Brad(Brad* this, const int& vechime, const float& inaltime, const double& pret) - asa arata la compilare Brad::Brad(const int& vechime, const float& inaltime, const double& pret) { this->pret = pret; this->vechime = vechime; this->inaltime = inaltime; } int main() { Brad brad; // se apeleaza constructorul default Brad bradut(4.75f); // se apeleaza constructorul cu un parametru (echivalent cu a scrie Brad bradut = Brad(4.75f);) Brad b(2, 2.25f, 399.99); // se apeleaza constructorul cu un parametru (echivalent cu a scrie Brad b = Brad(2, 2.25f, 399.99);) return 0; }
Aceste metode sunt speciale, deoarece ele oferă un mod controlat de acces la membrii clasei. Menționăm că nu este obligatoriu să existe pentru fiecare membru al clasei o pereche de accesori de tip get-set, acest lucru depinzând de cerințele aplicației și de modul de implementare. Getterii permit accesul la valorile câmpurilor unui obiect, în timp ce setterii oferă posibilitatea de a modifica valorile acestora, asigurând astfel un control mai strict și securizat asupra proprietăților unui obiect.
Denumirea acestor metode trebuie să înceapă cu prefixul get sau set și să conțină numele câmpului asupra căruia acționează. Să ne îndreptăm atenția spre următoarele exemple de declarări pentru getteri și pentru setteri în clasa Brad.
class Brad { int vechime; float inaltime; double pret; public: Brad(); Brad(const int& vechime, const float& inaltime, const double& pret); int getVechime() const; // metoda la compilare arata asa: int getVechime(const Brad* this); float getInaltime() const; double getPret() const; void setVechime(const int& vechime); // metoda la compilare arata asa: void setVechime(Brad* this, const int& vechime); void setInaltime(const float& inaltime); void setPret(const double& pret); };
Iar în exemplul de mai jos am implementat aceste metode și le-am testat pentru a putea evidenția funcționalitatea lor.
Brad::Brad() { vechime = 0; inaltime = 0.0f; pret = 0.0; } Brad::Brad(const int& vechime, const float& inaltime, const double& pret) { this->pret = pret; this->vechime = vechime; this->inaltime = inaltime; } int Brad::getVechime() const // acest const este adresat pointerului this (continutul de la adresa spre care pointeaza nu poate fi modificat) { // this->vechime = 5; // imposibil din cauza const-ului din semnatura functiei return vechime; // era corect si daca scriam return this->vechime; } float Brad::getInaltime() const { return inaltime; } void Brad::setVechime(const int& vechime) { if (vechime <= 0) // verificam daca valoarea primita ca parametru este una valida { return; } this->vechime = vechime; // trebuie folosit this deoarece parametrul are acelasi nume ca si campul clasei } void Brad::setInaltime(const float& inaltime) { if (inaltime <= 0) { return; } this->inaltime = inaltime; } int main() { Brad brad; // se apeleaza constructorul default brad.setVechime(3); brad.setInaltime(5.5f); std::cout << "Vechimea bradului este: " << brad.getVechime() << " ani\n"; std::cout << "Inaltimea bradului este: " << brad.getInaltime() << " metri\n"; return 0; }
Pentru a vă familiariza cu noul mod de a scrie codul, declarați și implementați un constructor cu doi parametri la alegere și în plus, implementați metodele accesor de tip get și set pentru câmpul pret și apoi testați ce ați implementat în funcția main.
În acest laborator, am explorat în detaliu diferențele fundamentale dintre clase și structuri, subliniind faptul că singura deosebire constă în specificatorul de acces implicit: la structuri, membrii sunt publici, iar la clase sunt privați. Totodată, am învățat cum să creăm obiecte și cum să le gestionăm proprietățile folosind constructori, precum și accesori prin intermediul getter-ilor și al setter-ilor. Acest lucru ne oferă un control mai fin asupra datelor și ne permite o manipulare clară și sigură a obiectelor într-un program care folosește conceptele OOP. Astfel, am dobândit o înțelegere mai bună a principiilor fundamentale ale Programării Orientate Obiect, principii pe care le vom aplica pe întregul parcurs al semestrului.