Laborator 08 - Funcții și clase template

Autor: Răzvan Cristea

Obiective Specifice

Studentul va fi capabil la finalul acestui laborator să:

  • recunoască și să definească funcții template
  • recunoască și să definească clase template
  • înțeleagă importanța conceptului de programare generică
  • organizeze în fișiere header și .cpp codul pentru funcțiile și clasele template

Introducere

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:

  • Reutilizarea codului ⇔ Cu template-uri, putem crea funcții și clase care nu depind de un anumit tip de date. Codul devine mai flexibil și reutilizabil, reducând efortul de scriere și întreținere.
  • Flexibilitate și extensibilitateFuncțiile și clasele template permit adaptarea ușoară la tipuri noi de date și comportamente diferite, fără modificări majore în structura codului.
  • Tipizarea la timp de compilare ⇔ C++ folosește template-urile într-un mod specific, denumit generare la timp de compilare. Asta înseamnă că tipul de date efectiv este determinat atunci când se realizează apelul funcției sau metodei, optimizând performanța și ajutând la identificarea erorilor de tip încă din faza de compilare.
  • Specializare de template-uri ⇔ În anumite situații, se poate utiliza specializarea template-urilor, o tehnică prin care se definește un comportament specific al funcției sau clasei pentru un anumit tip de date. Aceasta permite adăugarea de optimizări sau modificări atunci când un tip special necesită un tratament diferit.

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ții template

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.

Declararea și implementarea funcțiilor template

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

T-ul reprezintă tipul de date folosit de funcția template, atât ca tip de returnare, cât și ca tip pentru parametrii funcției. Observăm că am definit o singură funcție generică, iar la momentul compilării, aceasta își va adapta automat tipul de date pe baza tipurilor parametrilor primiți. Astfel, funcția poate fi folosită cu diferite tipuri de date fără a necesita rescrierea pentru fiecare tip în parte, asigurând flexibilitate și reducând duplicarea codului.

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.

În cazul exemplului de mai sus a fost obligatorie speficarea tipului de date returnat între parantezele unghiulare, deoarece compilatorul nu ar fi știut în ce tip de date să facă conversia rezultatului obținut în urma operației de adunare. Pentru celelalte două tipuri de date nu a fost necesară menționarea lor între parantezele unghiulare, deoarece compilatorul a știut să pună automat tipurile de date corecte pe baza valorilor parametrilor funcției adunare în momentul în care aceasta a fost apelată în funcția main.

Supraîncărcarea unei funcții template

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.

Separarea declarației de implementărea unei funcții template

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.

Declararea funcției

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);
Implementarea funcției

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 în alt fișier

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.

Eroarea de linker apare în cazul funcțiilor template separate în fișiere header și .cpp din cauza modului în care funcționează compilarea template-urilor în C++. Spre deosebire de funcțiile obișnuite, funcțiile template sunt generate la momentul compilării pentru fiecare tip de date specific utilizat în cod. Așadar, compilatorul trebuie să aibă acces la implementarea completă a funcției template de fiecare dată când o utilizează cu un nou tip de date.

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

Funcția de test nu trebuie neapărat apelată în codul principal, motiv pentru care este prezentă doar în fișierul .cpp. Rolul său este pur și simplu de a forța compilatorul să genereze instanțe ale template-ului pentru tipurile de date dorite, fără a fi nevoie să fie efectiv utilizată în alte părți ale programului.

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

Datorită faptului că în funcția de testare nu am apelat o instanță a funcției template pentru tipul de date long, dacă se decomentează linia din codul de mai sus, va apărea o eroare de linker. Acest lucru se întâmplă deoarece compilatorul nu a generat o implementare pentru tipul long, care nu a fost utilizat în funcția de testare.

Clase template

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.

Implementarea constructorilor

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

Implementarea operatorului de asignare

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

Implementarea destructorului

template<typename T>
Student<T>::~Student()
{
	if (nume != nullptr)
	{
		delete[] nume;
	}
}

Implementarea metodelor accesor

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

Implementarea operatorului de afișare

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

Crearea funcției de testare

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 principiu clasele template sunt folosite pentru implementarea structurilor de date într-o manieră generică, exemplul cu clasa Student fiind unul pur didactic.

Concluzii

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

poo-is-ab/laboratoare/07.txt · Last modified: 2025/01/19 22:29 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