• Nie Znaleziono Wyników

Dziedziczenie

N/A
N/A
Protected

Academic year: 2021

Share "Dziedziczenie"

Copied!
10
0
0

Pełen tekst

(1)

1

Dziedziczenie

Ćwiczenie to poświęcone jest poznaniu podstawowych zagadnień związanych dziedziczeniem — procesem budowania nowych klas, w oparciu o klasy istniejące. Obejmuje m.in. ćwiczenia pozwalające opanować definiowanie klas pochodnych, definiowanie konstruktorów tych klas, redefinicję metod oraz rozbudowę listy pól. Materiał teoretyczny, niezbędny dla zrozumienia prezentowanych przykładów, zawierają materiały wykładowe dostępne online, w postaci dokumentu pdf, pod adresem: http://www.us.edu.pl/~siminski.

1.1 Koncepcja dziedziczenia

Koncepcja dziedziczenia (ang. inheritance) pozwala na budowanie nowych klas z wykorzystaniem klas już istniejących. Te nowe klasy, nazywane są klasami

pochodnymi, zaś klasy stanowiące podstawę dziedziczenia, nazywamy klasami bazowymi. Dziedziczenie jest zatem procesem tworzenia klas potomnych (ang. derivation).

Dziedziczenie pozwala urzeczywistnić pomysł powtórnego wykorzystania kodu. Koncepcja ta w oryginale nosi angielską nazwę code reusability. Dzięki temu podejściu nie trzeba tworzyć klas od nowa, o ile istnieją takie, które można rozszerzyć lub zaadaptować do stojących przed programistą zadań.

Prześledźmy to na następującym przykładzie. Załóżmy, że naszym zadaniem jest napisanie programu obliczającego pole i objętość sześcianu (ang. cube). Sześcian jest oparty na kwadracie, o jego polu i objętości decyduje długość boku jednej ze ścian. A każda z nich jest kwadratem. Można zatem założyć, że sześcian to specyficzny kwadrat – wyciągnięty w przestrzeni, obdarzony trzecim wymiarem. Ilustruje to Rysunek 1.

W trakcie opracowania programów z Ćwiczenia 1-szego, należało opracować klasę Square (kwadrat). Klasa ta definiowała kwadrat, jako figurę geometryczną określoną długością boku pamiętaną w polu o nazwie side (bok). Klasa ta definiowała również funkcję składową area, obliczającą pole kwadratu.

Nasuwa się pytanie – czy można wykorzystać istniejący już kod klasy Square do utworzenia klasy reprezentującej sześcian? Niech ta klasa nazywa się Cube. Rzeczywiście, klasa Square może posłużyć jako klasa bazowa do opracowania klasy

(2)

2

Cube. Niestety, funkcja area klasy Square oblicza pole kwadratu a nie sześcianu – trzeba będzie coś z tym zrobić. Klasa Square nie posiada również funkcji obliczającej objętość (ang. volume). Trzeba ją będzie zdefiniować.

Rysunek 1 Od kwadratu do sześcianu — koncepcja dziedziczenia Zacznijmy jednak od klasy Square. Jej definicja jest następująca:

class Square {

public : Square();

Square( double side );

void setSide( double side ); double getSide();

double area(); private:

double side; };

A definicje funkcji składowych mają następującą postać: Square::Square() : side( 0 )

{ }

Square::Square( double side ) : side( side ) {

}

void Square::setSide( double side ) { Square::side = side; } double Square::getSide() { return side; } double Square::area() bok bok Kwadrat Sześcian

(3)

}

Koncepcję dziedziczenia ilustruje Rysunek 2. Po lewej diagram wg. zunifikowanej notacji obiektowej (język UML). Umieszczono na nim inne od konstruktorów składowe klasy Square. Składowe poprzedzone znakiem (+) to składowe publiczne, znakiem (–) to składowe prywatne. Strzałka oznacza dziedziczenie, grot wskazuje klasę bazową. Prawa część rysunku symbolicznie ilustruje to, że obiekt klasy Cube będzie zawierał w sobie wszystko to, co obiekt klasy Square, oraz dodatkowe dwie funkcje składowe.

