Notice: Undefined index: mode in /home/misc/mst/public_html/common.php on line 63 Notice: Undefined index: mode in /home/misc/mst/public_html/common.php on line 65 Notice: Undefined index: mode in /home/misc/mst/public_html/common.php on line 67 Notice: Undefined index: mode in /home/misc/mst/public_html/common.php on line 69 Notice: Undefined variable: base in /home/misc/mst/public_html/lecture.php on line 36 Programowanie obiektowe i C++ – 7. Dziedziczenie i hierarchie klas – MIM UW

Zagadnienia

7. Dziedziczenie i hierarchie klas

Dziedziczenie i hierarchie klas

7.1. Dziedziczenie

7.1.1. Wprowadzenie

  • Dziedziczenie jest jednym z najistotniejszych elementów obiektowości.

  • Dziedziczenie umożliwia pogodzenie dwóch sprzecznych dążeń:

    • Raz napisany, uruchomiony i przetestowany program powinien zostać w niezmienionej postaci.

    • Programy wymagają stałego dostosowywania do zmieniających się wymagań użytkownika, sprzętowych itp..

  • Dziedziczenie umożliwia tworzenie hierarchii klas.

  • Klasy odpowiadają pojęciom występującym w świecie modelowanym przez program. Hierarchie klas pozwalają tworzyć hierarchie pojęć, wyrażając w ten sposób zależności między pojęciami.

  • Klasa pochodna (podklasa) dziedziczy po klasie bazowej (nadklasie). Klasę pochodną tworzymy wówczas, gdy chcemy opisać bardziej wyspecjalizowane obiekty klasy bazowej. Oznacza to, że każdy obiekt klasy pochodnej jest obiektem klasy bazowej.

  • Zalety dziedziczenia:

    • Jawne wyrażanie zależności między klasami (pojęciami). Np. możemy jawnie zapisać, że każdy kwadrat jest prostokątem, zamiast tworzyć dwa opisy różnych klas.

    • Unikanie ponownego pisania tych samych fragmentów programu (ang. reuse).

7.1.2. Jak definiujemy podklasy

Przykładowa klasa bazowa:

	class A{
     private:
      int skl1;
     protected:
      int skl2;
     public:
      int skl3;
    };
Słowo protected, występujące w przykładzie, oznacza że składowe klasy po nim wymienione są widoczne w podklasach (bezpośrednich i dalszych), nie są natomiast widoczne z zewnątrz1Dokładna semantyka protected jest nieco bardziej skomplikowana, ale w praktyce nie ma to znaczenia..

7.1.3. Przykładowa podklasa

   class B: public A{
    private:
     int skl4;
    protected:
     int skl5;
    public:
     int skl6;
     void m();
   };

7.1.4. Przykłady użycia

 void B::m(){
  skl1 = 1; // Błąd, składowa niewidoczna
  skl2 = 2; // OK!
  skl3 = 3; // OK
  skl4 = skl5 = skl6 = 4; // OK
 }

int main(){
 A a;
 B b;
 int i;
 i = a.skl1; // Błąd, składowa niewidoczna
 i = a.skl2; // Błąd, składowa niewidoczna
 i = a.skl3; // OK
 i = a.skl4; // Błąd, nie ma takiej składowej (to samo dla skl5 i skl6)
 i = b.skl1; // Błąd, składowa niewidoczna
 i = b.skl2; // Błąd, składowa niewidoczna
 i = b.skl3; // OK!
 i = b.skl4; // Błąd, składowa niewidoczna
 i = b.skl5; // Błąd, składowa niewidoczna
 i = b.skl6; // OK
}

Jak wynika z tego przykładu, w języku C++ nie ma żadnego składniowego wyróżnika klas bazowych - można je definiować tak jak zwykłe klasy. Jednak jeśli chcemy żeby projektowana przez nas klasa była kiedyś klasą bazową, to już w momencie jej deklarowania należy myśleć o dziedziczeniu, odpowiednio ustalając, które składowe mają mieć atrybut protected.

7.1.5. Podsummowanie

  • składowe prywatne (private) są widoczne jedynie w klasie, z której pochodzą, i w funkcjach/klasach z nią zaprzyjaźnionych,

  • składowe chronione (protected) są widoczne w klasie, z której pochodzą, i w funkcjach/klasach z nią zaprzyjaźnionych oraz w jej klasach pochodnych i funkcjach/klasach z nimi zaprzyjaźnionych,

  • składowe publiczne (public) są widoczne wszędzie tam, gdzie jest widoczna sama klasa.

