3. Dziedziczenie i polimorfizm

3.1. Dziedziczenie

Jednym z głównych powodów, dla których obiektowe techniki programowania zyskały taką popularność, jest znaczący postęp w kwestii ponownego wykorzystania kodu oraz możliwość jego rozszerzania i dostosowania do indywidualnych potrzeb. Ta cecha leży u samych podstaw programowania zorientowanego obiektowo: program konstruowany jako zbiór współdziałających obiektów przestaje być pojedynczą całością, gdzie dane są ściśle powiązane z operacjami na nich wykonywanymi (jeden z pierwszych szeroko znanych podręczników IT miał tytuł "Algorytmy + struktury danych = programy" ;). Współcześnie dążymy do czegoś innego - program wewnętrznie powinien mieć jak najmniej powiązań o możliwie najlepiej zaprojektowanej semantyce. Staramy się wyodrębniać podsystemy i biblioteki do ponownego wykorzystania w kolejnych projektach. To ułatwia i przyspiesza tworzenie nowych aplikacji. 

Sporo tu zależy od umiejętności i doświadczenia programisty - programowanie zorientowane obiektowo nie sprawi, że napisany przy jego użyciu program nie będzie miał wewnętrznych zależności na tyle silnych, iż nie będzie możliwości ponownego wykorzystania jego kodu w innym projekcie. 

Elementem, który ułatwia podział odpowiedzialności, jasne definiowanie interfejsów między klasami, czy też dostosowywanie jednego kodu do wielu zastosowań - jest dziedziczenie. Korzyści płynące z jego stosowania nie ograniczają się jednakże tylko do powtórnego wykorzystania istniejącego kodu. - jest ich znacznie więcej. Jeśli dodamy do dziedziczenia polimorfizm - pojawią się nowe możliwości, niezmiernie przydatne przy tworzeniu każdej niebanalnej aplikacji. 

Czymże jest więc owo dziedziczenie?

Nasz świat opisujemy stosując różnego rodzaju schematy, ontologie, metodyki. Spośród nich - podejście oparte na hierarchii jest chyba najpopularniejsze i najczęściej spotykane. Wyraźnie to widać w różnych naukach - biologia zbudowała całe wielkie drzewo klasyfikujące wszystkie organizmy żywe. Co ciekawe i charakterystyczne w tym drzewie - to fakt, iż relację łączącą jego elementy określa słowo "jest". Człowiek jest naczelnym, i jest ssakiem, i jest zwierzęciem, i jest organizmem żywym. VW Golf jest samochodem osobowym, a samochód osobowy jest pojazdem, i takich przykładów można podać wiele. Co charakterystyczne - na każdym z poziomów opisu (abstrakcji) możemy wydzielić wspólne cechy i funkcjonalność (wszystkie ssaki są żyworodne i stałocieplne, i w początkowym okresie życia żywią się mlekiem matki - i jest to prawda niezależnie od tego czy mówimy o myszy, słoniu czy człowieku). 

Tyle ze w obiektowym podejściu do programowania pojawia nam się problem - jak przygotować taką klasę, która opisze np lwa? Jeśli będzie to tylko jedna klasa - to wszystkie cechy muszą się w niej znaleźć. Jeśli będziemy tylko lwem się zajmowali - to jest rozwiązanie ok. Lecz wprowadzając do programu następne pojęcie - powiedzmy pawiana - jak powinniśmy postąpić? Powtórzyć całość opisu? Przecież większość cech jest wspólna dla wszystkich ssaków - w tym i  dla lwa,  i dla pawiana. To może warto utworzyć klasę zwierzę gdzie wrzucę te cechy wspólne? Tylko potem nie bardzo wiadomo jak utworzyć obiekt z takiej klasy - musiałby on być tworzony na podstawie dwóch klas - dwóch przepisów. Programowanie obiektowe nie dopuszcza tego - nie może być typ który jest jednocześnie liczbą całkowitą i zmiennoprzecinkową. Pewnym rozwiązaniem jest kompozycja - ale ona nie oddaje w pełni związku który istnieje w realnym świecie. Pawian nie "ma" zwierzę lecz po prostu jest zwierzęciem - jest to część jego tożsamości, a zwierzę w nim - nie ma jej oddzielnej. 

I tu rozwiązaniem będzie dziedziczenie. 

