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 fiuprotected
: ceea ce este public în clasa părinte devine protected în clasa fiu, iar în rest totul rămâne la felprivate
: 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.
-
Creați clasa
Vector2
care conține:x
: număr realy
: număr real
-
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 plantargetPoint
: punct în planspeed
: număr real exprimat în unități/tură
-
Creați clasa
Bow
care poate să tragă cu săgeți:position
: punct în planArrow *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ă
-
Creați clasa
Target
ce reprezintă o țintă care poate fi nimerită de săgeți:position
: punct în planradius
: raza cercului acoperit de țintă, în unități
-
Creați un sistem turn-based în care execuția rulează la infinit și reacționează la următoarele directive:
shoot x y lifetime
undex
șiy
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
turestop
oprește programulshow
afișează coordonatele tuturor țintelor, săgeților și a arcului și numărul turei la care s-a ajunsnext
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. -
Adăugați mai multe tipuri de
Bow
, de exempluShortbow
șiLongbow
care moștenesc de laBow
și îi permit jucătorului să tragă cu alți parametri. Se poate schimba tipul de arc utilizat în joc prin directivaset bowname
, undebowname
este numele categoriei de arc.