Inheritance, Encapsulation, Polymorphism

Am vorbit despre clase, dar nu am stabilit cu adevărat ce scop au ele încă. O latură foarte importantă este reprezentată de noțiunea de încapsulare, care implică să ascundem funcționalitate și stare de cod din exteriorul clasei și de moștenire, care ne permite să ajută foarte mult să refolosim cod.

Encapsulation

Încapsularea se referă la situația în care nu expunem aproape deloc starea obiectului către cod din exteriorul clasei astfel încât să fie imposibil de ajuns cu stări invalide. Primul pas în direcția asta este să implementăm mereu funcții de tip "getter" și "setter".

În exemplul de mai jos este modelată foarte simplificat logica odometrului unui autoturism. Nu ar fi de dorit să se poată modifica numărul de kilometri parcurși fără a fi incrementat 1 câte 1, pe parcurs ce mașina este condusă. Soluția este să nu expunem efectiv variabila kmCounter, ci doar o metodă simplă care o afectează cum ne dorim noi.

class Odometer {
private:
    uint32_t kmCounter = 0;
    
public:
    void incrementKM() {
        kmCounter++;
    }
};

Am menționat mai sus noțiunea de funcții de tip "getter" și "setter", dar încă nu am subliniat exact la ce se referă:

class Employee {
private:
    std::string name;
    uint32_t id;
    
public:
    std::string getName() {
        return name;
    }
    
    void setName(const std::string &name) {
        this->name = name;
    }
};

Poate că par inutile aceste funcții pe exemple așa de mici, dar lucrurile se schimbă pe parcurs ce evoluează codul și apar diverse efecte care trebuie să aibă loc în momentul în care se schimbă câte o variabilă. Filozofia în industrie se bazează pe faptul că tot codul ar trebui scris preventiv, în caz că se întâmpla ceva neașteptat, pentru că efortul este mult mai mare să repari ceva din urmă decât să îl scrii cât mai general din prima (aici intervine și alocarea dinamică).

Inheritance

Moștenirea între clase implică preluarea comportamentului unei clase mai generale pentru implementarea unor alte clase, mai specifice. De exemplu:

class Animal {
protected:
    std::string name;
    uint32_t age;
    
// Constructorul trebuie sa fie public, altfel nu putem crea obiecte
public:
    Animal(const std::string &name, const uint32_t &age) {
        this->name = name;
        this->age = age;
    }
    
    std::string getName() {
        return name;
    }
    
    void setName() {
        this->name = name;
    }
    
    uint32_t getAge() {
        return age;
    }
};

class Dog: public Animal {
public:
    Dog(const std::string &name, const uint32_t &age): Animal(name, age) {}
};

class Cat: public Animal {
public:
    Cat(const std::string &name, const uint32_t &age): Animal(name, age) {}
};

int main() {
    Cat cat("Joe", 3);
    cat.setName("Tom");
    std::cout << cat.getName() << '\n'; // Tom
}

Această secvență de cod funcționează deoarece clasa Cat a moștenit de la clasa Animal toate variabilele și metodele. Dacă am încerca să modificăm direct numele pisicii din main, nu ar funcționa datorită directivei protected: din clasa părinte. De asemenea, toată această funcționalitate există și pe Dog.

Au apărut câteva elemente noi de sintaxă în acest exemplu, anume class Cat: public Animal și Cat(const std::string &name, const uint32_t &age): Animal(name, age). public din definiția clasei, nu se referă la membrii clasei Cat, ci la ce se întâmplă cu membrii clasei Animal odată ce sunt moștenite. De cele mai multe ori vom specifica public, dar avem următoarele opțiuni:

  • public: toți membrii și metodele clasei părinte își păstrează access specifierul în clasa fiu
  • protected: ceea ce este public în clasa părinte devine protected în clasa fiu, iar în rest totul rămâne la fel
  • private: toți membrii și metodele clasei părinte devin private în clasa fiu

Mai exact:

class Cat: protected Animal {
    ...
}

nu ne-ar fi permis să apelăm cat.getName() în main(), dar ar fi putut fi apelat dintr-o eventuală clasă fiu a lui Cat.