Uwaga: obiekty podklas, mimo że nie mają bezpośredniego dostępu do prywatnych odziedziczonych składowych, mają także i te odziedziczone składowe.

Można powiedzieć, że obiekt klasy pochodnej przypomina kanapkę (czy tort), zawierający warstwy pochodzące ze wszystkich nadklas. W szczególności obiekt b z przykładu zawiera składową skl1 (choć metody z warstwy B nie mają do tej składowej dostępu). Jest tak dlatego, że każdy obiekt klasy pochodnej (tu B) jest obiektem klasy bazowej (tu A).

7.1.6. Przesłanianie nazw

W podklasach można deklarować składowe o takiej samej nazwie jak w nadklasach:

class C {
 public:
  int a;
  void m();
};

class D: public C {
 public:
  int a;
  void m();
};
 

Kompilator zawsze będzie w stanie rozróżnić, o którą składową chodzi:

void C::m(){
 a = 1;  // Składowa klasy C
}

void D::m(){
 a = 2;  // Składowa klasy D
}

int main (){
 C a;
 D b;
 a.a = 2; 	// Składowa klasy C
 b.a = 2; 	// Składowa klasy D
 a.m();		// Składowa klasy C
 b.m();		// Składowa klasy D
}

To samo dotyczy metod. Można przesłaniać zwykłe metody (co jest mało użyteczne) oraz można przedefiniowywać metody wirtualne (co ma olbrzymie zastosowanie praktyczne zwn. polimorfizm).

7.1.7. Operator zasięgu

  • Do odwoływania się do składowych z nadklas służy operator zasięgu.

  • Ma on postać ::.

  • Przykład użycia:

         void D::m(){ a = C::a; }
       
    

Oczywiście stosowanie tych samych nazw w nadklasach i podklasach dla zmiennych obiektowych nie ma sensu (dla metod już ma, p. metody wirtualne). Ale co zrobić, gdy już tak się stanie i chcemy w podklasie odwołać się do składowej z nadklasy? Należy użyć operatora zasięgu (::). Oto inna definicja D::m():

  void D::m() { a = C::a; }
 
W podobny sposób można w funkcji odwoływać się do przesłoniętych zmiennych globalnych:

int i;
void m(int i){
  i   = 3;  // Parametr
  ::i = 5;  // Zmienna globalna
}

7.1.8. Zgodność typów

Obiekt klasy pochodnej jest obiektem klasy bazowej, chcielibyśmy więc, żeby można było wykorzystywać go wszędzie tam, gdzie można używać obiektów z klasy bazowej. Niestety, nie zawsze to jest możliwe (i nie zawsze ma sens):

A a, &ar=a, *aw;
B b, &br=b, *bw;

a = b; 	// OK, A::operator=
b = a; 	// Błąd, co miałoby być wartościami
		// zmiennych obiektowych występujących w B a w A nie?
		// Byłoby poprawne po zdefiniowaniu:
		//   B& B::operator=(A&);
ar = br;	// OK
br = ar;	// Błąd, tak samo jak b = a;
aw = bw;	// OK
bw = aw;	// Błąd, co by miało znaczyć bw->skl6 ?

 fA(a);	    // OK, A::A(&A)
 fA(b);	    // OK, A::A(&A) automatyczny konstruktor zadziała
 fAref(a);  // OK
 fAref(b);  // OK
 fAwsk(&a);	// OK
 fAwsk(&b); // OK
 fB(a);	    // Błąd, A ma za mało składowych
 fB(b);	    // OK
 fBref(a);  // Błąd, A ma za mało składowych
 fBref(b);  // OK
 fBwsk(&a); // Błąd, A ma za mało składowych
 fBwsk(&b); // OK

7.1.9. Na co wskazują wskaźniki?

Zwróćmy uwagę na interesującą konsekwencję reguł zgodności przedstawionych powyżej:

 D d;
 C *cwsk=&d;

 cwsk->m();
jaka funkcja powinna się wywołać? cwsk pokazuje na obiekt klasy D. Ale kompilator o tym nie wie i wygeneruje kod wywołujący funkcję z klasy C. Powrócimy do tego tematu przy okazji funkcji wirtualnych.

7.1.10. Dziedziczenie public, protected i private

