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ążone operatory, nie zostaną one odziedziczone przez klasę pochodną!

#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&);
};

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ń mailowo należy zamieścić (w formie komentarza w kodzie lub w treści maila) odpowiedzi na problemy postawione w zadaniach 2-4 wraz z uzasadnieniami. Zadanie bez właściwego uzasadnienia nie będzie zaliczone.

  1. [1 plus] Przetestuj przykład z sekcji Metody składowe
  2. [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.
  3. [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?
  4. [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;
  5. [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);
      }
  6. [2 plusy] Dopisz brakujące metody z przykładu Funkcje wirtualne i uruchom program.
  7. [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 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?

Jeśli chcesz zobaczyć jak można wykorzystać dziedziczenie i polimorfizm w większych przykładach, zapraszam do zapoznania się z laboratorium Dziedziczenie i Polimorfizm -- Przykłady

Za tydzień kolokwium! Czy pamiętasz o nim?

Informacje organizacyjne i przykładowe pytania znajdują się tutaj.

pl/dydaktyka/jimp2/2015/labs/dziedziczenie.txt · ostatnio zmienione: 2019/06/27 15:50 (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