2010-03-06

Kowariancja i kontrawariancja w C#

W C# 4.0 rozszerzono możliwości języka o kowariancję i kontrawariancję (zwana dalej KiK) szablonów interfejsów i delegatów. Rozszerzono, czyli KiK jako zjawisko istniało wcześniej. Zanim przejdziemy do omówienia rozszerzenia KiK w C# 4.0 warto dokładniej zapoznać się z pojęciem KiK. Całe poniższe omówienie dotyczy wersji .NET przed 4.0. Dopiero na samym końcu zostaną omówione rozszerzenie dodane w .NET 4.0. Warto też wspomnieć że opisane tutaj problemy mają tylko języki z silnym typowaniem. Źródła na postawie których powstał ten wpis: Fabulous Adventures In Coding - Covariance and Contravariance Wikipedia Covariance and Contravariance FAQ Pojęcie kowariancji i kontrawariancji Mówimy, że operator konwersji (niejawny) jest kowariantny jeśli zawsze zachowuje porządek typów, kontrawariantny jeśli zawsze nie zachowuje porząde typów, niezmienny (invariant) jeśli żadne z poprzednich nie zachodzi. Oznaczmy tą relację porządku jako B<=A, i zdefiniujmy jako: możliwa jest konwersja zmiennej typu B do zmiennej typu A. Przy czym nie ma to nic wspólnego z hierarchią klas. Konwersja jest bezpośrednia (implicity), związku z tym generalnie typy wartościowe nie podpadają pod tą definicję, jako, że mogą one zajmować różną ilość bajtów w pamięci (dokładniej jest to omówione dalej). Generalnie przyjmujemy, że o ile nie zostanie powiedziane inaczej dalej mówimy tylko o typach referencyjnych. Tablice są kowariantne Przykład:
class A { }
class B : A { }

void Test()
{
    A[] a1 = new A[1];
    A[] a2 = new B[1];
    //B[] b1 = new A[1] Błąd kompilacji
    B[] b2 = new B[1]
}
Zachodzi relacja: B<=A i możemy zapisać: dla A=A => A[] = A[] dla B<A => B[] < A[] dla B=B => B[] = B[] ogólnie dla B<=A mamy B[] <= A[], czyli jest to kowariancja. W przypadku tablic typ B musi się wywodzić z lub być równy typowi A. Z drugiej strony B[] nie wywodzi się z A[], oba są podklasami System.Array. Kowariancja tablic ma też swoje wady:
void Test()
{
    A[] a = new B[1];
    a[0] = new A();
}
Druga linijka spowoduje wyjątek, jako, że do tablicy elementów typu B próbujemy podstawić element typu A. Tego rodzaju błąd jest wykrywany w czasie działania programu i z pewnością w pewnym stopniu go spowalnia. Język C# jest językiem silnego typowania, a więc wszelkie niekompatybilności typów powinny być sprawdzane w czasie kompilacji, a nie w trakcie wykonywania programu. Dlaczego więc zdecydowano się na takie rozwiązanie w nie tyle C# 1.0, co w samym CLR. Bo Java to miała, a twórcy CLR chcieli uzyskać zgodność z Javą. Zgryźliwie można by zauważyć, że fajnie by było jakby jeszcze w paru rzeczach też powzorowali się na Javie. Problem ten nie istniałby gdyby tablice były niezmienne (immutable), ale nie są... Konwersja grupy metod do delegatów jest kowariantna w sensie rezultatu Co oznacza nazwa grupa metod:
void A() { }
void A(int x) { }
void A(object x) { }
void A<T>(T x) { }

void B()
{
    Action a1 = A;
    Action<int> a2 = A;
    Action<object> a3 = A;
    Action<int> a4 = A<int>;
}
Do delegata podstawiamy tak naprawdę (za wyjątkiem ostatniego przykładu) nazwę metody. Dopasowanie właściwej zostawiamy regułom kompilatora. Pod nazwą metody może kryć się tak naprawdę wiele metod o różnych sygnaturach, taką grupę nazywamy grupą metod. W dalszym tekście będę się posługiwał delegatami zadeklarowanymi przy użyciu szablonów Action<> i Func<>, które są wygodniejsze w użyciu. Zostały one dodane w .NET 3.0. Oczywiście wszystko poniższe jest też prawdziwe dla delegatów zdefiniowanych bez użycia szablonów. Przykład:
class Animal
{
}

