To jest chyba najistotniejszy rozdział w całym kursie. Dziedziczenie to specjalny mechanizm, który powstał w celu udoskonalenia już wspaniałych klas. Umożliwia on tworzenie klas pochodnych, czyli będących niejako rozbudowaną wersją klas już istniejących. W połączeniu z funkcjami wirtualnymi, czyli polimorfizmem stanowi świetne narzędzie programistyczne. Dopiero umiejętność tworzenia klas pochodnych i definiowania funkcji wirtualnych daje możliwość stworzenia stuprocentowego programu orientowanego obiektowo [nie mylić z programowaniem obiektowym, to dwie różne rzeczy!]. Zacznijmy od dziedziczenia.

dziedziczenie
Dziedziczenie jak już powiedziałem na wstępie to świetne narzędzie programistyczne. Ogólnie mówiąc polega ono na tworzeniu nowych klas na podstawie już istniejących. Tylko co to oznacza w praktyce? Aby łatwo było to zrozumieć znów mała analogia. Powiedzmy, że piszesz program w stylu wyścigów samochodowych. Podstawowym elementem programu będą właśnie owe pojazdy. Najwygodniej jest zrealizować to w postaci klas. Tak więc zdefiniujmy sobie taką klasę.

Taka mała klasa na początek powinna wystarczyć. Znajdują się w niej zaledwie cztery składniki opisujące pojazd. Dla ułatwienia wszystkie one są publiczne. Jeden pojazd to jednak zbyt mało. Chcesz dać graczowi możliwość wyboru i tworzysz pojazd z dopalaczem 🙂

Przypatrz się teraz obu tym klasom. Czy zauważasz coś ciekawego? Obie klasy są niemal jednakowe. Odróżnia je zaledwie jeden detal. Jest to dopalacz w klasie drugiej. Zatem klasa super_pojazd jest jakby rozbudowaną klasą pojazd. Taka mała różnica i trzeba było definiować od nowa całą klasę. Czy nie można by zrobić tego jakoś szybciej i krócej? Okazuje się, że można. Wystarczy skorzystać tutaj z dziedziczenia. Realizacja tego jest na prawdę prosta, a późniejsze korzyści są ogromne. Zamiast pisać na piechotę całą klasę super_pojazd wystarczy poinformować kompilator, że ta klasa jest rozwiniętą wersją klasy pojazd. Robi się to tak:

Prawda, że krócej! W wyniku takiego zapisu otrzymaliśmy klasę super_pojazd, która jest pochodną od klasy pojazd. Oznacza to, że zawiera ona wszystkie wady i zalety klasy swojego przodka. Składniowa chyba wszystko jest jasne. Tuż na nazwą klasy pochodnej stawiamy [u nas super_pojazd] dwukropek. Teraz trzeba jeszcze określić sposób dziedziczenia. Ustala się to za pomocą etykiet, które już poznałeś. Dziedziczenie prywatne oznacza, że wszystkie składniki klasy podstawowej staną się składnikami prywatnymi w klasie pochodnej. Podczas dziedziczenia chronionego składniki prywatne klasy podstawowej będą również prywatne w klasie pochodnej. Zmienią się składniki publiczne i chronione. W klasie pochodnej będą chronione. Dziedziczenie publiczne jest najprostsze i chyba najczęściej stosowane. Praktycznie nie powoduje żadnych zmian. Dostęp do składników odziedziczonych jest nadal taki sam, jak w klasie podstawowej. Jeżeli jest to dla Ciebie nieco zawiłe, to poniżej znajduje się bardziej obrazowa wersja togo, co powiedziałem.

składniki klasy podstawowej sposób dziedziczenia składniki klasy pochodnej
składniki prywatne
składniki chronione
składniki publiczne
prywatne składniki prywatne
składniki prywatne
składniki chronione
składniki publiczne
chronione składniki prywatne
składniki chronione
składniki chronione
składniki prywatne
składniki chronione
składniki publiczne
publiczne składniki prywatne
składniki chronione
składniki publiczne

Podczas dziedziczenia zawartość klasy podstawowej staje się automatycznie zawartością klasy pochodnej. Zatem wszystkie składniki i funkcje składowe stają się dostępne w klasie odziedziczonej. Oczywiście ta dostępność jest uwarunkowana sposobem dziedziczenia. W powyższym przykładzie celowo użyłem składników publicznych. Co jednak stałyby się gdybyśmy użyli składników prywatnych? Odpowiedź jest prosta. Wówczas nie mielibyśmy do nich dostępu. Dodajmy teraz kilka funkcji do naszych klas.

Do naszej klasy pojazd dodaliśmy dwie funkcje informujące o aktualnej prędkości i przyspieszeniu pojazdu. Rozbudowaliśmy trochę klasę pojazd. Teraz zajmijmy się klasą super_pojazd.