Cube

+double volume() +double area()

Square

+void setSide( double side ) +double getSide() +double area() -bok: double Square Cube +double volume() +double area()

+void setSide( double side ) +double getSide()

+double area() -bok: double

Rysunek 2 Diagram UML dla dziedziczenia

Spróbujmy zbudować klasę Cube z wykorzystaniem koncepcji dziedziczenia. Klasę pochodną rozpoczynamy od następującej definicji:

class Cube : public Square {

};

Specyfikacja umieszczona po znaku dwukropka oznacza, że klasa Cube powstaje z klasy Square, dziedzicząc wszystkie jej składowe – pola i funkcje składowe. Słowo kluczowe public oznacza dziedziczenie w trybie publicznym, niech to oznacza w tym momencie, że ustalona w klasie Square widoczność składowych będzie taka sama w klasie Cube.

W tym momencie można już od biedy korzystać z klasy Cube – oczywiście nie da się zrobić z obiektem tej klasy niczego więcej niż z obiektem klasy Square. Rozszerzmy klasę Cube o deklarację funkcji obliczania objętości – volume():

class Cube : public Square {

public:

double volume(); };

oraz jej definicję:

(4)

4 {

return side * side * side; }

Niestety, próba kompilacji tak zdefiniowanej funkcji się nie powiedzie. Pole side jest bowiem prywatną własnością obiektów klasy Square i nie jest dostępne w funkcjach klasy pochodnej. Należy skorzystać z akcesora – funkcji getSide():

double Cube::volume() {

return getSide() * getSide() * getSide(); }

Gdyby przyjrzeć się jednak wzorowi na objętość, można stwierdzić, że jest to iloczyn pola powierzchni podstawy i wysokości. Pole powierzchni podstawy to nic innego jak pole kwadratu. A klasa Square posiada przecież funkcję służącą do obliczania tegoż pola. Zatem funkcję volume() można przepisać jeszcze raz, w następujący sposób:

double Cube::volume() {

return area() * getSide(); }

Nową funkcję obliczającą objętość sześcianu można wykorzystać następująco: Cube c;

c.setSide( 10 );

cout << "Objetosc szescianu o boku: " << c.getSide(); cout << " wynosi: " << c.volume() << endl;

Podsumowanie

Podsumowując, możemy stwierdzić, że klasa Cube dziedziczy wszystkie właściwości klasy Square. Zatem posiada w sobie wszystkie pola, takie jak klasa Square, i wszystkie zdefiniowane w tej klasie funkcje składowe.

Wykorzystując dziedziczenie, udało się rozszerzyć funkcjonalność klasy bazowej o jedną, nową funkcję – volume(). Wszystko to stało się bez pisania dodatkowego kodu, dziedziczenie pozwoliło utworzyć nową, działająca klasę poprzez napisanie 8-miu nowych linii kodu!

1.2 Redefiniujemy funkcję area()

Zauważmy, że funkcja area() obliczająca w klasie Square pole kwadratu została odziedziczona przez klasę Cube. Programista może zatem ją wykorzystać:

(5)

c.setSide( 10 );

cout << "Pole szescianu o boku: " << c.getSide(); cout << " wynosi: " << c.area() << endl;;

Niestety, wynik będzie niepoprawny! Nie powinno to być zaskoczeniem – odziedziczona funkcja składowa area() liczy dokładnie pole kwadratu i w żaden cudowny sposób nie zacznie samodzielnie liczyć pola sześcianu!

Aby temu zaradzić należy w klasie Cube zadeklarować własną, specyficzną dla tej klasy wersję funkcji area(). Wersja ta, w obrębie tej klasy, przesłaniać będzie funkcję area() odziedziczoną po klasie Square. Inaczej mówiąc, deklarując klasę Cube dokonujemy redefinicji funkcji składowej area().

double Cube::area() {

return 6 * ( getSide() * getSide() ); }

Spróbujmy napisać program testujący dotychczasowe wyniki naszej pracy: Cube c;

c.setSide( 10 );

