Klasy Abstrakcyjne

Klasy abstrakcyjne w "klasycznym" dziedziczeniu

Klasy abstrakcyjne to, najogólniej, takie klasy dla których nie można stworzyć obiektu. Można by zadać sobie pytanie: "Po co więc nam taka klasa?".
Odpowiedź jest prosta: aby z niej dziedziczyć.
Często zdarza się że mamy kilka klas które mają pewną ilość cech wspólnych, aczkolwiek między nimi samymi nie zachodzi relacja dziedziczenia (żadna z klas nie jest szczególnym przypadkiem innej klasy).
Przykład z życia wzięty to na przykład zwierzęta: Pies, Kot, Mysz. Nie mamy tu żadnej relacji dziedziczenia (ani tym bardziej kompozycji), ale można zauważyć że wszystkie 3 mają pewne cechy wspólne (każde ma pewną ilość nóg, kolor sierści, wagę, wielkość, wydawane odgłosy itd). Moglibyśmy więc wydzielić bazową klasę Zwierze gdzie zawarlibyśmy te wszystkie wspólne cechy. Daje nam to szereg korzyści:

  • jeśli postanowimy zmienić jakieś pole wspólne, to zmiany dokonujemy tylko w klasie bazowej,
  • jeśli pojawią się nowe wspólne cechy które potrzebujemy to dodajemy je tylko w jednej klasie
    te dwie kwestie chronią nas przed popełnieniem błędu
  • chyba najważniejsza kwestia - możliwość wywołań polimorficznych

Nie trudno zauważyć że większość "funkcji" Psa, Kota czy Myszy się pokrywa. Każde z nich je, śpi, biega, bawi się, wydaje dźwięki itd. Każde z nich robi to w inny sposób, ale jednak funkcja jako taka jest ta sama. Każdemu kto korzystał w swoich programach z polimorfizmu od razu przyjdzie na myśl: funkcje wirtualne! W klasycznym dziedziczeniu między "zwykłymi" klasami funkcje wirtualne zawsze miały pewne definicje. Tzn jeśli mieliśmy np klasy: ZegarekNaReke i ZlotyZegarekNaReke to mogliśmy wywołać metodę podajCzas() dla obiektów obu tych klas. W klasach abstrakcyjnych, które nie mogą mieć obiektów, nie muszą mieć tych definicji. Prawda jest taka że aby klasa była abstrakcyjna to musi mieć przynajmniej jedną metodę czysto wirtualną - czyli metodę wirtualną która nie ma ciała. Jak wygląda taka metoda?

class A
{
  public:
    virtual void metodaCzystoWirtualna() =0; //zamiast ciała mamy takie ładne = 0
};

Każda klasa z taką metodą będzie abstrakcyjna. Każda klasa dziedzicząca z takiej klasy także, dopóki nie będzie w niej definicji wszystkich metod czysto wirtualnych. Definiowanie takich metod w klasach pochodnych nie różni się niczym od definiowania innych metod.

class B : public A
{
  public:
    virtual void metodaCzystoWirtualna() {} //już nie jest czysto wirtualna, bo ma ciało - choćby i puste
};

Oczywiście próba utworzenia obiektu klasy abstrakcyjnej skończy się błędem kompilatora. (bo i jaki sens miało by utworzenie obiektu Zwierze, skoro nie wiadomo co to za zwierze?)
Rozważmy teraz bardziej życiowy przykład zastosowania klas abstrakcyjnych.

Stwórzmy sobie w ramach ćwiczenia hierarchię klas odpowiedzialną za przechowywanie kolejki zmiennych typu int (możemy też, dla bardziej wtajemniczonych, użyć szablonów i stworzyć kolejkę parametryzowaną typem przechowywanym). Załóżmy że chcemy mieć dwa typy kolejek. ArrayList (KolejkaTablica) i LinkedList (KolejkaLista). Nie trudno zauważyć że możemy wyodrębnić dla tych klas pewną wspólną bazę.
Obie kolejki potrzebują metod push(), pop() czy size() (może też różnych innych -> remove(), exists(), search() itd). Może nam się też przydać pole iloscElementow.
Widać też wyraźnie że tworzenie obiektu klasy Kolejka byłoby niedorzeczne, bo przecież ta Kolejka nie ma zdefiniowanego sposobu przechowywania danych! Wygląda ona mniej więcej tak:

