Autor: Răzvan Cristea
Studentul va fi capabil la finalul acestui laborator să:
Pe parcursul primului an de studiu, în cadrul disciplinei Proiectarea Algoritmilor, ați avut ocazia să explorați o gamă variată de structuri de date și algoritmi. Majoritatea implementărilor de algoritmi și structuri de date studiate s-au bazat pe un tip de date specific – de exemplu, structuri de date care funcționau doar cu valori de tip întreg sau doar cu șiruri de caractere. Programarea generică își propune să depășească aceste limitări și să ofere soluții care pot fi adaptate pentru orice tip de date, fără a fi nevoie să rescriem codul pentru fiecare tip nou.
În C++, programarea generică este realizată prin intermediul template-urilor. Un template este un model reutilizabil (șablon) care poate fi definit o singură dată și utilizat pentru o gamă variată de tipuri de date. Template-urile permit astfel crearea de funcții și clase care pot funcționa generic, pentru orice tip. De exemplu, o funcție de sortare implementată cu template-uri poate fi aplicată atât pe liste de întregi, cât și pe liste de numere în virgulă mobilă sau pe liste de obiecte de orice tip care suportă operatorul de comparație.
Principalele avantaje și caracteristici ale programării generice în C++ sunt:
Programarea generică în C++ îmbină aceste concepte pentru a realiza soluții mai scalabile, eficiente și organizat structurate. În cadrul acestui laborator, veți învăța cum să creați funcții și clase template, cum să organizați și să utilizați template-uri specializate și cum să structurați proiectele pentru a încorpora aceste practici de programare generică.
Funcțiile template sunt similare cu funcțiile obișnuite, însă oferă un avantaj important: permit crearea de funcții generice, care pot lucra cu diferite tipuri de date. În loc să definim funcții separate pentru fiecare tip de date (de exemplu, int
, float
, double
), o funcție template ne permite să scriem o singură funcție care să funcționeze pentru toate aceste tipuri.
Să luăm spre exemplu o funcție care face suma a două numere de același tip primite ca parametru și întoarce un rezultat de același tip. Vom implementa două funcții de adunare pentru numere întregi și pentru numere de tip float
după cum urmează mai jos.
#include <iostream> int adunare(const int& a, const int& b) { return a + b; } float adunare(const float& a, const float& b) { return a + b; } int main() { int sumaIntregi = adunare(2, 3); float sumaFloaturi = adunare(3.5f, 8.25f); std::cout << "Suma numerelor intregi este: " << sumaIntregi << '\n'; std::cout << "Suma numerelor reale este: " << sumaFloaturi << '\n'; return 0; }
Se poate observa că singurele diferențe între cele 2 funcții sunt tipul de return și tipul parametrilor acestora. Prin urmare putem spune că avem un cod duplicat care ne crește numărul de linii. Soluția mai elegantă și mai corectă este să implementăm o funcție template care va respecta structura celor 2 funcții de mai sus cu avantajul că va fi scrisă o singură dată pentru o gamă mai extinsă de tipuri de date.
În C++ pentru a declara o funcție generică se folosesc cuvintele cheie template și respectiv typename deasupra antetului funcției. Să reimplementăm acum funcția de adunare a două numere de același tip dar de acestă dată ca funcție template.
template <typename T> T adunare(const T& a, const T& b) { return a + b; }
Apelarea funcției se poate face în maniera următoare.
#include <iostream> template <typename T> T adunare(const T& a, const T& b) { return a + b; } int main() { int sumaIntregi = adunare(2, 3); /*int sumaIntregi = adunare<int>(2, 3); // corect si asa deoarece este pus explicit*/ /*float sumaFloaturi = adunare(3.5f, 8.25f); // valid*/ float sumaFloaturi = adunare<float>(3.5f, 8.25f); // valid std::cout << "Suma numerelor intregi este: " << sumaIntregi << '\n'; std::cout << "Suma numerelor reale este: " << sumaFloaturi << '\n'; return 0; }
Dacă am vrea să adunăm două tipuri diferite de date și să returnăm suma lor într-un alt tip de date am putea folosi următoarea formă de funcție template.
template <typename T1, typename T2, typename T3> T1 adunare(const T2& a, const T3& b) { return (T1)a + b; }
T1 reprezintă tipul de return al funcției iar T2 și respectiv T3 reprezintă tipurile de parametri pe care funcția îi poate primi. Funcția realizează suma valorilor celor 2 parametri și o convertește la tipul T1 înainte de a o returna. Ca și exemple de apel putem scrie în felul următor.
#include <iostream> template <typename T1, typename T2, typename T3> T1 adunare(const T2& a, const T3& b) { return (T1)a + b; } int main() { double suma = adunare<double>(2, 7.5f); /*double suma = adunare<double, int, float>(2, 7.5f); // corect de asemenea*/ /*double suma = adunare(2, 7.5f); // incorect deoarece compilatorul nu stie ce tip de return sa utilizeze*/ std::cout << "Suma este: " << suma << '\n'; return 0; }
Astfel, putem observa că funcțiile template oferă o formă de polimorfism cunoscută sub numele de polimorfism static sau polimorfism la compilare (compile time polymorphism sau early polymorphism). În loc să definim mai multe funcții supraîncărcate pentru fiecare tip de date posibil, folosim un singur model generic, iar compilatorul generează automat versiunile corespunzătoare pentru fiecare tip de date specific atunci când funcția este apelată. Aceasta este o abordare eficientă pentru a obține flexibilitate și reutilizare a codului, asigurând totodată performanță optimă, deoarece tipurile sunt determinate și validate la compilare, eliminând nevoia de verificări suplimentare la run time.
O funcție generică poate fi, într-adevăr, supraîncărcată folosind același nume, dar să difere prin numărul sau tipul parametrilor. Aceasta înseamnă că putem avea mai multe versiuni ale unei funcții generice, fiecare destinată unui caz particular, dar accesibile sub același nume. Astfel, compilatorul va selecta automat varianta corespunzătoare în funcție de tipurile de date și de numărul argumentelor transmise.
Ca și exemplu propunem două funcții generice de interschimbare a două valori, una cu parametri transmiși prin referință și cea de a doua cu parametri transmiși prin pointer.
#include <iostream> template <typename T> void interschimbare(T& x, T& y) { T aux = x; x = y; y = aux; } template <typename T> void interschimbare(T* x, T* y) { if (x == nullptr || y == nullptr) { return; } T aux = *x; *x = *y; *y = aux; } int main() { int a = 22; int b = 6; interschimbare(a, b); std::cout << "a = " << a << '\n'; std::cout << "b = " << b << '\n'; interschimbare(&a, &b); std::cout << "\na = " << a << '\n'; std::cout << "b = " << b << '\n'; return 0; }
Prin acest mecanism de supraîncărcare a funcțiilor template, am reușit să extindem funcționalitatea codului generic pentru a acoperi cazuri specifice, păstrând totodată lizibilitatea și coerența codului.
Până acum, în exemplele de cod cu funcții template, am realizat atât declarația, cât și implementarea în același fișier. Totuși, pentru a îmbunătăți organizarea codului și a facilita reutilizarea, intenționăm să separăm aceste componente. Separarea declarației și implementării funcțiilor template este o practică utilă, mai ales în proiectele de mari dimensiuni, deoarece oferă o structură mai clară și face codul mai ușor de întreținut.
În C++, spre deosebire de funcțiile obișnuite, implementarea funcțiilor template în fișiere separate reprezintă o provocare datorită mecanismului de instanțiere a template-urilor la momentul compilării, implementarile trebuie să fie vizibile în orice fișier care le utilizează. De aceea, vom explora modalități pentru a organiza corect template-urile în fișiere separate, păstrându-le accesibile la compilare și în același timp menținând o structură modulară.
Ca și exemplu vom scrie funcția de adunare în fișiere header și .cpp pentru a vedea exact cum trebuie procedat astfel încât să ne putem folosi de ea în orice fișier.
Pentru început vom muta antetul funcției într-un fișier header după cum urmează.
#pragma once #include <iostream> template <typename T> T adunare(const T& x, const T& y);
Fiind o funcție template va trebui să înștiințăm compilatorul acest lucru după cum urmează.
#include "Template.h" template<typename T> T adunare(const T& x, const T& y) { return x + y; }
Apelarea funcției se face în maniera următoare.
#include "Template.h" int main() { int a = 22; int b = 6; std::cout << adunare(a, b) << '\n'; return 0; }
Dacă vom încerca să rulăm codul exact în maniera în care l-am scris mai sus ne vom confrunta cu o eroare de linker.
În mod normal, atunci când împărțim o funcție într-un fișier header pentru declarare și un fișier .cpp pentru implementare, compilatorul generează codul obiect pentru implementare în fișierul .cpp, iar linker-ul leagă acest cod în etapa finală. Însă în cazul funcțiilor template, această separare cauzează o eroare de linker deoarece în momentul compilării fișierului header, compilatorul nu găsește implementarea completă a funcției template în fișierul .cpp pentru tipurile de date pe care încă nu le-a întâlnit.
Cea mai simplă soluție este să forțăm compilatorul să genereze funcția template pentru tipurile de date specifice pe care dorim să le testăm. Acest lucru se poate realiza prin implementarea unei funcții locale sau statice în fișierul .cpp care conține implementarea funcției template. Funcția respectivă va apela template-ul cu diverse tipuri de date, asigurând astfel compilarea și generarea de cod pentru fiecare tip necesar.
#include "Template.h" template<typename T> T adunare(const T& x, const T& y) { return x + y; } void testare() { int s1 = adunare(2, 3); float s2 = adunare(2.3f, 3.0f); double s3 = adunare(-1.4, 8.24); unsigned int s4 = adunare(2u, 3u); }
Prin urmare putem apela acum funcția de adunare pentru patru tipuri de date după cum urmează.
#include "Template.h" int main() { std::cout << "Suma numerelor este: " << adunare(22, 8) << '\n'; // valid std::cout << "Suma numerelor este: " << adunare(2.2f, 4.5f) << '\n'; // valid std::cout << "Suma numerelor este: " << adunare(10.0, 7.5) << '\n'; // valid std::cout << "Suma numerelor este: " << adunare(4u, 6u) << '\n'; // valid /*std::cout << "Suma numerelor este: " << adunare(4l, 6l) << '\n'; // invalid*/ return 0; }
Clasele template, la fel ca funcțiile template, au scopul de a susține programarea generică și de a elimina duplicarea codului, oferind o soluție flexibilă și reutilizabilă pentru gestionarea mai multor tipuri de date. Prin clasele template, putem crea structuri de date și obiecte care să funcționeze indiferent de tipul de date cu care lucrează, astfel încât codul să fie mai ușor de întreținut și mai eficient.
De exemplu, o clasă template pentru o structură de date precum un vector poate fi scrisă astfel încât să poată stoca orice tip de date, fie că este vorba de numere întregi, șiruri de caractere sau obiecte complexe. Aceasta înseamnă că nu este nevoie să redefinim întreaga clasă de fiecare dată când dorim să o utilizăm cu un alt tip de date.
Ca și exemplu de clasă template pentru acest laborator propunem clasa Student care are un câmp medieAnuala de tip template, deoarece media anuală poate fi cu sau fără virgulă.
#pragma once #include <string> #include <iostream> template <typename T> class Student { char* nume; T medieAnuala; public: Student(const char* nume, const T& medieAnuala); Student(const Student& student); Student& operator=(const Student& student); ~Student(); char* getNume() const; T getMedieAnuala() const; void setNume(const char* nume); void setMedieAnuala(const T& medieAnuala); template <typename T> friend std::ostream& operator<<(std::ostream& out, const Student<T>& student); };
În continuare vom prezenta detaliat maniera de implementare a fiecărei metode în parte.
template <typename T> Student<T>::Student(const char* nume, const T& medieAnuala) { if (nume != nullptr) { this->nume = new char[strlen(nume) + 1]; strcpy(this->nume, nume); } else { this->nume = nullptr; } this->medieAnuala = medieAnuala; } template<typename T> Student<T>::Student(const Student<T>& student) { if (student.nume != nullptr) { nume = new char[strlen(student.nume) + 1]; strcpy(nume, student.nume); } else { nume = nullptr; } medieAnuala = student.medieAnuala; }
template<typename T> Student<T>& Student<T>::operator=(const Student<T>& student) { if (this == &student) { return *this; } if (nume != nullptr) { delete[] nume; } if (student.nume != nullptr) { nume = new char[strlen(student.nume) + 1]; strcpy(nume, student.nume); } else { nume = nullptr; } medieAnuala = student.medieAnuala; return *this; }
template<typename T> Student<T>::~Student() { if (nume != nullptr) { delete[] nume; } }
template<typename T> char* Student<T>::getNume() const { return nume; } template<typename T> T Student<T>::getMedieAnuala() const { return medieAnuala; } template<typename T> void Student<T>::setNume(const char* nume) { if (nume == nullptr) { return; } if (this->nume != nullptr) { delete[] this->nume; } this->nume = new char[strlen(nume) + 1]; strcpy(this->nume, nume); } template<typename T> void Student<T>::setMedieAnuala(const T& medieAnuala) { if (medieAnuala <= 0) { return; } this->medieAnuala = medieAnuala; }
template <typename T> std::ostream& operator<<(std::ostream& out, const Student<T>& student) { out << "Numele studentului este: "; if (student.nume == nullptr) { out << "N/A\n"; } else { out << student.nume << '\n'; } out << "Media anuala a studentului este: " << student.medieAnuala << '\n'; return out; }
void testTemplate() { Student<int> s1("Ion", 10); Student<int> s2("George", 9); Student<int> s3 = s2; s3 = s1; s3.setNume("Maria"); s3.setMedieAnuala(8); std::cout << s3 << '\n'; std::cout << s3.getNume() << "\n\n"; std::cout << s3.getMedieAnuala() << "\n\n"; Student<double> s4("Ion", 10); Student<double> s5("George", 9); Student<double> s6 = s5; s5 = s4; s5.setNume("Maria"); s5.setMedieAnuala(9.9); std::cout << s4 << '\n'; std::cout << s4.getNume() << "\n\n"; std::cout << s4.getMedieAnuala() << "\n\n"; }
Având acum implementarea clasei template Student putem să o folosim în codul din programul principal după cum umrează.
#include "Student.h" int main() { Student<int> s1("Ion", 10); Student<int> s2("George", 9); Student<int> s3 = s2; s3 = s1; s3.setNume("Maria"); s3.setMedieAnuala(8); std::cout << s3 << '\n'; Student<double> s4("Ion", 10); Student<double> s5("George", 9); Student<double> s6 = s5; s5 = s4; s5.setNume("Maria"); s5.setMedieAnuala(9.9); std::cout << s4 << '\n'; return 0; }
În acest laborator, am explorat conceptul de programare generică, care permite scrierea de cod reutilizabil, flexibil și eficient. Utilizarea template-urilor ne permite să creăm clase și funcții independente de tipul de date specific, fiind astfel mai ușor să dezvoltăm structuri de date și algoritmi care pot fi utilizați pe o varietate de tipuri. Am învățat de asemenea cum să separăm în fișiere header definițiile funcțiilor și claselor template de implementările acestora din fișierele .cpp și ce probleme pot apărea în momentul în care facem acest lucru.
Template-urile oferă o bază flexibilă pentru extinderea funcționalităților fără a modifica codul existent. În mod particular, prin crearea de template-uri, putem construi un cod care este adaptabil pentru diverse tipuri de aplicații, de la procesarea numerelor până la manipularea textului și gestionarea obiectelor complexe.