40-783: Hierarchia typów

Po dłuższej przerwie wracam do tematu języka C#. Przede mną kolejny rozdział Certficiation Toolkit. Dzisiaj pomówimy sobie głównie na temat Hierarchii typów. Temat ten tak naprawdę nie jest zawiły, gdyż nie odbiega on znacząco od tego co znamy z Javy, aczkolwiek pojawi się kilka nowych rzeczy. Nie będę dłużej przeciągać – zaczynajmy :)

Dziedziczenie

Generalnie dziedziczenie w C# wygląda bardzo podobnie do Javowego. Składniowo jest trochę inaczej, gdyż dziedziczy się “dwukropkiem”:

public class A
{
}

public class B : A
{
}

W C# mamy coś takiego jak klasa statyczna. Jest to klasa oznaczona słówkiem kluczowym static i może ona mieć jedynie statyczne składowe. Dla takich klas zasady dziedziczenia mają swoje obostrzenia. Mianowicie po takich klasach nie można dziedziczyć wcale – natomiast one same mogą dziedziczyć jedynie po object.

Bardzo ciekawą sprawą jest natomiast pewna różnica w kolejności inicjalizacji. O ile kolejność odpalania konstruktorów jest taka sama jak w Javie (od bazowego do pochodnego) o tyle kolejność inicjalizacji pól jest odwrotna. Dzięki takiemu zabiegowi bezpieczniejsze staje się wywoływanie metod wirtualnych z poziomu konstruktora – mamy pewność, że pola, które zainicjalizowaliśmy ręcznie – będą faktycznie już istnieć. Co nie zmienia faktu, że generalnie wywoływanie metod wirtualnych w konstruktorze nie jest dobrą praktyką. Po szczegóły odsyłam do dwóch bardzo fajnych artykułów napisanycch przez E. Lipperta (linki jak zwykle na koniec).

Polimorfizm

Chyba najbardziej podstawową różnicą między Javą a C# jest fakt, że domyślnie metoda w C# nie jest wirtualna. Aby więc móc skorzystać z polimorfizmu musimy wprost oznaczyć metodę klasy bazowej słowem kluczowym virtual. I teraz w klasie pochodnej mamy kilka opcji:

  • Po pierwsze możemy nadpisać metodę – aby tak się stało musimy użyć słowa kluczowego override. W tym momencie możemy skorzystać z polimorfizmu.
  • Po drugię metodę możemy przesłonić – aby tak się stało musimy użyć słowa kluczowego new w deklaracji metody. Podobny efekt nastąpi jeżeli nie użyjemy słowa kluczowego new – ale wtedy kompilator pozdrowi nas warnigiem
  • Po trzecie możemy nadpisać metodę i jednocześnie zablokować możliwość dalszego nadpisywania. Aby tak się stało do pary do słówka kluczowego override musimy użyć sealead.

Oczywiście nie tylko metody mogą być polimorficzne – również propertiesy indeksery i eventy mogą.

Na sam koniec jeszcze dodam , że będąc wewnątrz nadpisywanej metody możemy się odwołać to jej wersji z klasy bazowej po przez słowo kluczowe base.

Interfejsy

Ciekawostką jest fakt w jaki sposób implementujemy interfejsy w C#. Nie licząc podstawowej opcji implementacji, która wygląda dokładnie tak samo jak w Javie (tzw. implicit implementation), możemy zrobić coś takiego co się nazywa explicit implementation. Taka konstrukcja sprawi, że dostęp do metody będziemy mieli tylko i wyłącznie jeżeli będziemy trzymać nasz obiekt za referencje do interfejsu.

Przykładowo – mamy prosty interfejs:

public interface IInterface
{
  void method();
}

Jeżeli zaimplementujemy go używając takiej składni (zwróćcie uwagę na nazwę interfejsu przed nazwą metody):

public class Implementation : IInterface
{
  void IInterface.method()
  {
  }
}

To od teraz wywołać tą metodę będziemy mogli tylko po przez referencję do IInterface:

Implementation i = new Implementation();
((IInterface)i).method();

Myślę, że głównym zastosowaniem takiej składni może być sytuacja, gdy implementujemy dwa interfejsy z metodą o takiej samej sygnaturze, natomiast zachowanie tych metod powino być inne – wtedy możemy zaimplementować explicit tą metodę dwa razy na rzecz różnych interfejsów. Oczywiście jeżeli implementowalibyśmy te interfejsy zwyczajnie to wszystko zadziała tak jak w Javie – ta sama metoda zaimplementuje oba interfejsy.

Konstruktory

Poza różnicami składniowymi również i w temacie konstruktorów nie poznamy zbyt wiele nowego. Jako, że c# troszeczkę bardziej składniowo nawiązuje do C++ niż Java, to i składnie związane z konstruktorami są podobne:

Aby wywołać konstruktor z klasy bazowej używamy słowa kluczowego base w sygnaturze:

public class A
{
  public A(){}
}

public class B : A
{
  public B() : base() {}
}

Aby wywołać inny konstruktor z tej samej klasy – używamy w podobny sposób słowa kluczowego this:

