Gdy ten sam algorytm ma działać dla liczb, tekstów i własnych klas, kopiowanie niemal identycznego kodu szybko zaczyna boleć. W tym artykule pokazuję, jak typy generyczne rozwiązują ten problem w C# i .NET, gdzie spotyka się je na co dzień, jak stosować ograniczenia where oraz kiedy uważać na kowariancję, kontrawariancję i zbyt skomplikowane abstrakcje.
Najważniejsze zasady generyków w C# w jednym miejscu
- Parametr typu pozwala używać tej samej klasy lub metody z różnymi typami danych.
- Bezpieczeństwo typów jest sprawdzane przez kompilator, więc wiele błędów wychodzi przed uruchomieniem programu.
-
List
i Dictionary to najczęstsze przykłady zastosowania generyków w .NET. - Ograniczenia where mówią, jakie możliwości musi mieć typ przekazany do klasy lub metody.
- Kowariancja i kontrawariancja ułatwiają współpracę między typami bazowymi i pochodnymi.
Dlaczego generyki są czymś więcej niż skrótem składniowym
Generyk pozwala napisać kod, który pracuje z nieznanym jeszcze typem, ale nadal zachowuje silne typowanie. Zamiast tworzyć osobne klasy dla liczb całkowitych, tekstów i obiektów domenowych, definiuję jedną klasę z parametrem typu, na przykład T.
List liczby = new List();
liczby.Add(10);
List nazwy = new List();
nazwy.Add("Kursdotnet"); List i List korzystają z tej samej konstrukcji, ale każda lista przyjmuje wyłącznie właściwy dla siebie typ. Próba dodania tekstu do listy liczb zakończy się błędem kompilacji, a nie przypadkowym wyjątkiem dopiero w czasie działania programu.
Przed upowszechnieniem generyków podobne problemy rozwiązywano często za pomocą object. Kolekcja mogła przechowywać dowolne wartości, lecz programista musiał później wykonywać rzutowania i sam pilnować poprawności danych. Przy typach wartości, takich jak int, dochodziło również do pakowania wartości w obiekt, czyli tak zwanego boxingu.
W praktyce generyki dają trzy korzyści naraz: mniej powielonego kodu, wcześniejsze wykrywanie błędów i zwykle lepszą wydajność kolekcji dla typów wartości. Nie traktuję ich jednak jako automatycznego lekarstwa na każdą abstrakcję. Dobrze zaprojektowany generyk upraszcza kod, a źle zaprojektowany tylko ukrywa jego skomplikowanie.
Gdzie generyki pracują codziennie w .NET
Najłatwiej zauważyć je w kolekcjach z przestrzeni nazw System.Collections.Generic. W codziennym kodzie używam ich również w interfejsach, delegatach, metodach pomocniczych, wynikach zapytań LINQ i modelach zwracanych przez warstwę aplikacji.
| Konstrukcja | Zastosowanie | Przykład |
|---|---|---|
List |
Lista elementów określonego typu | List |
Dictionary |
Mapowanie klucza na wartość | Dictionary |
IEnumerable |
Przechodzenie po sekwencji danych | IEnumerable |
Task |
Asynchroniczny wynik konkretnego typu | Task |
Action i Func
|
Typowane delegaty i funkcje | Func |
Dobrym przykładem jest Task. Metoda asynchroniczna może zwrócić nie tylko informację, że operacja się zakończyła, lecz także dokładnie opisany rezultat, na przykład Task. Dzięki temu kod wywołujący nie musi zgadywać, co znajduje się w wyniku.
Metody generyczne
Parametr typu może należeć tylko do metody, nawet jeśli sama klasa nie jest generyczna. Kompilator często potrafi wywnioskować typ na podstawie argumentów, dlatego nie trzeba zawsze zapisywać go ręcznie.
static void Swap(ref T first, ref T second)
{
T temporary = first;
first = second;
second = temporary;
}
int a = 1;
int b = 2;
Swap(ref a, ref b); Ta metoda działa dla int, string i własnych klas. Najważniejsze jest to, że nie traci informacji o typie. Kompilator wie, że oba argumenty muszą być tego samego rodzaju.
Klasy i interfejsy generyczne
Generyczne interfejsy dobrze pasują do kodu aplikacyjnego. Przykładowo IRepository może opisywać operacje na wielu encjach, a konkretna implementacja dostarczy typ zamówienia, użytkownika albo produktu. Nie oznacza to jednak, że każda aplikacja potrzebuje uniwersalnego repozytorium. Czasem taka warstwa tylko komplikuje użycie Entity Framework Core.
Jak projektować własny kod z parametrem typu
Własny typ generyczny warto tworzyć wtedy, gdy kilka klas wykonuje tę samą operację na różnych danych. Parametr typu powinien oznaczać rzeczywistą różnicę w danych, a nie być ozdobą dodaną wyłącznie po to, aby kod wyglądał bardziej elastycznie.
public interface IEntity
{
Guid Id { get; }
}
public sealed class InMemoryStore where T : IEntity
{
private readonly List _items = new();
public void Add(T item)
{
_items.Add(item);
}
public T? FindById(Guid id)
{
return _items.FirstOrDefault(item => item.Id == id);
}
} W tym przykładzie T może być dowolną encją implementującą IEntity. Ograniczenie daje klasie dostęp do właściwości Id, ale nadal pozwala użyć jej z wieloma modelami. To znacznie bezpieczniejsze niż przechowywanie elementów jako object i sprawdzanie ich typu w każdym miejscu.
Przy projektowaniu zwracam uwagę także na liczbę parametrów. Dictionary ma dwa, bo klucz i wartość pełnią różne role. Jeśli własna klasa zaczyna wymagać pięciu parametrów typu, zwykle jest to sygnał, że model można rozdzielić albo uprościć.
Kiedy kompilator wywnioskuje typ
W metodach generycznych typ jest najczęściej ustalany z argumentów. Nie zostanie jednak wywnioskowany wyłącznie z typu zwracanego albo z samego ograniczenia.
static T Max(T first, T second)
where T : IComparable
{
return first.CompareTo(second) >= 0 ? first : second;
}
int result = Max(7, 3);
string text = Max("kot", "pies"); Ograniczenie IComparable mówi kompilatorowi, że typ potrafi porównywać swoje wartości. Bez niego operacja CompareTo nie byłaby dostępna dla dowolnego T. Właśnie tutaj widać największą wartość generyków: elastyczność nie oznacza rezygnacji z reguł.
Ograniczenia where pozwalają odzyskać konkretne możliwości
Sam parametr T jest dla kompilatora bardzo ogólny. Nie mogę założyć, że ma konstruktor, właściwość albo metodę, których nie zadeklarowałem w ograniczeniu. Służy do tego klauzula where.
| Ograniczenie | Co gwarantuje | Przykładowe użycie |
|---|---|---|
where T : class |
T jest typem referencyjnym |
Obsługa wartości null
|
where T : struct |
T jest niepustym typem wartości |
Własne operacje na strukturach |
where T : new() |
T ma publiczny konstruktor bez parametrów |
Tworzenie przez new T()
|
where T : BaseClass |
T dziedziczy po klasie bazowej |
Dostęp do jej członków |
where T : IInterface |
T implementuje interfejs |
Wywoływanie jego metod |
Ograniczenia można łączyć, na przykład where T : class, IComparable. Kolejność ma znaczenie składniowe, a zbyt wiele warunków może utrudnić użycie klasy. Zwykle zaczynam od najmniejszego ograniczenia, które naprawdę jest potrzebne.
Częsty błąd polega na założeniu, że każdy typ ma operator + albo właściwość Length. Dla zwykłego T kompilator tego nie wie. Zamiast obchodzić problem rzutowaniami, lepiej użyć odpowiedniego interfejsu jako ograniczenia lub zmienić projekt metody.
Kowariancja i kontrawariancja bez magii
Kowariancja i kontrawariancja dotyczą sytuacji, w której typ generyczny współpracuje z typem bazowym oraz pochodnym. Najprostsza reguła brzmi tak: kowariancja pozwala bezpiecznie odczytywać bardziej szczegółowe typy jako ogólniejsze, a kontrawariancja działa w odwrotnym kierunku dla typów wejściowych.
IEnumerable dogs = GetDogs();
IEnumerable animals = dogs; Dog dziedziczy po Animal, a IEnumerable jest kowariantny. Jest to bezpieczne, ponieważ z sekwencji psów mogę tylko odczytywać zwierzęta. Nie mogę przez zmienną typu IEnumerable dodać do niej kota albo innego obiektu.
Kontrawariancję dobrze pokazuje delegat:
Action handleAnimal = animal => Console.WriteLine(animal.Name);
Action handleDog = handleAnimal; Metoda obsługująca dowolne zwierzę potrafi również obsłużyć psa. W kodzie bibliotek i frameworków takie konwersje są bardzo przydatne, choć w typowej aplikacji biznesowej nie muszę używać słów in i out codziennie. Trzeba też pamiętać, że wariancja dotyczy typów referencyjnych, a nie typów wartości, takich jak int.
Nowoczesne zastosowania i granice tej techniki
Generyki nie kończą się na kolekcjach. W nowszych wersjach .NET można używać generycznej matematyki opartej na interfejsach takich jak INumber. Pozwala to napisać jedną metodę dodawania zamiast osobnych wersji dla int, double i innych typów liczbowych.
using System.Numerics;
static T Add(T left, T right)
where T : INumber
{
return left + right;
} To rozwiązanie jest szczególnie interesujące dla autorów bibliotek, algorytmów i narzędzi obliczeniowych. W zwykłym kontrolerze internetowym nie ma sensu dodawać takiej abstrakcji tylko dlatego, że język na nią pozwala. Generyczność powinna odpowiadać realnej potrzebie, a nie zastępować prosty kod.
Błędy, które widuję najczęściej
- Zastępowanie niemal wszystkiego typem
object, a później wykonywanie rzutowań w wielu miejscach. - Tworzenie generycznej klasy bez wspólnej logiki, tylko po to, aby uniknąć kilku prostych przeciążeń.
- Dodawanie
where T : new(), mimo że tworzenie obiektu powinno być jawnie przekazane przez fabrykę. - Udostępnianie na zewnątrz
List, gdy lepszy byłby tylko do odczytu interfejsIReadOnlyList. - Mylenie bezpieczeństwa typów z walidacją danych. Generyk pilnuje rodzaju wartości, ale nie sprawdzi, czy cena jest dodatnia albo czy e-mail ma poprawny format.
W projektach .NET najczęściej stosuję prostą zasadę: generyk powinien zmniejszać powtarzalność, poprawiać kontrakt API albo pozwalać kompilatorowi zweryfikować ważną regułę. Jeśli po jego dodaniu kod staje się trudniejszy do wywołania niż wcześniejsze, konkretne rozwiązanie, zysk prawdopodobnie jest pozorny.
Co zabrać do kolejnego projektu w C#
Najlepiej zacząć od konstrukcji, które już znasz: List, Dictionary, IEnumerable i Task. Później warto przećwiczyć własną metodę generyczną, dodać jedno sensowne ograniczenie where i dopiero wtedy sięgnąć po wariancję albo generyczną matematykę.
Moja praktyczna wskazówka jest prosta: najpierw określ wspólną operację, a dopiero potem parametr typu. Generyki mają pomagać pisać kod wielokrotnego użytku bez utraty kontroli nad typami. Gdy zachowują tę równowagę, stają się jedną z najbardziej użytecznych cech C# i całej platformy .NET.
