Programowanie obiektowe i C++ dla matematyków Bartosz Szreder szreder (at) mimuw... 8 XI 2 1 Sposoby przekazywania argumentów Powiedzmy, że chcemy napisać funkcję, która zamieni miejscami wartość dwóch zmiennych całkowitoliczbowych. Pierwsze podejście mogłoby wyglądać na przykład tak: 001 #include <iostream> 003 using namespace std; 004 005 void zamien(int a, int b) 006 { 007 int c = a; 008 a = b; 009 b = c; 010 } 012 int main() 013 { 014 int x = 2, y = 5; 015 zamien(x, y); 016 cout << "x = " << x << "\n"; 017 cout << "y = " << y << "\n"; 018 return 0; 019 } To oczywiście nie zadziała. Tak zaimplementowana funkcja zamien otrzymuje dwie liczby całkowite, które są kopiami argumentów przekazanych w miejscu wywołania. Wywołanie zamien(x, y) spowoduje utworzenie nowych zmiennych x i y, których wartość zostanie zamieniona miejscami, ale nie wpłynie to na wartość zmiennych x i y. Poradzić sobie można z tym na dwa sposoby różne składniowo sposoby, które pod maską działają analogicznie. Powyższy sposób przekazywnia argumentów nazywamy przekazywaniem przez wartość. 1
1.1 Przekazywanie przez referencję i wskaźnik Chcemy do funkcji zamien przekazać rzeczywiste adresy zmiennych x i y, a nie jedynie kopie tych zmiennych. Możemy to zrobić przez tzw. referencje (oznaczane symbolem &), które oznaczają odwołanie do tego samego adresu w pamięci pod inną nazwą: 001 #include <iostream> 003 using namespace std; 004 005 void zamien(int &a, int &b) 006 { 007 int c = a; 008 a = b; 009 b = c; 010 } 012 int main() 013 { 014 int x = 2, y = 5; 015 zamien(x, y); 016 cout << "x = " << x << "\n"; 017 cout << "y = " << y << "\n"; 018 return 0; 019 } Identyczna w działaniu wersja używająca wskaźników w sposób jawny może wyglądać tak: 001 #include <iostream> 003 using namespace std; 004 005 void zamien(int *a, int *b) 006 { 007 int c = *a; 008 *a = *b; 009 *b = c; 010 } 012 int main() 013 { 014 int x = 2, y = 5; 015 zamien(&x, &y); 016 cout << "x = " << x << "\n"; 017 cout << "y = " << y << "\n"; 018 return 0; 019 } 2
Druga metoda jest zwykle nazywana przekazywaniem przez wskaźnik, chociaż można się też spotkać z nazywaniem jej przekazywaniem przez referencję, co może nieco zamieszać. Z używaniem referencji należy uważać, bowiem ich działanie jest ciche, w przeciwieństwie do wskaźników, których użycie wymaga jawnego pobrania adresu przez &. Tzn. mając jakąś zmienną v, której adres jawnie przekazujemy do pewnej funkcji widzimy od razu, że v może ulec zmianie. Natomiast przekazując v jak przez wartość nie wiemy, czy w sygnaturze argumentu funkcji nie czai się referencja, która znienacka coś nam w zmiennej v zmodyfikuje (hint: znaczące nazwy funkcji pomagają). Tym niemniej referencje różnią się od wskaźników w kilku miejscach, które sprawiają, że wskaźniki są bardziej potężne, ale też bardziej niebezpieczne i trzeba ich silniej pilnować. O tych różnicach i ich zastosowaniach jeszcze będziemy mówić. Przekazywanie przez referencję jest też o tyle miłe, że uniknięcie kopiowania argumentu może zaoszczędzić komputerowi trochę pracy. Trzeba jednakże pamiętać, że referencja nie jest tak zupełnie darmowa i przekazywanie argumentów w taki sposób jest opłacalne (tzn. mając na uwadze tylko i wyłącznie rozważania optymalizacyjne) dopiero, gdy typy przekazywanych parametrów są odpowiednio duże. Jeśli chcemy zaoszczędzić na kopiowaniu i przekazać do funkcji wskaźnik albo referencję do zmiennej, co do której nie chcemy, aby była modyfikowana (np. przez pomyłkę albo literówkę), to możemy w sygnaturze funkcji dodać przed typem zmiennej słowo const. W ten sposób naruszenia przekazywanych zmiennych będą raportowane w czasie kompilacji. 001 struct ulamek { int licznik, mianownik; 003 }; 004 005 void zmien_licznik(const ulamek *a, ulamek *b) 006 { 007 a->licznik += 2; //błąd 008 b->licznik += 3; //OK 009 } 010 void zmien_mianownik(ulamek &a, const ulamek &b) 012 { 013 a.mianownik = 5; //OK 014 b.mianownik = 8; //błąd 015 } 2 Konstruktory i destruktory Pod koniec poprzedniego pliku z notatkami poruszony został problem zainicjowania prywatnego pola root w zmiennej typu BST. Jako ćwiczenie należało zaimplementować publiczne metody init oraz destroy, których odpowiedzialnością było inicjowanie i usuwanie BST. Implementacja tych metod może wyglądać następująco: 001 #include <cstddef> 003 struct BST { 004 private: 005 struct BST_node { 3
006 int key; 007 BST_node *left, *right; 008 }; 009 010 BST_node *root; 012 BST_node * BST_search(BST_node *, int); 013 BST_node * BST_insert(BST_node *, int); 014 void BST_destroy(BST_node *); 015 016 public: 017 bool search(int); 018 void insert(int); 019 void init(); 020 void destroy(); 021 }; 022 023 void BST::init() 024 { 025 root = NULL; 026 } 027 028 void BST::BST_destroy(BST_node *node) 029 { 030 if (node == NULL) 031 return; 032 BST_destroy(node->left); 033 BST_destroy(node->right); 034 delete node; 035 } 036 037 void BST::destroy() 038 { 039 BST_destroy(root); 040 init(); 041 } 042 043 int main() 044 { 045 BST tree; 046 tree.init(); 047 048 tree.insert(10); 049 tree.insert(8); 050 051 tree.destroy(); 4
052 053 return 0; 054 } Jak widać, metodę init należy wywołać przed pierwszym użyciem jakiejkolwiek innej metody ze struktury BST, a z kolei destroy należy zawsze wywołać przed zniknięciem zmiennej tegoż typu z pola widzenia. Czyli jeśli przykładowo weźmiemy zmienną typu BST jako lokalną zmienną w jakiejś funkcji, to na początku należy wywołać metodę init, a przed wyjściem z funkcji destroy. W przeciwnym wypadku nie zwolnimy pamięci przydzielonej dla ewentualnych węzłów takiego lokalnego drzewa i pamięć po prostu wycieknie. Już po rozważaniach z poprzedniego laboratorium widać, że to nie jest Dobry TM interfejs, bo z całą pewnością ktoś kiedyś zapomni zainicjować na początku albo posprzątać na końcu. Problem z niesprzątaniem już został przedstawiony (wyciek pamięci). Problem z nieinicjowaniem najpewniej zakończy się bardzo szybko i brutalnie, bo każda operacja na drzewie dotyka w jakiś sposób wskaźnika root, więc bez zainicjowania go polecimy po losowej pamięci. Miło byłoby mieć mechanizm autoinicjujący naszą strukturę danych w momencie deklaracji i autoniszczący, gdy znika z pola widzenia. Przez znikanie z pola widzenia rozumiemy wyjście z bloku kodu, w którym zadeklarowaliśmy naszą zmienną. Dzieje się to albo w toku zwykłego osiągnięcia klamry zamykającej blok, albo jakiegoś gwałtownego wyjścia z bloku, w rodzaju użycia return albo break. Do takiego inicjowania i niszczenia służą specjalne metody zwane konstruktorami i destruktorami. Sposób ich definicji polega na określeniu metod o takiej samej nazwie, jak definiowany typ. Przy czym destruktory poprzedzone są dodatkowo znakiem tyldy (przykład poniżej). Zarówno konstruktory jak i destruktory nie zwracają niczego przed ich deklaracją nie pojawia się nawet słowo void, które do tej pory oznaczało funkcje nie zwracające żadnego argumentu. Konstruktory można przeciążać jak zwykłe funkcje, co także zostało zaprezentowane na przykładzie poniżej. 001 #include <cstddef> 003 struct BST { 004 private: 005 struct BST_node { 006 int key; 007 BST_node *left, *right; 008 }; 009 010 BST_node *root; 012 BST_node * BST_search(BST_node *, int); 013 BST_node * BST_insert(BST_node *, int); 014 void BST_destroy(BST_node *); 015 016 public: 017 bool search(int); 018 void insert(int); 019 020 //konstruktory 021 BST(); 022 BST(int); 5
023 BST(int *, int); 024 025 //destruktor 026 BST(); 027 }; 028 029 BST::BST_node * BST::BST_search(BST_node *node, int key) 030 { 031 if (node == NULL) 032 return NULL; 033 if (node->key == key) 034 return node; 035 if (node->key > key) 036 return BST_search(node->left, key); 037 else 038 return BST_search(node->right, key); 039 } 040 041 bool BST::search(int key) 042 { 043 return BST_search(root, key)!= NULL; 044 } 045 046 BST::BST_node * BST::BST_insert(BST_node *node, int key) 047 { 048 if (node == NULL) { 049 BST_node *temp = new BST_node; 050 temp->key = key; 051 temp->left = temp->right = NULL; 052 return temp; 053 } 054 055 if (key < node->key) 056 node->left = BST_insert(node->left, key); 057 else 058 node->right = BST_insert(node->right, key); 059 return node; 060 } 061 062 void BST::insert(int key) 063 { 064 root = BST_insert(root, key); 065 } 066 067 void BST::BST_destroy(BST_node *node) 068 { 6
069 if (node == NULL) 070 return; 071 072 BST_destroy(node->left); 073 BST_destroy(node->right); 074 delete node; 075 } 076 077 //konstruktor domyślny, generujący drzewo puste 078 BST::BST() 079 { 080 root = NULL; 081 } 082 083 //konstruktor przyjmujący inicjalny węzeł do wstawienia 084 BST::BST(int x) 085 { 086 root = NULL; 087 insert(x); 088 } 089 090 //konstruktor przyjmujący tablicę elementów do wstawienia 091 BST::BST(int *tab, int len) 092 { 093 root = NULL; 094 for (int i = 0; i < len; ++i) 095 insert(tab[i]); 096 } 097 098 BST:: BST() 099 { 100 BST_destroy(root); 101 } 102 103 int main() 104 { 105 //domyślny konstruktor 106 BST tree_zero_1; 107 BST tree_zero_2(); 108 BST tree_zero_3 = BST(); 109 110 //konstruktor jednoargumentowy 111 BST tree_one_1(42); 112 BST tree_one_2 = BST(42); 113 114 //losowe wartości testowe 7
115 int keys[5] = {7, 42, 29, 13, 5}; 116 117 //konstruktor dwuargumentowy 118 //przy okazji pokazane są różne sposoby przekazania listy argumentów 119 BST tree_two_1(keys, 5); 120 BST tree_two_2 = BST(&keys[0], 5); 121 BST tree_two_3 = BST(keys + 3, 2); //tutaj pchamy tylko keys[3] i keys[4] 122 BST tree_two_4(&keys[1], 3); //a tutaj keys[1], keys[2] i keys[3] 123 124 //przy new także podajemy konstruktor 125 BST *ptr_zero = new BST; 126 BST *ptr_one = new BST(42); 127 BST *ptr_two = new BST(keys, 5); 128 129 //UWAGA 130 //pamięć zalokowaną new trzeba tak czy inaczej zwolnić samemu 131 //wywołanie delete na "BST *" powoduje wywołanie destruktora BST:: BST() 132 //ale trzeba pamiętać, że same wskaźniki się nie zerują 133 //i trzeba je wyzerować samodzielnie 134 //jeśli chcemy z nich później korzystać 135 delete ptr_zero; 136 ptr_zero = NULL; 137 138 delete ptr_one; 139 ptr_one = NULL; 140 141 delete ptr_two; 142 ptr_two = NULL; 143 144 return 0; 145 } //po wyskoczeniu poza tę klamrę (return zaraz powyżej) 146 //wszystkie zmienne typu "BST" się usuną poprzez swoje destruktory Powyższy kod zawiera pełną implementację BST wraz z przykładowym użyciem różnych konstruktorów. Ćwiczeniem pozostaje wypełnienie luk w funkcjonalności, w rodzaju metody służącej do usuwania elementów z drzewa, wypisywania całej jego zawartości w kolejności leksykograficznej czy znajdowania k-tego leksykograficznie elementu (po odpowiednim wzbogaceniu drzewa). Uwaga na boku: jeśli dla własnego typu danych nie zdefiniujemy żadnego konstruktora, to używany jest zeroargumentowy tzw. konstruktor domyślny, który działa w taki sposób, że dla każdego pola składowego swojej struktury wywołuje jego konstruktor domyślny. Analogiczne rozumowanie dotyczy domyślnych destruktorów dla pól składowych. Jeśli mimo wszystko zdefiniujemy jakiś własny konstruktor/destruktor, ale pominiemy w jego implementacji któreś z pól klasy, to dla takich pól także używane będą domyślne konstruktory/destruktory w trakcie konstruowania/niszczenia klasy głównej. Ćwiczenie (łatwe i wartościowe jednocześnie): zdefiniować konstruktor dla typu BST_node, żeby proces tworzenia nowego wierzchołka w funkcji BST_insert dało się skrócić do jednej linijki w rodzaju return new 8
BST_node(key). 9