public class A
{
  public A(string s) { }
  public A() : this("foo") { }
}

Podobnie jak w Javie jeśli mamy zadeklarowany jakikolwiek konstruktor w klasie bazowej to musimy go zawołać w klasie pochodnej (bo nie mamy domyślnego konstruktora, który mógłbybyć zawołany automatycznie).

Czymś natomiast nowym jest tzw. statyczny konstruktor. Najbliższym przybliżeniem w Javie byłby statyczny blok inicjalizacyjny. Statyczny konstruktor służy nam do inicjalizacji statycznych pól. Taki konstruktor składniowo nie może mieć modyfikatora dostępu i nie może definiować parametrów. Podobnie jak ze statycznym blokiem inicjalizacyjnym – nie możemy ręcznie wywołać statycznego konstruktora – zostanie on automatycznie wywołany przed stworzeniem pierwszej instancji danego typu w naszej aplikacji. Warto przypilnować wyjątków, które ewentualnie mogłyby z takiego konstruktora wylecieć, ponieważ statyczny konstruktor nie będzie wywołany drugi raz.

Destruktory

Kontynuując temat podobieństw między C# i C++ – w obu tych językach mamy do czynienia z destruktorami. W C# jest to jednak leciutkie kłamstwo – ponieważ destruktor z grubsza będzie odpowiadać finalizerowi w Javie. Tak naprawdę nawet w procesie kompilacji nasz destruktor zostanie zastąpiony metodą Finalize, która podobnie jak w Javie – zostanie wywołana w momencie kiedy Garbage Collector dobierze się do instancji naszej klasy – co może nastąpić kiedykolwiek (w przeciwieństwie do klasycznego destruktora który jest deterministyczny i wiemy kiedy zostanie wywołany).

Ideą destruktorów w C# jest zawarcie w nich kodu który sprząta tzw. unamanged resources – czyli teoretycznie wszystko to co trzeba “posprzątać” a nie implementuje IDisposable o którym też dziś wspomnę. Bardzo ważne jest, aby pamiętać, że jeżeli nasz destruktor został odpalony to znaczy, że jesteśmy w samym środku garbage collectingu. Oznacza to, że dowolona referencja w naszym obiekcie może już nie wskazywać na obiekt (bo ten mógł zostać już usunięty). Stąd właśnie wynika to, że destruktor nie powinien się zajmować “managed resources” bo odpalenie w jego wnętrzu Dispose będzie ryzykowne.

Z technicznych szczegółów – klasa może mieć tylko jeden destruktor – metodę o nazwie takiej samej jak nazwa klasy, ale poprzedzonej znakiem tyldy (podobnie jak w C++). Destruktor może być tylko jeden, nie przyjmuje on argumentów ani nie ma modyfikatorów dostępu. Destruktor również nic nie zwraca. Destruktory się nie dziedziczą. Nie jesteśmy w stanie wywołać destruktora ręcznie.

Technicznie rzecz biorąc – w procesie garbage collectingu obiekty muszą przejść przez tzw. finalization queue co może trochę potrwać. Warto o tym pamiętać, bo jeżeli nasz obiekt zawiera tylko managed resoureces, to implementując IDispose możemy “wyłączyć” odpalanie finalizerów, ale o tym powiem przy okazji IDispose.

Interfejsy, które warto znać

T. Covaci w piątym rozdziale Certification Toolkit wskazuje na kilka interfejsów które warto znać:

  • IDisposable
  • IComparable
  • IComparer
  • IEquatable
  • IClonable
  • IEnumerable

Przedstawię je więc króciutko

IDisposable

Jest to interfejs, którego zasada działania jest taka jak Closable w Javie. Metoda Dispose, którą dostarcza powinna “posprzątać” wszystko co jest do posprzątania. Warto aby sprzątała zarówno managed resources (czyli np wywoływała Dispose na swoich memberach, jeżeli implementują one IDisposable) jak i unmanaged resources. Przede wszystkim dlatego, że mamy możliwośc wywołania metody GC.SupresFinalize(), dzięki której obiekt nie trafi do finalization queue (a nie ma takiej potrzeby jeśli Dispose wszystko posprzątał). Warto pamiętać, że jeżeli klasa ma unmanaged resources, to powinniśmy do pary z Dispose zadeklarować też destruktor, ponieważ tak naprawdę nie mamy gwarancji, że ktoś to nasze Dispose wywoła.

W Javie 7 weszła taka fajna konstruktcja, która nazywa się try-with-resources. Dzięki niej obiekt który implementuje interfejs Closable będzie mógł być automatycznie zakończony po wyjściu z bloku try-catch. W C# możemy zrobić coś bardzo podobnego z blokiem using. Jeżeli nasza klasa implementuje IDispose to możemy zrobić coś takiego:

using (var a = new A())
{
  a.method();
}

Po wyjściu z bloku using – metoda Dispose zostanie automatycznie odpalona na obiekcie.

IComparable

