typ

Czego nie wiesz o typach i klasach w C#

Czym się różni typ od klasy? Czy podtyp koniecznie musi dziedziczyć ze swojego nadtypu? Czy dziedziczenie z klasy bazowej wystarcza, aby być podtypem?

Na te i inne pytania odpowiemy sobie w tym poście. Zapraszam do lektury!

Ten post należy do serii Podejrzane typy.

Typ != Klasa

Typ i klasa to dwa różne pojęcia. Aby poznać szczegóły, spójrzmy, jak są definiowane, np. na Wikipedii (Typ, Klasa). Gdy wczytamy się w treść, dostrzeżemy różnice.

Typ to wymagania (kontrakt) narzucone na obiekt. W szczególności opisuje to, na jakie sygnały nasz abstrakcyjny byt reaguje (wywołania metod, pola). Jest to interfejs, opisujący dostępne dla nas akcje do wykonania na obiekcie.

Klasa stanowi konkretną implementację tych zachowań.

W popularnych językach programowania, takich jak C# czy Java, pojęcie interfejsu może być utożsamiane z typem, natomiast pojęcie klasy to już typ i jego implementacja. Warto również pamiętać, że klasa jest jednocześnie swoim interfejsem.

Podtyp != Podklasa

Gdy wiemy już, czym różni się typ od klasy, pora zastanowić się nad różnicami w relacji “bycia podtypem” i “bycia podklasą”.

Podklasa

Podklasę z nadklasą łączy relacja dziedziczenia, a pojęcie to dotyczy tylko języków klasy OOP (Object-Oriented Programming).

