Nasza strona używa cookies. Dowiedz się więcej o celu ich używania i zmianie ustawień w przeglądarce. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Czego nie wiesz o typach i klasach w C#

Tomasz Sitarek Software Engineer, Architect
Poznaj różnice i zasady typów i klas w języku C#, aby ułatwić sobie codzienną pracę.
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 odpowiem w tym artykule. Zapraszam do lektury!


Typ != Klasa

Typ i klasa to dwa różne pojęcia. Aby poznać szczegóły, spójrzmy, jak są definiowane, np. na Wikipedii (TypKlasa). 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 interfejsopisują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 (DocsC# 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 BA 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.

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).

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. 

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.

Masz coś do powiedzenia?

Podziel się tym z 120 tysiącami naszych czytelników

Dowiedz się więcej
Rocket dog