class Dog : Animal
{
}

static Dog Func_Dog()
{
   return null;
}

static Animal Func_Animal()
{
   return null;
}

static void Test1()
{
   Func<Dog> a1 = Func_Dog;
   //Func<Dog> a2 = Func_Animal; Błąd kompilacji.

   Func<Animal> a3 = Func_Dog;
   Func<Animal> a4 = Func_Animal;
}
Mamy: Dog == Dog => Func<Dog> == Func<Dog> Animal > Dog => Func<Animal> > Func<Dog> Animal = Animal => Func<Animal> = Func<Animal> czyli ogólnie dla Dog >= Animal mamy Func<Dog> >= Func<Animal>, czyli jest to kowariancja. Konwersja grupy metod do delegatów jest kontrawariantna w sensie parametrów Przykład:
static void Action_Animal(Animal animal)
{
}

static void Action_Dog(Dog dog)
{
}

static void Test2()
{
   Action<Dog> a1 = Action_Dog;
   Action<Dog> a2 = Action_Animal;

   //Action<Animal> a3 = Action_Dog; Błąd kompilacji.
   Action<Animal> a4 = Action_Animal;
}
Mamy: Dog == Dog => Action<Dog> == Action<Dog> Dog < Animal => Action<Dog> > Action<Animal> Animal = Animal => Action<Animal> = Action<Animal> czyli ogólnie: dla Dog <= Animal mamy: Action<Dog> >= Action<Animal>, czyli jest to kontrawariancja. Dlaczego ref i out nie mogą być KiK Przykład:
class A 
{
    public void TestA() { }
}

class B : A 
{
    public void TestB() { }
}

class Program
{
    A a = new A();
    B b = new B();

    void RefA(ref A a)
    {
        a.TestA();
        a = new A();
    }

    void RefB(ref B b)
    {
        b.TestB();
        b = new B();
    }

    void OutA(out A a)
    {
        a = new A();
    }

    void OutB(out B b)
    {
        b = new B();
        b.TestB();
        RefA(ref a);
        b.TestB();
    }

    void Test()
    {
        RefA(ref a);
        //RefA(ref b); // Błąd kompilacji
        //RefB(ref a); // Błąd kompilacji
        RefB(ref b);

        OutA(out a);
        //OutA(out b); // Błąd kompilacji
        //OutB(out a); // Błąd kompilacji
        OutB(out b);

        a.TestA();
        b.TestB();
    }

