Generyki

Generyki #

Generyki w C# wyglądają podobnie i służą do tego samego celu co szablony z C++. Również za ich pomocą piszemy kod niezależny od typu, ale jest to mechanizm prostszy i bezpieczniejszy.

Szablony w C++ działają w czasie kompilacji, dla każdego typu użytego w szablonie kompilator C++ tworzy nową oddzielną kopię klasy lub funkcji. W C# generyki działają w czasie wykonania. Kompilator generyki kompiluje zawsze raz do języka pośredniego, gdzie typy na razie pozostają niepodstawione. Dopiero podczas uruchomienia kompilator JIT (Just-In-Time) przy pierwszym użyciu generyka tworzy jej wyspecjalizowaną wersję.

W C++ (przynajmniej przed C++20) kompilator nie weryfikuje operacji na typach szablonowych, aż do próby użycia. Często generuje to bardzo nieprzejrzyste i trudne w zrozumieniu błędy kompilacji. W C# typy generyczne są w pełni bezpieczne, żeby wiedzieć jak można użyć typu, należy go ograniczyć. Podobny mechanizm w C++ istnieje od wersji C++20: Constraints and concepts.

W C++ ten szablon jest jak najbardziej poprawny, jednak jeżeli go spróbujemy użyć na typie T, który nie definiuje operatora porównywania, to dostaniemy trudny do zdebugowania błąd.

template <class T> T Max(T a, T b)
{
    return a > b ? a : b;
}

W C# żeby wiedzieć, że typy możemy ze sobą porównywać musielibyśmy na przykład ograniczyć T do typu implementującego IComparable<T>. Dzięki temu, tej generycznej metody możemy używać tylko na typach porównywalnych, przez co nie będzie niespodzianek po podstawieniu.

static T Max<T>(T a, T b) where T : IComparisonOperators<T, T, bool>
{
    return a > b ? a : b;
}

Typy generyczne w C# mogą przyjmować tylko parametry typowe (czyli inne typy, np. string, int, MyClass), a nie parametry nietypowe (stałe wartości, np. 10, true, "hello"), jak ma to miejsce w C++.

Bez generyków #

Wyobraźmy sobie, że mamy do zaimplementowania stos, który ma działać z typami: int, float i string. Bez typów generycznych możemy na przykład zaimplementować trzy klasy: IntStack, FloatStack, StringStack. Jest to jednak dużo kodu do utrzymania, w dodatku powtórzonego. Innym rozwiązaniem jest użycie klasy object, żeby napisać jedną implementację stosu, która będzie działać z każdym typem:

public class Stack
{
    private object[] _items = new object[8];
    public int Count { get; private set; }

    public void Push(object item)
    {
        if (_items.Length == Count)
        {
            Array.Resize(ref _items, _items.Length * 2);
        }
        _items[Count++] = item;
    }

    public object Pop()
    {
        if (Count == 0)
        {
            throw new InvalidOperationException("Stack is empty");
        }
        return _items[--Count];
    }
}

Kod źródłowy - ObjectStack

Jednak próba użycia takiego stosu uwidacznia problemy z tą implementacją. Po pierwsze wrzucanie na stos typów bezpośrednich wymaga pakowania. Po drugie, taki stos nie zapewnia nam bezpieczeństwa typów. Pobranie elementu wiąże się z niebezpiecznym rzutowaniem w dół. Typy generyczne rozwiązują oba te problemy.

Stack stack = new Stack();

for (int i = 0; i < 10; i++)
{
    stack.Push(i);
}

int number = (int)stack.Pop();
string str = (string)stack.Pop(); // Runtime error: InvalidCastException

Typy generyczne #

Generyczna implementacja wygląda następująco:

public class Stack<T>
{
    private T[] _items = new T[8];
    public int Count { get; private set; }

    public void Push(T item)
    {
        if (_items.Length == Count)
        {
            Array.Resize(ref _items, _items.Length * 2);
        }
        _items[Count++] = item;
    }

    public T Pop()
    {
        if (Count == 0)
        {
            throw new InvalidOperationException("Stack is empty");
        }
        return _items[--Count];
    }
}

Kod źródłowy - GenericStack

Korzystanie z takiego stosu nie powoduje już operacji pakowania i jest bezpieczne:

Stack<int> stack = new Stack<int>();

for (int i = 0; i < 10; i++)
{
    stack.Push(i);
}

// string str = stack.Pop(); // Compilation error
while (stack.Count > 0)
{
    int number = stack.Pop();
    Console.WriteLine(number);
}

Można definiować wiele parametrów generycznych:

class Test<T, U, W>;

Metody generyczne #

Metody również mogą wprowadzać parametry generyczne:

public static void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

Jeżeli kompilator potrafi wywnioskować parametry generyczne, to nie trzeba ich podawać przy wywołaniu:

int i = 3, j = 5;
Swap<int>(ref i, ref j);
Swap(ref i, ref j);
Console.WriteLine($"i: {i}, j: {j}");

Ograniczenia typów generycznych #

Normalnie o parametrze generycznym wiemy jedynie, że jest typu object, co znaczy, że można na nim wywołać metody dostępne na typie object. Nie można zrobić nic więcej.

Ograniczenia pozwalają na przekazanie dodatkowych informacji o typie generycznym. Na przykład, jeśli ograniczysz parametr generyczny do konkretnej klasy lub interfejsu, kompilator pozwoli Ci używać metod z tej klasy/interfejsu.

public static T Max<T>(T value, params T[] values) where T : IComparable<T>
{
    var max = value;
    foreach (var t in values)
    {
        if (max.CompareTo(t) < 0)
        {
            max = t;
        }
    }
    return max;
}

