Genericitate
In foarte multe aplicatii nu avem nevoie de mostenire/implementare, ci avem nevoie de implementari generice de logica a unei clase care sa functioneze pe mai multe tipuri de date. Interfetele aici nu pot fi folosite pentru ca am putea vrea sa folosim clasele generice pe tipuri care nu sunt sub controlul nostru cum ar fi tipuri din biblioteci.
Tipuri de genericitate
In limbajele de programare exista in mare trei moduri de implementare a genericitatii:
- Monomorfizare (termen aparut in comunitatea de Rust) ca in C++ sau Rust. La precompilare toate instantele parametrizate se creaza practic prin copy-paste si inlocuind cu tipul cerunt oriunde se gasesc specializarile pentru a le include in assembly. Aceasta abordare beneficiaza de optimizari la compilare si face ca executabilul rezultat sa beneficieze de eficienta ridicata.
- Type erasure ca in Java. La un pas intermediar pentru fiecare specializare unde se intalneste tipul parametrului de genericitate se inlocuieste cu clasa Object, iar oriunde e folosita o variabila de acel tip se fac cast-uri ca sa fie folosite metode si campuri de la acel tip. Acesta abordare a fost buna pentru Java doar pentru a oferi compatibilitate inversa cu versiunile anterioare fara genericitate si pentru ca implementarea era usoara. Insa este o abordare ineficienta din cauza cast-urilor. Alta problema este ca nu se permite genericitate folosind primitive pentru ca nu mostenesc Object si trebuie sa existe alte clase care sa le incapsuleze. Ca ultima problema, polimorfismul parametric este restricionat pentru o singura specializare a clasei generice (ex. o functie nu poate avea un overload cu List<Integer> si unul cu List<String> in acelasi timp).
- Reificare ca in C#. Specializarile nu exista la compilare, exista doar clasa sau metoda generica la compile-time, specializarile sunt create la runtime, adica sunt concretizate/reificate la runtime. Chiar si daca se pierde din eficienta prin overhead-ul pentru ca framework-ul sa creeze tipul, aceasta abordare ofera posibilitatea de a crea la runtime instante de obiecte pentru o specializare care nu a fost explicit declarata de catre programator. Astfel reificarea claselor generice ofera flexibilitate la runtime pentru diverse aplicatii fara a suferii de aceleasi probleme ca la type ereasure.
Folosirea genericitatii
O clasa sau metoda toate fi generica parametrizand-o cu o lista de paramtri pusa intre <> dupa nume ca sa tina locul pentru tipurile concrete care vor fi folosite. Clasa sau metoda parametrizata cu un tipurile concrete se numeste specializare.
Mai jos este un exemplu de cum se poate implementa un array list generic.
/* Aceasta este un exemplu simplu de clasa generica parametrizata cu un singur parametru pentru genericitate, poti fi mai multi.
* Aceasta clasa reprezinta un array list, o lista care are un array in spate a carui capacitate se dubleaza cand este depasita la inserare.
*/
public class ArrayList<T>
{
private T[] _buffer = Array.Empty<T>(); // Putem folosi oriunde parametrul generic inclusiv pentru declarare de variabile, aici bufferul stocheaza elementele listei.
public int Length { get; private set; } // Lista are o lungime care va fi monitorizata la inserare si stegere.
public int Capacity => _buffer.Length; // Capacitatea listei este lungimea bufferului.
public void Add(T item) // Putem folosi parametrul generic T ca sa-l folosim ca parametru de functie.
{
if (Length == Capacity) // Daca bufferul este complet ocupat trebuie sa il marim.
{
var tmp = new T[Capacity != 0 ? Capacity * 2 : 1]; // Se creaza un nou buffer cu lungime dubla.
Array.Copy(_buffer, tmp, Length); // Se copiaza continutul bufferului vechi in cel nou.
_buffer = tmp; // Se inlocuieste bufferul.
}
_buffer[Length++] = item; // Adaugam elementul nou si incrementam lungimea listei.
}
public T RemoveAt(int index) // Putem folosi parametrul generic T ca sa-l folosim ca tip returnat de o functie.
{
var item = _buffer[index]; // Citim elementul cautat mai intai ca sa-l stergem.
Array.Copy(_buffer, index + 1, _buffer, index, Length - index - 1); // Mutam continutul de dupa elementul scos cu o pozitie in fata.
_buffer[--Length] = default!; // Eliminam ultimul element ca daca este tip referinta sa nu ramana agata in buffer si sa poata fi reciclat.
if (Length != Capacity / 4 || Length == 0) // Decrementam lungimea listei si daca lungimea nu este la un sfert din capacitate returnam rezultatul.
{
return item;
}
var tmp = new T[Capacity / 2]; // Altfel, cream un nou buffer de jumatatea capacitatii ca sa reducem memoria consumata.
Array.Copy(_buffer, tmp, Length); // Se copiaza continutul bufferului vechi in cel nou.
_buffer = tmp; // Se inlocuieste bufferul.
return item;
}
public T this[int index] => _buffer[index]; // Aici am creat un operator de indexare si putem sa accesam un element ca si cand am accesa un array.
}
Constrangeri
Atunci cand folosim parametri generici nu putem face asuptii asupra tipului dat pentru ca nu stim ce tip va fi folosit. Totusi, putem folosi constrangeri pentru a restrictiona ce tipuri pot fi folosite pentru specializarea tipului.
/* Putem pune mai multe constrangeri pentru toti parametri prin cuvantul cheie "where".
* Aici obligam ca tipul T sa fie un tip valoare, U un tip referinta care implementeaza IComparer<T>
* si care are un constructor implicit.
*/
public class ConstaintExample<T, U> where T : struct where U : class, IComparer<T>, new()
{
// ...
}
Covarianta si contravarianta
Mai multe limbaje cum e Java pentru clase generice care mostenesc alte clase sau implementeaza interfete generice nu se poate face o conversie. De exemplu, chiar daca Interger mosteneste Object IList<Integer> nu poate fi convertit la IList<Object>. In C# putem face acest lucru si invers in anumite cazuri prin covarianta si contravarianta, si in Java se poate doar ca nu in mod direct ca in exemplul mentionat.
Daca avem o interfata unde un parametru generic T apare doar la tipurile returnate de metode (tipurile pot fi si generice parametrizate de T), T poate fi un parametru covariant notat out T iar interfata parametrizata cu o clasa derivata poate fi convertita la o interfata parametrizata cu clasa parinte. Altfel, daca T apare doar la tipurile parametrilor ai metodelor (tipurile pot fi si generice parametrizate de T) atunci T este contravariant notat in T iar interfata parametrizata cu o clasa poate fi convertitata la o interfata parametrizata de o clasa derivata a acelei clase. Practic covarianta pastreaza directia de de conversie de la clasa derivata la cea de baza cand se parametrizeaza interfata iar contravarianta o inverseaza.
Pentru a exemplifica avem urmatorul exemplu:
// Aceasta este doar o clasa de baza ce sa poata fi mostenita.
public class BaseClass
{
protected readonly string Str;
public BaseClass(string str) => Str = str;
public bool IsOddLength => Str.Length % 2 == 1;
public override string ToString() => $"{nameof(BaseClass)}({Str})";
}
// Prin acesta clasa vom demonstra varianta interfetelor generice.
public class DerivedClass : BaseClass
{
public DerivedClass(string str) : base(str + "!")
{
}
public override string ToString() => $"{nameof(DerivedClass)}({Str})";
}
/* Definim o interfata pentru un predicat, predicatul reprezinta o functie
* care pentru un input da o valoare booleana. Aceasta interfata este contravarianta
* pentru ca T se afla doar la parametri functiilor acestei interfete.
*/
public interface IPredicate<in T>
{
public bool Test(T test);
}
/* Definim o interfata pentru un reader, reader reprezinta o functie
* care pentru un string returneaza un obiect de tip T. Aceasta interfata este covarianta
* pentru ca T se afla doar la tipul iesire a functiilor acestei interfete.
*/
public interface IReader<out T>
{
public T Read(string str);
}
/* Aici implementam interfata parametrizata cu clasa de baza ca sa poata fi convertita
* la interfata parametrizata cu clasa derivata.
*/
public class Predicate : IPredicate<BaseClass>
{
public bool Test(BaseClass test) => test.IsOddLength;
}
/* Aici implementam interfata parametrizata cu clasa derivata ca sa poata fi convertita
* la interfata parametrizata cu clasa de baza.
*/
public class Reader : IReader<DerivedClass>
{
public DerivedClass Read(string str) => new(str);
}
IReader<DerivedClass> derivedClassReader = new Reader();
// Putem converti aici interfata in directia conversiei clasei derivate la clasa de baza la covarianta.
IReader<BaseClass> baseClassReader = derivedClassReader;
IPredicate<BaseClass> baseClassPredicate = new Predicate();
// Putem converti aici interfata in directia contrala conversiei clasei derivate la clasa de baza la cotravarianta.
IPredicate<DerivedClass> derivedClassPredicate = baseClassPredicate;
Console.WriteLine("Testing {0}<{1}> results in {2}", nameof(IPredicate<BaseClass>), nameof(BaseClass), baseClassPredicate.Test(new BaseClass(TestString)));
Console.WriteLine("Testing {0}<{1}> results in {2}", nameof(IPredicate<DerivedClass>), nameof(DerivedClass), derivedClassPredicate.Test(new DerivedClass(TestString)));
Console.WriteLine("Reading {0}<{1}> results in {2}", nameof(IReader<DerivedClass>), nameof(DerivedClass), derivedClassReader.Read(TestString));
Console.WriteLine("Reading {0}<{1}> results in {2}", nameof(IReader<BaseClass>), nameof(BaseClass), baseClassReader.Read(TestString));
Motivul pentru care aceste conversii functioneaza este simplu. In exemplu, la covarianta tipul returnat poate fi convertit de la clasa derivata la clasa de baza, astfel daca folosim functia care returneaza clasa derivata avem direct si o functie care returneaza clasa de baza pentru ca se poate face conversia dupa returnare la clasa de baza. La contravarianta, daca parametrul primit la functie este de tipul clasei de baza avem implicit o functie care primeste tipul derivat pentru ca se poate face conversia inainte de aplicarea parametrului de la clasa derivata la cea de baza.
De retinut e ca aceste reguli prin care se determina daca interfata este covarianta sau contravarianta printr-un parametru pot fi inversate cand avem de a face cu combinatii de covarinata si contravarianta. Daca de exemplu IPredicate ar avea la functia Test o alta interfata contravarianta in loc de T IPredicate ar deveni covariant, iar daca IReader ar returna o interfata contravarianta in T la functia Read in loc de T atunci ar deveni contravarianta. Practic, covarianta si contravarianta rezulta in contravarianta, covarianta dupla sau contravarianta dupla rezulta in covarianta.