    static void Main(string[] args)
    {
        new Program().Test();
    }
}
Skupmy się na przypadkach powodujących błąd kompilacji. Pierwszy podstawia pod zmienną typu B zmienną typ A. Drugi wywróci się na wywołaniu metody A.TestB(). Trzeci spowoduje podstawienie pod zmienną typu B zmiennej typu A. W czwartym drugie wywołanie TestB zawiedzie, będzie to wywołanie A.TestB(). Wniosek: ref i out nie mogą być KiK. siebie takich obiektów. Musiałyby mieć one takie same sygnatury (mieć taką samą reprezentacje w pamięci). Chwila refleksji Zauważmy, że kowariancji jest poprawna tylko dla rezultatu wyjściowego, zaś kontrawariancji dla parametrów wejściowych. Inaczej mówiąc rzutowanie parametrów wyjściowych spełnia definicje kowariancji, rzutowanie parametrów wejściowych spełnia definicje kontrawariancji. Samo pojęcie KiK opisuje zachowanie, nie definiuje go. Pojęcie KiK jest więc w pewnym sensie sztuczne. Na samym początku wspomnieliśmy, że o KiK rozszerzono szablony interfejsów i delegatów. Upraszcza to kod i daje nam możliwość dokonywania bezpośrednich konwersji tam gdzie widzimy, że mogą one zajść bez problemów, a kompilator krzyczy nam błąd. Dodanie takiej możliwości bez rozszerzenia składni prowadziłoby do takich samych problemów jak z tablicami. To dlatego typy generyczne dodane w .NET 2.0 w przeciwieństwie do tablic nie są KiK (aż do .NET 4.0, gdzie KiK stały się szablony interfejsów). Czyli po pierwsze ich dodanie wiąże się z rozszerzeniem składni. I tak jak w przypadku tablic, możliwy jest taki kod który może wygenerować nam wyjątki. Mamy dwie możliwości rozwiązania tego, sprawdzanie na etapie kompilacji, czy kod może generować problemy, lub sprawdzanie wyjątków w czasie działania (jak w tablicach). Ale jeśli wybierzemy drugą opcję, czy w ogóle potrzebujemy specjalnej składni ? Pierwsza opcja gwarantuje nam zachowanie szybkości kodu. Niewątpliwą zaletą KiK jest uproszczenie kodu. Naturalne wydaje się też uczynienie KiK obecnych w bibliotece .NET typów. Alternatywne rozwiązanie w postaci inaczej nazwanych albo znajdujących się w innej przestrzeni nazw typów komplikuje zbytnio sprawę. Ale czy czyniąc dotychczasowe interfejsy w bibliotece .NET KiK nie spowodujemy, że obecny kod przestanie się kompilować lub co gorsza zacznie działać błędnie. Rozbijmy to na przypadki. Szablony interfejsów i delegatów - przykład zastosowania w .NET 4.0 Popatrzmy na przykład:
interface IZoo<out T> where T : Animal
{
   T[] GetAnimals();
}

interface ICage<in T> where T : Animal
{
   void Trap(T animal);
}

class Zoo<T> : IZoo<T> where T : Animal
{
   public T[] GetAnimals()
   {
       return null;
   }
}

class Cage<T> : ICage<T> where T : Animal
{
   public void Trap(T animal)
   {
   }
}

void Test()
{
   IZoo<Dog> zoo1 = new Zoo<Dog>();
   Dog[] a1 = zoo1.GetAnimals();
   //IZoo<Dog> zoo2 = new Zoo<Animal>(); Błąd kompilacji
   //Dog[] a = zoo2.GetAnimals() Błąd kompilacji
   IZoo<Animal> zoo4 = new Zoo<Animal>();

   ICage<Dog> cage1 = new Cage<Dog>();
   ICage<Dog> cage2 = new Cage<Animal>();
   //ICage<Animal> cage3 = new Cage<Dog>(); Błąd kompilacji
   //cage3.Trap(new Animal()); Błąd kompilacji
   ICage<Animal> cage4 = new Cage<Animal>();
}
Identyczny przykład moglibyśmy stworzyć dla szablonów delegatów. Zauważmy, że kłopoty które mamy przy tablicach zostały tutaj uniknięte przez odpowiednie zaprojektowanie reguł. Może nie do końca. O regułach które zapobiegają tym błędom dalej. Błędów niespójności tablic nie unikniemy w dalszym ciągu: zoo3.GetAnimals()[0] = new Animal(); i mamy wyjątek. Uproszczenie kodu osiągane dzięki KiK Przykład na kowariancję: C# 3.0:
class A { }
class B : A { }

class Program
{
    static void Process(IEnumerable<A> e)
    {
    }

    static void Main(string[] args)
    {
        List<A> la = new List<A>();
        List<B> lb = new List<B>();

        Process(la);
        Process(lb.Cast<A>());
    }
}
Moglibyśmy uczynić metodę Process szablonem i ewentualnie użyć where dla dostępu w niej do metod klasy A. W C# 4.0 kod może być prostszy, szybszy, bardziej naturalny dzięki zrezygnowaniu z Cast<T>(). Nawet nie używając KiK w swoich klasach interakcja z metodami z biblioteki .NET w wielu przypadkach będzie szybsza i bardziej naturalna. Tak sobie myślę, że KiK dodano do C# by usprawnić i przyspieszyć LINQ, w którym często zdarzało mi się używać Cast<T>(), teraz wszystko jest bardziej naturalne. Niewątpliwie potrzeba KiK narastała, aż przekroczyła masę krytyczną i dodaną ją do specyfikacji. Na samym końcu jest poruszona sprawa kiedy KiK dodano do specyfikacji IL. Przykład na kontrawariancję: Kod w C# 3.5:
class A 
{
    public int Value = 0;
}