class Kolejka
{
  protected:
    int iloscElementow;
  public:
    virtual void push(int v)=0;
    virtual int pop()=0;
};

Klasy dziedziczące z Kolejki muszą mieć zdefiniowany sposób przechowywania elementów, np. KolejkaTablica musi mieć dodatkowe pole które będzie tablicą (lub wskaźnikiem do jej pierwszego elementu) z danymi.
Jeśli nagle wpadniemy na pomysł że potrzebujemy mieć w naszych kolejkach (a będzie ich na przykład 10 rodzajów) dodatkowe pole to dodanie go sprowadzi się do modyfikacji jedynie naszej klasy abstrakcyjnej.

Wyodrębnianie klas abstrakcyjnych bardzo pomaga przy bardziej złożonych sytuacjach. Dekomponowanie problemu na mniejsze fragmenty pozwala skupić się na mniejszej ilości spraw i uniknąć błędów. Dodatkowo możemy przetestować "małe" klasy w których łatwiej szuka się błędów. Oczywiście nie warto przesadzać w drugą stronę - tworzenie dziesiątek malutkich klas może nam utrudnić życie, jeśli będziemy musieli o nich wszystkich pamiętać ;)

Klasy abstrakcyjne jako interfejsy

Warto w tym miejscu poruszyć jeszcze jedno zastosowanie klas abstrakcyjnych (a bardziej: szczególny ich typ). Języki takie jak C# czy Java nie pozwalają, w przeciwieństwie do C++, na dziedziczenie z więcej niż 1 klasy bazowej, ale pozwalają na dziedziczenie z wielu interfejsów. Czym właściwie jest taki interfejs? Interfejs to klasa abstrakcyjna która ma tylko i wyłącznie metody czysto wirtualne i nie ma żadnych pól.
Jakie jest więc zastosowanie takiej klasy? Są one bardzo przydatne gdy chcemy zupełnie niezwiązanym ze sobą obiektom udostępnić taki sam zestaw metod. Najprościej pokazać to na przykładzie.
Załóżmy że chcemy napisać funkcję min(a,b). Wiadomo że potrzebujemy obiekty które można w jakiś sposób porównać (istnieje jakaś relacja porządku w zbiorze tych obiektów), ale w jaki sposób zagwarantować że nasza funkcja sortująca dostanie właśnie takie obiekty? Co więcej, jakie właściwie argumenty powinna dostać? Rozwiązaniem jest np. stworzenie klasy abstrakcyjnej - interfejsu który nazwiemy Porownywalne (ang. Comparable)

class Porownywalne
{
  public:
  virtual bool operator<(Porownywalne& d) const = 0;
};

Jak może wyglądać klasa implementująca ten interfejs?

Przykład:

```cpp class Klocek : public Porownywalne { private: int waga; public: Klocek(int w):waga(w) {} virtual bool operator<(Porownywalne& d) const { Klocek& k = dynamic_cast<klocek&>(d); //jak się nie uda to poleci nam std::bad_cast ! return waga < k.waga; } }; ```

Teraz jeśli stworzymy dwa klocki to możemy te klocki porównać za pomocą operatora <.
W ten sposób możemy napisać uniwersalne funkcje które wymagają od naszych obiektów jedynie określonego zestawu metod, np. metod porównujących obiekty dla funkcji sortującej.
Dodatkowo dziedziczenie z wielu interfejsów nie jest obarczone problemami jakie wynikają ze zwykłego dziedziczenia wielobazowego. Nie ma problemów z rzutowaniem w górę, ani w poprzek. Możemy co najwyżej mieć kilka dodatkowych metod.

0 komentarzy