cout << "Szescian o boku: " << c.getSide() << endl; cout << " Objetosc: " << c.volume() << endl; cout << "Powierzchnia: " << c.area() << endl; Zobaczmy wyniki jego działania — prezentuje to Rysunek 3:

Rysunek 3 Objętość i powierzchnia — coś tu nie gra... .

Powierzchnia się zgadza. Objętość nie. Dlaczego? Przypomnijmy sobie, jak zdefiniowana została funkcja składowa obliczająca objętość:

double Cube::volume() {

return area() * getSide(); }

Pole razy bok… , niby dobrze… , ale… przecież to chodziło o pole kwadratu stanowiącego podstawę sześcianu! A my dokonaliśmy redefinicji funkcji area(), i ona teraz oblicza pole powierzchni nie kwadratu a sześcianu — a to jest sześciokrotnie większe — popatrzmy, na umieszczoną wyżej, definicję funkcji składowej Cube::area().

(6)

Co z tym zrobić? Funkcji volume() działa źle. Wywołuje funkcję obliczania pola sześcianu a nie kwadratu, stąd błędna wartość. Jak temu zaradzić? Odpowiedź tkwi w poprzednim zdaniu. Przeczytajmy je jeszcze raz. I co? Ano to, że funkcja volume() będzie działać dobrze, jeżeli zamiast funkcji area() klasy Cube, wywoła funkcję area() klasy Square. Ale jak to zrobić? Zobaczmy, czym różnią się definicje obu tych funkcji:

double Square::area() {

return side * side; }

oraz

double Cube::area() {

return 6 * ( getSide() * getSide() ); }

Oczywiście różnią się ich ciała. Ale przyjrzyjmy się nagłówkom. Nazwy niby takie same, ale przed nazwami…, no właśnie, przed nazwami są kwalifikatory nazw w postaci nazwy klasy i operatora zakresu (::). Takich nazw kwalifikowanych można używać również przy wywołaniach funkcji, zatem definicję funkcji składowej volume() można teraz przepisać w następujący sposób:

double Cube::volume() {

return Square::area() * getSide(); }

Cos dziwnego? W żadnym wypadku. Przecież objętość sześcianu to pole powierzchni podstawy, będącej kwadratem, przemnożone przez długość boku. I to właśnie napisaliśmy powyżej. Zobaczmy wyniki działania poprawionego programu — przedstawia je Rysunek 4.

Rysunek 4 Poprawiona funkcja volume() – wyniki działania programu

No, jest wyraźnie lepiej. Dokonajmy teraz kolejnych remanentów. Definicja funkcji area() klasy Cube wygląda nieco siermiężnie. Powierzchnia sześcianu to sześciokrotność pola powierzchni jednego boku — będącego, jak wiemy, kwadratem. Można zatem przepisać tą funkcję następująco:

double Cube::area() {

return 6 * Square::area(); }

(7)

piszący tą funkcję pomylił się i napisał ją następująco: double Cube::area()

{

return 6 * area(); }

Cóż takiego napisał? Ano napisał, że powierzchnia sześcianu to sześciokrotność powierzchni… sześcianu! Brak nazwy kwalifikowanej Square:: — a właśnie na jej zapomnieniu polegał błąd programisty — spowoduje, że funkcja area() będzie wywoływała samą siebie! Mamy tutaj swoistą, niezamierzoną rekurencję, bez warunku jej zakończenia. Jak się zachowa program w takiej wersji? Proponuję eksperyment — uruchomcie program z tak zdefiniowaną funkcją area(), skompilowany Waszym ulubionym kompilatorem, w Waszym ulubionym środowisku systemowym. Proponuję jednak przed uruchomieniem na wszelki wypadek zapisać wszystkie otwarte dokumentu.

Podsumowanie

Redefiniowanie funkcji składowych w klasach pochodnych pozwala na modyfikowanie ich działania tak, by było ono zgodne z wymaganiami stawianymi nowej klasie. Często jednak „przykryta” funkcja odziedziczona po klasie bazowej jest użyteczna. Aby się nią posłużyć, wystarczy jej wywołanie poprzedzić nazwą kwalifikowaną, zawierającą operator zakresu (::) poprzedzony nazwą klasy.