class B : A 
{ 
}

class Comparer_A : IComparer<A>
{
    public int Compare(A x, A y)
    {
        return x.Value - y.Value;
    }
}

class Comparer_B : IComparer<B>
{
    public int Compare(B x, B y)
    {
        return x.Value - y.Value;
    }
}

class Program
{
    static void Main(string[] args)
    {
        List<A> la = new List<A>();
        List<B> lb = new List<B>();

        la.Sort(new Comparer_A());
        lb.Sort(new Comparer_B());
    }
}
W C# 4.0 możemy zapisać jako:
class Program
{
    static void Main(string[] args)
    {
        List<A> la = new List<A>();
        List<B> lb = new List<B>();

        la.Sort(new Comparer_A());
        lb.Sort(new Comparer_A()); 
    }
}
Dlaczego wybrano taki sposób oznaczania składni O tym czy w ogóle potrzebujemy specjalnej składni dla KiK mówię dalej. Tutaj skupmy się na tym jakie mamy możliwości oznaczenia, potencjalni kandydaci:
  • poprzez atrybuty nałożone na szablon typu lub parametry typu
  • poprzez ograniczenie szablonów where
  • poprzez odpowiednie udekorowanie parametrów szablonu
Tak jak wspomniałem wcześniej terminy KiK słabo się kojarzą i ciężko je odróżnić, stąd jako oznaczenia odpadły w przebiegach. Padały propozycje na składnie z + i -, na zapisy typu iEnumerable<T:*>. Ostatecznie stanęło na in i out (może kiedyś dowiemy się jakie były kulisy, może po zażartych bojach postanowiono losować?). Dlaczego? Słowo kluczowe out w szablonie oznacza, że dopuszczamy w szablonach wywodzących się z tego szablonu, by rezultaty metod mogły być zwracane jako klasy bazowe rezultatu. Słowo kluczowe in oznacza, że dopuszczamy, by nasza metoda mogła przyjmować jako parametry klasy pochodne. Jeszcze większego znaczenia in i out nabierają później, kiedy omówimy jakie są restrykcje na parametry szablonów oznaczone jako KiK. Czy potrzebujemy rozszerzenia składni Niewątpliwie brak rozszerzenia składni oznacza pojawienie się takich samych problemów jak przy tablicach. Każda operacja zarówno zwracanie wyniku metody, jak i wywołanie metody wymagałaby sprawdzania typu, byłoby to kłopotliwe i spowalniałoby działanie aplikacji. Ale czy tylko ? Kod może także przestać działać poprawnie lub przestać się kompilować. Więcej o tym później. Rozszerzenie składni dają programiście szansę przejście na .NET 4.0 bez zaprzątania sobie głowy tematem KiK w własnym kodzie. Aczkolwiek kod korzystający z biblioteki standardowej gdzie zastosowano KiK może zacząć błędnie działać. Więcej o tym później. Poniższy fragment warto zostawić sobie na drugie czytanie. Przypuśćmy, że nie ma rozszerzenia składni. Jeśli nie chcemy opierać się na wyjątkach w wielu przypadkach musielibyśmy zgadnąć jakiego rodzaju są parametry szablonu (wnioskując z kontekstu). Oznaczałoby to analizę kodu przed podjęciem decyzji (wydłużenie czasu kompilacji) i poza tym nie jest zbyt mądre. Dotychczasowo język C# został tak zaprojektowany, że jak kompilator musi zgadywać, to mamy błąd kompilacji i musimy mu ręcznie podpowiedzieć (tutaj przy użyciu rozszerzenia składni). Samo to zgadywanie jest problematyczne. Wyobraźmy sobie, że że stworzyliśmy obiekt który wykorzystujemy w sposób kowariancyjny. Teraz dodajemy metodę i wykorzystujemy go w sposób kontrawariancyjny. Prowadzi to do konfliktu. Mamy do wybory inaczej napisać ostatnio dodaną metodę, albo zmienić te poprzednie. Dosyć problematyczne. Decydujemy się jednak na wyjątki. Wtedy zawsze 4 z 4 możliwych podanych w różnych przykładach konwersji byłyby możliwe. Wyjątki o niezgodności typów mielibyśmy wtedy zarówno przy wywoływaniu metody jak i zwracaniu wyniku. O ile tym pierwszym zajęłoby się ciało metody i to w tym drugim?. Sprawdzać za każdym razem? Osobna metoda na to sprawdzanie? Dodatkowy ukryty parametr do metody? Poza tym jakie metody mamy tym oznaczyć? W przypadku szablonów klas wystarczy analiza statyczna kodu, ale w przypadku delegatów chyba w całym kodzie musielibyśmy prowadzić takie sprawdzanie. Same problemy... Czy możemy błędy sprawdzać w czasie wykonywania Tak więc dodatkowa składnia jest potrzebna. Ale ciągle możemy przecież sprawdzać błędy w czasie działania, a nie w czasie kompilacji. A dlaczego? Po to jest ta składnia, aby niczego nie musieć sprawdzać. Czy dotychczasowy kod może zacząć błędnie działać Może. W wyniku modyfikacji parametrów szablonu o in albo out inaczej może zacząć działać is oraz określanie właściwej przeładowanej metody do wywołania w trakcie kompilacji. W pierwszym przypadku IZoo<Dog> staje się konwertowalne na IZoo<Animal> przez co operator is może inaczej zadziałać w czasie wykonywania. W drugim w czasie kompilacji może zostać wybrana inna przeładowana metoda niż dotychczas. Błędy te mogą się pojawić w naszym kodzie nawet jeśli nie wprowadzimy w nim zmian, gdyż wiele standardowych typów w .NET zostało wyposażonych w KiK. Oczywiście zostaje jeszcze nam sytuacja gdy na jakimś naszym typie danych dopuścimy kontrawariancję. Wtedy zupełnie legalnie, jakaś nasza metoda może otrzymywać jako parametry obiekty różnych typów (klasa bazowa dla której szablon został domknięty i jego pochodne). Kod może ale nie musi przestać działać. Kontrawariancja ref i out Słowa kluczowe ref i out dotyczą parametrów wejściowych metod, więc w tym przypadku możemy mówić tylko o kontrawariancji. W przypadku ref nie można przekazać do metody elementu klasy pochodnej:
void TestRef(ref Animal a)
{
   a = new Animal();
}