Celălalt element interesant de sintaxă, Cat(const std::string &name, const uint32_t &age): Animal(name, age) specifică faptul că trebuie apelat constructorul cu 2 parametri din Animal în loc de cel implicit care nu mai există. Constructorul clasei părinte primește argumenții clasei fiu ca atare, urmând să inițializeze cele 2 variabile.

Atenție: Constructorii se apelează pe rând, începând cu clasa părinte și incheind cu clasa instanțiată. Destructorii se apelează tot la rând, dar în ordine inversă. Nu este posibil să fie instanțiată o clasă sau distrus un obiect fără să se apeleze toți constructorii și destructorii din toate clasele strămoș.

Polymorphism

Polimorfismul se referă la proprietatea obiectelor de tip clasă fiu să se comporte ca și cum ar fi clasa părinte, până este nevoie de detalii de implementare specifice. Construind pe exemplul de mai sus, am putea crea un array de pointeri de tip Animal * în care să instanțiem Cat și Dog:

vector<Animal *> animals;
animals.push_back(new Dog("Azorel", 4));
animals.push_back(new Cat("Tom", 2));

Adăugăm la clasa Animal metoda requestFood():

class Animal {
protected:
    std::string name;
    uint32_t age;

public:
    Animal(const std::string &name, const uint32_t &age) {
        this->name = name;
        this->age = age;
    }
    
    void requestFood() {
        std::cout << name << " is hungry!";
    }
    ...
};

class Dog: public Animal {
public:
    Dog(const std::string &name, const uint32_t &age): Animal(name, age) {}
};

class Cat: public Animal {
public:
    Cat(const std::string &name, const uint32_t &age): Animal(name, age) {}
};

În această situație, dacă executăm pentru exemplul anterior cu vectorul de Animal *:

animals[0]->requestFood();
animals[1]->requestFood();

ambele vor afișa mesajul din Animal::requestFood().

Observatie: Operatorul :: se numește "scope resolution operator" și înseamnă că ne referim la simbolul din dreapta care face parte din simbolul din stânga.

Method Overload

Avem posibilitatea de a implementa comportament mai specific pe fiecare clasă fiu în parte:

class Animal {
protected:
    std::string name;
    uint32_t age;

public:
    Animal(const std::string &name, const uint32_t &age) {
        this->name = name;
        this->age = age;
    }
    
    void requestFood() {
        std::cout << name << " is hungry!\n";
    }
    ...
};

class Dog: public Animal {
public:
    Dog(const std::string &name, const uint32_t &age): Animal(name, age) {}
    
    void requestFood() {
        std::cout << name << ": Woof!\n";
    }
};

class Cat: public Animal {
public:
    Cat(const std::string &name, const uint32_t &age): Animal(name, age) {}
    
    void requestFood() {
        std::cout << name << ": Meow!\n";
    }
};

Cu această implementare, am putea să executăm ceva de genul:

vector<Animal *> animals;
animals.push_back(new Dog("Azorel", 4));
animals.push_back(new Cat("Tom", 2));
animals.push_back(new Animal("Tweety", 8));

animals[0]->requestFood(); // "Azorel is hungry!"
animals[1]->requestFood(); // "Tom is hungry!"
animals[2]->requestFood(); // "Tweety is hungry!"

Constatăm o problemă: toate cele 3 apeluri au executat codul din Animal::requestFood, și nu din propria clasă. Motivul este că pointerul este de tip Animal * la toate cele 3 obiecte, așa că programul nu se va uita decât în implementarea din clasa Animal. Acest comportament se numește overload și este dorit.

Method Override, Virtual Method

Adesea, overload nu este ceea ce ne dorim. Pentru aceste situații, există comportamentul de override care poate fi obținut cu următoarea sintaxă:

În Animal:

virtual void requestFood() {
    std::cout << name << " is hungry!\n";
}

În Cat și Dog:

void requestFood() override {
    ...
}

În acest caz, apelurile metodei se vor comporta astfel:

vector<Animal *> animals;
animals.push_back(new Dog("Azorel", 4));
animals.push_back(new Cat("Tom", 2));
animals.push_back(new Animal("Tweety", 8));

