Programowanie obiektowe i C++ dla matematyków Bartosz Szreder szreder (at) mimuw... 13 XII 2011 1 Dziedziczenie Mamy dwa światy: gorszy (rzeczywisty) i lepszy (komputerowy). Lepsiejszość drugiego polega na prostocie i uporządkowaniu. Zabawa polega na psuciu drugiego świata poprzez modelowanie rzeczywistości. O przedmiotach w świecie rzeczywistym często myślimy w sposób bardzo ogólny, przykładowo taki kosz na śmieci może być: pojemnikiem wyłącznie na puszki albo szkło, wielkim kontenerem pod blokowiskiem, kuchennym pojemnikiem na ziemniaczane obierki. O każdym z tych obiektów możemy powiedzieć zarówno, że są klasy KoszNaSmieci jak i określić je bardziej szczegółowo, np. Kontener. Tym niemniej każdy kosz na śmieci, niezależnie od swojej specjalizacji, będzie miał pewne cechy wspólne z każdym innym koszem na śmieci, chociażby objętość. Będziemy zatem mówić, że KoszNaSmieci jest klasą bazową, Kontenter zaś klasą pochodną. W kontekście języka C++ oznacza to tyle, że jeśli weźmiemy sobie klasę KoszNaSmieci, która będzie miała pewną objętość, np. int objetosc (atrybut klasy), to każda klasa pochodna także będzie miała ten atrybut. W dodatku możemy określić niektóre metody klasy bazowej w taki sposób, żeby były one dostępne także w klasie pochodnej na przykład każdy śmietnik będziemy chcieli kiedyś opróżnić, więc zapewne przyda się metoda w rodzaju void oproznij(smieciarz *). Takie wydzielanie pewnych wspólnych atrybutów i zachowań w postaci metod jest istotną częścią programowania zorientowanego obiektowo. Uzyskujemy mechanizm, który w praktyce potrafi skupić sporą część logiki programu w jednym miejscu (w sensie ułatwienia nieprogramowania copypaste) oraz niezwykle silną zdolność rozwijania istniejących hierarchii klas w przyszłości. Będzie to widoczne w momencie wprowadzenia tzw. polimorfizmu. Odkładając na razie na bok metafizyczne rozważania, spróbujmy wziąć jakiś kawałek kodu i zobaczyć co on robi (przykład z ćwiczeń): 001 #include <iostream> 002 #include <string> 003 004 using namespace std; 005 006 class Zwierz { 1
007 public: 008 string imie; 009 010 string dajglos() const 011 { 012 return "nie wiem czym jestem, ale nazywam sie " + imie + "\n"; 013 } 014 015 Zwierz(const string &s) 016 { 017 imie = s; 018 } 019 }; 020 021 class Kot : public Zwierz { 022 public: 023 string dajglos() const 024 { 025 return "jestem kotem i nazywam sie " + imie + "\n"; 026 } 027 028 Kot(const string &s) 029 : Zwierz(s) {} 030 }; 031 032 class Pies : public Zwierz { 033 public: 034 string dajglos() const 035 { 036 return "jestem psem i nazywam sie " + imie + "\n"; 037 } 038 039 Pies(const string &s) 040 : Zwierz(s) {} 041 }; 042 043 int main() 044 { 045 Zwierz Z("Metan"); 046 Kot K("Etan"); 047 Pies P("Propan"); 048 049 cout << Z.dajGlos(); 050 cout << K.dajGlos(); 051 cout << P.dajGlos(); 052 return 0; 2
053 } Pojawiło się kilka nowych elementów językowych. Zapisaliśmy pewne klasy w formie B : public A. Oznacza to, że klasa B jest klasą pochodną względem klasy A, ewentualnie klasa B dziedziczy po klasie A. Każde pole składowe klasy bazowej będzie także występowało w klasie pochodnej (co widać po użyciu zmiennej imie w metodach dajglos()). Nie znaczy to jednakże, że zawsze będziemy mieli w klasie pochodnej dostęp do każdego pola składowego klasy bazowej, ale o tym poniżej. Ze względu na ograniczony czas zajęć będziemy skupiali się tylko na dziedziczeniu publicznym, tak jak w przykładach powyżej użycie po dwukropku słowa public jest znaczące. Zakładamy od teraz, że jeśli mówimy o dziedziczeniu, to chodzi o dziedziczenie publiczne i nie wnikamy jak działają inne typy dziedziczenia. 1.1 Listy inicjalizacyjne Konstruktory dla kota i psa mają niespotykany wcześniej wygląd. Użyte w nich zostały tzw. listy inicjalizacyjne, czyli wywołania konstruktorów dla klasy bazowej i dla pól składowych. Czasami użycie list inicjalizacyjnych jest tylko przyjemne, czasami także niezbędne. Należy w tym miejscu powiedzieć trochę o kolejności wykonywanych operacji w konstruktorze. Zacznijmy od przypadku bez dziedziczenia: mamy klasę A, która nie dziedziczy po żadnej innej klasie, ale ma jakieś pola składowe. Konstruktory działają w taki sposób, że gdy docieramy do klamerki otwierającej ich implementacje, to wszystkie pola składowe już muszą być skonstruowane, nawet jeśli zaraz w tymże konstruktorze nadamy im jakieś wartości. Powoduje to pewien problem, jeśli przynajmniej jedno z pól składowych nie posiada konstruktora zeroargumentowego (lub więcejargumentowego, ale z odpowiednimi argumentami domyślnymi). Nie możemy bowiem ominąć konstrukcji zmiennej składowej i jakoś ręcznie skonstruować ją wewnątrz konstruktora A: 001 class MojaLiczba { 002 public: 003 int x; 004 005 MojaLiczba(int a) 006 { 007 a = x; 008 } 009 }; 010 011 class KlasaLiczbowa { 012 MojaLiczba ml; 013 014 KlasaLiczbowa(int a) 015 { //błąd: w tym miejscu MojaLiczba ml musi już być zbudowane 016 ml.x = a; 017 } 018 }; Jak widać na przykładach, listę inicjalizacyjną budujemy po napisaniu sygnatury konstruktora, ale przed klamerką rozpoczynającą implementację. Dodajemy dwukropek, a następnie wpisujemy nazwę pola składowego klasy (rzeczywistą nazwę pola, nie typ) i w nawiasie argumenty do konstruktora tego pola. Poniższy fragment wykonuje nieco nadmiarowej pracy, ponieważ niejako dwukrotnie ustala wartość pola int x w składowej ml: 3
001 class KlasaLiczbowa { 002 MojaLiczba ml; 003 004 KlasaLiczbowa(int a) 005 : ml(0) //legalne, ale bez sensu... 006 { 007 ml.x = a; //...bo właśnie nadpisaliśmy część pracy 008 } 009 }; Najlepiej byłoby zrobić to w ten sposób: 001 class KlasaLiczbowa { 002 MojaLiczba ml; 003 004 KlasaLiczbowa(int a) 005 : ml(a) 006 { 007 //niczego już nie musimy tutaj robić 008 } 009 }; Ważne! Zauważmy, że użycie list inicjalizacyjnych powoduje, że możemy przemieścić w klasie MojaLiczba składową x do części prywatnej tejże klasy grzebanie w tym polu składowym odbywać się teraz będzie wyłącznie z poziomu klasy MojaLiczba i reszcie świata (w tym konstruktorowi klasy KlasaLiczbowa) można odciąć dostęp. Możemy oczywiście korzystając z list inicjalizacyjnych wywoływać konstruktory wieloargumentowe. Co więcej, jeśli potrzebujemy zainicjować więcej niż jedno pole składowe, to oddzielamy wywołania poszczególnych konstruktorów pól przecinkiem. Poniżej przykład pokazujący obie techniki. 001 #include <string> 002 003 using namespace std; 004 005 struct ulamek { 006 int licznik, mianownik; 007 ulamek(int l, int m) 008 : licznik(l), mianownik(m) {} 009 }; 010 011 class MojaLiczba { 012 int x; 013 public: 014 MojaLiczba(int a) 015 : x(a) {} 016 }; 017 4
018 class DuzaKlasa { 019 string napis; 020 MojaLiczba ml; 021 ulamek ulam; 022 public: 023 DuzaKlasa(string s, int a, int licznik, int mianownik) 024 : napis(s), ml(a), ulam(licznik, mianownik) {} 025 }; 026 027 int main() 028 { 029 DuzaKlasa dk("jestem sobie DuzaKlasa", 42, 5, 7); 030 return 0; 031 } Jeśli teraz dojdzie nam dziedziczenie, to sprawa powinna już być prosta listę inicjalizacyjną należy rozpocząć (o ile jest to potrzebne/pożądane) od wywołania konstruktora klasy nadrzędnej, co też dzieje się w klasach Kot i Pies. 1.2 Prywatność a dziedziczenie W naszych zwierzęcych podklasach używamy zmiennej napisowej imie, która jest publiczna w klasie Zwierz. Co by się stało przy przemieszczeniu imienia do innej strefy dostępowej i jak się to ma do dziedziczenia? 1.2.1 private Atrybuty i metody oznaczone jako prywatne w klasie bazowej są niedostępne poza tą klasą. Aby można było odczytać imię zwierzaka z poziomu klasy pochodnej, musiałaby zostać w Zwierz zdefiniowana nieprywatna metoda, potrafiąca odczytać imię: 001 class Zwierz { 002 private: 003 string imie; 004... 005 nie-private: //np. public 006 string getimie() const 007 { 008 return imie; 009 } 010 }; 011 012 class Kot : public Zwierz { 013... 014 public: 015 string dajglos() const 016 { 017 return "jestem kotem i nazywam sie " + getimie() + "\n"; 018 } 5
019 }; Czym może być nie-private oprócz public zaraz się okaże. Tak czy inaczej dostajemy implementację zwierząt, która ustala imię w trakcie konstruowania obiektów tego typu i później już tego imienia zmienić się nie da, ale przynajmniej możemy je z dowolnego miejsca odczytywać za pośrednictwem publicznej metody getimie(). Takie zachowanie (inicjalizacja na stałe niektórych pól) czasami jest bardzo pożądane. 1.2.2 protected Brakujące ogniwo to tryb chroniony. Metody i pola chronione zachowują się jak publiczne dla klas pochodnych, ale prywatne dla reszty świata. Jeśli zatem umieścimy zmienną imie w przestrzeni chronionej, to nadal legalna będzie zupełnie pierwotna implementacja metod dajglos(): 001 #include <iostream> 002 #include <string> 003 004 using namespace std; 005 006 class Zwierz { 007 protected: 008 string imie; 009 public: 010 string dajglos() const 011 { 012 return "nie wiem czym jestem, ale nazywam sie " + imie + "\n"; 013 } 014 015 Zwierz(const string &s) 016 { 017 imie = s; 018 } 019 }; 020 021 class Kot : public Zwierz { 022 public: 023 string dajglos() const 024 { 025 return "jestem kotem i nazywam sie " + imie + "\n"; 026 } 027 028 void setimie(const string &s) 029 { 030 imie = s; //działa, bo imie jest protected 031 } 032 033 Kot(const string &s) 034 : Zwierz(s) {} 6
035 }; 036 037 class Pies : public Zwierz { 038 public: 039 string dajglos() const 040 { 041 return "jestem psem i nazywam sie " + imie + "\n"; 042 } 043 044 Pies(const string &s) 045 : Zwierz(s) {} 046 }; 047 048 int main() 049 { 050 Zwierz Z("Metan"); 051 Kot K("Etan"); 052 Pies P("Propan"); 053 054 cout << Z.dajGlos(); 055 cout << K.dajGlos(); 056 cout << P.dajGlos(); 057 058 Z.setImie("Butan"); //nie ma takiej metody! 059 Z.imie = "Pentan"; //nie działa, bo protected 060 K.setImie("Heksan"); //działa 061 K.imie = "Heptan"; //nie działa, bo protected 062 P.setImie("Oktan"); //nie ma takiej metody! 063 P.imie = "Nonan"; //nie działa, bo protected 064 065 return 0; 066 } 2 Akcesory W wielu projektach/korporacjach/czymkolwiek uważa się, że publiczne atrybuty są Złe i Obrzydliwe. Ustawia się więc wszystko co się da jako prywatne, a do atrybutów tworzy publiczne metody dostępowe w rodzaju T getcokolwiek() const i void setcokolwiek(const T &), podobnie jak powyżej zrobiliśmy string getimie() const i void setimie(const string &). Takie metody nazywa się często akcesorami. Nie znaczy to z miejsca, że publiczne atrubyty są rzeczywiście Złe, bo niektórzy tworzą akcesory do wszystkiego, niezależnie od rzeczywistej potrzeby. Tak naprawdę dostęp przez takie najprostsze akcesory niewiele się wtedy różni od zwykłego oznaczenia atrybutu jako publicznego. W jakich sytuacjach możemy zatem chcieć istotnie kontrolować dostęp do atrybutu przez akcesory? Gdy podczas przypisań albo odczytów chcemy móc wygenerować jakieś zapisy księgujące, np. wpisy do logów diagnostycznych. 7
Jeśli podczas zmian atrybutu chcemy dokonać kontroli poprawności danych. Na przykład, co jeśli w klasie ulamek zostanie wywołany zapis do mianownika z wartością 0? Jeśli pewne wewnętrzne szczegóły implementacyjne mogą ulec zmianie (np. typ atrybutu), a my musimy zachować sensowną kompatybilność (albo raczej stabilność) interfejsu w przyszłości. Jak widać w każdym z tych przypadków śledzenie wszelkich operacji na publicznych atrybutach stałoby się bólem w pewnej tylnej części ciała. Warto zatem czasami skupić logikę dostępową w jednym miejscu. 3 Polimorfizm i metody wirtualne Coś tam zaczęliśmy mówić o tym, ale wgłębimy się w temat przy najbliższej okazji, więc na razie notatek z tego nie spisuję. 8