Dziedziczenie może być oparte o prototyp (np. JavaScript) lub o inną klasę (np. C#). Podklasa może mieć tylko jedną klasę bazową (np. C#) lub może mieć ich wiele (np. C++).

Podtyp

Podtyp z nadtypem łączy relacja podtypowania*, która to jest pojęciem powiązanym z teorią typów, i jest wspólna dla wszystkich języków programowania (oczywiście poza tymi, które z definicji nie mają typów). Z podtypem wiąże się pojęcie polimorfizmu.

Podtyp może wynikać z zastosowania specjalnej konstrukcji językowej (podtypowanie nominalne), przez kompatybilność w strukturze (podtypwanie strukturalne) lub przez kompatybilność w run-time (podtypowanie duck-typing).

*Relacja podtypowania została między innymi zdefiniowana przez B. Liskov i J. Wing i jest znana jako Liskov Substitution Principle (lub Behavioral Subtyping, lub Substitutability). W specyfikacji języka C# nie ma nigdzie mowy o podtypowaniu. W dokumencie tym pojawiają się jedynie pojęcia takie jak: typ bazowy (base type, tylko w kontekście member lookup), typ kompatybilny (compatible type), typ bardziej ogólny (more generic type, less derived type), typ bardziej szczegółowy (less generic type, more derived type). Dwa ostatnie pojęcia są powtórzone przy opisie kowariancji i kontrawariancji (Docs, C# FAQ i Eric Lippert).

Behavioral Subtyping jest problemem nierozstrzygalnym (nie można napisać algorytmu, który sprawdzi, czy dwa typy są w relacji podtypowania; czyli w szczególności kompilator języka C# nie może tego sprawdzić, zatem w specyfikacji języka C# nie ma użytego pojęcia podtypowania w sensie Behavioral Subtyping), zatem w dalszej części artykułu relację podtypowania będę definiować jako relację bycia typem kompatybilnym (czyli z grubsza mówiąc takim, który dla operatora “is” zwraca true lub daje się umieścić po prawej stronie operatora “=”, ale nie jest to boxing ani wrapping).

Wzajemne relacje klasa <=> typ na przykładzie C#

W tym momencie jesteśmy już świadomi, że klasa i typ to pojęcia od siebie niezależne i teoretycznie wszelkie kombinacje są możliwe. Teraz musimy ograniczyć się już tylko do języków OOP, gdyż mówić będziemy nie tylko o typach, ale i o klasach. Przyjrzyjmy się zatem kolejnym przypadkom.

A nie jest podtypem B, A nie dziedziczy z B

A nie dziedziczy z B
A nie dziedziczy z B

Jako przykład można podać tutaj string i int, ale takich par jest mnóstwo! System.String oraz System.Int32 nie są połączone relacją dziedziczenia. Nie jest to możliwe, bo string to typ referencyjny, a int jest typem wartościowym. Ponadto oba typy nie są połączone relacją podtypowania, gdyż nie można jednego przypisać do drugiego.

            string str = "";
            int i = 0;
            str = i;//Cannot implicitly convert type 'int' to 'string'
            i = str;//Cannot implicitly convert type 'string' to 'int'

A nie jest podtypem B, A dziedziczy z B

Teoretycznie* taki przypadek jest możliwy, jednak gdy zajrzymy do specyfikacji języka C#:

(12.5.2) If T is a class-type, the base types of T are the base classes of T, including the class type object.

zauważymy, że język ten nie pozwala na taką sytuację, gdyż dla typu referencyjnego jego klasa bazowa jest jednocześnie jego nadtypem.

*Teoretyczny przykład takiej sytuacji (A nie jest podtypem B, A dziedziczy z B) opiera się o dziedziczenie inwariantnej funkcji, jednak nie udało mi się do tej pory znaleźć przykładu w jakimkolwiek języku programowania. Poszukiwania trwają… Pierwszą osobę, która podrzuci mi w komentarzu taki przykład, zapraszam na piwo, aby o tym porozmawiać!

Sytuacja taka jest natomiast możliwa w przypadku języka C++, w którym występuje dziedziczenie prywatne, które wprowadza relację dziedziczenia, jednak nie powstaje relacja bycia podtypem.


#include <iostream>
using namespace std;

class A{
};

class B : private A{

};

int main() {
A* a = new B();//error: ‘A’ is an inaccessible base of ‘B’
return 0;
}

A jest podtypem B, A nie dziedziczy z B

W tym punkcie na warsztat weźmy interfejsy generyczne. Załóżmy również istnienie hierarchii dziedziczenia jak niżej:

        class Base { };
        class Derived : Base { };

Interfejs kowariantny

IEnumerable<Base> jest bardziej ogólny od IEnumerable<Derived>
IEnumerable<Base> jest bardziej ogólny od IEnumerable<Derived>

Wtedy IEnumerable<Base> jest nadtypem IEnumerable<Derived>. Dzieje się tak, gdyż interfejs IEnumerable jest kowariantny ze względu na swój parametr generyczny. Dodatkowo IEnumerable<Base> oraz IEnumerable<Derived> nie są powiązane relacją dziedziczenia.

 
            IEnumerable<Base> @base = null;
            IEnumerable<Derived> derived = null;
            @base = derived;//OK
            derived = @base;//Cannot implicitly convert type...

Interfejs kontrawariantny

IComparer<Base> jest bardziej szczegółowy od IComparer<Derived> (strzałka odwrócona)
IComparer<Base> jest bardziej szczegółowy od IComparer<Derived> (strzałka odwrócona)

Odwrotnie sytuacja przedstawia się dla interfejsów kontrawariantnych, np. IComparer. IComparer<Derived> jest nadtypem IComparer<Base>. Oba typy nie są powiązane relacją dziedziczenia.

            IComparer<Base> @base = null;
            IComparer<Derived> derived = null;
            @base = derived;//Cannot implicitly convert type...
            derived = @base;//OK

Różnice IEnumerable vs IComparer

Nasuwa się pytanie: co odróżnia IEnumerable od IComparer? Spójrzmy na deklaracje.

public interface IEnumerable<out T> : IEnumerable
public interface IComparer<in T>;

Widzimy, że IEnumerable jest zadeklarowany jako kowariantny względem T (słowo kluczowe out, które oznacza, że typ T może pojawiać się tylko jako wyjściowy). Natomiast IComparer jest zadeklarowany jako kontrawariantny względem T (słowo kluczowe in, które oznacza, że typ T może pojawiać się tylko jako wejściowy). To właśnie te dwa słowa kluczowe decydują o relacji podtypowania!

Własny interfejs

Jako ostatni przykład, zadeklarujmy własny interfejs.

        interface IMy<in T1, out T2>
        {
            T2 SomeMethod(T1 t);
        }

Wtedy nasze podtypowanie przedstawia się następująco.

            IMy<Derived, Base> @base = null;
            IMy<Base, Derived> derived = null;
            @base = derived;//OK
            derived = @base;//Cannot implicitly convert type...

Powyższy przykład wymaga chwili analizy, ale po namyśle łatwo zauważyć, że to połączenie interfejsów IEnumerable i IComparer w jednym!

Podtypowanie funkcji

Na koniec tego rozdziału mały bonus: podtypowanie funkcji! Zadeklarujmy 4 delegaty (delegaty w C# to typy dla funkcji, coś w stylu wskaźników na funkcje w C/C++)

delegate void BaseAsInput(Base x);
delegate void DerivedAsInput(Derived x);

delegate Base BaseAsOutput();
delegate Derived DerivedAsOutput();

oraz 4 funkcje, które nie robią nic szczególnego, bo chodzi jedynie o sygnatury.

static void BaseAsInputFunc(Base x) { }
static void DerivedAsInputFunc(Derived x) { }

static Base BaseAsOutputFunc() { return null; }
static Derived DerivedAsOutputFunc() { return null; }

Wtedy nasze podtypowania przedstawiają się następująco.

BaseAsInput a1 = BaseAsInputFunc;//OK
BaseAsInput a2 = DerivedAsInputFunc;//No overload for 'DerivedAsInputFunc' matches delegate...

DerivedAsInput b1 = BaseAsInputFunc;//OK, podtypowanie
DerivedAsInput b2 = DerivedAsInputFunc;//OK

BaseAsOutput c1 = BaseAsOutputFunc;//OK
BaseAsOutput c2 = DerivedAsOutputFunc;//OK, podtypowanie

DerivedAsOutput d1 = BaseAsOutputFunc;//'BaseAsOutputFunc()' has the wrong return type
DerivedAsOutput d2 = DerivedAsOutputFunc;//OK

Trochę to zawiłe na pierwszy rzut oka, ale można zauważyć, że wszystkie typy *AsInput zachowują się jak IComparer z poprzedniego przykładu, czyli jak interfejs ze słowem kluczowym in (kontrawariantny). Natomiast *AsOutput podtypują się dokładnie jak IEnumerable, czyli jak interfejs ze słowem kluczowym out (kowariantny).

Drogi Czytelniku, jako ćwiczenie spróbuj przygotować przykład z podtypowaniem funkcji, która przyjmuje parametr i zwraca wartość. Jako podpowiedź może posłużyć konstrukcja interfejsu IMy z poprzedniego przykładu. Powodzenia!

A jest podtypem B, A dziedziczy z B

A dziedziczy z B

Na deser został nam najpowszechniejszy przypadek. W przypadku C# prawdziwość tej zależności wynika ze specyfikacji języka, przytoczonej już wcześniej.

(12.5.2) If T is a class-type, the base types of T are the base classes of T, including the class type object.

Aby potwierdzić, że tak jest, spójrzmy na poniższy przykład.

Base @base = null;
Derived derived = null;
@base = derived;//OK
derived = @base;//Cannot implicitly convert type...

Faktycznie, podklasa jest jednocześnie podtypem.

Podsumowanie

Mam nadzieję, że dzisiejszy post rozjaśnił pojęcia typu i klasy. Zauważyłem, że pojęcia te są rozmyte i czasami niepoprawnie używane na różnych forach i dyskusjach, a warto znać różnice i panujące zasady wynikające z teorii typów, gdyż pomaga to w codziennej pracy.

Bardzo dziękuję Adamowi Przybyło za recenzję tego posta.

About the author

Programista lubiący ten fach. Połączenie perfekcjonisty i skauta z odrobiną eksperymentatora. Pracował dla dużych i małych, polskich i zagranicznych, prywatnych i publicznych podmiotów. Miał również przyjemność opiekować się praktykantami w jednej z poprzednich firm, jak również prowadzić ćwiczenia na Politechnice Warszawskiej, której jest dumnym absolwentem.

Comments

    1. Faktycznie! Dzięki za ten przykład!
      Jeśli masz chęć spotkać się na obiecane piwo, to podeślij zaproszenie na LI:)

Leave a Reply

Your email address will not be published. Required fields are marked *