Cuprins
Cuprins

Supraincarcare (overloading) & supradefinire (overriding)

Supraincarcare functii

In C++ putem avea mai multe functii cu acelasi nume dar trebuie ca parametrii ori sa fie de tipuri diferite, ori sa difere numarul de parametrii, ori parametrii sa fie in alta ordine (ma refer la tipul lor de ex.
int, int, float
si
float, int, int
nu la denumire, asta nu conteaza). De exemplu:
void f(int x) {} // numar diferit de parametrii fata de functiile de mai jos void f(int x, float y) {} // difera ordinea tipurilor parametriilor fata de functia de mai jos void f(float x, int y) {}
Totusi ai grija la parametrii cu valori implicite, de exemplu mai jos avem eroare pentru ca apelul de functie este ambiguu, nu se stie care functie sa se aleaga.
#include <iostream> using namespace std; void f(int x) {} void f(int x, int y = 4) {} int main() { f(5); // eroare aici }
Tehnic vorbind functiile trebuie sa aiba signaturi diferite. Signatura se refera la numele functiei, numarul, ordinea si tipul parametrilor.
Daca avem ceva de genul:
#include <iostream> using namespace std; void f(int x, int y) {} void f(double x, int y) {} int main() { f(2, 2.3); }
Cum se alege ce functie se apeleaza? Compilatorul se uita pe rand la fiecare argument si creaza o multime de functii candidat pentru fiecare argument, adica tipul argumentului (cand apelezi functia) se poate converti (cumva) in tipul parametrului (in definitia functiei). Pentru fiecare argument se creeaza o multime de functii care se potrivesc cel mai bine (chiar daca exista multe functii canditat unele fac mai multa "treaba" decat altele sa converteasca, de aceea se aleg cele care se potrivesc cel mai bine). Din multimile astea de functii care se potrivesc cel mai bine pentru fiecare argument se face o intersectie si functia rezultata este cea care se apeleaza. Daca intersectia e vida sau contine mai mult de 1 functie avem eroare de compilare.
De exemplu:
#include <iostream> using namespace std; class MyClass { // o clasa goala pentru exemplu }; void f(MyClass x, long long y) {} // functia 1 void f(long long x, MyClass y) {} // functia 2 void f(MyClass x, MyClass y) {} // functia 3 int main() { int x = 4; MyClass y; f(x, y); f(x, x); }
Sa ne uitam la linia 16:
f(x, y);
. Mai intai la primul argument de tip
MyClass
, multimea functiilor candidat = {functia 1, functia 2},multimea functiilor care ce potrivesc cel mai bine = {functia 1, functia 2}. Acum sa ne uitam la al doilea argument de tip
int
, multimea functiilor candidat = multimea functiilor care se potrivesc cel mai bine = {functia 1} (pentru ca se poate converti
int
in
long long
).
{functia 1, functia 2} ∩ {functia 1} = {functia 1}. Asta inseamna ca in final pentru
f(x, y);
se apeleaza functia 1.
Daca ne uitam la linia 17:
f(x, x);
, pentru primul argument multimea functiilor care se potrivesc cel mai bine = {functia 2}, pentru al doilea argument multimea functiilor care se potrivesc cel mai bine = {functia 1}, iar {functia 2} ∩ {functia 1} = Ø si deci eroare.
In general daca avem o functie care e supraincarcata si avem doua variante F1 si F2, cum alege compilatorul functia care se potriveste cel mai bine? Functia F1 este mai "buna" decat F2 daca in primul rand conversiile implicite pe care le face F1 (de la tipul argumentelor la tipul parametrilor) nu sunt mai rele decat cele facute de F2 (pt. fiecare argument) si in plus:
  1. E cel putin un argument in F1 a carui conversie implicita este mai buna decat conversia implicita pentru acel argument in F2
  2. Sau daca nu e asa, dar doar in cazul conversiilor definite de tine (gen operatorul de cast intr-o clasa), daca conversia implicita de la tipul returnat de F1 la tipul destinatie este mai buna decat conversia implicita de la tipul returnat de F2 la tipul destinatie.
    class MyClass { public: operator float() { return 5.5; } operator int() { return 1; } }; int main() { MyClass a; double x = a; // se apeleza prima functie vezi mai jos de ce cout << x; // se afiseaza 5.5 }
  3. Sau daca nu e asa, F1 este non-template iar F2 este template sau ambele sunt template dar F1 este mai specializata (dar despre template vorbesc separat).
Acum ce inseamna ca o conversie e mai buna decat alta? Exista 3 tipuri (in ordinea importantei):
  1. Exact match
    Nu trebuie sa se faca nicio "conversie" sau daca se face se considera exact match.
    void f(int& x); void f(double x); int x = 42; f(x); // argument type is int; exact match with int& ///////////////////////////////////////// void g(int* p); void g(void* p); int a[100]; g(a); // calls f(int*); exact match with array-to-pointer conversion
  2. Promotion
    Aici intra integral promotion si floating-point promotion. Promotia e un tip special de conversie pentru tipurile de date built-in (
    int
    ,
    float
    etc.) si este garantat ca nu schimba valoarea. (adica tipul de date la care se converteste poate reprezenta exact orice valoare a tipului de date de la care se converteste)
    Integral promotion
    • signed char
      sau
      signed short
      se pot converti la
      int
    • unsigned char
      sau
      unsigned short
      se pot converti la
      int
      daca
      int
      poate retine toate valorile sau la
      unsigned int
      daca nu poate. (e posibil ca short/char si int sa aiba acelasi nr. de biti pe un calculator si de aceea nu poti din unsigned short/char in int)
    • char
      se poate converti la
      int
      sau
      unsigned int
      (depinde daca prin
      char
      te referi la
      signed char
      sau
      unsigned char
      )
    • bool
      se poate converti la
      int
      ,
      false
      devine
      0
      iar
      true
      devine
      1
    Floating-point promotion
    • Se refera doar la conversia de la
      float
      la
      double
      .
  3. Conversion
    Aici intra integral conversion, floating-point conversion, floating-integral conversion, bool conversion
    Integral conversion
    • aici intra conversii dintre 2 tipuri de date ce reprezinta nr. intregi dar nu e promotion, de exemplu din
      int
      in
      long
      sau din
      unsigned short
      in
      short
    Floating-point conversion
    • aici intra conversii dintre 2 tipuri de date ce reprezinta nr. cu virgula dar nu e promotion, de exemplu din
      double
      in
      float
      sau din
      float
      in
      long double
      void f(double); void f(long double); f(0.0f);
      Se apeleaza prima functie pentru ca promotia (de la
      float
      la
      double
      ) este mai buna decat conversia (de la
      float
      la
      long double
      )
    Floating-integral conversion
    • aici intra conversii dintre un tip de date ce reprezinta nr. cu virgula si un tip de date care reprezinta nr. intregi de exemplu din
      double
      in
      int
      sau din
      int
      in
      float
      void f(int); void f(long double); f(0.0f);
      Eroare de compilare, apelul este ambiguu, avem 2 conversii (de la
      float
      la
      int
      si de la
      float
      la
      long double
      )
    Bool conversion
    • de exemplu din
      int
      in
      bool
      sau din
      float
      in
      bool
      . (de ex.
      bool x = 5; // true
      )

Supraincarcare operatori

  1. Majoritatea operatorilor din C++ pot fi supraincarcati (ori ca functii membre intr-o clasa ori ca functii prieten, insa unii pot fi DOAR functii membre).
  2. Nu poti fi supraincarcati:
    . .* :: ?: sizeof
    (da,
    sizeof
    e un operator, nu functie).
  3. Nu poti modifica precedenta operatorilor sau numarul de argumente pe care le primesc.
  4. Toti operatorii care pot fi supraincarcati NU pot avea parametrii default, exceptie face
    ()
    (operatorul 'apel de functie').
  5. Toti operatorii supraincarcati intr-o clasa de baza sunt mosteniti de clasa derivata,cu exceptia
    =
    (operatorul de atribuire).
  6. Operatorii
    =
    ()
    []
    ->
    trebuie declarati ca functii membre ale unei clase (nu merge sa fie functii prieten).
Mai multe informatii despre supraincarcarea operatorilor sunt aici.

Supradefinire functii

Supradefinirea de functii, folosind
virtual
, are legatura cu polimorfismul la runtime. Daca o metoda din clasa de baza este virtuala si este redefinita intr-o clasa derivata, o putem apela folosindu-ne de un pointer/referinta la clasa de baza (care arata spre un obiect de tipul clasei derivate). In mod normal, daca metoda nu ar fi virtuala, nu am putea face asta deoarece compilatorul s-ar uita doar la tipul pointerului/referintei (nu il intereseaza daca am facut upcast)
Cand supradefinesti o functie (care e virtuala in clasa de baza) in clasa derivata trebuie sa fie exact la fel, cu tot cu tipul returnat (o diferenta fata de supraincarcarea functiilor). Totusi pentru tipul returnat exista o exceptie: cand intorci pointeri/referinte. Metoda supradefinita din clasa derivata poate intoarce ceva mai "specializat" (daca metoda din clasa de baza intorcea B*, cea din clasa derivata poate intoarce D*, unde D mosteneste B). La ce ne trebuie asta? De obicei asta se foloseste la un design pattern care se numeste "prototype" sau "clone".
Metodele statice nu pot fi virtuale (pentru ca nu corespund unui obiect ci intregii clase).
#include <iostream> using namespace std; class Base { public: // f nu este virtuala, g si h sunt virtuale void f() { cout << "Base f()" << endl; } virtual void g() { cout << "Base g()" << endl; } virtual void h() { cout << "Base h()" << endl; } }; class Derived : public Base { public: void f() { cout << "Derived f()" << endl; } void g() { cout << "Derived g()" << endl; } // g este virtuala si aici chiar daca nu apare virtual ! }; int main() { Base* p1 = new Base; Base* p2 = new Derived; // upcast p1->f(); // Base f() // ne uitam la tipul lui p1 care este Base*, metoda f din clasa Base nu este virtuala, deci ea se executa p2->f(); // Base f() // ne uitam la tipul lui p2 care este Base*, metoda f din clasa Base nu este virtuala, deci ea se executa p1->g(); // Base g() // ne uitam la tipul lui p1 care este Base*, metoda g din clasa Base este virtuala // ne uitam acum la tipul obiectului la care arata p1, adica ceva de tip Base // deci metoda g din clasa Base se executa p2->g(); // Derived g() // ne uitam la tipul lui p2 care este Base*, metoda g din clasa Base este virtuala // ne uitam acum la tipul obiectului la care arata p2, adica ceva de tip Derived // in clasa Derived este supradefinita metoda g, deci ea se apeleaza. p1->h(); // Base h() // p1 este de tip Base*, metoda h din Base este virtuala // ne uitam la tipul obiectului la care arata p1, adica Base, deci se executa metoda din Base. p2->h(); // // p2 este de tip Base*, metoda h din Base este virtuala // ne uitam la tipul obiectului la care arata p2, adica Derived // in Derived nu este supradefinita metoda h, deci se executa metoda din Base (care e mostenita practic in clasa Derived). return 0; }
Daca nu lucrezi cu pointeri sau referinte nu adauga nimic in plus faptul ca o functie e virtuala.
Ce faci daca ai metode virtuale dar totusi vrei sa apelezi numai metoda din clasa de baza de exemplu? Folosesti operatorul de scop si numele clasei. Te poti duce doar in sus in ierarhia de clase, nu in jos.
class SuperBase { public: virtual void g() { cout << "SuperBase g()"; } }; class Base : public SuperBase{ public: virtual void g() { cout << "Base g()" << endl; } // g este deja virtual, nu este necesar sa scrii iar virtual }; class Derived : public Base { public: void g() { cout << "Derived g()" << endl; } // g este virtuala si aici chiar daca nu apare virtual ! }; int main(){ Base *p = new Derived; // upcast p->g(); // Derived g() (polimorfism la runtime) p->Base::g(); // Base g() p->SuperBase::g(); // Superbase g() // p este de tip Base*, Base mosteneste SuperBase deci putem merge in "sus" in ierarhie p->Derived::g(); // EROARE // nu putem merge in "jos" in ierarhie }
Metoda virtuala din clasa de baza nu trebuie sa fie accesibila sau vizibila neaparat ca sa fie supradefinita. Poate fi
private
de exemplu. Pe langa asta se ignora faptul ca o metoda e virtuala in constructori.
class B { private: virtual void f() { cout << "B"; } // f e private public: B() { f(); } // in constructor se apeleaza metoda f de mai sus, nu se tine cont de virtual ! // de ce? pentru ca am apela metoda f din clasa derivata inainte sa terminam de construit ce avem in clasa de baza void do_f() { f(); } // aici se apeleaza ori metoda f din clasa de baza ori cea din clasa derivata // in functie de obiectul curent }; class D : public B { void f() { cout << "D"; } }; int main() { D ob; B& ref = ob; ref.do_f(); // D }
Ai grija ca atunci cand ai mostenire multipla, daca clasele care mostenesc virtual supradefinesc o functie, trebuie supradefinita si in clasa "nepot" pentru ca altfel ar exista 2 supradefiniri in clasa cea mai derivata si n-ar fi ok.
class Base { public: virtual int f() const { return 5; } // metoda e const deci trebuie sa fie const si cand supradefinesti !! }; class Derived_1 : public virtual Base { public: int f() const { return 10; } }; class Derived_2 : public virtual Base { public: int f() const { return 20; } }; class MM : public Derived_1, public Derived_2{ public: int f() const { return 50; } // daca lipseste metoda f aici, ai eroare // ai avea aici si metoda f din Derived_1 si metoda f din Derived_2 // care supradefinesc metoda f din Base deci nu se stie care e varianta finala }; int main() { Base *p = new MM; cout << p->f() // 50; return 0; }
Daca functia din clasa derivata arata altfel (acelasi nume, lista de parametrii diferita), nu o mai supradefineste pe cea din clasa de baza, o ascunde.
class SuperBase { public: virtual void f() { cout << "SuperBase" << endl; } }; class Base : public SuperBase { public: void f(int) { cout << "Base" << endl; } // asta nu supradefineste, o ascunde pe cea din SuperBase }; class Derived : public Base { public: void f() { cout << "Derived" << endl; } // supradefineste metoda din SuperBase // metoda de aici o ascunde si pe cea din Base }; int main() { SuperBase* p1 = new SuperBase; SuperBase* p2 = new Base; SuperBase* p3 = new Derived; p1->f(); // SuperBase p2->f(); // SuperBase (pt ca in Base nu se supradefineste) p3->f(); // Derived (pt ca in Derived se supradefineste) Derived ob; ob.f(); // Derived // ob.f(2); // eroare, f(int) din Base nu e vizibila ob.Base::f(2); // acum e ok, se apeleaza explicit Base* p4 = new Base; Base* p5 = new Derived; // p4->f(); // eroare, in Base avem doar f(int) // p5->f(); // eroare, in Base avem doar f(int) p4->SuperBase::f(); // SuperBase p5->SuperBase::f(); // SuperBase (cand apelezi asa explicit nu se tine cont de virtual) return 0; }
Putem avea destructori virtuali. Asta se face cand vrei sa stergi un obiect de tipul clasei derivate printr-un pointer de tipul clasei de baza. Destructorii sunt automat supradefiniti (pentru ca fiecare clasa). Daca clasa derivata nu aloca memorie pe heap, nu este necesar sa mai fie virtual.
class B { public: virtual ~B() { cout << "~B"; } }; class D : public B { int* v; public: D() { v = new int[100]; } ~D() { delete[] v; cout << "~D"; } }; int main() { B* p = new D; delete p; // ~D~B // daca destructorul din B nu ar fi virtual, nu s-ar executa destructorul din D // la delete p, pentru ca p este de tip B* (se comporta ca o metoda normala) return 0; }
Mai multe detalii despre functii virtuale aici (de la Microsoft) si aici (de la cppreference)

Ce face endl?

Fisiere si std::flush

Sa scrii intr-un fisier dureaza, pentru ca acel fisier nu este stocat in RAM. Dureaza pana se creeaza o conexiune la harddisk sau ssd ca sa accesezi ce e pe el, asa ca in loc sa scrii cate un byte (caracter) pe rand intr-un fisier, mai bine pui mai multi intr-un loc temporar (buffer) si cand se umple, pui totul in fisier deodata. Asta se intampla si cand scrii ceva intr-un fisier (sau cand citesti) folosind
fstream
.
#include <iostream> #include <fstream> using namespace std; int main() { ofstream g("out.txt"); g << "Hello world!"; return 0; }
Aici
"Hello world!"
se pune intr-un buffer, un fel de array cu cateva mii de caractere, si cand se umple, continutul lui e pus in fisier (tot deodata). Asta se intampla si cand se apeleaza destructorul pentru obiectul g (pentru ca g este un obiect de tip
ofstream
). Acest lucru se face pentru a se goli bufferul in caz ca s-a scris ceva si nu s-a umplut bufferul. In cazul asta bufferul nu e plin, dar la sfarsitul functiei main, se distruge obiectul g si se scrie in fisier ce mai avea in buffer. Acum daca am avea o eroare dupa
g << "Hello world!";
, programul s-ar termina brusc si in fisier nu am mai avea nimic, pentru ca nici bufferul nu s-a umplut, nici destructorul nu a fost apelat.
#include <iostream> #include <fstream> using namespace std; int main() { ofstream g("out.txt"); g << "Hello world!"; int x = 0; cout << 1 / x; // eroare aici return 0; }
Acum o sa avem o eroare la linia 12, ca impartim la 0. Eroarea asta nu e prinsa si programul se opreste. Fisierul ramane gol (chiar daca avea ceva in el la inceput, cand creezi un obiect de tip
ofstream
, daca nu mai dai alte argumente constructorului, se sterge continutul mai intai). Ok dar daca avem un fisier special unde scriem niste date din program sa tinem evidenta de ceva. Daca avem eroare si nu este prinsa, fisierul nu o sa fie complet, poate ne-ar fi ajutat continutul fisierului sa gasim eroarea.
Pentru asta exista
std::flush
, care este un manipulator pentru operatii de output (definit in <ostream>), ceva gen
std::setw()
. Daca scrii
g << "Hello world!" << flush;
fortezi bufferul sa fie scris in fisier, indiferent daca e plin sau nu. Deci este scris
"Hello world!"
in fisier si dupa avem eroarea, deci la final avem in fisier string-ul. Acum, sa fortezi scrierea bufferului in fisier daca nu e plin nu este deloc eficient, tocmai de asta exista bufferul, ca sa scrii cat mai mult deodata in fisier deci trebuie folosit doar cand ai nevoie.
Ce treaba are asta cu
endl
? Poate sti ca
g << endl
trece la urmatoarea linie, e ca si cum ai scrie
g << "\n";
dar pe langa asta face si un flush!, deci e echivalent cu
g << '\n' << flush;
(apropo,
cout << "\n";
e la fel cu
cout << '\n';
doar ca
"\n"
e un sir de caractere si
'\n'
e un caracter, deci mai bine folosesti un caracter)
Daca scrii multe linii in fisier si folosesti la fiecare
endl
, o sa ruleze mult mai incet decat daca nu ai folosi
endl
si ai pune
'\n'
pentru linie noua.
#include <iostream> #include <fstream> using namespace std; void printWithEndl() { ofstream g("out.out"); for (int i = 0; i < 100000; ++i) { g << "Hello world!" << endl; } } void printNoEndl() { ofstream g("out.out"); for (int i = 0; i < 100000; ++i) { g << "Hello world!" << '\n'; } } int main() { printWithEndl(); // 300 ms printNoEndl(); // 18 ms return 0; }
Functiile astea de mai sus printeaza 100000 de linii intr-un fisier. Functia care foloseste
endl
a durat 300ms si cea care nu foloseste a durat 18ms (am facut un test rapid pe calculatorul meu). Diferenta este mai mare de 10 ori (daca marim numarul de linii la 1 milion, diferenta e si mai drastica, 3000ms vs 130ms )
Deci, daca vrei ca programul tau sa ruleze mai rapid, nu mai folosi
endl
.