Wcześniejsze notatki zawierały sporo informacji dotyczących typów. Istnieje jednak dodatkowy "argument" pozwalający na rozszerzenie: klas, interfejsów, metod czy struktur. Tym "argumentem" jest parametr typu pozwalający w czasie kompilacji kodu na określenie typu. Jako przykład może posłużyć nam klasa List<T>
, będącą tablicą o zmiennej długości. T
w przetoczonym przykładzie pełni rolę parametru typu. W jego miejsce wstawiamy konkretny typ. Typy ogólne mają charakterystyczny wygląd. Za nazwą umieszcza się parę ostrych nawiasów, wewnątrz których podawana jest lista parametrów oddzielonych od siebie przecinkami. Korzystając z klasy List<T>
, która definiuje jeden parametr T, możemy utworzyć kolekcję liczb całkowitych List<int>
lub listę łańcuchów znaków List<string>
.
Podczas nadawania nazw dla parametrów typu przyjęto zasadę, by w sytuacji gdy występuje jeden parametr typu, stosować nazwę T. W przypadku wielu parametrów zalecane jest stosowanie bardziej opisowych nazw. Dla przykładu .NET Framework udostępnia klasę o nazwie Dictionary, definiującą dwa parametry typu: TKey oraz TValue. Zdarza się, że opisowe nazwy dla parametrów typu stosowane są również dla jednego parametru. W takich sytuacjach nazwę również poprzedza przedrostek T, dla zachowania czytelności kodu.
Przykład poniżej prezentuje klasę ogólną, wewnątrz której, wszędzie tam, gdzie normalnie użylibyśmy typu, podajemy parametr T. W naszym przykładzie parametr T został przekazany jako argument konstruktora oraz typ właściwości Variable. Nic nie stoi na przeszkodzie, aby użyć T do deklaracji pola lub jako jeden z parametrów metody. Składnia używana do definiowania interfejsów oraz struktur wygląda podobnie.
public class NamedVariable<T>
{
public NamedVariable(T variable, string name)
{
Variable = variable;
Name = name;
}
public T Variable { get; private set; }
public string Name { get; private set; }
}
Zdefiniowaliśmy klasy NamedVariable, pytanie jak utworzyć instancje? Musimy wiedzieć, że zdefiniowana klasa nie jest typem kompletnym (jak każdy inny typ ogólny). Oznacza to, że w celu uzyskania kompletnego typu, musimy określić czym jest T, bez tego, kompilator nie może określić, ile miejsca w pamięci ma zarezerwować na typ NamedVariable<T>. Aby użyć typu ogólnego, należy podać argument typu. Przykłady poniżej pokazują jak utworzyć instancje klasy NamedVariable<T> dla: liczby całkowitej, łańcucha znaków czy klasy typu Customer posiadającej konstruktor przyjmujący parametr typu string.
var idx = new NamedVariable<int>(123, "Index");
var animal = new NamedVariable<string>("Snake", "King cobra");
var customer = new NamedVariable<Customer>(new Customer("945-211-00-74"), "The Best");
Każda unikatowa kombinacja argumentów typu tworzy nowy unikalny typ, przez co nie istnieje coś takiego jak zgodność pomiędzy dwoma różnymi formami tego samego typu ogólnego. Próba wykonania idx = animal;
zostanie zgłoszona przez kompilator jako błąd.
Działanie kompilatora jest całkiem logiczne. Co by się jednak stało, gdyby dla zmiennej idx przekazać argument typu object
? Można oczekiwać, że kompilator dopuści do takiej sytuacji, jednak i to rozwiązanie nie zadziała. Wyjątkiem będzie obsługa interfejsów oraz delegatów, pomiędzy którymi możliwa jest zgodność. Mechanizmy wspierające taką zgodność nazywamy kowariancją oraz kontrawariacją. Więcej o ich działaniu przeczytasz w notatce dotyczącej dziedziczenia.
Warto wiedzieć, że liczba parametrów typu jest jednym z elementów określających tożsamość typu, dzięki czemu zarówno typ NamedVariable<T> jak i NamedVariable<T1, T2> mogą występować w tej samej przestrzeni nazw. Liczba podanych argumentów jednoznacznie określa typ, który ma zostać użyty. Poniżej przykład klasy NamedVariable<T1, T2>.
public class NamedVariable<T1, T2>
{
public NamedVariable(T1 variable1, T2 variable2, string name)
{
...
}
}
C# pozwala określić kilka ograniczeń, jakie musi spełnić typ, korzystając z typu ogólnego. Załóżmy, że potrzebujemy wymusić, aby typy korzystające z klasy NamedVariable<T> udostępniały konstruktor bezargumentowy. Ograniczenia wprowadzamy korzystając ze słowa kluczowego where, które wstawiamy przed blokiem klasy. Po słowie kluczowym podajemy nazwę parametru typu, dla którego wprowadzamy ograniczenie oraz po znaku : rodzaj ograniczenia. Przykład poniżej pokazuje jak zapewnić, aby parametr typu <T> implementował konstruktor bezargumentowy.
public class NamedVariable<T>
where T : new()
{
public NamedVariable(string name)
{
Variable = new T(); // Używa konstruktora bezargumentowego
Name = name;
}
public T Variable { get; private set; }
public string Name { get; private set; }
}
Dodanie takiego ograniczenia w naszym wcześniejszym przykładzie, gdzie typ Customer implementował wyłącznie konstruktor przyjmujący typu string, spowoduje zgłoszenie błędu przez kompilator.
Innym udostępnionym ograniczeniem są ograniczenia typu, wymuszające, aby parametr typu był zgodny ze wskazanym typem. Możemy zażądać, aby typ <T> implementował wskazany interfejs. Załóżmy, że potrzebujemy zaimplementować metodę Equals przyjmującą parametr typu Customer, która porównuje obiekty wyłącznie na podstawie właściwości Tax, reprezentującej NIP. W tym celu skorzystajmy z interfejsu IEquatable<T>, który wymaga przekazania parametru typu, gdyż nie jest to typ kompletnym. Nasza metoda będzie porównywać typy Customer, dlatego ten typ zostanie przekazany jako argument. Przejdźmy do dodania ograniczenia wymuszającego implementację interfejsu IEquatable<T>. W tym celu zamiast new()
przekazujemy nazwę interfejsu, o tak where T : IEquatable<T>
. Czy to wszystko? Nie. Interfejs IEquatable<T> wymaga określenia parametru typu, dlatego nasze ograniczenie powinno wyglądać tak where T : IEquatable<T>
. Najprościej mówiąc, ograniczenie wymusza implementację interfejsu IEquatable<T>, który musi obsługiwać typ <T>. Na koniec potrzebujemy metody porównującej <T> public bool Equals(T other)
. Dobrze byłoby ponownie skorzystać z interfejsu IEquatable<T>, tym razem dla klasy NamedVariable<T>.
public class Customer : IEquatable
{
public Customer(string tax)
{
Tax = tax;
}
public string Tax { get; set; }
public string Name { get; set; }
public bool Equals(Customer other)
{
return this.Tax.Equals(other.Tax);
}
}
public class NamedVariable : IEquatable
where T : IEquatable
{
public NamedVariable(T variable, string name)
{
Variable = variable;
Name = name;
}
public T Variable { get; private set; }
public string Name { get; private set; }
public bool Equals(T other)
{
return this.Variable.Equals(other);
}
}
Warto wspomnieć, że nie musimy ograniczać się do interfejsów, możemy przekazać klasę, enum, delegata lub jak w przykładzie poniżej wskazać ograniczenia wymuszającego, aby argument <T1> był typem pochodnym od <T2>.
public class NamedVariable<T1, T2>
where T1 : T2
{
...
}
Kolejne ograniczenie nakazuje, aby argument był typem referencyjnym. W tym przypadku po określeniu parametru typu używamy słowa kluczowego class
. Użycie tego ograniczenia uniemożliwia stosowanie jako typu argumentu typu wartościowego, takich jak int
, bool
czy struct
. Dzięki takiemu rozwiązaniu możemy zweryfikować czy zmienna nie jest równa null oraz użyć słowa kluczowego as
.
public class NamedVariable<T>
where T : class
{
...
}
C# udostępnia ograniczenie, które wymusza, aby argument był typem wartościowym. W tym przypadku używamy słowa kluczowego struct
.
Nic nie stoi na przeszkodzie, aby skorzystać z kilku ograniczeń dla tego samego argumentu typu. Poszczególne ograniczenia rozdzielamy przecinkiem. Pokazuje to przykład poniżej. Należy jednak wiedzieć, że kolejność podawania ograniczeń nie może być przypadkowa. Ograniczenie new()
podajemy jako ostatnie, natomiast class
lub struct
podajemy na początku.
public class NamedVariable<T>
where T : class, IEquatable<T>, new()
{
...
}
Oprócz opisanych już typów ogólnych język C# udostępnia metody ogólne, które mogą zostać użyte poza typem ogólnym. Listę parametrów typu umieszczamy przed nazwą metody. Przykład poniżej posiada jeden parametr typu, który służy zarówno do określenia rezultatu jak i tablicy. Zadaniem metody jest zwrócenie drugiego elementu z przekazanej tablicy. W przypadku metod ogólnych możemy skorzystać z ograniczeń, identycznie jak w przypadku typów ogólnych.
public static T GetSecond<T>(T[] items)
where T : struct
{
return items[1];
}
Troska Robert