Dziedziczenie to mechanizm, za pomocą którego można definiować nowe klasy (nazywane klasami podrzędnymi lub pochodnymi) na podstawie już istniejących klas (nazywanych klasami nadrzędnymi lub bazowymi). Klasa pochodna automatyczne posiada wszystkie pola i metody zdefiniowane w klasie nadrzędnej. 

Za każdym razem definiując klasę pochodną, definiujemy tylko to czym ona się różni od klasy bazowej - człowiek to jest takie zwierzę, które potrafi mówić, czy używać narzędzi, i ma iloraz inteligencji. 

Dziedziczenie to główny mechanizm wyróżniający programowanie zorientowane obiektowo od programowania obiektowego. Opieramy się na cechach wspólnych grupy klas – zarówno jeśli chodzi o pola jak i metody. Wspólne cechy lądują w klasie bazowej, cechy szczególne – w klasach pochodnych.

Popatrzmy na pierwszy przykład: 

Pokazany powyżej diagram pokazuje nam hierarchię dziedziczenia dla figur. Mamy prostokąt, który jest taką figurą nieobrotową, która ma szerokość i wysokość. Tyle że z faktu, iż jest figurą nieobrotową - wynika to że ma także kont obrotu. A ponieważ figura nieobrotowa jest figurą (dziedziczy po figurze) - to prostokąt ma także jej cechy, w tym kolor czy położenie środka. 

Klasa pochodna zawiera wszystkie pola i metody odziedziczone po klasach bazowych. Może także posiadać dodatkowe, unikalne dla siebie składowe - nie jest to jednak obowiązkowe z punktu widzenia składni języka.

W dziedziczeniu istotny jest fakt, iż klasy pochodne jednak powinny różnić się czymś - albo polami, albo zachowaniem - od klasy podstawowej. Złym przykładem dziedziczenia jest klasa "Żółty prostokąt" - bo od prostokąta różni się jedynie wartością koloru - więc stanem. I tworząc obiekt prostokąt możemy polu kolor przypisać wartość żółty - nie musimy do tego definiować klasy. Co gorsza - prostokąt można by było "przemalować" na inny kolor - ale jak powstanie już konkretny obiekt - to w C++ nie da się zmienić klasy z której powstał. 

Jeśli natomiast prawidłowo zdefiniujecie hierarchę - to można nie tylko ograniczyć ilość kodu (wyeliminować powtarzające się fragmenty dla różnych klas) - ale także korzystać z różnych typów na ich poziomie abstrakcji. Możemy mieć funkcję która przesuwa figurę o zadany wektor - i do tego nie jest potrzebna wiedza odnośnie tego czy jest to koło, czy prostokąt, czy figura ma promień czy wysokość - każdą figurę można przesunąć, vide- funkcja mogłaby przyjmować jako parametr wskaźnik / referencję na obiekt klasy CFigura

W C++ mechanizm dziedziczenia jest niezwykle rozbudowany, przewyższając często możliwości dziedziczenia w innych językach wspierających programowanie zorientowane obiektowo. Zapewnia on wiele zaawansowanych funkcji, które mogą być nie zawsze niezbędne, ale pozwalają na dużą elastyczność w definiowaniu hierarchii klas. Znajomość wszystkich tych możliwości nie jest konieczna do efektywnego korzystania z programowania obiektowego - lecz na pewno nie przeszkodzi. W sumie, jak mawiają, dodatkowa wiedza jeszcze nikomu nie zaszkodziła! 😊

Zacznijmy zatem przegląd możliwości od postaw: 

Zasady dostępu do pól i metod w hierarchii

Jak pamiętamy, deklarując klasę podajemy listę jej pól oraz metod, podzielonych na kilka części wedle specyfikatorów praw dostępu. W przypadku dziedziczenia pojawia się nowe określenie – protected. Same zasady dostępu wyglądają następująco:

  • Pola prywatne pozostają prywatne
  • Pola publiczne nadal są publiczne
  • Pola chronione – dostępne dla danej klasy i wszystkich klas pochodnych – dla reszty nie.
  • Obowiązują reguły przykrywania nazw
  • Nie obowiązują zasady przeciążania nazw funkcji w dziedziczeniu!

Możecie to zobaczyć na poniższym przykładzie:


class CMojObiekt {
public:
  int zmienna_publiczna;
  void metoda_publiczna();
protected:
  int zmienna_chroniona;
  void metoda_chroniona();
private:
  int zmienna_prywatna;
  void metoda_prywatna();
};