Możliwe ograniczenia:

  • where T : <base class/interface> - najczęstsze ograniczenie i najbardziej przydatne, pozwala wywołać metody z ograniczanych klas i interfejsów.
  • where T : <base class/interface>? - pozwala wywołać metody z ograniczanych klas i interfejsów, dodatkowo może być nullowalny
  • where T : new() - typ musi zawierać konstruktor bezparametrowy, przydatne jeżeli musimy tworzyć nowe instancje
  • where T : class - typ musi być referencyjny
  • where T : class? - typ musi być referencyjny, może być nullowalny
  • where T : struct - typ musi być bezpośredni
  • where T : allows ref struct - typ może być “ref strukturą”
  • where T : unmanaged - typ musi być bezpośredni i rekursywnie składać się z innych typów bezpośrednich lub wskaźnikowych
  • where T : notnull - nie może być nullowalny

Na typ generyczny możemy nanosić kilka ograniczeń:

class Base {}
class Test<T, U>
    where U : struct
    where T : Base, new()
{}

Samoodnoszące się deklaracje generyczne #

W deklaracji typu można używać deklarowanego typu jako parametru generycznego:

public class Product : IEquatable<Product>
{
    public string EanCode { get; }
    
    public Product(string eanCode) => EanCode = eanCode;
    
    public bool Equals(Product? other) => EanCode == other?.EanCode;
}

To ma sens, komunikujemy w ten sposób, że Product jest porównywalny z innymi przedstawicielami swojego typu pod względem równości.

W deklaracji możemy także używać parametru generycznego do jego ograniczenia.

public class Finder<T> where T : IEquatable<T>
{
    public T? Find(IEnumerable<T> collection, T item)
    {
        foreach(var t in collection)
        {
            if (t.Equals(item)) return t;
        }

        return default(T);
    }
}

To też ma sens, chcemy szukać obiektów, które są porównywalne ze sobą równościowo, inaczej nie wiedzielibyśmy jak szukać.

Poprawne jest też: class Foo<Bar> where Bar : Foo<Bar>.

Niezmienność #

Typy generyczne domyślnie są niezmienne. Nie można rzutować ich parametrów generycznych w dół ani w górę.

Rzutowanie w dół jest niedozwolone, bo moglibyśmy ze stosu samochodów nagle zdjąć inny rodzaj pojazdu.

Stack<Vehicle> vehicleStack = new Stack<Vehicle>();
Stack<Car> carStack = vehicleStack; // Compilation error

public abstract class Vehicle;
public class Car : Vehicle;
public class Bike : Vehicle;

Rzutowanie w górę jest niedozwolone, bo moglibyśmy na stos samochodów nagle wepchnąć inny rodzaj pojazdu.

Stack<Car> carStack = new Stack<Car>();
Stack<Vehicle> vehicleStack = carStack; // Compilation error

public abstract class Vehicle;
public class Car : Vehicle;
public class Bike : Vehicle;

Wariancja #

W interfejsach możemy deklarować zmienne (wariantne) parametry generyczne. Ograniczają one sposób użycia parametru generycznego, ale dzięki temu pozwalają na rzutowanie tego parametru w jedną ze stron. Parametry kowarientne (out) mogą być używane tylko do zwracania wartości. Parametry kontrawarientne (in) mogą być używane tylko jako parametry wejściowe do metod.

// Covariant T type parameter (can be used only as a return value)
public interface IPoppable<out T> 
{
    int Count { get; }
    T Pop();
}

// Contravariant T type parameter (can be used only as an input parameter)
public interface IPushable<in T> 
{
    void Push(T item);
}

public class Stack<T> : IPoppable<T>, IPushable<T>
{
    private T[] _items = new T[8];
    public int Count { get; private set; }

    public void Push(T item)
    {
        if (_items.Length == Count)
        {
            Array.Resize(ref _items, _items.Length * 2);
        }
        _items[Count++] = item;
    }

    public T Pop()
    {
        if (Count == 0)
        {
            throw new InvalidOperationException("Stack is empty");
        }
        return _items[--Count];
    }
}

Kod źródłowy - VariantStack

Kowariancja #

Kowariantne parametry generyczne (out) pozwalają na rzutowanie w górę. Będzie to umożliwiało przekazanie wyspecjalizowanego typu do bardziej ogólnej metody:

var carStack = new Stack<Car>();
carStack.Push(new Car());
carStack.Push(new Car());
IPoppable<Car> vehiclePoppable = carStack;
WashVehicles(vehiclePoppable);

public void WashVehicles(IPoppable<Vehicle> vehicles)
{
    while (vehicles.Count > 0)
    {
        Vehicle vehicle = vehicles.Pop();
        Console.WriteLine($"Washing {vehicle}");
    }
}

public abstract class Vehicle;
public class Car : Vehicle;
public class Bike : Vehicle;

Kontrawariancja #

Kontrawariantne parametry generyczne (in) pozwalają na rzutowanie w dół. Będzie to umożliwiało przekazanie ogólniejszego typu do bardziej wyspecjalizowanej metody:

var vehiclesStack = new Stack<Vehicle>();
vehiclesStack.Push(new Car());
vehiclesStack.Push(new Bike());
IPushable<Vehicle> carPushable = vehiclesStack;
DeliverCars(carPushable, 2);

public void DeliverCars(IPushable<Car> cars, int count)
{
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine("Adding car to IPushable");
        cars.Push(new Car());
    }
}

public abstract class Vehicle;
public class Car : Vehicle;
public class Bike : Vehicle;