Menu

Typy generyczne w C#: klasy, metody generyczne i ograniczenia

Jak działają typy generyczne w C#: pisanie klas i metod generycznych z parametrami typu, wnioskowanie typów, ograniczenia przez where (class, struct, new(), klasy bazowe, interfejsy), default(T), składowe statyczne osobno dla każdego typu i kowariancja z IEnumerable<out T>.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Typy generyczne (generics) pozwalają napisać typ lub metodę raz i używać ich z wieloma typami, bez utraty bezpieczeństwa typów. Zamiast List elementów object, która wymaga rzutowań i przyjmuje wszystko, dostajesz List<int> i List<Order>, a każdą z nich sprawdza kompilator. Symbol zastępczy w nawiasach ostrych, zwyczajowo T, to parametr typu; typ podany przez kod wywołujący to argument typu.

Po co typy generyczne: problem, który rozwiązują

Przed typami generycznymi (C# 1) kolekcje przechowywały object. To się kompilowało, ale błędy wychodziły dopiero w trakcie działania, a każdy int był opakowywany (boxing) przy wstawianiu:

var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50");                  // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
    total += (decimal)p;              // InvalidCastException on the string

Z List<decimal> drugie Add to błąd kompilacji, pętla nie potrzebuje rzutowania, a wartości decimal są przechowywane jako decimal.

Klasy generyczne

Zadeklaruj parametry typu po nazwie klasy, a potem używaj ich w klasie jak każdego innego typu:

Wynik:

Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False

Page<string> i Page<int> to dwa różne typy powstałe z jednej definicji. Klasa może przyjmować kilka parametrów typu (Dictionary<TKey, TValue>); nazywaj je T, gdy jest jeden, i TSomething, gdy jest ich kilka.

Metody generyczne i wnioskowanie typów

Metoda może mieć własne parametry typu, niezależnie od tego, czy jej klasa jest generyczna. Kod wywołujący zwykle pomija argument typu, bo kompilator wnioskuje go z argumentów:

Wynik:

2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR

Wnioskowanie działa tylko na podstawie argumentów, nigdy typu zwracanego. Repeat(0, 3) nie potrzebuje adnotacji, ale metoda taka jak T Create<T>() nie ma z czego wnioskować, więc kod wywołujący musi napisać Create<Customer>(). Większość LINQ to metody generyczne oparte na tym wnioskowaniu: prices.Where(p => p > 1) to Where<decimal>, choć tego nie piszesz.

Ograniczenia: czym może być T

Wewnątrz metody generycznej kompilator pozwala tylko na to, co działa dla każdego możliwego T. Bez ograniczeń to niewiele więcej niż to, co obsługuje object. Aby wywołać CompareTo, utworzyć instancję albo odczytać właściwość, powiedz kompilatorowi przez where, czym musi być T:

Wynik:

17
plum
19.99
42 Zoe

Ograniczenia, których będziesz używać:

OgraniczenieZnaczeniePozwala
where T : classtyp referencyjnyprzypisać null, użyć as
where T : structtyp wartościowy, nie nullableużyć T? jako Nullable<T>
where T : new()publiczny konstruktor bezparametrowywywołać new T() (musi być ostatnie na liście)
where T : Entitydziedziczy po Entityużywać składowych Entity
where T : IComparable<T>implementuje interfejswywołać CompareTo
where T : Udziedziczy po innym parametrze typupowiązać dwa parametry
where T : unmanaged (C# 7.3)struktura bez referencji w środkuwskaźniki, stackalloc
where T : Enum (C# 7.3)typ wyliczeniowyprzekazać do metod Enum
where T : notnull (C# 8)typ, który nie jest nullableadnotacje nullable reference

Kilka ograniczeń jednego parametru oddziela się przecinkami; każdy z kilku parametrów dostaje własną klauzulę where: class Cache<TKey, TValue> where TKey : notnull where TValue : class.

Brakujące ograniczenie objawia się błędem kompilacji w miejscu użycia składowej, na przykład CS1061 ('T' does not contain a definition for 'CompareTo') albo CS0304 przy wywołaniu new T() bez new().

default(T)

Kod generyczny czasem potrzebuje "pustej wartości" typu, którego nie zna. default(T) daje 0 dla liczb, false dla bool, null dla typów referencyjnych i nullable, a w pozostałych przypadkach wyzerowaną strukturę:

Wynik:

31
0
Porto
True
0001-01-01

Druga linia pokazuje słabość tego wzorca: dla typów wartościowych "nie znaleziono" i "znaleziono 0" wyglądają tak samo. Dlatego metody frameworka używają zamiast tego kształtu TryGet (bool TryGetValue(TKey key, out TValue value)). Od C# 7.1 możesz pisać krócej return default;, gdy typ wynika z kontekstu.

Składowe statyczne osobno dla każdego typu

Każdy skonstruowany typ (Counter<int>, Counter<string>) dostaje własną kopię pól statycznych klasy:

Wynik:

2
1
False

Czasem to przydatne (pamięć podręczna dla typu, np. TypeInfo<T>.Name obliczane raz dla każdego T), a czasem zaskakuje, gdy spodziewasz się jednego wspólnego licznika. Wspólny stan trzymaj w klasie niegenerycznej.

Kolekcje generyczne

Kolekcje generyczne pokrywają większość potrzeb: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T> oraz wspólne dla nich interfejsy IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. Przyjmuj najwęższy interfejs, jakiego metoda potrzebuje (IEnumerable<T>, jeśli tylko iteruje), a zwracaj typ konkretny albo tylko do odczytu.

Kowariancja i kontrawariancja

Dog jest Animal, ale List<Dog> nie jest List<Animal>:

List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'

Gdyby to było dozwolone, animals.Add(new Cat()) wstawiłoby kota do listy psów. Interfejsy, które tylko produkują wartości, są bezpieczne i deklaruje się je jako kowariantne przez out:

IEnumerable<Animal> readOnly = dogs;          // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs;        // fine: IReadOnlyList<out T>

Odbicie lustrzane, kontrawariancja, oznaczana in, dotyczy interfejsów i delegatów, które tylko konsumują wartości: Action<Animal> można użyć tam, gdzie oczekiwany jest Action<Dog>, bo coś, co obsłuży każde zwierzę, obsłuży też psa. IComparer<in T> jest kontrawariantny, a Func<in T, out TResult> używa obu oznaczeń. Wariancja dotyczy tylko interfejsów i delegatów i tylko argumentów typów referencyjnych: IEnumerable<int> nie konwertuje się na IEnumerable<object>.

Typowe błędy

  • Używanie object tam, gdzie powinien być parametr typu. Rezygnujesz ze sprawdzania w czasie kompilacji i opakowujesz typy wartościowe.
  • Porównywanie dwóch wartości T przez ==. Bez ograniczenia class to się nie kompiluje (CS0019), a z nim porównuje referencje. Używaj EqualityComparer<T>.Default.Equals(a, b), które działa dla każdego T i respektuje własne Equals typu.
  • Zwracanie default(T) jako "nie znaleziono". Dla typów wartościowych to niejednoznaczne. Używaj bool TryX(..., out T value).
  • Za dużo ograniczeń. Każde zawęża krąg tych, którzy mogą używać metody. Ograniczaj tylko to, czego ciało naprawdę potrzebuje.
  • Oczekiwanie, że List<Derived> skonwertuje się na List<Base>. Zamiast tego przyjmuj IEnumerable<Base> albo IReadOnlyList<Base>.

Najczęściej zadawane pytania

Czym są typy generyczne w C#?

Typy generyczne pozwalają napisać klasę, interfejs lub metodę raz, z typem zastępczym T, który uzupełnia kod wywołujący: List<int>, List<Customer>. Kompilator sprawdza każde użycie względem prawdziwego typu, więc masz bezpieczeństwo typów bez rzutowań, a typy wartościowe są przechowywane bez boxingu. Najbardziej znanym przykładem są kolekcje z System.Collections.Generic.

Jak napisać metodę generyczną w C#?

Umieść parametry typu po nazwie metody: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. Kod wywołujący zwykle pomija argument typu, bo kompilator wnioskuje go z argumentów: Largest(prices) działa, gdy prices to List<decimal>. Zapisz go jawnie, Largest<decimal>(...), gdy nie ma z czego wnioskować.

Co oznacza where T : class w C#?

To ograniczenie: T musi być typem referencyjnym, więc kod generyczny może przypisać null do T i użyć as T. Inne ograniczenia to where T : struct (typ wartościowy, który nie jest nullable), where T : new() (ma publiczny konstruktor bezparametrowy, więc kod może wywołać new T()), where T : SomeBaseClass oraz where T : ISomeInterface, które pozwala wywoływać na T składowe tego interfejsu.

Czym jest default(T) w C#?

To wartość domyślna typu, którym okaże się T: 0 dla liczb, false dla bool, null dla typów referencyjnych i nullable oraz wyzerowana struktura dla pozostałych typów wartościowych. Kod generyczny używa jej, gdy potrzebuje "braku wartości" bez znajomości typu, na przykład jako wyniku nieudanego wyszukiwania. C# 7.1 dodał krótszy literał default.

Dlaczego w C# nie mogę przypisać List<Dog> do List<Animal>?

Bo List<Animal> przyjmuje dowolne zwierzę: gdyby przypisanie było dozwolone, kod mógłby dodać Cat do czegoś, co naprawdę jest List<Dog>. Kompilator to odrzuca (CS0029). Interfejsy tylko do odczytu są kowariantne, więc IEnumerable<Animal> animals = dogs; i IReadOnlyList<Animal> animals = dogs; kompilują się, bo nie da się przez nie niczego dodać.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