Programowanie

Programowanie obiektowe #3 - polimorfizm

image

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 - przykład

Każdy wielokąt możemy opisać tymi samymi właściwościami: ilością boków, obwodem, polem, kątami, przekątnymi, zachowaniem przekątnych etc. Każdy z katów ma jednak swoje własne właściwości i zachowania.

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.

grupa matura z informatyki

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

#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

#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

#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

#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

#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

#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

#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 :)