void Test3()
{
   Dog a2 = null;
   TestRef(a);
}
Zmienna typu Dog przyjęłaby typ Animal. Jeśli chodzi o out mamy dokładnie ten sam problem. Wniosek jaki z tego płynie dla szablonów interfejsów jest taki, że kontrawariantne parametry szablonu nie mogą być używane jako parametry metod oznaczone out i in. Błąd ten jest wykrywany jest podczas kompilacji. Jak realizowane jest sprawdzanie poprawności kodu na etapie kompilacji Używając słów kluczowych out i in możemy stworzyć niepoprawny kod:
interface IList1<T>
{
   void Add(T ele);
   T First();
}

interface IList2<out T>
{
   void Add(T ele);
   T First();
}

interface IList3<in T>
{
   void Add(T ele);
   T First();
}

interface IList4<in out T>
{
   void Add(T ele);
   T First();
}
W pierwszym przypadku lista może przyjmować tylko elementy klasy T. W przypadku drugim można ją skonwertować na listę klasy bazowej T, ale to oznacza, że taki element możemy dodać do listy, jest to ten sam problem to konwersją tablicy: metoda Add() staje się problematyczna. W trzecim przypadku lista może zostać skonwertowana na listę klasy pochodnej od T i tutaj metoda First() staje się problematyczna. Czwarty przypadek (niepoprawny składniowo: out i in nie może być użyte jednocześnie) jest problematyczny w obu metodach. Jaki z tego wniosek: kowariantny parametr szablonu interfejsu nie może być używany jak typ parametrów metod, zaś kowariantny jako typ rezultatu metod. Błędy tego typu zostaną wychwycone na etapie kompilacji. Dlatego właśnie kolekcje z metodami odczytu i wstawiania nie można uczynić kowariantnymi. Tutaj Immutability in C# Part Three: A Covariant Immutable Stack mamy przykład kowariantnej kolejki. Poniższy kod też prowadzi do błędu kompilacji:
interface IZoo<out T>
{
    T[] GetAnimals();
}