class CMojNastepca : public CMojObiekt {
public:
  ...
 void metoda_publiczna_nastepcy();
protected:
  ...
private:
  void metoda_prywatna_nastepcy;
};
void CMojNastepca::metoda_prywatna_nastepcy {
  // tak mozna
  zmienna_chroniona = 1;
  metoda_chroniona();
  // tak natomiast nie da rady
  zmienna_prywatna = 1;
  metoda_prywatna();
};
void CMojObiekt::metoda_publiczna_nastepcy {
  // tak mozna
  zmienna_chroniona = 1;
  metoda_chroniona();
  // tak natomiast nie da rady
  zmienna_prywatna = 1;
  metoda_prywatna();
};

CMojNastepca mn;
...
// ponizszy kod jest nieprawidłowy
mn.zmienna_chroniona = 1;
mn.metoda_chroniona();

Należy używać specyfikatora protected , kiedy chcemy uchronić składowe przed dostępem z zewnątrz, ale jednocześnie mieć je do dyspozycji w klasach pochodnych. Przy czym nie nadużywajcie tego dostępu , wspomniana wcześniej zasada hermetyzacji ciągle obowiązuje. Preferowaną sekcją dla pól zawsze powinna być sekcja private - a umieszczanie pól w sekcji protected powinno mieć jakieś uzasadnienie. 

Dopiero posiadając zdefiniowaną klasę bazową możemy przystąpić do określania dziedziczącej z niej klasy pochodnej. Jest to konieczne, bo w przeciwnym wypadku kazalibyśmy kompilatorowi korzystać z czegoś, o czym nie miałby wystarczających informacji.

Samo dziedziczenie może być wykonane wg trzech różnych schematów. Tak więc mamy dziedziczenie:

  • Publiczne - Wszystkie odziedziczone pola i metody mają taki zasięg jak zdefiniowano w klasie bazowej. Klasa pochodna i jej pochodne mają dostęp do części chronionej i publicznej, korzystający z obiektów pochodnych mają dostęp do części publicznej. 
  • Chronione - Odziedziczone pola i metody publiczne i chronione stają się chronione w klasie pochodnej i jej pochodnych. Klasa pochodna i jej pochodne mają dostęp do części chronionej i publicznej, korzystający z obiektów pochodnych nie mają dostępu do żadnych pól i metod klasy bazowej.
  • Prywatne - Odziedziczone pola i metody publiczne i chronione stają się prywatne w klasie pochodne. Klasa pochodna ma do nich dostęp, klasy wyprowadzone z pochodnej, oraz korzystający z obiektów pochodnych nie mają dostępu do żadnych pól i metod klasy bazowej. Dziedziczenie prywatne przerywa w zasadzie hierarchię dziedziczenia. 

Najczęściej stosowane jest dziedziczenie publiczne -ponieważ w większości przypadków nie ma  potrzeby zmiany praw dostępu do składowych odziedziczonych po klasie bazowej. Jeżeli więc któreś z nich zostały tam zadeklarowane jako protected , a inne jako public , to prawie zawsze życzymy sobie, aby w klasie pochodnej zachowały te same prawa. Pozostałe dwa kwalifikatory są stosunkowo rzadko stosowane.


class CMojObiekt {
public:
  int zmienna_publiczna;
  void metoda_publiczna();
protected:
  int zmienna_chroniona;
  void metoda_chroniona();
private:
  int zmienna_prywatna;
  void metoda_prywatna();
};

class CMojNastepca : public CMojObiekt {
public:
  ...
 void metoda_publiczna_nastepcy();
protected:
  ...
private:
  void metoda_prywatna_nastepcy();
};

void CMojNastepca::metoda_prywatna_nastepcy {
  // tak mozna
  zmienna_chroniona = 1;
  metoda_chroniona();
  // tak natomiast nie da rady
  zmienna_prywatna = 1;
  metoda_prywatna();
};
void CMojObiekt::metoda_publiczna_nastepcy {
  // tak mozna
  zmienna_chroniona = 1;
  metoda_chroniona();
  // tak natomiast nie da rady
  zmienna_prywatna = 1;
  metoda_prywatna();
};

CMojNastepca mn;
...
// ponizszy kod jest nieprawid³owy
mn.zmienna_chroniona = 1;
mn.metoda_chroniona();

