Programowanie obiektowe i C++ dla matematyków Bartosz Szreder szreder (at) mimuw... 25 X 2011 1 Funkcje składowe struktur i klas Jeśli wcześniej mieliśmy typ ulamek, to mogliśmy dla nich zdefiniować funkcję skroc, która przyjmuje ulamek, oblicza największy wspólny dzielnik licznika i mianownika (np. korzystając z pomocniczej funkcji nwd), dzieli licznik i mianownik przez tak otrzymaną liczbę i zwraca wynikowy ułamek. Całość mogłaby wyglądać na przykład tak: 001 #include <iostream> 003 using namespace std; 004 005 struct ulamek { 006 int licznik, mianownik; 007 }; 008 009 //założenie: a >= b 010 //jeśli tak nie jest, to wołamy nwd z odwróconymi argumentami 011 int nwd(int a, int b) 012 { 013 if (a < b) 014 return nwd(b, a); 015 016 int c; 017 while (b!= 0) { 018 c = a % b; 019 a = b; 020 b = c; 021 } 022 023 return a; 024 } 025 1
026 ulamek skroc(ulamek x) 027 { 028 int d = nwd(x.licznik, x.mianownik); 029 x.licznik /= d; 030 x.mianownik /= d; 031 return x; 032 } 033 034 int main() 035 { 036 ulamek a; 037 a.licznik = 27; 038 a.mianownik = 6; 039 a = skroc(a); 040 cout << a.licznik << " / " << a.mianownik << "\n"; 041 return 0; 042 } Funkcję skroc będziemy najprawdopodobniej używać do przypisania na ten sam ułamek, dla którego ją wywołujemy, tzn. można się spodziewać, że prawie zawsze będziemy działać na zasadzie x = skroc(x), a już niekoniecznie y = skroc(x). Nietrudno zauważyć, że w takim użytkowaniu łatwo się pomylić: a to przypiszemy wynik funkcji skroc na niewłaściwą zmienną, a to zapomnimy o przypisaniu w ogóle... Kompilator oczywiście nie zwróci żadnego błędu, bo niby czemu miałby? Wszak działamy cały czas poprawnie z punktu widzenia języka programowania. Zdefiniujemy zatem skracanie jako funkcję wewnątrz struktury (wtedy taką funkcję nazywamy metodą określonej struktury). Nasza metoda skroc będzie działała na polach struktury, dla której ją wywołujemy: 001 #include <iostream> 003 using namespace std; 004 005 int nwd(int a, int b) 006 { 007 if (a < b) 008 return nwd(b, a); 009 010 int c; 011 while (b!= 0) { 012 c = a % b; 013 a = b; 014 b = c; 015 } 016 017 return a; 018 } 019 020 struct ulamek { 2
021 int licznik, mianownik; 022 023 void skroc() 024 { 025 int d = nwd(licznik, mianownik); 026 licznik /= d; 027 mianownik /= d; 028 } 029 }; 030 031 int main() 032 { 033 ulamek a; 034 a.licznik = 27; 035 a.mianownik = 6; 036 a.skroc(); 037 cout << a.licznik << " / " << a.mianownik << "\n"; 038 return 0; 039 } Dla zaprezentowania składni języka C++ pokazany dalej został też drugi sposób definiowania metod na przykładzie kodu obliczającego NWD. Funkcję nwd zapiszemy jako metodę struktury ulamek. Jednakże w opisie ułamka zawrzemy jedynie samą deklarację metody nwd, a jej definicję (kod) napiszemy w innym miejscu. Takie podejście może pomóc w redukcji bałaganiarstwa i oddzielaniu interfejsu od implementacji (o czym więcej poniżej). W szczególności niedługo nauczymy się kompilować programy zapisane w więcej niż jednym pliku źródłowym oraz rozgraniczenia pomiędzy zawartością plików nagłówkowych (dołączanych do kodu programu dyrektywami #include) a plików z implementacją bytów deklarowanych w tychże nagłówkach. W poniższym przykładzie warto zwrócić uwagę na sposób definiowania metod struktur, tzn. przedrostek ulamek:: przed nazwą metody. Składnia ta pojawiła się już przy okazji używania bytów zawartych w przestrzeni nazw biblioteki standardowej std. 001 #include <iostream> 003 using namespace std; 004 005 struct ulamek { 006 int licznik, mianownik; 007 008 void skroc() 009 { 010 int d = nwd(licznik, mianownik); 011 licznik /= d; 012 mianownik /= d; 013 } 014 015 int nwd(int, int); 016 }; 017 3
018 int ulamek::nwd(int a, int b) 019 { 020 if (a < b) 021 return nwd(b, a); 022 023 int c; 024 while (b!= 0) { 025 c = a % b; 026 a = b; 027 b = c; 028 } 029 030 return a; 031 } 032 033 034 int main() 035 { 036 ulamek a; 037 a.licznik = 27; 038 a.mianownik = 6; 039 a.skroc(); 040 cout << a.licznik << " / " << a.mianownik << "\n"; 041 return 0; 042 } 2 Moralizowanie na śniadanie Wspomniane wcześniej zostało, że należy rozgraniczać implementację od interfejsu, ale co właściwie przez to rozumiemy? Spójrzmy na kod, który piszemy jak na pewną biblioteczkę, którą udostępnimy dalszym użytkownikom, tj. programistom do stosowania w ich programach. W szczególności często sami jesteśmy swoimi użytkownikami dzielimy programy na pewne podmoduły, np. obliczeń ułamkowych, które wykorzystujemy w różnych częściach większego projektu. Interfejs w takim wypadku jest jak najbardziej zminimalizowanym i uproszczonym zbiorem pól składowych i metod, które chcemy udostępnić użytkownikom. Wszelkie szczegóły implementacyjne należy ukryć. Jedną z korzyści oddzielania implementacji od interfejsu jest swoboda w zmienianiu implementacji bez naruszania fragmentów kodu, które wołają metody interfejsu. Jest to jeden z przejawów programowania warstwowego, tzn. takiego konstruowania projektów programistycznych, w których są one podzielone na pewne poziomy, z których każdy komunikuje się jedynie z poziomem nadrzędnym (o ile jest jakiś) i podrzędnym (o ile jest jakiś). Dzięki takiemu podejściu programy zyskują na modularności możemy wprowadzać zmiany i poprawki w wyizolowanych fragmentach projektu, co zwykle wspomaga rozumienie kodu i wpływa korzystnie na zmniejszenie liczby wprowadzanych błędów. Najbardziej spektakularnym przykładem działania zasady warstwowości w praktyce jest stos protokołów sieciowych, przenoszących komunikację w Internecie (http://en.wikipedia.org/wiki/tcp/ip_model). W przypadku wcześniej wspomnianych ułamków użytkownik nie powinien samemu wołać NWD dla licznika i mianownika, a jedynie korzystać z metody skroc, która w razie potrzeby policzy największy wspólny dzielnik w dowolny sposób. Wolność w zmianie implementacji bez ruszania interfejsu przejawia się w tym przypadku 4
na możliwości wpisania kodu obliczającego NWD bezpośrednio w kod metody skroc i wyeliminowanie ze struktury ulamek nadmiarowej metody. Dobrze zachowujące się programy (tzn. wołające skroc, ale nie nwd) działałyby jakby nigdy nic się nie stało, natomiast programy wołające (umownie) wewnętrzną metodę nwd przestałyby się kompilować. Sam sposób użycia metody skroc także uprościliśmy, aby zredukować potencjalne miejsca popełnienia błędu. Możemy zatem uznać metodę skroc za część interfejsu, a metodę nwd za część implementacji. Pola licznik i mianownik są pewnego rodzaju szarą strefą, do kwestii której jeszcze wrócimy. Przykład z ułamkami jest zmontowany nieco na siłę. Spróbujmy zatem pokazać jakiś bardziej rzeczywisty przykład, mianowicie wcześniejszy zbiór funkcji operujących na wskaźnikach do węzłów BST zbierzemy w jedną strukturę, implementującą w scentralizowany sposób funkcjonalność BST. Motywacja dla upraszczania interfejsu stanie się dużo bardziej widoczna. 2.1 Przykład: BST jako pełna struktura danych Przykładowy program zamieszczony poniżej wprowadza pewną nowość, mianowicie definiowanie typów zagnieżdżonych. Typ BST_node definiujemy jako składową typu BST, czyniąc go tym samym widocznym tylko wewnątrz metod struktury BST. Oczywiście wprowadza to konieczność prefiksowania nazwy typu przedrostkiem BST:: w niektórych kontekstach, co dokładnie widać w przykładzie. Jest to jeden z przejawów oddzielania interfejsu od implementacji: typ BST_node traktujemy jako właściwy i użyteczny tylko dla struktury BST, więc określamy go jako typ składowy zamiast globalny. Nie zaśmiecamy w ten sposób globalnej przestrzeni nazw typami właściwymi tylko dla pewnych ściśle określonych fragmentów projektu. Ułatwia to też rozumienie kodu: od razu widać, że tym typem nie musimy się przejmować nigdzie poza metodami własnymi struktury BST. 001 #include <cstddef> 003 struct BST { 004 struct BST_node { 005 int key; 006 BST_node *left, *right; 007 }; 008 009 BST_node *root; 010 011 BST_node * BST_search(BST_node *, int); 012 BST_node * BST_insert(BST_node *, int); 013 }; 014 015 BST::BST_node * BST::BST_search(BST_node *node, int key) 016 { 017 if (node == NULL) 018 return NULL; 019 if (node->key == key) 020 return node; 021 if (node->key > key) 022 return BST_search(node->left, key); 023 else 024 return BST_search(node->right, key); 5
025 } 026 027 028 BST::BST_node * BST::BST_insert(BST_node *node, int key) 029 { 030 if (node == NULL) { 031 BST_node *temp = new BST_node; 032 temp->key = key; 033 temp->left = temp->right = NULL; 034 return temp; 035 } 036 037 if (key < node->key) 038 node->left = BST_insert(node->left, key); 039 else 040 node->right = BST_insert(node->right, key); 041 return node; 042 } 043 044 int main() 045 { 046 BST tree; 047 tree.root = NULL; 048 049 tree.root = tree.bst_insert(tree.root, 10); 050 tree.root = tree.bst_insert(tree.root, 8); 051 052 return 0; 053 } Wywołania BST_insert są analogiczne do pokazanego na poprzednich zajęciach: pierwszym argumentem jest korzeń drzewa, drugim wstawiany klucz. Wynik wywołania zapisujemy na korzeń drzewa. To bardzo dużo rzeczy, o których trzeba pamiętać, a których pominięcie lub podanie gdzieś niewłaściwej zmiennej w przypisaniu albo pierwszym argumencie (literówki zdarzają się... ) będzie przyczyną poważnych i potencjalnie trudnych do wyśledzenia błędów. Metoda BST_insert to nie jest dobra metoda interfejsu. Naprawdę Dobrą TM metodą interfejsu będzie taka, która przyjmuje jeden argument: klucz do wstawienia. Analogicznie dobrą metodą interfejsu dla operacji wyszukiwania będzie metoda, która przyjmuje jeden argument (oczywiście klucz) i zwraca wartość logiczną (prawda albo fałsz) zamiast obrzydliwego wskaźnika, noszącego znamiona bycia częścią wewnętrznej implementacji. W poniższym przykładzie pokazano lepszy interfejs, zaimplementowany w postaci krótkich metod opakowujących odpowiednie wywołania metod wewnętrznych. Dla czytelności pominięto implementację BST_insert i BST_search (są identyczne jak w poprzednim przykładzie). 001 #include <cstddef> 003 struct BST { 004 struct BST_node { 005 int key; 6
006 BST_node *left, *right; 007 }; 008 009 BST_node *root; 010 011 BST_node * BST_search(BST_node *, int); 012 BST_node * BST_insert(BST_node *, int); 013 bool search(int); 014 void insert(int); 015 }; 016 017 bool BST::search(int key) 018 { 019 return BST_search(root, key)!= NULL; 020 } 021 022 void BST::insert(int key) 023 { 024 root = BST_insert(root, key); 025 } 026 027 int main() 028 { 029 BST tree; 030 tree.root = NULL; 031 032 //teraz wygląda dużo lepiej 033 tree.insert(10); 034 tree.insert(8); 035 036 return 0; 037 } 3 Pola publiczne i prywatne Powyższa implementacja BST jest już całkiem porządna. Jedyny problem jaki pozostaje polega na samym istnieniu metod (np. BST_insert), które są niebezpieczne w użyciu. Chcielibyśmy w jakiś sposób zabronić użytkownikowi wołania takiej metody, żeby nie strzelił sobie w stopę. Język C++ zawiera wsparcie dla podziału struktur na część prywatną (implementację) i publiczną (interfejs) 1. Każde pole prywatne (zmienna albo metoda) jest dostępne wyłącznie z poziomu metod struktury. Próba wywołania metody prywatnej albo dokonania zapisu/odczytu zmiennej prywatnej kończy się błędem kompilacji. Składnię użycia pokazuje poniższy program (z pominięciem implementacji metod pozostaje ona bez zmian względem wcześniejszego programu). 001 #include <cstddef> 1 Są też części chronione (protected), ale o nich innym razem. 7
003 struct BST { 004 private: 005 struct BST_node { 006 int key; 007 BST_node *left, *right; 008 }; 009 010 BST_node *root; 011 012 BST_node * BST_search(BST_node *, int); 013 BST_node * BST_insert(BST_node *, int); 014 015 public: 016 bool search(int); 017 void insert(int); 018 }; 019 020 021 BST tree; 022 023 int main() 024 { 025 tree.insert(10); 026 tree.insert(8); 027 028 return 0; 029 } W tak określonej strukturze BST możemy jedynie wołać z zewnątrz metody insert i search. Pozostałe metody oraz zmienna root są dostępne jedynie z innych metod struktury. Zauważmy, że powoduje to pewien problem przy inicjowaniu drzewa wcześniej mogliśmy dobrać się do pola root i ustawić je na NULL. Teraz nie jest to możliwe, więc deklarujemy zmienną typu BST globalnie, bo wtedy wszystkie jej pola są wyzerowane. Ćwiczenie: napisać metodę publiczną BST::init(), której jedynym zadaniem będzie ustawienie root na NULL. Ćwiczenie: napisać metodę publiczną BST::destroy(), która ma usunąć całe drzewo (zwalniając pamięć po każdym zaalokowanym węźle żadnych wycieków!), a następnie ustawia root na NULL (np. przez wywołanie init), w efekcie odtwarzając puste drzewo. Podpowiedź: wystarczy lekko zmodyfikować kod wypisujący wszystkie klucze drzewa w kolejności leksykograficznej. 3.1 Słowa kluczowe struct i class Do definiowania własnych typów złożonych można użyć słowa kluczowego class. Jedyna różnica w C++ pomiędzy struct i class jest taka, że w pierwszym przypadku wszystkie składowe typu są domyślnie publiczne, natomiast w drugim są prywatne. 8