Klasa może dziedziczyć po nadklasie na trzy różne sposoby. Określa to słowo wpisane w deklaracji podklasy przed nazwą klasy bazowej. Decyduje ono o tym kto wie, że klasa pochodna dziedziczy po klasie bazowej. Tym słowem może być:

  • public: wszyscy wiedzą

  • protected: wie tylko klasa pochodna, funkcje/klasy zaprzyjaźnione z nią oraz jej klasy pochodne i funkcje/klasy zaprzyjaźnione z nimi,

  • private: wie tylko klasa pochodna i funkcje/klasy z nią zaprzyjaźnione.

Co daje ta wiedza? Dwie rzeczy:

  • pozwala dokonywać niejawnych konwersji ze wskaźników do podklas na wskaźniki do nadklas,

  • pozwala dostawać się (zgodnie z omówionymi poprzednio regułami dostępu) do składowych klasy bazowej.

Jeśli pominiemy to słowo, to domyślnie zostanie przyjęte private (dla struktur public).

7.1.11. Przykłady ilustrujące rodzaje dziedziczenie

Oto przykłady ilustrujące przedstawione reguły:

 class A{
  public:
   int i;
   // ...
 };
 class B1: public A{};
 class B2: protected A{};
 class B3: private A{
		void m(B1*, B2*, B3*);
 };

 class C2: public B2{
  void m(B1*, B2*, B3*);
 };

 void m(B1* pb1, B2* pb2, B3* pb3){
  A* pa = pb1; // OK
  pb1->a = 1;  // OK
  pa = pb2;    // Błąd (f nie wie, że B2 jest podklasą A)
  pb2->a = 1;  // Błąd
  pa = pb3;    // Błąd
  pb3->a = 1;  // Błąd
 }
 void C2::m(B1* pb1, B2* pb2, B3* pb3){
  A* pa = pb1; // OK
  pb1->a = 1;  // OK
  pa = pb2;    // OK
  pb2->a = 1;  // OK
  pa = pb3;    // Błąd (C2::f nie wie, że B3 jest podklasą A
  pb3->a = 1;  // Błąd
 }
 void B3::m(B1* pb1, B2* pb2, B3* pb3){
  A* pa = pb1; // OK
  pb1->a = 1;  // OK
  pa = pb2;    // Błąd (B3::f nie wie, że B2 jest
               // podklasą A
  pb2->a = 1;  // Błąd
  pa = pb3;    // OK
  pb3->a = 1;  // OK
 }

Zatem jeśli nazwa klasy bazowej jest poprzedzona słowem protected, to składowe publiczne tej klasy zachowują się w klasie pochodnej jak chronione, zaś jeśli nazwa klasy bazowej jest poprzedzona słowem private, to jej składowe publiczne i chronione zachowują się w klasie pochodnej jak prywatne. Przedstawione tu mechanizmy określania dostępu do klasy podstawowej mają zdecydowanie mniejsze znaczenie, niż omówione poprzednio mechanizmy ochrony dostępu do składowych.

7.2. Metody wirtualne

7.2.1. Przykład klasy Figura

Rozważmy poniższy (uproszczony) przykład:2W tym i w pozostałych przykładach pozwalamy sobie zapisywać identyfikatory z polskimi znakami. Jest to niezgodne ze składnią C++ (taki program się nie skompiluje), ale zwiększa czytelność przykładów, zaś doprowadzenie do zgodności ze składnią C++ jest czysto mechaniczne i nie powinno Czytelnikowi sprawić kłopotu.

 class Figura{
 // ...
 protected:
  int x,y;	// położenie na ekranie
 public:
  Figura(int, int);
  void ustaw(int, int);
  void pokaż();
  void schowaj();
  void przesuń(int, int);
 };
 

Mamy zatem klasę reprezentująca figury geometryczne na ekranie. Obiekty tej klasy znają swoje położenie na ekranie (atrybuty x i y), oraz potrafią:

  • zapamiętać nowe położenie, czyli nowe wartości współrzędnych x i y (ustaw),

  • narysować się na ekranie w bieżącym położeniu (pokaż),

  • wymazać się z ekranu w bieżącym położeniu (schowaj),

  • przesunąć się na ekranie z bieżącego do wskazanego parametrami położenia (przesuń).

Ponadto jest zadeklarowany konstruktor. Spróbujmy zapisać implementację tych metod.

Figura::Figura(int n_x, int n_y){
 ustaw(x,y);
}

Figura::ustaw(int n_x, int n_y){
 x = n_x;
 y = n_y;
}