W przypadku dziedziczenia mamy do dyspozycji ten sam mechanizm co w przypadku przestrzeni nazw - czyli przesłanianie. Jeśli w kodzie klasy pochodnej zdefiniujemy pola o tych samych nazwach co już istniejące pola w klasie bazowej - to nie jest błąd. To co się stanie - to stracimy bezpośredni dostęp od pola odziedziczonego (o ile go w ogóle mieliśmy), w to miejsce uzyskując dostęp do nowego pola. Ilustruje to poniższy przykład:


class CBaza {
public:
    CBaza() : a(0), b(0), c(0) {};
    int a;
    void fa() {cout <<"Baza fa: "<< a <<" "<< b <<" "<< c << endl; }
    void fb() {cout <<"Baza fb\n";}
protected:
    int b;
private:
    int c;
};

class CPoch : public CBaza {
public:
    CPoch() : a(1), c(1) {};
    int a, c;
    void fa() {cout <<"Poch fa: "<< a <<" "<< b <<" "<< c << endl; }
    void fb(int i) {cout <<"Poch fb\n";}
    void fc() { CBaza::fa(); }
};

int main(int argc, char *argv[])
{
    CPoch t;

    t.fa();
    // t.fb();
    t.fb(0);
    t.fc();

    return EXIT_SUCCESS;
}

Dziedziczenie wielokrotne

Skoro możliwe jest dziedziczenie z wykorzystaniem jednej klasy bazowej, to raczej naturalne jest rozszerzenie tego zjawiska także na przypadki, w której z kilku klas bazowych tworzymy jedną klasę pochodną.

Niestety - wiąże się to z problemem, który może się pojawić - tzw problemem diamentu. Problem diamentu dotyczy głównie konfliktów związanych z polami klas bazowych. Przyjrzyjmy się przykładowi, w którym problem diamentu występuje w kontekście pól:


#include "iostream"

class A {
public:
    int value;

    A() : value(0) {}
};

class B : public A {
public:
// Dziedziczy pole value z klasy A
};

class C : public A {
public:
// Dziedziczy pole value z klasy A
};

class D : public B, public C {
public:
// Dziedziczy pole value z klas B i C, ale także ma własne pole value
};

int main() {
    D d;
    // Konflikt, ponieważ D dziedziczy pole value z B i C
    // d.value = 10; // To spowoduje błąd kompilacji

    // Musimy jawnie określić, z której klasy dziedziczone jest pole value
    d.B::value = 5;
    d.C::value = 7;

    // Wartość pola value w klasie D
    std::cout << "D.value: " << d.value << std::endl;

    return 0;
}

W tym przykładzie klasa `D` dziedziczy zarówno po klasie `B`, jak i `C`, a obie te klasy dziedziczą po klasie `A`. Problem pojawia się, gdy chcemy korzystać z pola value w klasie `D`, ponieważ istnieją dwie kopie tego pola (jedna z klasy `B`, druga z klasy `C`), co powoduje konflikt nazw. A nawet nie tylko konflikt nazw - zazwyczaj w ogóle nie chcemy by klasa `D` Zawierała dwie kopie pól klasy `A`

Aby rozwiązać ten problem, możemy jawnie określić, z której klasy dziedziczone są pola value, używając operatora zakresu. Jednakże, w przypadku bardziej złożonych hierarchii dziedziczenia, zarządzanie takimi konfliktami może stać się bardziej skomplikowane. Wirtualne dziedziczenie może być jednym ze sposobów radzenia sobie z problemem diamentu w kontekście pól.

Ze względu na ten problem zazwyczaj unika się udostępniania wielodziedziczenia, wprowadzając w to miejsce koncepcję interfejsu - czyli w uproszeniu - klasy która nie ma pól, a wszystkie jej metody są czysto wirtualne Jak nie ma pól - nie ma problemu ... Ale C++ jest jednym z niewielu języków, które udostępniają wielodziedziczenie. Nie świadczy to jednak o jego niebotycznej wyższości nad innymi. Tak naprawdę technika dziedziczenia wielokrotnego nie daje żadnych nadzwyczajnych korzyści, a jej użycie jest przy tym dość skomplikowane. Decydując się na jej wykorzystanie należy więc posiadać całkiem spore doświadczenie w programowaniu, choć w niektórych wzorcach programistycznych jej stosowanie jest wskazane.

Może jeszcze przykład bardziej "źyciowy" - mamy pojazd, po którym dziedziczy samochód i łódka, no i chcemy wyprowadzić klasę amfibia

No i pytanie - jaką prędkość ma amfibia? Problem można rozwiązać korzystając z wirtualnego dziedziczenia:


