Witajcie! W dzisiejszym wpisie dowiecie się czym jest polimorfizm, a także jak on działa. Zanim jednak przejdziemy do wpisu chcemy życzyć wszystkim maturzystom połamania długopisów i klawiatur na dzisiejszej maturze z informatyki – mamy nadzieję, że materiały z naszego bloga przyczynią się ku waszemu sukcesowi 🙂
Polimorfizm – definicja
Polimorfizm, jak pewnie pamiętacie z pierwszej lekcji, jest jednym z czterech filarów, na których opiera się programowanie obiektowe – dziedziczenie i hermetyzację omówiliśmy już w poprzednich wpisach. W krócie polega on na definiowaniu innych zachowań tej samej funkcji dla różnych, często dziedziczących po tym samym rodzicu, klas. Polimorfizm jest więc silnie powiązany z dziedziczeniem. Wynika to z faktu, że najczęściej w klasie bazowej definiowane są metody wirtualne, których nadpisanie jest wymagane w klasach pochodnych. Istnieją także metody czysto wirtualne, które w klasie bazowej nie mają implementacji, co czyni zawierające je klasy abstrakcyjnymi.
Czym jest klasa abstrakcyjna? Jest to klasa, której instancji nie da się stworzyć. Oznacza to, że nie da się jej wykorzystać jako typu obiektu. W języku C++ możliwe jest jednak zwracanie referencji na klasę abstrakcyjną, co wykorzystamy w późniejszym przykładzie. Jeżeli metoda czysto wirtualna nie zostanie nadpisana w klasach pochodnych, to one z automatu też stają się klasami abstrakcyjnymi.
Polimorfizm – typy
Ze względu na sposób wdrożenia, polimorfizm możemy rozróżnić na dwa typy. Pierwszy z nich polega na przeciążaniu metod lub operatorów. Oznacza to, że tą samą metodę możemy zdefiniować wielokrotnie, jednak każda z tych definicji różni się ilością i/lub typami przyjmowanych argumentów. Często zdarza się też, że metody o mniej rozbudowanej konstrukcji korzystają z tych bardziej skomplikowanych, przekazując domyślne wartości w brakujących argumentach. Przykładowo klasa Boss może zawierać metodę paySalary(int amount, string message, Worker worker), która wypłaca amount pieniędzy pracownikowi worker, przekazując mu jednocześnie wiadomość o treści message. Szef tej firmy jest jednak bardzo gburowaty. Z tego powodu często przy wypłacie bez powodu krzyczy na pracownika, dlatego w jego klasie znajduje się również metoda paySalary(int amount, Worker worker), która wywołuje metodę o większej ilości argumentów, przekazując amount, wiadomość „Następnym razem Cię zwolnię!” i worker jako parametry.
Drugi typ polimorfizmu polega na definiowaniu tych samych metod w różny sposób w klasach pochodnych. Dzięki temu możemy określić specyficzne dla danego typu działanie funkcji, jednocześnie pozwalając na wywołanie jej bez dokładnej znajomości danego typu. Przykładowo chcemy wywołać metodę work() każdego z pracowników firmy. Wiemy, że każdy wykonuje pracę inaczej, nie znamy jednak konkretnych typów pracowników – wiemy jedynie, że wszystkie dziedziczą po klasie Worker. Okazuje się, że to już nam w zupełności wystarcza. Wystarczy bowiem do klasy Worker dodać metodę wirtualną, której implementacja jest wymagana w każdym z dziedziczących typów. Sama klasa bazowa nie może jednak implementować metody wirtualnej, dlatego przy wywołaniu metody work() każdego z pracowników wywoła się metoda specyficzna dla niego. Trochę zawikłane, prawda? Jednak, aby rozwiać wszelkie wątpliwości przejdźmy do przykładu.
Polimorfizm – przykład
Dla przykładu stworzymy klasę komórki. W głównej metodzie programu stworzymy obszerny zbiór komórek, a następnie co określony czas będziemy wywoływać metody „tik” każdej z nich. Załóżmy, że komórka będzie przechowywała pewną ilość energii, którą będzie zużywała podczas każdego tiku życia. Określimy również ilość cykli, którą dana komórka będzie żyła. Warto również dodać metodę, która będzie sprawdzać czy dana komórka nadal żyje – przyjmijmy, że komórka jest żywa dopóki nie przekroczyła maksymalnej długości życia, a także posiada jeszcze jakąś energię. Dodatkowo dodamy dwie metody wirtualne: jedna będzie odpowiadała za wytwarzanie energii, a druga za rozmnażanie komórki. Tak wygląda implementacja klasy komórki:
Cell.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#ifndef CELL_H #define CELL_H #include <string> #include <iostream> using namespace std; class Cell { public: Cell(const string &cellName, intinitialEnergy, int maxAge); //konstruktor klasy komorki void lifeTick(); //pojedynczy tick zycia komorki bool isAlive(); //metoda zwracajaca, czy dana komorka jest nadal zywa std::string getName(); //tworzymy publiczne metody ujawniajace informacje o komorce int getEnergy(); int getMaxAge(); int getAge(); //teraz definiujemy metody czysto wirtualne, ktorych odziedziczenie, a takze deklaracja jest wymagana w klasach pochodnych virtual void gainEnergy() = 0; //metoda czysto wirtualna, ktora pozyskuje energie dla danej komorki virtual Cell* reproduce() = 0; //metoda czysto wirtualna podzialu komorki - zwraca nowa komorke protected: //tworzymy metody gettery dla prywatnych pol, aby byly dostepne dla klas dziedziczacych int getChildCount(); void setEnergy(int amount); //a takze metode pozwalajca modyfikowac ilosc aktualnej energii void setChildCount(int amount); //i zmieniajaca ilosc dzieci private: int _energy = 1000; //energia pojedynczej komorki int _age = 0; //wiek komorki int _maxAge = 20; //maksymalny wiek do jakiego dozyje komorka std::string _name; //nazwa komorki int _childCount = 0; //zmienna przechowujaca ilosc dzieci komorki }; #endif // CELL_H |
Cell.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
#include "Cell.h" Cell::Cell(const std::string &cellName, int initialEnergy, int maxAge) { _name = cellName; //nadajemy odpowiednie wartosci zmiennym w konstruktorze _energy = initialEnergy; _maxAge = maxAge; } void Cell::lifeTick() { _energy -= 100 + _energy * 0.3; //kazda komorka co kazdy tik zuzywa taka sama ilosc energii _age++; //a takze zwieksza swoj wiek o jeden } bool Cell::isAlive() { return _age < _maxAge && _energy > 0; //komorke uznajemy za zywa, gdy posiada dostatecznie duzo energii, a takze gdy nie przekroczyla maksymalnego limitu zycia } int Cell::getEnergy() { return _energy; } int Cell::getMaxAge() { return _maxAge; } int Cell::getAge() { return _age; } std::string Cell::getName() { return _name; } void Cell::setEnergy(int amount) { _energy = amount; } void Cell::setChildCount(int amount) { _childCount = amount; } int Cell::getChildCount() { return _childCount; } |
Następnie utwórzmy pochodną klasę komórki roślinnej. Jak już wiecie musi ona implementować z góry narzucone metody wirtualne klasy bazowej. Musimy więc określić w jakich warunkach i w jaki sposób komórka się reprodukuje, a także w jaki sposób pozyskuje energie. Stworzymy w tym celu uproszczoną metodę fotosyntezy, która będzie generowała losową ilość energii. Założymy też, że komórka zreprodukuje się tylko, gdy będzie miała odpowiednią ilość energii. Jeżeli tak, to podzieli ją na równo pomiędzy nowoutworzoną komórką a sobą. Poniżej kod klasy:
PlantCell.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#ifndef PLANTCELL_H #define PLANTCELL_H #include <Cell.h> class PlantCell : public Cell { public: PlantCell(const std::string &id, int initialEnergy); //konsturktor klasy roslinnej Cell* reproduce() override; //deklaracja nadpisan wirtualnych metod dziedziczonych od rodzica void gainEnergy() override; protected: void beginPhotosynthesis(); //metoda rozpoczynajaca produkcje energii - nie chcemy, aby byla dostepna na zewnatrz klasy }; #endif // PLANTCELL_H |
PlantCell.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#include "PlantCell.h" PlantCell::PlantCell(const std::string &id, int initialEnergy) : Cell("PC" + id, initialEnergy, 7) { } Cell *PlantCell::reproduce() { if(getEnergy() > 400 && getAge() > 3){ //do reprodukcji dojdzie tylko wtedy, gdy komorka jest zywa i posiada dostatecznie duzo energii int finalEnergy = getEnergy() / 2; //nowo utworzona komorka bedzie posiadala polowe posiadanej przez rodzica energii setEnergy(finalEnergy); //pomniejszamy energie komorki o polowe setChildCount(getChildCount() + 1); //zwiekszamy ilosc dzieci o jeden return new PlantCell(getName() + ":" + to_string(getChildCount()), finalEnergy); //zwracamy wskaznik na nowa komorke } else { return nullptr; //w przeciwnym wypadku zwracamy pusty wskaznik - nie zostala stworzona zadna nowa komorka } } void PlantCell::gainEnergy() { beginPhotosynthesis(); //komorka roslinna pozyskuje energie przez fotosynteze } void PlantCell::beginPhotosynthesis() { //duuuzo reakcji z wieloma skladnikami int energy = getEnergy() + ((rand() % 600 + 70)); setEnergy(energy); //przyjmujemy ze ilosc energii byla zalezna od wieluu czynnikow //skorzystamy wiec z bliblioteki rand, aby wygenerowala losowa ilosc energii od 40 do 400 jednostek } |
Następnie stworzymy klasę komórki zwierzęcej. Tutaj dodamy ilość przechowywanego pokarmu, a także stworzymy przeciążoną metodę pozyskiwania go. Następnie, tak jak w przypadku komórki roślinnej, musimy zaimplementować dziedziczone metody. Przyjmijmy, że klasa reprodukuje się, gdy ma wystarczająco pokarmu, a następnie dzieli się nim z nowo utworzoną komórką.
AnimalCell.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#ifndef ANIMALCELL_H #define ANIMALCELL_H #include <Cell.h> class AnimalCell : public Cell { public: AnimalCell(const string &id, int initialEnergy, int foodAmount); Cell* reproduce() override; //deklaracja nadpisan wirtualnych metod dziedziczonych od rodzica void gainEnergy() override; protected: void gainFood(); //metoda pozyskujaca pokarm void gainFood(int currentFood); //przeciazenie metody pozyskujacej pokarm private: int _foodAmount; //ilosc przechowanego pokarmu przez komorke }; #endif // ANIMALCELL_H |
AnimalCell.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
#include "AnimalCell.h" AnimalCell::AnimalCell(const string &id, int initialEnergy, int foodAmount) : Cell("AC" + id, initialEnergy, 10), _foodAmount(foodAmount) //inicjalizujemy pola { } Cell *AnimalCell::reproduce() { if(_foodAmount > 40 && getEnergy() > 100 && getAge() > 7){ //jezeli komorka ma wystarczajco pokarmu i energii int finalFoodAmount = 10 + _foodAmount * 0.1; //ustawiamy ilosc przekazywanego pokarmu setEnergy(getEnergy() - 100); //zmniejszamy ilosc energii setChildCount(getChildCount() + 1); //zwiekszamy ilosc dzieci _foodAmount -= finalFoodAmount; //pomniejszamy ilosc pokarmu o przekazana wartosc return new AnimalCell(getName() + ":" + to_string(getChildCount()), 300, finalFoodAmount); //zwracamy wskaznik nowoutworzonej komorki } return nullptr; } void AnimalCell::gainEnergy() { if(rand() % 4 + 1 == 2){ //komorka ma 25% szansy na przemnozenie polowy posiadanego pokarmu gainFood(_foodAmount * 0.5); //korzystamy z jednego z dwoch wariantow funkcji gainFood }else{ gainFood();//korzystamy z jednego z dwoch wariantow funkcji gainFood } setEnergy(getEnergy() + _foodAmount * 0.35 * 10); //komorka przetwarza 35% posiadanego pokarmu na energie _foodAmount -= _foodAmount * 0.35; } void AnimalCell::gainFood() //komorka pozyskuje jedzenie { //dluuugie polowanie na pokarm int food = rand() % 50 + 5; _foodAmount += food; //komorka pozyskuje losowa ilosc pokarmu } void AnimalCell::gainFood(int currentFood) //zalozmy ze komorka jest w stanie powiekszyc ilosc jedzenia bazujac na przekazanym parametrze { _foodAmount += currentFood * 1.3; } |
Następnie możemy przejść do wykorzystania utworzonych klas:
Main.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
#include <iostream> #include <Cell.h> //importujemy nasze klasy #include <AnimalCell.h> #include <PlantCell.h> #include <vector> //biblioteka zawierajaca bector - kontener dla innych obiektow #include <iomanip> //biblioteka pozwlajaca na schludniejsze drukowanie danych w konsoli using namespace std; int main() { vector<Cell*> cells; //tworzymy kontener przechowujacy wskazniki na nasze komorki AnimalCell* animalCell = new AnimalCell(to_string(1), rand() % 200 + 20, rand() %40 + 10); //dodajemy po jednej komorce PlantCell* plantCell = new PlantCell(to_string(1), rand() % 200 + 20); //kazdego rodzaju cells.push_back(animalCell); //do naszego kontenera cells.push_back(plantCell); vector<int> toRemove; //pomocniczy kontener przechowujacy komorki martwe - do usuniecia vector<Cell*> toAdd; //pomocniczy kontener przechowujacy nowe komorki do dodania while(!cells.empty()){ //dana petla bedzie sie wykonywac dopoki beda jeszcze jakies zywe komorki system("cls"); //co kazda iteracje czyscimy konsole int i = 0; //zmienna pomocnicza przchowujaca numer iteracji cout << left << setw(15) << setfill(' ') << "Name" << " | " //drukowanie zawartosci kontenera w formie tabelki << left << setw(6) << setfill(' ') << "Energy" << " | " << left << setw(7) << setfill(' ') << "Lifetime" << endl; for(auto c : cells){ //iterujemy poprzez wszystkie komorki if(!c->isAlive()){ //zbieramy te martwe toRemove.push_back(i); } else { //dla pozostalych komorek wywolujemy metody wirtualne c->gainEnergy(); //pozyskiwanie energii c->lifeTick(); //tik zycia Cell* reproduced = c->reproduce(); //a nastepnie reprodukcje if(reproduced != nullptr){ //jezeli byla ona mozliwa toAdd.push_back(reproduced); //to dodajemy nowo stworzone komorki do kontenera pomocniczego } cout << left << setw(15) << setfill(' ') << c->getName().c_str() << " | " << left << setw(6) << setfill(' ') << c->getEnergy() << " | " << left << setw(7) << setfill(' ') << to_string(c->getAge()) + "/" + to_string(c->getMaxAge()) << endl; //drukujemy informacje o komorce } i++; } for(auto c : toRemove){ //po iteracji wyrzucamy z glownego kontenera komorki martwe Cell* cell = cells.at(c); cells.erase(cells.begin() + c); delete cell; //nalezy pamietac o zwalnianiu pamieci } for(auto c : toAdd){ cells.push_back(c); //a nastepnie dodajemy te nowe } toRemove.clear(); toAdd.clear(); _sleep(500); //petla bedzie wykonywac sie co pol sekundy } return 0; } |
Zwróćcie uwagę, że w pętli nie usuwamy/dodajemy komórek do głównego kontenera. Dlaczego? Modyfikacja zbioru iterowanych danych zmieniłaby zakres działania pętli, przez co program najprawdopodobniej by się posypał. Skorzystaliśmy więc z pomocniczych kontenerów, których zawartość sukcesywnie obsługiwaliśmy poza pętlą. Zauważcie, że w pętli for nie wiemy jakiego typu są przechowywane komórki, a jednak jesteśmy w stanie wywołać metodę reproduce i gainEnergy w sposób specyficzny dla każdego z tych typów. Zresztą widać to w działaniu samego programu:
Tym przykładem kończymy dzisiejszą lekcję. W następnym wpisie z serii omówimy czym jest ostatni z filarów: abstrakcja. Udanej środy i wysokich wyników z matur 🙂