Dziedziczenie i polimorfizm

Dziedziczenie

Klasy podstawowe i pochodne

Dziedziczenie to jeden z najważniejszych mechanizmów programowania obiektowego. Polega on na ponownym wykorzystaniu kodu w taki sposób, że nowe klasy tworzone są na podstawie już istniejących dziedzicząc jej metody i pola i jednocześnie dodając nowe metody i nowe pola.

Dziedziczenie

Aby zapisać relację dziedziczenia w C++ stosuje się następujący schemat:

class NazwaKlasyPochodnej : Tryb dziedziczenia NazwaKlasyBazowej { definicja klasy pochodnej }

Chcąc zapisać W C++ relację z rysunku, moglibyśmy to zrobić w następujący sposób:

class CzlonekUczelni{...};
class Student: public CzlonekUczelni{...};
class Pracownik: public CzlonekUczelni{...};
class PracownikNaukowy : public Pracownik{...}
class PradownikAdministracyjny: public Pracownik{...};
//Dziedziczenie wielokrotne
class Stażysta : public Student, PracownikNaukowy {...};

Dziedziczenie składowych

Dostęp do składowych klasy bazowej może być ograniczony modyfikatorami dostępu zastosowanymi w klasie bazowej jak również typem dziedziczenia.

Poniższa tabela przedstawia zależności pomiędzy trybem dziedziczenia a tym jak zmienia sie znaczenie modyfikatorów dostępu w klasie pochodnej.

Modyfikator dostępu w klasie bazowejRodzaj dziedziczenia
publicprotectedprivate
publicpublic w klasie pochodnej.protected w klasie pochodnejprivate w klasie pochodnej
protectedprotected w klasie pochodnej protected w klasie pochodnejprivate w klasie pochodnej
privateUkryte w klasie pochodnejUkryte w klasie pochodnejUkryte w klasie pochodnej

Konstruktory i destruktory

Klasa pochodna tak naprawdę jest jednocześnie klasą bazową, dlatego nie można utworzyć jej obiektu bez wcześniejszego utworzenia obiektu klasy bazowej. Każdy konstruktor klasy pochodnej w pierwszej kolejności będzie wywoływał konstruktor klasy bazowej.

Dziedziczenie

Konstruktora klasy bazowej nie można wywołać w ciele konstruktora klasy pochodnej, ponieważ klasy pochodne dziedziczą pola klasy podstawowej, a wszystkie pola klasy muszą zostać utworzone przed utworzeniem danej klasy.

Chcąc wywołać konstruktor klasy Punkt2D przez konstruktor klasy pochodnej Punkt3D stosujemy następujący zapis:

#include "Punkt2D.h"
#include "Punkt3D.h"
 
Punkt3D::Punkt3D(double x, doubley, double _z) : Punkt2D(x,y) {
z = _z;
}

Metody składowe

Ponieważ każdy obiekt klasy pochodnej jest jednocześnie obiektem klasy bazowej nie ma żadnych różnic pomiędzy używaniem metod klasy bazowej i pochodnej w klasie pochodnej, czy tez na obiekcie klasy pochodnej. Należy jednak pamiętać o trybach dziedziczenia i ograniczeń jakie z tego wynikają.

W przypadku gdy w klasie pochodnej zdefiniowana zostanie ponownie taka sama metoda jak w klasie bazowej, nastąpi tak zwane napisanie tej metody. Wywołując tą metodę na obiekcie klasy pochodnej będzie wywoływana metoda nowo zdefiniowana.

Jeśli klasa bazowa ma przeciążony operator=, nie zostanie on odziedziczony przez klasę pochodną! (pozostałe zostaną odziedziczone)

#include <iostream>
using namespace std;
 
class LiczbaRzeczywista{
  protected:
    double re;
  public:
    LiczbaRzeczywista(double r){re=r;}
    void wypisz(){
      cout << re << endl;
    }
    void powitaj(){
      cout << "Czesc!" << endl;
    }
 