interface IZoo2<in T> : IZoo<T>
{
}
Raz zadeklarowane jako KiK parametry szablonu nie mogą zmieniać swojego rodzaju. Prowadziłoby to do takich samych błędów jak wspomniane wcześniej. Ten kod jest poprawny, jakkolwiek klasa ILT jest niezmienna (invariant).
interface IL<in T>
{
}

class ILT : IL<int>
{
}
Dzięki takiemu zachowaniu nie potrzebujemy dla typów wartościowych definiować osobnych typów szablonowych. Dlaczego szablony klas nie mogą być KiK Z dotychczasowych rozważań wynika, że nie powinno być z tym problemu. Przykład (niekompilujący się):
class A
{
}

class B : A
{
}

class Covariant<out T> 
{
    public event Action<T> e;
    public T a;
    public T aa { get; set; }
    public T Get() { return default(T); }
}

class Contravariant<in T>
{
    public event Func<T> e;
    public readonly T a;
    public T aa { get { return default(T); } }
    public T Get() { return default(T); }
    public void Set(T t) { }
}

void Test()
{
    Covariant<A> a = new Covariant<B>();
    A a1 = a.a;
    A a2 = a.aa;
    A a3 = a.Get();
    a.e += (aa) => { };

    Contravariant<B> b = new Contravariant<A>();
    b.Set(new B());
    b.e += () => { return new B(); };

}
Jak widzimy ograniczenia dla szablonów klas KiK są konsekwentnym rozszerzeniem ograniczeń dla szablonów interfejsów KiK. Co stało za tym, że szablony klas nie mogą być KiK nie wiem. Dlaczego nie typy wartościowe Tutaj wyjaśnienie jest prostsze. W przypadku typów które zajmują taki sam obszar pamięci i są konwertowalne bezpośrednio byłoby to możliwe, w innych przypadkach nie. Ostatecznie tak jak w przypadku tablic, zrezygnowano z KiK typów wartościowych. Jakie typy zostały rozszerzone w .NET 4.0 Interfejsy:
  • IEnumerable<out T>
  • IEnumerator<out T>
  • IQueryable<out T>
  • IGrouping<out TKey, out TElement>
  • IComparer<in T>
  • IEqualityComparer<in T>
  • IComparable<in T>
Delegaty:
  • Action<in T1, ... >
  • Func<out TResult>
  • Func<in T, ..., out TResult>
  • Predicate<in T>
  • Comparison<in T>
  • Converter<in TInput, out TOutput>
Zachowanie kowariancji i kontrawariancji podczas dziedziczenia Przykład:
interface IZoo1<out T>
{
    T GetFirstAnimal();
}

interface IZoo2<out T> : IZoo1<T>
{
}

interface IZoo3<T> : IZoo1<T>
{
}

class Zoo2 : IZoo2<Dog>
{
    public Dog GetFirstAnimal()
    {
        return null;
    }
}

class Zoo3 : IZoo3<Dog>
{
    public Dog GetFirstAnimal()
    {
        return null;
    }
}

static void Test()
{
    Zoo2 zoo2 = new Zoo2();
    IZoo2<Animal> zoo2a = zoo2;

    Zoo3 zoo3 = new Zoo3();
    //IZoo3<Animal> zoo3a = zoo3; Błąd kompilacji
}
IZoo2 jest kowariantne. IZoo3 jest niezmienne (invariant), pomimo tego, że interfejs bazowy jest kowariantny. Kowariancja i kontrawariancja w CLR Wsparcie dla KiK pojawiło się w CLR 2.0 wraz z nadejściem .NET 2.0. To prawie 5 lat temu. Dopiero teraz pojawiło się w C#. Do oznaczania KiK CLR używa oznaczeń + i -.