class CPojazd {
public:
    CPojazd() {}
protected:
    int m_vMax{200};
};

class CSamochod : virtual public CPojazd {
protected:
    int m_iloscKol{4};
};

class CLodz : virtual public CPojazd {
protected:
    int m_wypornosc{50};
};

class CAmfibia: public CSamochod, public CLodz {
public:
    void jedziemy() {
        std::cout << "Zasuwam z prędkością " << m_vMax;
    }
};

Teraz jest tylko jedno pole m_vMax. Jest jeszcze problem związany z niejednoznacznością inicjacji tego odziedziczonego pola. Załóżmy że mamy konstruktor, który wymaga podania parametru - pytanie, która wersja tego konstruktora będzie wywołana w klasie CAmfibia?


class CPojazd {
public:
    CPojazd(int v) : m_vMax(v) {}
protected:
    int m_vMax;
};

class CSamochod : virtual public CPojazd {
public:
    CSamochod() : CPojazd(200) {}
protected:
    int m_iloscKol{4};
};

class CLodz : virtual public CPojazd {
public:
    CLodz() : CPojazd(50) {}
protected:
    int m_wypornosc{50};
};

class CAmfibia: public CSamochod, public CLodz {
public:
    void jedziemy() {
        // 200 czy 50?
        std::cout << "Zasuwam z prędkością " << m_vMax;
    }
};

Ciężko powiedzieć ... a skoro ciężko powiedzieć - to kompilator się podda i oznajmi że w kodzie jest błąd. Należy jawnie pokazać, którą wersję konstruktora klasy bazowej należy wykorzystać:


class CAmfibia: public CSamochod, public CLodz {
public:
    CAmfibia() : CPojazd(150) {}
    void jedziemy() {
        // moje własne - 150
        std::cout << "Zasuwam z prędkością " << m_vMax;
    }
};

Pułapki dziedziczenia

Pomimo że idea dziedziczenia może wydawać się prosta, w praktyce jej zastosowanie może przynieść pewne trudności. Problemy te są często specyficzne dla konkretnego języka programowania - w naszym podręczniku skupimy się na aspektach związanych z dziedziczeniem w języku C++. Przykłady takich problemów obejmują m.in. zarządzanie pamięcią dla obiektów dziedziczących, rozstrzyganie konfliktów nazw i przysłanianie / polimorfizm (o którym w następnym rozdziale), czy  mechanizm wielokrotnego dziedziczenia (omówiony wyżej). 

Napisałem wcześniej, że klasa pochodna ma wszystkie składowe klasy z której dziedziczy. W zasadzie jest to prawda, nie wpadnijcie w pułapkę części prywatnej!. To, że klasie pochodnej tracimy dostęp do pewnych pól i metod - wcale nie oznacza że ich nie ma. Oznacza jedynie, że tracimy dostęp.  Jeśli pole prywatne ma getter  - to możemy jego wartość odczytać poprzez wywołanie gettera. Podobnie pośrednio możemy wywoływać metody prywatne - jeśli skorzystamy z tych metod w klasie bazowej, które prywatne nie są, ale w swojej implementacji wywołują metody prywatne. 

Sposób, w jaki C++ realizuje pomysł dziedziczenia, jest sam w sobie dosyć interesujący. Większość programistów na początku  całkiem logicznie przypuszcza, że kompilator tworząc obiekt i mapując jego metody, zwyczajnie pobiera deklaracje z klasy bazowej i wstawia je do pochodnej, ewentualne powtórzenia rozwiązując na korzyść tej drugiej.

W rzeczywistości wewnętrznie używana przez kompilator definicja klasy pochodnej jest identyczna z tą, którą wpisujemy do kodu; nie zawiera żadnych pól i metod pochodzących z klas bazowych. Sztuczka polega na budowaniu z klocków - podczas tworzenia obiektu klasy pochodnej tworzony jest także  obiektu klasy bazowej - i łączony z obiektem pochodnym. Mają tą samą tożsamość, lecz tworzone są etapami. 

W C++ obowiązuje zasada, iż najpierw wywoływany jest konstruktor klasy najwyższej w hierarchii, a potem następne, zgodnie z kolejnością dziedziczenia.  Dlatego sam proces budowy obiektu jest wieloetapowy, i po drodze występuje wiele wywołań różnych konstruktorów. To dodatkowo pokazuje, że konstruktor jest specyficzną metodą - nie może być wirtualny, i nie działają na nim mechanizmy przysłaniania.