Figura::przesuń(int n_x, int n_y){
 schowaj();
 ustaw(n_x, n_y);
 pokaż();
}

Figura::pokaż(){
 // Nie umiemy narysować dowolnej figury
}

Figura::schowaj(){
 // j.w.
}
 

Udaje się to tylko częściowo. Łatwo można zapisać treść ustaw. Przesuń też wydaje się proste. Natomiast nie wiedząc z jaką figurą mamy do czynienia, nie potrafimy jej ani narysować ani schować. Spróbujmy zatem zdefiniować jakąś konkretną figurę geometryczną, np. okrąg:

 class Okrąg: public Figura{
  protected:
   int promień,
  public:
   Okrag(int, int, int);
   pokaż();
   schowaj();
   //
 };

 Okrąg::Okrąg(int x, int y, int r): Figura(x,y), promień(r){}
 

Oczywiście okrąg oprócz współrzędnych musi też znać swój promień, definiujemy więc stosowny atrybut. W klasie Okrąg wiemy już co należy wyrysować na ekranie, więc stosując operacje z dostępnej biblioteki graficznej pokaż implementujemy np. jako rysowanie okręgu kolorem czarnym, zaś schowaj jako rysowanie kolorem tła. Naturalnie można by zastosować bardziej subtelne algorytmy (szczególnie jeśli chodzi o chowanie), ale dla naszych rozważań nie ma to znaczenia.

Można teraz zacząć działać z opisanymi wcześniej Okręgami. Przykładowy program mógłby wyglądać następująco:

 Okrąg o(20, 30, 10); // Okrąg o zadanym położeniu i promieniu
 o.pokaż();           // Rysuje okrąg
 o.przesuń(100, 200); // Nie przesuwa !
 

Niestety tak pieczołowicie przygotowany zestaw klas zdaje się nie działać. Ale czemu? Każda z operacji czytana osobno wydaje się być poprawna. Problem polega oczywiście na tym, że w treści metody przesuń wywołały się metody Figura::pokaż i Figura::ukryj zamiast Okrąg::pokaż i Okrąg::ukryj. To bardzo nienaturalne, przecież używamy obiektów klasy Okrąg, która ma te operacje prawidłowo zdefiniowane.

7.2.2. Znaczenie metod wirtualnych

  • Czemu nie działa przykład z Okręgiem?

  • Czy instrukcja warunkowa lub wyboru jest tu rozwiązaniem?

  • Różnica między metodami wirtualnymi a funkcjami.

  • Składnia deklaracji metod wirtualnych.

  • W podklasie klasy z metoda wirtualną można tę metodę:

    • zdefiniować na nowo (zachowując sygnaturę),

    • nie definiować.

Problem wynika stąd, że kompilator kompilując treść metody Figura::przesuń nie wie o tym, że kiedyś zostanie zdefiniowana klasa Okrąg. Zatem wywołanie metody pokaż traktuje jako wywołanie metody Figura::pokaż. Wydaje się zresztą, że jest to jedyna możliwość, bo przecież moglibyśmy zdefiniować zaraz klasę Trójkąt, i wtedy chcielibyśmy, żeby w przesuń wywołało się nie Okrąg::pokaż tylko Trójkąt::pokaż.

Jak zaradzić temu problemowi? W tradycyjnym języku programowania jedynym rozwiązaniem byłoby wpisanie do metod pokaż i ukryj w klasie Figura długich ciągów instrukcji warunkowych (lub wyboru) sprawdzających w jakim obiekcie te metody zostały wywołane i wywoływanie na tej podstawie odpowiednich funkcji rysujących. Takie rozwiązanie jest bardzo niedobre, bo stosując je dostajemy jedną gigantyczną i wszystko wiedzącą klasę. Dodanie nowej figury wymagałoby zmian w większości metod tego giganta, byłoby więc bardzo trudne i łatwo mogłoby powodować błędy. Ponieważ w programowaniu obiektowym chcemy, żeby każdy obiekt reprezentował jakąś konkretną rzecz z modelowanego przez nas świata i żeby jego wiedza była w jak największym stopniu lokalna, musimy mieć w językach obiektowych mechanizm rozwiązujący przedstawiony problem. Tym mechanizmem są metody wirtualne.