Do klasy super_pojazd dodaliśmy dwie funkcje. Pierwsza zwraca stan dopalacza. Druga jest nieco ciekawsza – daje nam kopa, włączając dopalacz 😀 W klasie pojazd znajduje się prywatny składnik predkosc. Jest on dziedziczony przez klasę super_pojazd.Dziedziczenie jest publiczne, zatem dostęp do składników nie zmienia się. W klasie super_pojazd wszystkie składniki są nadal prywatne. Teraz uważaj! Oznacza, to że w klasie nie można się do nich odwołać z klasy pochodnej :-O Wiesz, co to oznacza? Składniki klasy pojazd są teraz zawartością klasy super_pojazd. Problem w tym, że nie ma do nich dostępu! Skoro tak jest to funkcja wlacz_dopalacz nie zostanie skompilowana. Jak więc temu zaradzić? Jak można odnieść się do prywatnych składników klasy podstawowej z wnętrza klasy pochodnej? Jest sposób i już go znasz. Przypomnij sobie etykietę protected. Nadmieniłem o niej jakiś czas temu. Powiedziałem, że działa podobnie, jak etykieta private. Teraz wyjaśnię, jakie są różnice. Etykieta protected została zaprojektowana z myślą o dziedziczeniu. Składniki klasy oznaczone tą etykietą są traktowane w klasie podstawowej jako prywatne. Jednakże w przeciwieństwie do składników prywatnych są dostępne w klasach pochodnych. Zatem, aby funkcja wlacz_dopalacz zadziałała należy zastosować etykietę protected zamiast private w klasie pojazd i po sprawie. Należy tutaj jednak pamiętać, że nie dziedziczy się kilku rzeczy. Po pierwsze nie dziedziczy się konstruktorów. To chyba oczywiste. Konstruktor zawsze posiada nazwę swojej klasy. Klasa pochodna ma inną nazwę, zatem konstruktor też będzie się inaczej nazywał. Drugim elementem, którego się nie dziedziczy jest destruktor. Tutaj również jest sprawa prosta. Destruktor nazywa się tak jak klasa, do której należy. Jego nazwa jest dodatkowo poprzedzona wężykiem. Kolejna rzecz to operator przypisania. Jak sądzę teraz jeszcze nie dostrzegasz korzyści płynących z dziedziczenia. Samo dziedziczenie to jeszcze nic. Dopiero w połączeniu z funkcjami wirtualnymi staje się przydatne. Właśnie o tym teraz pogadamy.

funkcje wirtualne
Zacznę trochę nietypowo. Przypuśćmy, że piszesz program. Niech to będzie program graficzny do rysowania brył przestrzennych. Wszystkie bryły będą powiązane ze sobą związkami dziedziczenia. W zależności od rodzaju bryły sposób jej narysowania będzie inny. Zatem w programie umieszczasz liczne instrukcje warunkowe, jak if, czy switch. Po pewnym czasie zamierzasz dodać kilka brył. Należy zdefiniować nową klasę i.. no właśnie dokonać modyfikacji kodu. Niby nie problem. Wystarczy odszukać wszystkie wystąpienia instrukcji warunkowych dotyczących poszczególnych brył i dodać kilka linijek kodu. Wydaje się proste. Jednakże w rzeczywistości jest inaczej. Tego typu modyfikacje są bardzo czasochłonne, a ich wykonanie naprawdę bardzo męczące. Ponadto trzeba ingerować w kod źródłowy, co nie zawsze jest osiągalne. Jak może zauważyłeś, zawsze kiedy tak narzekam chcę przedstawić jakieś rewelacyjne rozwiązanie. Tym razem nie będzie inaczej. Polimorfizm to narzędzie, na pewno polubisz. Funkcje wirtualne, bo o nich właśnie mowa to jakby 'inteligentne’ funkcje, które potrafią się dostosować do samego programu :-/ Stworzenie takiej funkcje jest naprawdę banalne i skupia się na dodaniu tylko jednego słówka przed deklaracją funkcji! Wystarczy poprzedzić deklarację słówkiem virtual i program stanie się 'inteligentny’ 🙂

Zdefiniowaliśmy sobie klasę bryla, która służy do przechowywania informacji o bryle przestrzennej. Sama klasa nie reprezentuje żadnego konkretnego obiektu. Jest klasą abstrakcyjną i służy jedynie do dziedziczenia.

Teraz dodaliśmy kilka klas. Każda z nich jest pochodną od klasy bryla. Każda z nich posiada składnik kolor oraz ilosc_wierzcholkow. Dodatkowo niektóre z brył posiadają jeszcze inne składniki. Oczywiście to ma tylko charakter demonstracyjny. Skorzystajmy teraz z naszych klas.

Mając już zdefiniowane obiekty możemy się do nich odwoływać za pomocą funkcji składowych w taki sposób:

