Generyki — własne klasy i metody dla dowolnego typu
Od pierwszej lekcji o kolekcjach piszesz List<int> i Dictionary<string, int>. Czas zobaczyć, co znaczą te nawiasy ostre — i napisać własny typ, który działa tak samo dobrze dla liczb, napisów i obiektów.
Czego się dziś nauczysz
- Wyjaśnisz, dlaczego przeciążanie i typ
objectnie rozwiązują problemu powielania kodu - Napiszesz metodę generyczną z parametrem typu
- Zbudujesz własną klasę generyczną
- Dobierzesz ograniczenie
wherepozwalające wykonać potrzebne operacje - Ocenisz, kiedy generyk pomaga, a kiedy tylko komplikuje kod
Przygotowanie: lekcje 01–51. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.
Ten sam kod dla każdego typu
Napisz metodę zwracającą większą z dwóch wartości. Dla liczb całkowitych łatwo. A dla double? A dla napisów? A dla obiektów klasy Uczen z lekcji 51?
static int Wiekszy(int a, int b) { return a > b ? a : b; }
static double Wiekszy(double a, double b) { return a > b ? a : b; }
static string Wiekszy(string a, string b) { /* ... */ }
// i tak dla kazdego typu
Przeciążanie z lekcji 20 rozwiązuje to tylko pozornie: kod jest kopiowany, a poprawka w jednej wersji nie trafia do pozostałych. Można też użyć typu object, który przyjmie wszystko — ale wtedy tracimy kontrolę kompilatora:
static object Wiekszy(object a, object b) { /* ... */ }
int x = (int)Wiekszy(3, 7); // rzutowanie przy KAZDYM uzyciu
string s = (string)Wiekszy(3, 7); // kompiluje sie, wywala sie przy uruchomieniu
Generyki (w podstawie programowej nazywane szablonami) pozwalają napisać kod raz i zostawić typ do uzupełnienia w miejscu użycia.
Metoda z parametrem typu
static void Zamien<T>(ref T a, ref T b)
{
T pomoc = a;
a = b;
b = pomoc;
}
int p = 3, q = 7;
Zamien(ref p, ref q); // T ustalone jako int - kompilator sam to widzi
string x = "Ala", y = "Ola";
Zamien<string>(ref x, ref y); // mozna podac typ jawnie
T to parametr typu — nazwa zastępcza, którą kompilator podmienia na konkretny typ w miejscu wywołania. Litera nie ma znaczenia; przyjęło się T, a dla większej liczby parametrów TKey, TValue, TWynik.
Znasz to już z praktyki: List<T>, Dictionary<TKey, TValue>, Stack<T> i IEnumerable<T> to typy generyczne. Teraz napiszesz własne.
Ograniczenia — where
W środku metody generycznej nie wolno wszystkiego. Ten kod się nie skompiluje:
static T Wiekszy<T>(T a, T b)
{
return a > b ? a : b; // BLAD CS0019: operator > nie dziala dla dowolnego T
}
Kompilator ma rację: nie wie, czy typ podstawiony pod T w ogóle da się porównać. Trzeba mu to obiecać ograniczeniem:
static T Wiekszy<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
Console.WriteLine(Wiekszy(3, 7)); // 7
Console.WriteLine(Wiekszy(2.5, 1.5)); // 2,5
Console.WriteLine(Wiekszy("Ala", "Ola")); // Ola
| Ograniczenie | Znaczenie |
|---|---|
where T : IComparable<T> | typ musi implementować wskazany interfejs — można wywoływać jego metody |
where T : class | typ referencyjny; dopuszcza null |
where T : struct | typ wartościowy, np. int, double, własna struktura |
where T : new() | typ ma konstruktor bezparametrowy — wolno użyć new T() |
where T : Uczen | typ musi być klasą Uczen albo pochodną |
Wartość domyślna nieznanego typu
Gdy potrzebujesz „pustej” wartości typu T, napisz default albo default(T): dla liczb da zero, dla bool fałsz, dla typów referencyjnych null. Zapis T x = null; bez ograniczenia class się nie skompiluje.
Własna klasa generyczna
Parametr typu może dotyczyć całej klasy. Oto pudełko na jedną wartość dowolnego typu — z historią zmian:
class Pudelko<T>
{
private T zawartosc;
private int zmiany;
public Pudelko(T poczatkowa)
{
zawartosc = poczatkowa;
}
public T Wyjmij() => zawartosc;
public void Wloz(T nowa)
{
zawartosc = nowa;
zmiany++;
}
public int LiczbaZmian => zmiany;
public override string ToString() => $"[{zawartosc}] zmian: {zmiany}";
}
Pudelko<int> punkty = new Pudelko<int>(0);
punkty.Wloz(18);
punkty.Wloz(24);
Console.WriteLine(punkty); // [24] zmian: 2
Pudelko<string> notatka = new Pudelko<string>("pusto");
notatka.Wloz("zebranie o 15");
Console.WriteLine(notatka.Wyjmij()); // zebranie o 15
// punkty.Wloz("tekst"); // BLAD KOMPILACJI - i o to chodzi
Ostatni wiersz pokazuje zysk: Pudelko<int> i Pudelko<string> to dla kompilatora dwa różne typy. Błędne użycie zostaje wykryte przy budowaniu, a nie u użytkownika.
Drugi zysk: brak pakowania
Wersja z object musiałaby opakować każdą liczbę w obiekt na stercie (boxing) i rozpakować przy odczycie. Przy tysiącach elementów to zauważalny koszt. Wersja generyczna trzyma liczby jako liczby.
Kiedy pisać własny typ generyczny
Rzadko — biblioteka .NET ma już listy, słowniki, stosy i kolejki. Własnego generyka piszesz, gdy tworzysz strukturę lub narzędzie działające tak samo dla wielu typów: własny bufor, parę wartości, wynik operacji z informacją o powodzeniu. Dla jednego typu zwykła klasa jest czytelniejsza.
Przykład: własna kolekcja z limitem
using System.Collections.Generic;
class Ograniczona<T> where T : IComparable<T>
{
private readonly List<T> dane = new List<T>();
private readonly int limit;
public Ograniczona(int limit)
{
if (limit < 1)
{
throw new ArgumentOutOfRangeException(nameof(limit), "Limit musi byc dodatni.");
}
this.limit = limit;
}
public int Ile => dane.Count;
public bool Dodaj(T element)
{
if (dane.Count >= limit) return false;
dane.Add(element);
return true;
}
public T Najwiekszy()
{
if (dane.Count == 0)
{
throw new InvalidOperationException("Kolekcja jest pusta.");
}
T wynik = dane[0];
foreach (T x in dane)
{
if (x.CompareTo(wynik) > 0) wynik = x;
}
return wynik;
}
public override string ToString() => string.Join(", ", dane);
}
class Program
{
static void Main()
{
Ograniczona<int> wyniki = new Ograniczona<int>(3);
Console.WriteLine(wyniki.Dodaj(18)); // True
Console.WriteLine(wyniki.Dodaj(24)); // True
Console.WriteLine(wyniki.Dodaj(7)); // True
Console.WriteLine(wyniki.Dodaj(30)); // False - limit
Console.WriteLine(wyniki); // 18, 24, 7
Console.WriteLine(wyniki.Najwiekszy());// 24
Ograniczona<string> imiona = new Ograniczona<string>(2);
imiona.Dodaj("Ola");
imiona.Dodaj("Ala");
Console.WriteLine(imiona.Najwiekszy());// Ola
}
}
True
True
True
False
18, 24, 7
24
Ola
Co dzieje się po kolei
- Ograniczenie
where T : IComparable<T>pozwala wywołaćCompareTona elementach — bez niego metodaNajwiekszyby się nie skompilowała. - Ta sama klasa obsługuje liczby i napisy; dla napisów „największy” znaczy „ostatni alfabetycznie”.
Dodajzwracaboolzamiast zgłaszać wyjątek — przekroczenie limitu jest przewidzianą sytuacją, a nie błędem wykonania.Najwiekszyna pustej kolekcji to co innego: nie ma sensownej wartości do zwrócenia, więc leci wyjątek (lekcja 35).readonlyprzy polach oznacza, że po utworzeniu obiektu nie da się podmienić listy ani limitu. Do listy nadal wolno dopisywać elementy —readonlychroni referencję, nie zawartość.
Zestawienie elementów
| Element | Znaczenie | Uwagi |
|---|---|---|
class Nazwa<T> | klasa generyczna | T zostaje ustalone przy tworzeniu obiektu: new Nazwa<int>(). |
static W Metoda<T>(T x) | metoda generyczna | Typ zwykle wynika z argumentów; można go też podać jawnie w nawiasach ostrych. |
where T : … | ograniczenie | Obietnica złożona kompilatorowi. Bez niej wolno używać tylko metod typu object. |
default(T) | wartość domyślna | Zero, false albo null — zależnie od podstawionego typu. |
typeof(T) | Type | Informacja o podstawionym typie; przydatna w komunikatach diagnostycznych. |
List<T>, Dictionary<TKey, TValue> | gotowe generyki | Zwykle wystarczają — własny typ generyczny piszemy rzadko. |
readonly przy polu | modyfikator | Przypisanie tylko w deklaracji albo w konstruktorze. Nie zamraża obiektu wskazywanego przez referencję. |
Na co uważać
| Zapis | Problem |
|---|---|
a > b w metodzie generycznej | CS0019 — operator nie jest zdefiniowany dla dowolnego T. Dodaj ograniczenie i użyj CompareTo. |
T x = null; bez where T : class | Typ wartościowy nie przyjmuje null. Użyj default. |
new T() bez where T : new() | Kompilator nie wie, czy typ ma konstruktor bezparametrowy. |
Pudelko<object> „żeby pasowało wszystko” | Wraca dokładnie ten problem, który generyki miały usunąć — brak kontroli typów. |
| Generyk dla jednego typu | Jeśli T zawsze będzie int, klasa generyczna tylko utrudnia czytanie. |
Zadania
Napisz metody generyczne Zamien<T> oraz Wypisz<T>(T[] tablica). Sprawdź obie na tablicy liczb i tablicy napisów.
Napisz metodę T Najmniejszy<T>(T[] dane) z odpowiednim ograniczeniem. Zadbaj o sensowne zachowanie dla tablicy pustej.
Zbuduj klasę Para<TPierwszy, TDrugi> z dwiema właściwościami, metodą Odwroc() zwracającą Para<TDrugi, TPierwszy> i nadpisanym ToString.
Napisz klasę MojStos<T> z metodami Wloz, Zdejmij, Podejrzyj i właściwością Ile, opartą na List<T>. Puste zdjęcie ma zgłaszać wyjątek. Na koniec porównaj swoją klasę z wbudowanym typem Stack<T>: wypisz, które metody nazywają się inaczej i czego w twojej wersji brakuje.
Zaprojektuj typ Wynik<T> niosący albo wartość, albo komunikat błędu — z właściwościami Udalo, Wartosc i Blad. Użyj go w metodzie parsującej wiersz imie;punkty zamiast pary bool i out.
Napisz metodę T[] Utworz<T>(int ile) where T : new(), tworzącą tablicę nowych obiektów. Następnie sprawdź, co się stanie po usunięciu ograniczenia i zapisz treść komunikatu kompilatora. Wyjaśnij, dlaczego to ograniczenie jest potrzebne.
Co trzeba zapamiętać
- Generyk pozwala napisać kod raz, a typ ustalić w miejscu użycia — bez rzutowania i bez powielania metod.
Tto parametr typu; kompilator podstawia pod niego konkretny typ przy wywołaniu.- Bez ograniczenia
wherena typieTwolno wywoływać tylko metody klasyobject. where T : IComparable<T>umożliwia porównywanie,where T : new()— tworzenie obiektów.defaultdaje wartość domyślną typu, gdy nie wiadomo, czy jest wartościowy, czy referencyjny.- Własne typy generyczne pisz do struktur i narzędzi; do zwykłych danych wystarczą gotowe kolekcje .NET.
Dokumentacja: Microsoft Learn — temat tej lekcji.