    LiczbaRzeczywista operator+(const LiczbaRzeczywista& r){
      LiczbaRzeczywista rr(re+r.re);
      return rr;
    }
 
};
 
class LiczbaZespolona : public LiczbaRzeczywista{
  protected:
    double im;
  public:
    LiczbaZespolona(double re, double i):LiczbaRzeczywista(re){im=i;}
    void wypisz(){
      cout << re << " + " << im << "i" << endl;
    }
};
 
int main(){
  LiczbaRzeczywista a(12);
  LiczbaZespolona b(23,5);
 
  a.powitaj();         //Czesc!
  a.wypisz();          //12
  (a+a).wypisz();      //24
 
  b.powitaj();         //Czesc!
  b.wypisz();          //23 + 5I
 
 
  (b+b).wypisz();      //Co się wypisze?
}            

Polimorfizm

Polimorfizm to możliwość różnej odpowiedzi na ten sam komunikat (wywołanie tej samej metody) przez obiekty różnych klas powiązanych dziedziczeniem. Poniższe podsekcje składają się na opis tego mechanizmu.

Funkcje wirtualne

Podczas dziedziczenia, klasy pochodne mogą nadpisywać metody swoich klas bazowych. Kiedy jednak dokonujemy rzutowania w górę (np. z LiczbaUrojona na LiczbaRzeczywista), nadpisane metody wracają z powrotem do swoich pierwotnych postaci (np. obliczanie pola przekroju powierzchni kuli za pomocą rzutowania na koło). Takie zachowanie nie zawsze jest pożądane.

Załóżmy, że chcemy zbudować prosty program obsługujący bazę pracowników pewnej firmy. Zakładamy, że firma zatrudnia pracowników na umowę o dzieło, lub na umowę o pracę; wpływa to na obliczenie pensji netto każdego pracownika.

Mamy zatem taką relację:

Umowa - diagram UML

Taka relacja umożliwia stworzenie tylko jednej klasy Pracownik, która będzie miała jedno pole Umowa, któremu z kolei będzie przypisywana abo UmowaDzielo albo UmowaPraca. W takim wypadku po rzutowaniu UmowaDzielo lub UmowaPraca na typ bazowy Umowa, musi być możliwe wywołanie metod odpowiednio obliczających wynagrodzenie netto. W tym celu stosowane są funkcje wirtualne.

Funkcja wirtualna, to taka metoda klasy, która nadpisana w klasie pochodnej, nawet po rzutowaniu obiektu na typ bazowy zachowa swoją implementacje. Klasa bazowa z funkcją wirtualną będzie polimorficzna - w zależności od tego jaki obiekt klasy pochodnej był na nią rzutowany, taka implementacja metody zostanie uruchomiona.

Przykład:

#include <iostream>
#include <list>
 
using namespace std;
 
class Umowa{
  protected:
    double wynagrodzenieBrutto;
  public:
    Umowa(double pensja):wynagrodzenieBrutto(pensja){};
    virtual double pobierzNetto();
    double pobierzBrutto();
};
 
class UmowaDzielo: public Umowa{
  public:
    UmowaDzielo(double pensja):Umowa(pensja){};
    virtual double pobierzNetto();
};
 
class UmowaPraca: public Umowa{
  public:
    UmowaPraca(double pensja):Umowa(pensja){};
    virtual double pobierzNetto();
};
 
class Pracownik{
  private:
    string imie,nazwisko,pesel;
    Umowa* umowa;
  public:
    Pracownik(string i,string n,string p,Umowa* u)
      :imie(i),nazwisko(n),pesel(p),umowa(u){};
    Pracownik(const Pracownik&);
    ~Pracownik(){};
    double pobierzPensje();
    friend ostream& operator<<(ostream&,Pracownik&);
};

Stary dobry C