Metoda wirtualna tym różni się od metody zwykłej, że dopiero w czasie wykonywania programu podejmuje się decyzję o tym, która wersja tej metody zostanie wywołana. Deklarację metody wirtualnej poprzedzamy słowem virtual. Wirtualne mogą być tylko metody (a nie np. funkcje globalne). Deklarowanie metod wirtualnych ma sens tylko w hierarchiach klas. Jeśli w klasie bazowej zadeklarowano jakąś metodę jako wirtualną, to w klasie pochodnej można:

  • Zdefiniować jeszcze raz tę metodę (z inną treścią). Można wówczas użyć słowa virtual, ale jest to nadmiarowe. Ta metoda musi mieć dokładnie tę samą liczbę i typy parametrów oraz wyniku. Wówczas w tej klasie obowiązuje zmieniona definicja tej metody.

  • Nie definiować jej ponownie. Wówczas w tej klasie obowiązuje ta sama definicja metody co w klasie bazowej.

Przyjrzyjmy się teraz trochę bardziej abstrakcyjnemu przykładowi, ale za to z głębszą hierarchią klas. Tym razem trójpoziomową. Przekonajmy się, czy te same reguły dotyczą dalszych klas pochodnych.

 class A{
  public:
   void virtual m(int);
 };

 class B: public A{
 };

 class C: public B{
  public:
   void m(int);	// Ta metoda jest wirtualna!
 };
 

Mamy trzy klasy A, B, C dziedziczące jedna po drugiej. W klasie B nie ma deklaracji żadnych składowych, zatem obiekty tej klasy mają wirtualną metodę m, odziedziczoną z klasy A. Dziedziczy się zatem także to, czy metoda jest wirtualna. W klasie C metoda m została zdefiniowana ponownie (podmieniona). Zwróćmy uwagę na to, że mimo braku słowa virtual w tej klasie i w klasie B metoda m jest wirtualna.

 int main(){
  A *p;
  p = new A;
  p->m(3);  //  A::m()
  p = new B;
  p->m(3);  //  A::m()
  p = new C;
  p->m(3);  //  C::m(), bo *p jest obiektem klasy C
 

Przedstawiony fragment programu ilustruje niezwykle istotną technikę programowania obiektowego w C++. Wskaźnik p jest zadeklarowany jako wskaźnik do klasy, po której dziedziczy cała hierarchia klas. Następnie na ten wskaźnik przypisywane są adresy obiektów klas dziedziczących (pośrednio lub bezpośrednio) po użytej w deklaracji wskaźnika klasie. Ponieważ obiekty podklas są też obiektami nadklas, tak napisany program nie tylko będzie się kompilował, ale ponadto będzie działał zgodnie z naszymi oczekiwaniami. Tak zapisany fragment programu ilustruje wykorzystanie polimorfizmu.

 // Ale:
  // ...
  A a;
  C c;
  a = c;
  a.m(3);  // A::m(), bo a jest obiektem klasy A
 }
 

Pamiętajmy jednak, że polimorfizm jest możliwy tylko wtedy, gdy używamy wskaźników bądź referencji. Wynika to stąd, że choć obiekty podklas logicznie są obiektami nadklas, to fizycznie zwykle mają większy rozmiar. Zatem zmienna typu nadklasy nie może przechowywać obiektów podklas. W przedstawionym przykładzie przypisanie a = c; skopiuje jedynie pola zadeklarowane w klasie A i nic więcej. Zmienna a nadal będzie zmienną typu A, czyli będzie przechowywać obiekt typu A.

7.2.3. Implementacja metod wirtualnych

Implementacja:

  • Jest efektywna.

  • Np. tablica metod wirtualnych.

Mechanizmy kompilacji języków programowania nie wchodzą w zakres tego wykładu, zatem poprzestaniemy tylko na dwóch stwierdzeniach.

Po pierwsze, metody wirtualne są implementowane efektywnie. Zatem nie ma powodów, by unikać ich w swoich programach. Dodatkowy narzut związany z wywołaniem metody wirtualnej odpowiada pojedynczemu sięgnięciu do pamięci. Ponadto (przy typowej implementacji) każdy obiekt przechowuje jeden dodatkowy wskaźnik.

Po drugie, typowa implementacja metod wirtualnych wykorzystuje tablice metod wirtualnych (ang. vtables).

7.2.4. Klasy abstrakcyjne

