pdf z opisem i zadaniami

Transkrypt

pdf z opisem i zadaniami
Ćwiczenie 3
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
Sześcian
Kwadrat
bok
bok
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()
{
return side * side;
}
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.
Square
+void setSide( double side )
+double getSide()
+double area()
-bok: double
Cube
Square
+void setSide( double side )
+double getSide()
+double area()
-bok: double
Cube
+double volume()
+double area()
+double volume()
+double area()
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ę:
double Cube::volume()
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ć:
Cube c;
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().
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();
}
W tej wersji funkcji volume() czai się jednak pułapka. Załóżmy, że programista
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:
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
z wykorzystaniem konstruktora klasy Square ma następującą postać:
pole
side
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:
Cube::Cube()
{
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.
b
b
c
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
Prostokąt
a
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
aktywowanie konstruktorów klas bazowych?

Podobne dokumenty