Przypomnijmy sobie zadanie z wcześniejszych laboratoriów z utworzeniem tablicy jednowymiarowej. Wtedy algorytm składał się z dwóch kroków alokacja pamięci i inicjalizacja wartości tablicy. Ale co powinniśmy zrobić, gdyby była potrzeba zdefiniowania kilku różnych sposobów wypełniania tablicy w zależności od tego co zażyczy sobie użytkownik (a więc nie możemy tego przewidzieć w trakcie pisania programu) :?:

Kod mógłby wyglądać następująco:

int *CreateArray(size_t size, int version_of_filling) {
   int *tab = NewArray(size);
   FillArray(tab, size, version_of_filling);
}
 
void FillArray(int *tab, size_t size, int version_of_filling) {
   switch(version_of_filling) {
     case 0: {//INIT TO 0s
        for (int i=0; i<size; i++) {
           tab[i] = 0;
        }
        break;
     }
     case 1: {//INIT TO NUMBER?
        for (int i=0; i<size; i++) {
           tab[i] = NUMBER; //skąd ją wziąć? dołożyć nowy parametr do obydwu funkcji?
        }
        break;
     }
     //tutaj trzeba będzie pamiętać o dołożeniu kolejnego algorytmu jak pojawi się kolejne rozszerzenie
   }
}

Ten kod ma jednak sporo wad przy większej ilości i komplikacji algorytmów metoda Fill się niebezpiecznie rozrasta. Należy pamiętać, że jak pojawi się nowe wymaganie to należy dodać nowy warunek do switcha, itd…

Czy da się to zrobić jednak inaczej? Tak z wykorzystaniem wskaźników do funkcji :!:

int *CreateArray(size_t size, int (*filler)(int)) {
   int *tab = NewArray(size);
   FillArray(tab, size, version_of_filling);
}
 
void FillArray(int *tab, size_t size, int (*filler)(int)) {
    for (int i=0; i<size; i++) {
       tab[i] = filler(i);
    }
}

Kod FillArray znacznie się uprościł, ale żeby pozostawić poprzednią funkcjonalność musimy jeszcze dopisać brakujące metody. Każda metoda, która spełnia zadany interfejs się nada (tzn. metoda musi zwracać int jako wartość pola tablicy i przyjmować int jako index ustawianego pola):

int UniformFillWithZero(int index) {
  return 0;
}
 
int UniformFillWith77(int index) {
  return 77;
}
 
int IncrementalFill(int index) {
  return 8 * index + 14;
}

I przykładowe wywołanie kodu:

int * tab = CreateArray(1024, IncrementalFill);

Znacznie lepiej, ale wciąż musimy zadeklarować mnóstwo funkcji jeśli byśmy chcieli chociażby wypełnić tablicę identycznymi liczbami, ale każdą kolejną tablicę inną wartością. Nasze funkcje są pozbawione niestety kontekstu :!:

Jednak kontekst można dołożyć do każdej z tych funkcji w postaci pomocniczej struktury danych, która dodatkowo będzie przechowywać kontekst dla funkcji:

struct Filler {
  int (*fill)(Filler *, int);
};
 
void FillArray(int *tab, size_t size, Filler *filler) {
    for (int i=0; i<size; i++) {
       tab[i] = filler->fill(filler, i);
    }
}

I wypełniacze:

//sepcyficzna struktura kontekstu pasująca do jednorodnego wypełniacza
struct UniformFiller {
  int (*fill)(UniformFiller *,int);
  int value;
};
 
//context to self z Pythona!
int UniformFillerMethod(UniformFiller *context, int index) {
  return context->value;
}
 
//inicjalizacja naszej struktury danych ustawienie wskaźnika do odpowiedniej funkcji
//Python i C++ robią to automatycznie
UniformFiller f;
f.fill = UniformFillerMethod; 
f.value = 77;
 
//wywołanie utworzenia tablicy
int *tab = CreateTable(1024,(Filler*)f);

Wracając do C++

