Programowanie obiektowe i C++ dla matematyków Bartosz Szreder szreder (at) mimuw... 22 XI 2011 Uwaga! Ponieważ już sobie powiedzieliśmy np. o wskaźnikach i referencjach, przez które nie chcemy przegrzebywać wskazywanych obiektów, to od teraz postaram się wstawiać słowo const w sensownych miejscach, celem pokazania pewnych dobrych nawyków. W niedalekiej przyszłości obszar wstawiania tego słówka jeszcze poszerzymy. 1 Kopiowanie obiektów Domyślnie (tzn. o ile nie zdefiniujemy inaczej) przypisywanie do siebie zmiennych złożonych tego samego typu spowoduje zwyczajne przepisanie wartości ich pól składowych. Np. powołując się na przykłady z wcześniejszych zajęć, przypisując do siebie ułamki przekopiujemy ich pola licznik i mianownik: 001 #include <iostream> 002 003 using namespace std; 004 005 struct ulamek { 006 int licznik, mianownik; 007 }; 008 009 int main() 010 { 011 ulamek a, b; 012 013 a.licznik = 5; 014 a.mianownik = 13; 015 b = a; 016 017 cout << "a = " << a.licznik << "/" << a.mianownik << "\n"; 018 cout << "b = " << b.licznik << "/" << b.mianownik << "\n"; 019 return 0; 020 } 1
Czasami jednak takie postępowanie może być przyczyną katastrofalnych błędów. Jeśli bowiem rozważymy inny z naszych przykładów drzewo wyszukiwań binarnych szybko zauważymy, że naiwne kopiowanie wartości pól to nie jest dobry pomysł. Każde BST związane jest z pewnymi obszarami pamięci alokowanymi przez system, do której odwołujemy się przez wskaźnik na korzeń root i oczywiście dalsze wskaźniki potomków korzenia. Jeśli dokonamy przypisania na nową zmienną typu BST, to skopiowana zostanie wartość wskaźnika root oba drzewa korzystać będą z tego samego obszaru pamięci. Zatem jeśli zmodyfikujemy w jakiś sposób jedno z drzew, to te same zmiany będą widoczne także w drugim. Katastrofa nadejdzie w momencie wywoływania destruktorów. Powiedzmy, że mamy dwa drzewa T 1 i T 2 z polem root wskazującym ten sam obszar pamięci oraz przyjmijmy bez straty ogólności, że najpierw destrukcji ulegnie T 1, a w chwilę potem T 2. Oczywiście T 1 zostanie usunięte prawidłowo, bo co mogłoby się zepsuć? Niestety destrukcja T 2 zakończy się naruszeniem ochrony pamięci pole root w tym drzewie nadal wskazuje ten sam adres, w którym jednakże pamięć została już zwolniona. Dwukrotne zwalnianie pamięci pod tym samym adresem jest operacją niedozwoloną. Jeśli definiujemy klasy, których obiekty zawierają wskaźniki do pewnych obszarów pamięci wyłącznych dla tego obiektu, to musimy zadbać o poprawne kopiowanie. Przypisanie a = b zatem musi być operacją, która poprawnie zaalokuje nowe obszary pamięci dla obiektu a i przekopiuje do niej zawartość pamięci obiektu b. Poprawne skopiowanie BST polegałoby na przejściu całego drzewa i utworzeniu kopii każdego jednego węzła. Coś takiego pokazuje poniższy wycinek kodu: 001 #include <iostream> 002 003 using namespace std; 004 005 struct BST { 006 private: 007 struct BST_node { 008 int key; 009 BST_node *left, *right; 010 011 BST_node(int); 012 }; 013 014 BST_node *root; 015 016 const BST_node * BST_search(const BST_node *, int); 017 BST_node * BST_insert(BST_node *, int); 018 void BST_destroy(BST_node *); 019 BST_node * BST_copy(const BST_node *); 020 021 public: 022 bool search(int); 023 void insert(int); 024 025 BST(); 2
026 BST(int); 027 BST(const int *, int); 028 029 BST(); 030 031 void operator=(const BST &); 032 }; 033 034 BST::BST_node::BST_node(int k) 035 { 036 key = k; 037 left = right = NULL; 038 } 039 040 BST::BST_node * BST::BST_copy(const BST_node *node) 041 { 042 if (node == NULL) 043 return NULL; 044 045 BST_node *result = new BST_node(node->key); 046 result->left = BST_copy(node->left); 047 result->right = BST_copy(node->right); 048 return result; 049 } 050 051 void BST::operator=(const BST &tree) 052 { 053 root = BST_copy(tree.root); 054 } 055 056 int main() 057 { 058 BST a, b; 059 060 a.insert(5); 061 a.insert(10); 062 b = a; 063 b.insert(7); 064 065 cout << a.search(7) << "\n"; 066 cout << b.search(7) << "\n"; 067 return 0; 068 } Jako bonus przedstawiony został konstruktor dla BST_node. Aby jeszcze lepiej pokazać moc konstruktorów w upraszczaniu i skracaniu kodu, dodamy konstruktor trójargumentowy, przyjmujący od razu wskaźniki na lewe i prawe 3
poddrzewo: 001 #include <iostream> 002 003 using namespace std; 004 005 struct BST { 006 private: 007 struct BST_node { 008 int key; 009 BST_node *left, *right; 010 011 BST_node(int); 012 BST_node(int, BST_node *, BST_node *); 013 }; 014 015 BST_node *root; 016 017 const BST_node * BST_search(const BST_node *, int); 018 BST_node * BST_insert(BST_node *, int); 019 void BST_destroy(BST_node *); 020 BST_node * BST_copy(const BST_node *); 021 022 public: 023 bool search(int); 024 void insert(int); 025 026 BST(); 027 BST(int); 028 BST(const int *, int); 029 030 BST(); 031 032 void operator=(const BST &); 033 }; 034 035 BST::BST_node::BST_node(int k, 036 BST_node *left_subtree, BST_node *right_subtree) 037 { 038 key = k; 039 left = left_subtree; 040 right = right_subtree; 041 } 042 043 044 BST::BST_node * BST::BST_copy(const BST_node *node) 4
045 { 046 if (node == NULL) 047 return NULL; 048 049 return new BST_node(node->key, 050 BST_copy(node->left), BST_copy(node->right)); 051 } 052 053 void BST::operator=(const BST &tree) 054 { 055 root = BST_copy(tree.root); 056 } 057 058 int main() 059 { 060 BST a, b; 061 062 a.insert(5); 063 a.insert(10); 064 b = a; 065 b.insert(7); 066 067 cout << a.search(7) << "\n"; 068 cout << b.search(7) << "\n"; 069 return 0; 070 } Można to jeszcze uprościć używając tzw. argumentów domyślnych, ale do tego jeszcze wrócimy. Ćwiczenie: przeczytać http://en.wikipedia.org/wiki/rule_of_three_(c++_programming) i zaimplementować konstruktor o sygnaturze BST::BST(const BST &) (konstruktor kopiujący), który poprawnie zbuduje drzewo binarne z drzewa przekazanego w argumencie konstruktora. 1.1 Znowu o pamięci W powyższej implementacji operatora przypisania znajduje się poważny problem, polegający na niesprzątaniu poprzedniego drzewa. Przypisujemy na root adres do nowoprzydzielonego obszaru pamięci niezależnie od tego, czy wcześniej już jakąś pamięć otrzymaliśmy. Poprawne przypisanie dba o takie szczegóły i posprząta po ewentualnym poprzedniku: 001 void BST::operator=(const BST &tree) 002 { 003 destroy(); 004 root = BST_copy(tree.root); 005 } 5
2 Wskaźnik this Pisząc operatory przypisania należy zabezpieczyć się przed sytuacją, w której przypisujemy obiekt na niego samego. Pomijając nawet względy wydajnościowe (przypisanie na siebie zwykle jest mało sensowne i można je po prostu zignorować), takie działanie może prowadzić do pewnych mniej lub bardziej subtelnych błędów. Nietrudno sobie wyobrazić sytuację, w której operacja kopiowania zakłada spójność kopiowanego obiektu w trakcie trwania całej operacji. Tymczasem przypisując na obiekt, z którego kopiujemy zmieniamy ten stan i naruszamy założenie. Aby rozpoznać sytuację kopiowania na siebie możemy użyć specjalnego wskaźnika this. Wskaźnik ten zawiera adres obiektu, w którym aktualnie przebywamy. Możemy używać tego wskaźnika do wywoływania metod i odwoływania się do pól składowych, jednak zwykle jest to nadmiarowe. Poniższe implementacje metody BST::destroy() są równoważne: 001 void BST::destroy() 002 { 003 root = BST_destroy(root); 004 } 005 006 void BST::destroy() 007 { 008 this->root = this->bst_destroy(root); 009 } 010 011 void BST::destroy() 012 { 013 root = BST_destroy(this->root); 014 } 015 016 void BST::destroy() 017 { 018 this->root = this->bst_destroy(this->root); 019 } Jeśli otrzymujemy w operatorze przypisania referencję do obiektu, to porównujemy adres zawarty w this z adresem przekazanego obiektu. Jeśli porównanie wypadnie pomyślnie, to mamy do czynienia z przypisaniem na siebie, które ignorujemy: 001 void BST::operator=(const BST &tree) 002 { 003 if (this == &tree) 004 return; 005 destroy(); 006 root = BST_copy(tree.root); 007 } 6