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ć:
| Ograniczenie | Znaczenie | Pozwala |
|---|---|---|
where T : class | typ referencyjny | przypisać null, użyć as |
where T : struct | typ wartościowy, nie nullable | użyć T? jako Nullable<T> |
where T : new() | publiczny konstruktor bezparametrowy | wywołać new T() (musi być ostatnie na liście) |
where T : Entity | dziedziczy po Entity | używać składowych Entity |
where T : IComparable<T> | implementuje interfejs | wywołać CompareTo |
where T : U | dziedziczy po innym parametrze typu | powiązać dwa parametry |
where T : unmanaged (C# 7.3) | struktura bez referencji w środku | wskaźniki, stackalloc |
where T : Enum (C# 7.3) | typ wyliczeniowy | przekazać do metod Enum |
where T : notnull (C# 8) | typ, który nie jest nullable | adnotacje 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
objecttam, gdzie powinien być parametr typu. Rezygnujesz ze sprawdzania w czasie kompilacji i opakowujesz typy wartościowe. - Porównywanie dwóch wartości
Tprzez==. Bez ograniczeniaclassto się nie kompiluje (CS0019), a z nim porównuje referencje. UżywajEqualityComparer<T>.Default.Equals(a, b), które działa dla każdegoTi respektuje własneEqualstypu. - Zwracanie
default(T)jako "nie znaleziono". Dla typów wartościowych to niejednoznaczne. Używajbool 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ę naList<Base>. Zamiast tego przyjmujIEnumerable<Base>alboIReadOnlyList<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ć.