animals[0]->requestFood(); // "Azorel: Woof!"
animals[1]->requestFood(); // "Tom: Meow!"
animals[2]->requestFood(); // "Tweety is hungry!"

Keywordul override nu este strict necesar, dar dacă îl punem, codul nu va compila decât dacă metoda din clasa părinte este virtual. Ca atare, este indicat să specificăm override de fiecare dată. virtual specifică faptul că vrem să se execute metoda de pe tipul de date al obiectului, nu tipul de date către care crede pointerul că arată.

Abstract Class

În exemplul nostru cu animale, nu are foarte mult sens să putem crea un obiect de tip Animal, având în vedere că nu știm ce tip de animal este, ce sunete scoate etc. Soluția pentru asta este simplă. Dacă definim metoda Animal::requestFood ca:

virtual void requestFood() = 0;

și lasăm Cat::requestFood și Dog::requestFood nemodificate, metoda rămâne neimplementată și nu vom mai putea instanția obiecte de tip Animal, doar de tip Cat sau Dog. O metodă fără implementare se numește pur virtuală (en: pure virtual) și o clasă care nu poate fi instanțiată se numește abstractă (en: abstract class). Scopul claselor abstracte este exact cel din exemplu: să implementeze o parte din comportament (logica de nume si vârstă) fără a permite obiecte incomplete să existe. Astfel, secvența noastră de cod se va comporta la fel ca mai devreme, doar că new Animal("Tweety", 8) ar da eroare de compilare și ar trebui șters înainte.

Observație: Noțiunea de interfață apare des în literatură, dar în C++ înseamnă doar o clasă pur abstractă, anume o clasă care are toate metodele pur virtuale. Interfețele sunt folosite ca să definească modul în care va fi utilizat un obiect, dar fără să se știe încă detaliile de implementare.

Exerciții

La fiecare dintre următoarele cerințe puteți adăuga membri, metode, sau orice vi se pare util. Aveți voie cu STL, nu aveți voie să alocați un număr aleator de obiecte și să vă bazați că ajung.

  1. Creați clasa Vector2 care conține:

    • x: număr real
    • y: număr real
  2. Creați clasa Arrow (considerăm că săgețile se mișcă drept și uniform și că sunt punctiforme) care conține:

    • position: punct în plan
    • targetPoint: punct în plan
    • speed: număr real exprimat în unități/tură
  3. Creați clasa Bow care poate să tragă cu săgeți:

    • position: punct în plan
    • Arrow *shootArrow(const Point2D &target, const float &speed): trage cu o săgeată în direcția și cu viteza dată
    • void move(const Point2D &newPosition): mută arcul la poziția dată
  4. Creați clasa Target ce reprezintă o țintă care poate fi nimerită de săgeți:

    • position: punct în plan
    • radius: raza cercului acoperit de țintă, în unități
  5. Creați un sistem turn-based în care execuția rulează la infinit și reacționează la următoarele directive:

    • shoot x y lifetime unde x și y sunt coordonatele unui punct în plan. O săgeată apare la poziția arcului se mișcă un anumit număr de unități, și dispare după lifetime ture
    • stop oprește programul
    • show afișează coordonatele tuturor țintelor, săgeților și a arcului și numărul turei la care s-a ajuns
    • next simulează un pas, mișcând toate săgețile și trece la următoarea tură

    Atunci când o săgeată lovește o țintă (intră în raza ei), acea țintă incrementează un contor, astfel încât fiecare țintă în parte să știe de câte ori a fost nimerită. Puteți crea clase noi, sau modifica orice ați scris anterior după cum considerați că are cel mai mult sens. Recomandăm crearea unei clase GameLogicHandler care să gestioneze turele și săgețile active, pentru a fi mai ușoară și elegantă implementarea.

  6. Adăugați mai multe tipuri de Bow, de exemplu Shortbow și Longbow care moștenesc de la Bow și îi permit jucătorului să tragă cu alți parametri. Se poate schimba tipul de arc utilizat în joc prin directiva set bowname, unde bowname este numele categoriei de arc.