Często tworząc hierarchię klas na jej szczycie umieszcza się jedną (lub więcej) klas, o których wiemy, że nie będziemy tworzyć obiektów tych klas. Możemy łatwo zagwarantować, że tak rzeczywiście będzie, deklarując jedną (lub więcej) metod w tej klasie jako czyste funkcje wirtualne. Składniowo oznacza to tyle, że po ich nagłówku (ale jeszcze przed średnikiem) umieszcza się =0 i oczywiście nie podaje się ich implementacji. O ile w klasie pochodnej nie przedefiniujemy wszystkich takich funkcji, klasa pochodna też będzie abstrakcyjna. W podanym poprzednio przykładzie z figurami, powinniśmy więc napisać:

 class Figura{
  // ...
  virtual void pokaż() = 0;
  virtual void schowaj() = 0;
 };
 

7.2.5. Konstruktory i destruktory w hierarchiach klas

Jak pamiętamy obiekt klasy dziedziczącej po innej klasie przypomina kanapkę, tzn. składa się z wielu warstw, każda odpowiadająca jednej z nadklas w hierarchii dziedziczenia. Tworząc taki obiekt musimy zadbać o zainicjowanie wszystkich warstw. Ponadto klasy mogą mieć składowe również będące obiektami klas - je też trzeba zainicjować w konstruktorze. Na szczęście w podklasie musimy zadbać o inicjowanie jedynie:

  • bezpośredniej nadklasy,

  • własnych składowych.

tzn. my nie musimy już (i nie możemy) inicjować dalszych nadklas oraz składowych z nadklas. Powód jest oczywisty: to robi konstruktor nadklasy. My wywołując go (w celu zainicjowania bezpośredniej klasy bazowej) spowodujemy (pośrednio) inicjację wszystkich warstw pochodzących z dalszych klas bazowych. Nie musimy inicjować nadklasy, jeśli ta posiada konstruktor domyślny (i wystarczy nam taka inicjacja). Nie musimy inicjować składowych, które mają konstruktor domyślny (i wystarcza nam taka inicjacja).

7.2.6. Inicjacja w hierarchiach klas

Składnia inicjacji: po nagłówku konstruktora umieszczamy nazwę nadklasy (składowej), a po niej w nawiasach parametr(y) konstruktora.

Kolejność inicjacji:

  • najpierw inicjuje się klasę bazową,

  • następnie inicjuje się składowe (w kolejności deklaracji, niezależnie od kolejności inicjatorów).

(Uniezależnienie od kolejności inicjatorów służy zagwarantowaniu tego, że podobiekty i składowe zostaną zniszczone w odwrotnej kolejności niż były inicjowane.)

Czemu ważna jest możliwość inicjowania składowych:

 class A{
  /* ... */
  public:
   A(int);
   A();
};

class B{
  A a;
 public:
  B(A&);
};
 

Rozważmy następujące wersje konstruktora dla B:

	B::B(A& a2){ a = a2; }
 
i
	B::B(A& a2): a(a2){};
 

W pierwszej na obiektach klasy A wykonują się dwie operacje:

  • tworzenie i inicjacja konstruktorem domyślnym,

  • przypisanie.

W drugim tylko jedna:

  • tworzenie i inicjowanie konstruktorem kopiującym.

Tak więc druga wersja konstruktora jest lepsza.

7.2.7. Niszczenie obiektu

Kolejność wywoływania destruktorów:

  • destruktor w klasie,

  • destruktory (niestatycznych) obiektów składowych,

  • destruktory klas bazowych.

Ważne uwagi:

  • W korzeniu hierarchii klas musi być destruktor wirtualny.

  • Uwaga na metody wirtualne w konstruktorach i destruktorach.

Treść destruktora wykonuje się przed destruktorami dla obiektów składowych. Destruktory dla (niestatycznych) obiektów składowych wykonuje się przed destruktorem (-rami) klas bazowych.

W korzeniu hierarchii klas musi być destruktor wirtualny.

W konstruktorach i destruktorach można wywoływać metody klasy, w tym także wirtualne. Ale uwaga: wywołana funkcja będzie tą, zdefiniowaną w klasie konstruktora/destruktora lub jednej z jej klas bazowych, a nie tą, która ją później unieważnia w klasie pochodnej.

Treść automatycznie generowana z plików źródłowych LaTeXa za pomocą oprogramowania wykorzystującego LaTeXML.

Projekt współfinansowany przez Unię Europejską w ramach Europejskiego Funduszu Społecznego.

Projekt współfinansowany przez Ministerstwo Nauki i Szkolnictwa Wyższego i przez Uniwersytet Warszawski.