Stosowanie nazw kwalifikowanych jest dobrą praktyką. Świadczy o tym najlepiej analizowany wyżej przykład. Zapobiegliwe stosowanie takich nazw, nawet, jeżeli pozornie nie wydaje się to konieczne, pozwala w przyszłości uniknąć wielu, dokuczliwych i trudnych w zlokalizowaniu błędów.

1.3 Konstruktory klasy pochodnej

Klasa pochodna dziedziczy wszystkie składowe każdej klasy podstawowej, z wyjątkiem konstruktorów, destruktorów i operatorów przypisania. Warto sobie to zdanie zapamiętać, choć — przynajmniej teoretycznie — nie znamy jeszcze destruktorów i operatorów przypisania. Dla znamy już konstruktory.

Konstruktory nie są dziedziczone. Przykładowo, nie powiedzie się próba skompilowania przedstawionego niżej kodu:

Cube c( 10 );

cout << "Szescian o boku: " << c.getSide() << endl; cout << " Objetosc: " << c.volume() << endl; cout << " Powierzchnia: " << c.area() << endl; W klasie Square zdefiniowano konstruktor ogólny:

(8)

Square::Square( double side ) : side( side ) {

}

Nie zostanie on jednak aktywowany automatycznie dla obiektu klasy Cube. W klasie pochodnej programista powinien zdefiniować konstruktory na nowo. Istnieje pewne rozluźnienie tej zasady, dotyczące konstruktorów domyślnych (bezparametrowych). Rozluźnienie to jest jednak mocno dyskusyjne, sam twórca języka — Brajne Stroustrup — namawia do definiowania również konstruktorów domyślnych klas pochodnych. Zapamiętajmy zatem: przy tworzeniu klasy pochodnej, programista zdefiniować powinien wszystkie niezbędne dla niej konstruktory.

Aktywowanie konstruktora klasy bazowej

Przy budowaniu klas pochodnych kierujemy się następującą zasadą: klasie pochodnej definiujemy metody do obsługi nowych pól, obsługę pól odziedziczonych realizujemy z wykorzystaniem metod odziedziczonych. Mimo, że konstruktory klasy pochodnej nie są jawnie dziedziczone, programista ma do nich dostęp. Może zatem aktywować je, i przy ich użyciu inicjować odziedziczone pola klasy bazowej.

Definicja domyślnego konstruktora klasy Cube, inicjującego pole side z wykorzystaniem konstruktora klasy Square ma następującą postać:

Cube::Cube() : Square() {

}

Na liście inicjalizacyjnej konstruktora klasy Cube umieszczono aktywowanie konstruktora domyślnego klasy Square. Co on robi? Ano przypisuje polu side domyślną wartość równą zero — zobacz definicja konstruktora domyślnego klasy Square.

Dlaczego aktywowanie konstruktora klasy bazowej odbywa się poprze umieszczenie go na liście inicjalizacyjnej a nie poprze jego jawne wywołanie w ciele konstruktora klasy Cube? W języku C++ zwykle nie wywołuje się jawnie konstruktorów, stąd mówimy raczej o aktywowaniu konstruktora na liście inicjalizacyjnej a nie jego

wywołaniu. Lista inicjalizacyjna jest legalnym miejscem aktywowania konstruktora

klasy bazowej.

Definicja konstruktora ogólnego klasy Cube może być następująca: Cube::Cube( double side ) : Square( side )

{ }

Dlaczego w konstruktorach klasy Cube posługujemy się konstruktorami klasy bazowej a nie inicjujemy pola side własnoręcznie, np. w następujący sposób:

(9)

setSide( 0 ); }

Cube::Cube( double side ) {

setSide( side ); }

Owszem, można tak — ale jakież to nieeleganckie! Oprócz tego, że nieeleganckie to rozrzutne i niezgodne z ideą programowania obiektowego. Przypomnijmy — skoro konstruktor domyślny klasy Square jest po to, by zainicjować obiekt wartością domyślną pole side, to użyjmy go do zainicjowania tej właśnie części obiektu klasy Cube, która została odziedziczona z klasy Square.