Interfejs ten jest odpowiednikiem Comparable z Javy. Nasz obiekt dostanie metodę CompareTo, która powie czy obiekt jest “przed” (metoda zwróci -1), “po” (1), czy “na równi” (0) w stosunku do obiektu przekazanego do metody. Inaczej mówiąc dzięki IComparable możemy sortować obiekty (Z CompareTo skorzysta np. metoda Array.Sort). Warto pamiętać, że istnieją dwie wersje tego interfejsu – jedna generyczna, druga nie.

IComparer

Odpowiednik interfejsu Comparator z Javy. Również i tutaj mamy możliwość sortowania obiektów za pomocą metody Compare. W przeciwieństwie jednak do IComparable, IComparer jest zazwyczaj osobnym obiektem, co oznacza, że możemy posiadać kilka implementacji sortujących obiekty na podstawie różnych kryteriów. Z tego interfejsu korzysta np. metoda Sort z interfejsu Listy. Również i ten interfejs istnieje w wersji zwykłej i generycznej.

IEquatable

Bardzo ciekawy interfejs ale chyba nie często używany – pozwala nam wymusić na obiekcie, żeby zaimplementował metodę Equals.

IClonable

Interfejs ten dostarcza nam metody Clone, której założeniem jest stworzenie kopi obiektu przekazanego na wejściu.

IEnumerable

Interfejs ten pozwola nam na zwrócenie z naszej klasy Enumeratora a co za tym idzie iterowaniu po nim. Interfejs ten istnieje w wersji zwykłej i generycznej. Warto tutaj wspomnieć o bardzo fajnej konstrukcji związanej ze słowem kluczowym yield:

public class SomeRange : IEnumerable<int>
{
  int min;
  int max;

  public SomeRange(int min, int max)
  {
    this.min = min;
    this.max = max;
  }

  public IEnumerator<int> GetEnumerator()
  {
    for (int i = min; i < max; ++i)
      yield return i;
  }

  IEnumerator IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
}

Dzięki yield return w metodzie GetEnumerator nie musimy deklarować osobnego Enumeratora, gdyż sama metoda posłuży nam jako Enumerator. Za każdym razem kiedy enumerator zostanie poproszony o zwrócenie nowej wartości to ta metoda “wznowi działanie” i zwróci to co akurat będzie zwracane przez yield return i zawiesi swoje działanie. I znów – kiedy enumerator zostanie poproszony o kolejny element – wywołanie metody zostanie wznowione, itd. itd.

Podsumowanie

Dzisiejszy “odcinek” nie obfitował w szczególną liczbę nowych rzeczy – wszystko to albo było już częściowo wcześniej omawiane, albo jest bardzo podobne do Javy. Następnym razem zajmę się rozdziałem szóstym z Certyfication Toolkitu który pokrywa m.in. temat Delegatów i Eventów, czyli konstrukcji których wprost w Javie nie mamy (no dobra od czasów Javy 8 mamy referencje na metody, które dość dobrze korespondują z koncepcją delegtów) i myślę, że może to być całkiem ciekawy artykuł.

Referencje

  1. T. Covaci – MCSD Certification Toolkit (Exam 70-483): Programming in C# – rozdział 5
  2. E. Lippert – Why Do Initializers Run In The Opposite Order As Constructors? Part One
  3. E. Lippert – Why Do Initializers Run Int The Opposite Order As Constructors? Part Two
  4. Polymorphism
  5. Versioning with the Override and New Keywords (C# Programming Guide)
  6. Interfaces (C# Programming Guide)
  7. Explicit Interface Implementation (C# Programming Guide)
  8. How to: Explicitly Implement Interface Members (C# Programming Guide)
  9. How to: Explicitly Implement Members of Two Interfaces (C# Programming Guide)
  10. Interface Properties (C# Programming Guide)
  11. Indexers in Interfaces (C# Programming GUide)
  12. Constructors (C# Programming Guide)
  13. Using Constructors (C# Programming Guide)
  14. Instance Constructors (C# Programming Guide)
  15. Private Constructors (C# PRogramming Guide)
  16. Static Constructors (C# Programming Guide)
  17. Destructors (C# Programming Guide)
  18. Cleaning Up Unamanaged Resources
  19. Implementing a Dispose Method
  20. Dispose Pattern
  21. IDisposable Interface
  22. Finalize Method
  23. SuppressFinalize
  24. IComparable Interface
  25. IComparable Methods
  26. IComparable Interface
  27. How to use IComparable and IComparer interfaces in Visual C#
  28. IComparable.CompareTo Method (Object)
  29. IComparer Interface
  30. IComparer[T] Interface
  31. List[T].Sort Method (IComparer[T])
  32. IEquatable[T] Interface
  33. IEquatable[T].Equals Method (T)
  34. What’s the difference between IEquatable and just overriding Object.Equals()
  35. When to use IEquatable and why?
  36. ICloneable Interface
  37. ICloneable.Clone
  38. Proper way to implement ICloneable
  39. Why should I implement ICloneable in C#?
  40. IEnumerable Interface
  41. IEnumerable[T] Interface
  42. Can anyone explain IEnumerable and IEnumerator to me?