Wówczas wywołaliśmy na rzecz każdego z obiektów funkcję składową rysuj. To nie jest żadna nowość. O tym już wiemy od dawna. Teraz definiujemy sobie kilka wskaźników mogących pokazywać na poszczególne bryły:

Teraz ustawiamy odpowiednio wskaźniki. Pamiętasz jeszcze jak się to robi?

Za pomocą tak ustawionych wskaźników także możemy wywoływać funkcje składowe. Zobacz, to zrealizować:

Prawda, że fajnie? Jednak co nam po tym? Praktycznie to tylko niewielkie przyspieszenie, bo jak pamiętasz operacje na wskaźnikach są wykonywane szybciej. W przykładzie zdefiniowaliśmy sobie cztery różne wskaźniki mogące pokazywać na obiekty poszczególnych klas. Teraz będzie najważniejsze! Okazuje się, że wcale nie potrzebujemy aż czterech wskaźników! Wystarczy tylko jeden, który będzie mógł pokazywać na wszystkie cztery obiekty. Pamiętasz jeszcze operator rzutowania? Przy jego użyciu mogliśmy zmienić rodzaj wskaźnika. Na nasze szczęście wskaźniki zostały tak zaprojektowane, aby usprawnić tego typu operacje. Zapamiętaj sobie: wskaźnik do klasy podstawowej może zostać niejawnie skonwertowany na wskaźnik do klasy pochodnej. Czy zdajesz sobie z tego sprawę? Mamy tylko jeden wskaźnik do klasy bryla. Może on pokazywać na obiekty klasy bryła, jak również na obiekty klas pochodnych. Konkretnie można go ustawić na obiekcie klasy szescian, walec, czy stozek! Nic nie stoi na przeszkodzie, aby to zrobić. Tutaj dotarliśmy do sedna sprawy. To jest właśnie polimorfizm, czyli ta 'inteligencja’. Popatrz teraz jak przedstawia się on od strony składniowej. Żeby było przejrzyściej napiszmy raz jeszcze definicje powyższych klas. Jednak tym razem zróbmy to 'inteligentnie’. Popatrz uważnie:

Przy deklaracji funkcji rysuj w klasie bryla pojawiło się słówko virtual. Informuje ono kompilator, iż dana funkcja jest funkcją wirtualną. Oznacza to, że podczas wywoływania funkcji za pomocą wskaźnika wystartuje funkcja obiektu aktualnie pokazywanego przez wskaźnik. Popatrz na przykład, a zrozumiesz:

Najpierw definiujemy sobie wskaźnik do obiektów klasy bryla. Później są definicje kilku obiektów klas pochodnych od niej. Sam wskaźnik służy do pokazywania na obiekty klasy bryla. W wyniku ustawienia go na obiekt klasy szescian, która jest klasą pochodną nastąpi niejawna konwersja wskaźnika. Korzystając teraz z tak ustawionego wskaźnika możemy wywołać dowolną nie-prywatną funkcję składową. Wywołujemy funkcję rysuj, która jest funkcją wirtualną. Raz jeszcze przypomnijmy. Wskaźnik wsk_bryla służy do pokazywania na obiekty klacy bryla. Zatem w wyniku wywołania funkcji za jego pośrednictwem do pracy powinna ruszyć funkcja rysuj z klasy bryla. A jednak tak się nie stanie! Wystartuje funkcja rysuj z klasy szescian. Wszystko to za sprawą tego jednego słówka virtual postawionego prze deklaracji funkcji rysuj w klasie podstawowej. Na koniec mała zagadka. Napisałeś program z użyciem klasy bryla. Sama klasa jest w wersji skompilowanej, zatem nie masz do niej dostępu. Chcesz rozbudować swój program. Tworzysz kilka klas pochodnych od klasy bryla. Oczywiście wszystkie one posiadają funkcję rysuj. W programie występuje taki fragment:

Na początku jest definicja wskaźnika. Następnie definicja obiektu klasy nowa_bryla będącą klasą pochodną od klasy bryla. Sama klasa bryla jest już skompilowana i nic nie wie o klasie nowa_bryla. Czy zatem wywołanie funkcje rysuj z klasy nowa_bryla zostanie uznane za błąd? Okazuje się, że nie! Dzięki temu, że funkcja rysuj w klasie podstawowej jest funkcją wirtualną program staje się bardzo elastyczny na takie modyfikacje. To jest podstawa dobrego programu orientowanego obiektowo. Dzięki temu program jest bardziej 'inteligentny’ i potrafi odpowiednio zareagować na późniejsze zmiany. To jest naprawdę świetne! Być może jeszcze tego nie dostrzegasz, ale wkrótce na pewno docenisz wspaniałość dziedziczenia i polimorfizmu. Wierz mi, umiejętność zaprojektowania takiego programu bardzo się przydaje i pozwala zaoszczędzić mnóstwo pracy i czasu.

Autor: Kesay

[Artykuł pochodzi ze strony guidecpp.prv.pl, autor wyraził zgodę na publikację]