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.

C# generyki where szablony klas 55 min
CEL LEKCJI

Czego się dziś nauczysz

  • Wyjaśnisz, dlaczego przeciążanie i typ object nie rozwiązują problemu powielania kodu
  • Napiszesz metodę generyczną z parametrem typu
  • Zbudujesz własną klasę generyczną
  • Dobierzesz ograniczenie where pozwalają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.

TEORIA

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?

droga donikąd
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:

Program.cs
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.

TEORIA

Metoda z parametrem typu

definicja metody
static void Zamien<T>(ref T a, ref T b)
{
    T pomoc = a;
    a = b;
    b = pomoc;
}
Program.cs
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:

Program.cs
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:

Program.cs
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
OgraniczenieZnaczenie
where T : IComparable<T>typ musi implementować wskazany interfejs — można wywoływać jego metody
where T : classtyp referencyjny; dopuszcza null
where T : structtyp wartościowy, np. int, double, własna struktura
where T : new()typ ma konstruktor bezparametrowy — wolno użyć new T()
where T : Uczentyp 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.

TEORIA

Własna klasa generyczna

Parametr typu może dotyczyć całej klasy. Oto pudełko na jedną wartość dowolnego typu — z historią zmian:

definicja typu
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}";
}
Program.cs
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

Przykład: własna kolekcja z limitem

Program.cs
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
    }
}
wynik w konsoli
True
True
True
False
18, 24, 7
24
Ola

Co dzieje się po kolei

  • Ograniczenie where T : IComparable<T> pozwala wywołać CompareTo na elementach — bez niego metoda Najwiekszy by się nie skompilowała.
  • Ta sama klasa obsługuje liczby i napisy; dla napisów „największy” znaczy „ostatni alfabetycznie”.
  • Dodaj zwraca bool zamiast zgłaszać wyjątek — przekroczenie limitu jest przewidzianą sytuacją, a nie błędem wykonania. Najwiekszy na pustej kolekcji to co innego: nie ma sensownej wartości do zwrócenia, więc leci wyjątek (lekcja 35).
  • readonly przy polach oznacza, że po utworzeniu obiektu nie da się podmienić listy ani limitu. Do listy nadal wolno dopisywać elementy — readonly chroni referencję, nie zawartość.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenieUwagi
class Nazwa<T>klasa generycznaT zostaje ustalone przy tworzeniu obiektu: new Nazwa<int>().
static W Metoda<T>(T x)metoda generycznaTyp zwykle wynika z argumentów; można go też podać jawnie w nawiasach ostrych.
where T : …ograniczenieObietnica złożona kompilatorowi. Bez niej wolno używać tylko metod typu object.
default(T)wartość domyślnaZero, false albo null — zależnie od podstawionego typu.
typeof(T)TypeInformacja o podstawionym typie; przydatna w komunikatach diagnostycznych.
List<T>, Dictionary<TKey, TValue>gotowe generykiZwykle wystarczają — własny typ generyczny piszemy rzadko.
readonly przy polumodyfikatorPrzypisanie tylko w deklaracji albo w konstruktorze. Nie zamraża obiektu wskazywanego przez referencję.
CZĘSTE BŁĘDY

Na co uważać

ZapisProblem
a > b w metodzie generycznejCS0019 — operator nie jest zdefiniowany dla dowolnego T. Dodaj ograniczenie i użyj CompareTo.
T x = null; bez where T : classTyp 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 typuJeśli T zawsze będzie int, klasa generyczna tylko utrudnia czytanie.
ZADANIA

Zadania

ZAD 1Zamiana i wypisanie★☆☆

Napisz metody generyczne Zamien<T> oraz Wypisz<T>(T[] tablica). Sprawdź obie na tablicy liczb i tablicy napisów.

ZAD 2Największy i najmniejszy★☆☆

Napisz metodę T Najmniejszy<T>(T[] dane) z odpowiednim ograniczeniem. Zadbaj o sensowne zachowanie dla tablicy pustej.

ZAD 3Para★★☆

Zbuduj klasę Para<TPierwszy, TDrugi> z dwiema właściwościami, metodą Odwroc() zwracającą Para<TDrugi, TPierwszy> i nadpisanym ToString.

ZAD 4Własny stos★★☆

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.

ZAD 5Wynik operacji★★☆

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.

ZAD 6Ograniczenia w praktyce★★★

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.

PODSUMOWANIE

Co trzeba zapamiętać

  • Generyk pozwala napisać kod raz, a typ ustalić w miejscu użycia — bez rzutowania i bez powielania metod.
  • T to parametr typu; kompilator podstawia pod niego konkretny typ przy wywołaniu.
  • Bez ograniczenia where na typie T wolno wywoływać tylko metody klasy object.
  • where T : IComparable<T> umożliwia porównywanie, where T : new() — tworzenie obiektów.
  • default daje 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.