Zdefiniowanie w klasie metody jako wirtualnej jest równoważne do zdefiniowania wskaźnika do funkcji. Klasa pochodna ustawia tylko wskaźnik na swoją wersję metody. Jeśli definiujemy metodę wirtualną jako abstrakcyjną jest to równoważne z nieustawieniem żadnej metody dla wskaźnika funkcji. Context jest automatycznie przesyłany do metody jako wskaźnik this.

A teraz kod (odpowiednik struktury Filler z C i metodą Value odpowiednikiem wskaźnika do funkcji fill):

class ArrayFill {
 public:
  virtual int Value(int index) const =0;
};

Odpowiednik FillArray:

void FillArray(size_t size, const ArrayFill &filler, std::vector<int> *v) {
  v->clear();
  v->reserve(size);
  for (size_t i = 0; i < size; i++) {
    v->emplace_back(filler.Value(i));
  }
}

I wreszcie odpowiednik UniformFiller (C++ sam ustawi nasz wskaźnik na odpowiednią metodę, do tego jak się pomylimy kompilator jest w stanie wychwycić czy w ogóle przesłaniamy odpowiednią metodę i czy ona instnieje:

class UniformFill : public ArrayFill {
 public:
  UniformFill(int value = 0) : value_{value} {}
  virtual int Value(int index) const override;
 private:
  int value_;
};
 
 
int UniformFill::Value(int index) const {
  return value_;
}

I wreszcie wywołanie:

std::vector<int> vs;
FillArray(1024, UniformFill {77}, &vs);

Destruktory wirtualne

Zwróć uwagę, że podobnie do metod zachowują się destruktory. Jeśli obiekt jest jawnie niszczony prze operator delete to wywoływany jest jego destruktor:

#include <iostream>
...
int main(){
  Umowa* umowa = new UmowaPraca(10000);
 
  // zostanie wywołany destruktor klasy Umowa, a nie UmowaPraca!
  // Jeśli UmowaPraca alokowałaby dodatkowo jakąś pamięć
  // NIE ZOSTAŁABY ONA ZWOLNIONA
  delete umowa;
}

Rozwiązaniem jest deklarowanie destruktora jako wirtualnego:

class Umowa{
  ...
  virtual ~Umowa();
};
 
class UmowaPraca :public Umowa{
  ...
  virtual ~UmowaPraca();
};
 
int main(){
  Umowa* umowa = new UmowaPraca(10000);
 
  // zostanie wywołany destruktor klasy UmowaPraca
  delete umowa;
}

Klasy abstrakcyjne

Czasem w klasie bazowej implementowanie jakiejś metody jest bezcelowe. W naszym przykładzie z pracownikami implementowanie metody pobierzNetto w klasie Umowa nie ma sensu, bo nie wiadomo jaki jest to typ umowy.

Metody takie można zadeklarować jako czysto wirtualne:

class UmowaDzielo: public Umowa{
  protected:
    double wynagrodzenieBrutto;
  public:
    Umowa(double pensja):wynagrodzenieBrutto(pensja){};
    // Metoda czysta. Nie posiada implementacji w klasie bazowej.
    // MUSI jednak być zaimplementowana w pochodnej (lub ponownie 
    // zadeklarowana jako czysta
    virtual double pobierzNetto() = 0;
};

Klasę zawierającą choć jedną metodę czystą nazywamy klasą abstrakcyjną. Nie jest możliwe utworzenie obiektu takiej klasy! Służy ona jedynie jako klasa bazowa dla innych klas. Swego rodzaju interfejs.

Rzutowanie

Rzutowanie to inaczej konwersja pomiędzy typami. Wyróżnia się dwa rodzaje rzutowania:

  • rzutowanie w górę (z klasy pochodnej na klasę bazową)
  • rzutowanie w dół (Z klasy bazowej na klasę pochodną)

Rzutowanie w górę

Rzutowanie w górę można wykonywać niejawnie, ponieważ każdy obiekt klasy pochodnej jest jednocześnie obiektem klasy bazowej:

void foore(LiczbaRZeczywista r){
  cout << "Wyswietlamrzeczywista ";
  r.wypisz();
}
 
int main(){
  LiczbaZespolona z(12,4);
 
  foore(z);  //nastąpi rzutowanie w górę
 
}

Rzutowanie w dół

Rzutowanie w dół oznacza rzutowanie z klasy bazowej na pochodną. Jeśli nie zostanie przeciążony odpowiedni operator rzutowania, operację taką należy wykonywać przy użyciu jednego z poniższych operatorów:

OperatorOpis
static_cast<nowy_typ>(wyrazenie)Sprawdzanie poprawności typów podczas rzutowania wykonywane jest podczas kompilacji
const_cast<nowy_typ>(wyrazenie)Może być stosowany tylko ze wskaźnikami lub referencjami. Pozwala na anulowanie stałości wskaźnika obiektu (const).
dynamic_cast<nowy_typ>(wyrazenie)Może być stosowany tylko ze wskaźnikami lub referencjami.Pozwala na rzutowanie w górę (dla wszystkich klas) i na rzutowanie w dół (tylko dla klas polimorficznych), sprawdzając czy obiekt który rzutujemy jest faktycznie kompletnym obiektem klasy na którą chcemy go rzutować. Operator przeprowadza ten test podczas działania programu; Jeśli konwersja nie może się odbyć, zwracany jest wskaźnik null ( w przypadku referencji rzucany jest wyjątek bad_cast)
reinterpretc_cast<nowy_typ>(wyrazenie)Stosowanie tego operatora jest potencjalnie bardzo niebezpieczne. Może on rzutować dowolny typ na dowolny inny typ, nawet jeśli oba typy nie mają ze sobą nic wspólnego.

Poniżej przedstawiono przykłady użycia operatorów i różnice w ich stosowaniu:

class A {
  public:
    virtual void foo() = 0;
};
 
class B: public A{
  public:
    virtual void foo(){}
};
 
class C {
  public:
    void bar(){};
};
 
class D: public C{
  public:
    void otherbar(){}
};
 
int main(){
  // static_cast - zwykłe rzutowanie w górę
  D* ptrD = new D();
  C* ptrC = static_cast<C*>(ptrD);
 
  // const_cast - pozbywamy się modyfikatora const
  const C* constPtrC = new C();
  C* ptrCC = const_cast<C*>(constPtrC);
 
  // dynamic_cast - rzutowanie w dół
  A* ptrA = new B();
  B* ptrB = dynamic_cast<B*>(ptrA);
 
  // reinterpret_cast - rzutowanie pomiedzy kompletnie niepowiazanymi klasami
  B* ptrBB = new B();
  D* ptrDD = reinterpret_cast<D*>(ptrBB);
}

Ćwiczenia

UWAGA
Przesyłając rozwiązania zadań należy zamieścić (w formie komentarza w kodzie) odpowiedzi na problemy postawione w zadaniach 5-7 wraz z uzasadnieniami. Zadanie bez właściwego uzasadnienia nie będzie zaliczone.

  1. [2 plusy] Zdefiniować metody wypełniania tablicy std::vector<int>:
    • jednorone (zawsze ta sama wartość), z wartością domyślną 0
    • z inkrementacją (uwzględniająca wartość początkową start i krok step, który ma wartość domyślną 1)
    • za pomocą generatora liczb losowych
    • z kwadratem indeksu (a*index^2+b), zarówno a i b mogą przyjąć domyślne wartości
  2. [3 plusy] Przygotować klasę abstrakcyjną StudentComparator z abstrakcyjną metodą
    bool IsLess(const Student &left, const Student &right)

    i zdefiniowanym operatorem wywołania funkcji, delegującym zachowanie do abstrakcyjnej meteody. Następnie zdefiniować różne implementacje porównywania studentów:

    • ByFirstNameAscending
    • ByFirstNameDescending
    • ByLastNameAscending
    • ByProgramAscendingEmptyFirst, porównuje kierunki studiów alfabetycznie ale przesuwa na początek nieustawiony program (string pusty)
    • ByProgramAscendingEmptyLast, j.w. ale przesuwa na koniec nie ustawiony program studiów
    • [1 plus] w programie main wykorzystać wybrany porównywacz do posortowania wektora studentów (algorytm sort z biblioteki algorithm)
  3. [2 plusy] Zdefiniować klasę abstrakcyjną Query z pojedynczą abstrakcyjną metodą
    bool Accept(const Student &student);

    . Klasa query reprezentuje ogólne zapytanie do repozytorium stuentów. Klasa repozytorium powinna udostępniać nową metodę

    std::vector<Student> FindByQuery(const Query &query)

    która przegląda wszystkich zgromadzonych studentów i każdego po kolei przekazuje do zaakceptowania, jeśli student został zaakceptowany powinien znaleźć się w wynikowym wektorze. Zdefiniować następnie implementacje zapytań:

    • [2 plusy]
      • ByFirstName
      • ByLastName
      • ByOneOfPrograms
      • ByYearLowerOrEqualTo
    • [2 plusy]
      • OrQuery
      • AndQuery
  4. [1 plus] Przetestuj przykład z sekcji Metody składowe
  5. [1 punkt] Wykorzystując klasę Punkt, napisz klasę Punkt3D dziedziczącą po niej i implementującą dodatkowo metodę double distance(Punkt3D). W każdym z konstruktorów i destruktorów klas Punkt i Punkt3D wypisz na ekran jakąś wiadomość i zaobserwuj w jakiej kolejności wywołują się konstruktory i destruktory.
  6. [1 punkt] Mając dwa obiekty, jeden klasy Punkt a drugi klasy Punkt3D o nazwach punkt2d i punkt3d, wywołaj punkt2d.distance(punkt3d). Co sie stało?
  7. [1 punkt] W klasie Punkt2D istnieje przeciążony operator wpisywania do strumienia („«”). Co się stanie jeśli będziesz chciał wypisać obiekt klasy Punkt3D w następujący sposób:
    Punkt3D p3d(1,2,3);
    cout << p3d << endl;
  8. [3 plusy] Napisz dwie klasy: Kolo i Kula. Klasa Kolo powinna być klasą bazową dla klasy Kula.
    • W klasie Kolo powinny znaleźć się następujące pola i metody:
      • double x, y, r - określające odpowiednio współrzędne środka koła i jego promień.
      • double pole() - obliczająca pole koła
    • W klasie Kula powinny znaleźć się następujące pola i metody:
      • double z - określajaca współrzędną przestrzenną środka kuli
      • double pole() - obliczająca pole powierzchni kuli
    • W jaki sposób mając następującą funkcję main można obliczyć pole przekroju zdefiniowanej tam kuli płaszczyzną przechodzącą przez środek kuli, wywołując wyłącznie jedną metodę?
      #include "Kula.h"
      #include "Kolo.h"
      #include <iostream>
       
      using namespace std;
       
      int main(){
        Kula k(0,0,0,10);
      }
  9. [2 plusy] Dopisz brakujące metody z przykładu Funkcje wirtualne i uruchom program.
  10. [2 punkty] Napisz klasę abstrakcyjną Ksztalt, która będzie posiadała jedna czystą metodę wirtualną rysuj. Następnie napisz kilka klasę dziedziczących po tej klasie (Trójkąt, Kwadrat, Koło) i odpowiednio implementujących metodę rysuj. Metoda powinna rysować kształty w trybie tekstowym.
    Zadeklaruj listę, która przechowuje wskaźniki na obiekty klasy Ksztalt i uzupełnij ją losowo Kolami, Kwadratami albo Trojkatami. Następnie wywołaj na każdym obiekcie z listy metodę rysuj. Jaki jest tego efekt?
pl/dydaktyka/jimp2/2017/labs/dziedziczenie.txt · ostatnio zmienione: 2017/07/17 08:08 (edycja zewnętrzna)
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0