1.4 Zadanie do wykonania

W ramach ćwiczeń należy napisać obiektową wersję programu, pozwalającego na obliczanie pola powierzchni i objętości brył takich jak:

• sześcian,

• prostopadłościan, • kula,

• graniastosłup o podstawie trójkąta.

Każda z brył ma być opisana w postaci klasy, analogicznie do tego w jaki sposób zdefiniowano klasę Cube. I tak, kula dziedziczy właściwości po klasie opisu koła, prostopadłościan po prostokącie, ostrosłupy odpowiednio po trójkącie i trapezie. Każda z brył ma mieć zdefiniowaną funkcję obliczania objętości oraz zdefiniowaną ponownie funkcję obliczania pola powierzchni.

W przypadku prostopadłościanu i graniastosłupa w klasie opisu danej bryły pojawić się musi nowe pole. W przypadku prostopadłościanu to długość krawędzi pionowej a w przypadku graniastosłupa to wysokość. Należy niezbędne pola zdefiniować w klasie pochodnej. Wszystkie niezbędne informacje na temat definiowania i inicjalizowania pól w klasie pochodnej zawierają materiały wykładowe, str. 28-31. Program ma działać analogicznie do programu obliczania pól figur płaskich z ćwiczenia 1. Ma być sterowany prostym menu tekstowy, pozwalającym na wybranie konkretnej bryły. Po jej wybraniu program mam wczytać parametry niezbędne do obliczenia pola powierzchni i objętości, co ma się odbyć obiektowo, w sposób analogiczny do opisanej klasy Cube.

(10)

Koncepcję utworzenia klasy opisu prostopadłościanu, w oparciu o klasę opisującą prostokąt ilustruje Rysunek 5. Klasa opisu prostopadłościanu posiadać będzie dodatkowe pole, przechowujące trzeci wymiar bryły.

a b a b c Prostokąt Prostopadłościan

Rysunek 5 Od prostokąta do prostopadłościanu

1.5 Co po tym ćwiczeniu należy umieć?

Zakładam, że po tym ćwiczeniu każdy potrafi odpowiedzieć na pytanie i zastosować te wiadomości w praktyce:

1. Co to jest dziedziczenie?

2. Co to jest klasa bazowa a co klasa pochodna?

3. W jaki sposób dokonać rozszerzenia klasy pochodnej nowe pola i funkcje składowe?

4. W jaki sposób dokonuje się redefinicji funkcji składowej w klasie pochodnej i w jaki sposób odwołuje się do przesłoniętej funkcji składowej? 5. W jaki sposób definiuje się konstruktory klasy pochodnej, jak wygląda

Cytaty

Powiązane dokumenty

- Domyślnie, jeżeli przed składową klasy nie występuje żadne określenie, dostęp jest pakietowy - dostęp do tej składowej mają wszystkie klasy wchodzące w skład danego

Ale gdy chcesz napisać, jaką mają długość to zakreślasz nazwy literowe w pionowe kreski, które czytamy, jako wartość bezwględna (wyjaśnione to będzie w klasach starszych).

[r]

Jeśli w grafie istnieje spójna składowa taka, że żaden spośród jej wierzchołków nie G należy do W , to poprawną odpowiedzią jest.. Jej definicja znajduje się w pliku

twierdzenie Lebesgue’a o dekompozycji rozkładów mieszanych, różne rodzaje zbieżności zmiennych losowych, charakterystyki liczbowe zmiennych losowych:.. wartość oczekiwana,

Forma i warunki zaliczenia przedmiotu Jedno kolokwium - do zdobycia 80 punktów oraz 5 serii zadań dających łącznie 20 punktów.. Warunkiem zaliczenia ćwiczeń jest zdobycie 50

wiadomości: pojęcie przestrzeni probabilistycznej, prawdopodobieństwa, zmiennej losowej jednowymiarowej i jej parametrów liczbowych, nierówności dla momentów zmiennych,

Modele wielorównaniowe: postać strukturalna i zredukowana modelu, klasyfikacja modeli wielorównaniowych, szacowanie parametrów modeli prostych i rekurencyjnych, identyfikowalność