Top 10 cele mai frecvente greșeli C++ pe care le fac dezvoltatorii

Publicat: 2022-03-11

Există multe capcane pe care le poate întâlni un dezvoltator C++. Acest lucru poate face programarea de calitate foarte grea și întreținerea foarte costisitoare. Învățarea sintaxei limbajului și abilitățile bune de programare în limbaje similare, cum ar fi C# și Java, pur și simplu nu sunt suficiente pentru a utiliza întregul potențial al C++. Este nevoie de ani de experiență și disciplină mare pentru a evita erorile în C++. În acest articol, vom arunca o privire asupra unora dintre greșelile comune pe care le fac dezvoltatorii de toate nivelurile dacă nu sunt suficient de atenți cu dezvoltarea C++.

Greșeala comună #1: Folosirea incorect a perechilor „nou” și „Șterge”.

Indiferent cât de mult am încerca, este foarte dificil să eliberăm toată memoria alocată dinamic. Chiar dacă putem face asta, adesea nu este ferit de excepții. Să ne uităm la un exemplu simplu:

 void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }

Dacă se aruncă o excepție, obiectul „a” nu este niciodată șters. Următorul exemplu arată o modalitate mai sigură și mai scurtă de a face asta. Folosește auto_ptr, care este depreciat în C++11, dar vechiul standard este încă utilizat pe scară largă. Poate fi înlocuit cu C++11 unique_ptr sau scoped_ptr din Boost, dacă este posibil.

 void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }

Indiferent de ce se întâmplă, după crearea obiectului „a”, acesta va fi șters de îndată ce execuția programului iese din domeniu.

Cu toate acestea, acesta a fost doar cel mai simplu exemplu al acestei probleme C++. Există multe exemple când ștergerea ar trebui făcută într-un alt loc, poate într-o funcție exterioară sau într-un alt fir. De aceea, folosirea new/delete în perechi ar trebui să fie complet evitată și ar trebui să fie folosite indicatoarele inteligente adecvate.

Greșeala comună nr. 2: Destructorul virtual uitat

Aceasta este una dintre cele mai frecvente erori care duce la scurgeri de memorie în cadrul claselor derivate dacă există memorie dinamică alocată în interiorul lor. Există unele cazuri când destructorul virtual nu este de dorit, adică atunci când o clasă nu este destinată moștenirii, iar dimensiunea și performanța sa sunt cruciale. Virtual destructor sau orice altă funcție virtuală introduce date suplimentare în interiorul unei structuri de clasă, adică un pointer către un tabel virtual care mărește dimensiunea oricărei instanțe a clasei.

Cu toate acestea, în cele mai multe cazuri, clasele pot fi moștenite chiar dacă nu a fost intenționat inițial. Prin urmare, este o practică foarte bună să adăugați un destructor virtual atunci când o clasă este declarată. În caz contrar, dacă o clasă nu trebuie să conțină funcții virtuale din motive de performanță, este o bună practică să puneți un comentariu în interiorul unui fișier de declarare a clasei care să indice că clasa nu trebuie moștenită. Una dintre cele mai bune opțiuni pentru a evita această problemă este utilizarea unui IDE care acceptă crearea destructorului virtual în timpul creării unei clase.

Un punct suplimentar la subiect sunt clasele/șabloanele din biblioteca standard. Nu sunt destinate moștenirii și nu au un destructor virtual. Dacă, de exemplu, creăm o nouă clasă de șir îmbunătățită care moștenește public de la std::string, există posibilitatea ca cineva să o folosească incorect cu un pointer sau o referință la std::string și să provoace o scurgere de memorie.

 class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }

Pentru a evita astfel de probleme C++, o modalitate mai sigură de reutilizare a unei clase/șabloane din biblioteca standard este utilizarea moștenirii private sau a compoziției.

Greșeala comună #3: ștergerea unei matrice cu „ștergerea” sau utilizarea unui indicator inteligent

Greșeala comună #3

Crearea de matrice temporare de dimensiune dinamică este adesea necesară. După ce acestea nu mai sunt necesare, este important să eliberați memoria alocată. Marea problemă aici este că C++ necesită un operator special de ștergere cu paranteze [], care este uitat foarte ușor. Operatorul delete[] nu va șterge doar memoria alocată unei matrice, ci va apela mai întâi destructorii tuturor obiectelor dintr-o matrice. De asemenea, este incorect să folosiți operatorul de ștergere fără paranteze [] pentru tipurile primitive, chiar dacă nu există un destructor pentru aceste tipuri. Nu există nicio garanție pentru fiecare compilator că un pointer către o matrice va indica primul element al matricei, așa că folosirea ștergerii fără paranteze [] poate duce și la un comportament nedefinit.

Utilizarea indicatoarelor inteligente, cum ar fi auto_ptr, unique_ptr<T>, shared_ptr, cu matrice este, de asemenea, incorectă. Când un astfel de indicator inteligent iese dintr-un domeniu, va apela un operator de ștergere fără paranteze [], ceea ce duce la aceleași probleme descrise mai sus. Dacă este necesară utilizarea unui pointer inteligent pentru o matrice, este posibil să utilizați scoped_array sau shared_array de la Boost sau o specializare unique_ptr<T[]>.

Dacă nu este necesară funcționalitatea de numărare a referințelor, ceea ce este în mare parte cazul matricelor, cel mai elegant mod este să folosiți vectori STL. Ele nu se ocupă doar de eliberarea memoriei, ci oferă și funcționalități suplimentare.

Greșeala comună #4: returnarea unui obiect local prin referință

Aceasta este în mare parte o greșeală a începătorului, dar merită menționat, deoarece există o mulțime de coduri vechi care suferă de această problemă. Să ne uităm la următorul cod în care un programator a vrut să facă un fel de optimizare evitând copierea inutilă:

 Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);

Obiectul „suma” va indica acum obiectul local „rezultat”. Dar unde este localizat obiectul „rezultatul” după ce funcția SumComplex este executată? Nicăieri. A fost localizat pe stivă, dar după ce funcția a revenit, stiva a fost desfășurată și toate obiectele locale din funcție au fost distruse. Acest lucru va duce în cele din urmă la un comportament nedefinit, chiar și pentru tipurile primitive. Pentru a evita problemele de performanță, uneori este posibil să utilizați optimizarea valorii returnate:

 Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);

Pentru majoritatea compilatoarelor de astăzi, dacă o linie de returnare conține un constructor al unui obiect, codul va fi optimizat pentru a evita orice copiere inutile - constructorul va fi executat direct pe obiectul „sumă”.

Greșeala comună #5: Utilizarea unei referințe la o resursă ștearsă

Aceste probleme C++ apar mai des decât ați putea crede și sunt de obicei văzute în aplicațiile multithreaded. Să luăm în considerare următorul cod:

Subiectul 1:

 Connection& connection= connections.GetConnection(connectionId); // ...

Subiectul 2:

 connections.DeleteConnection(connectionId); // …

Subiectul 1:

 connection.send(data);

În acest exemplu, dacă ambele fire au folosit același ID de conexiune, aceasta va avea ca rezultat un comportament nedefinit. Erorile de încălcare a accesului sunt adesea foarte greu de găsit.

În aceste cazuri, atunci când mai multe fire de execuție accesează aceeași resursă, este foarte riscant să păstrați pointeri sau referințe la resurse, deoarece un alt thread o poate șterge. Este mult mai sigur să folosiți pointeri inteligente cu numărarea referințelor, de exemplu shared_ptr de la Boost. Utilizează operații atomice pentru creșterea/scăderea unui contor de referință, deci este sigur pentru fire.

Greșeala comună #6: Permiterea excepțiilor să părăsească distrugătorii

Nu este adesea necesar să aruncați o excepție de la un destructor. Chiar și atunci, există o modalitate mai bună de a face asta. Cu toate acestea, excepțiile de cele mai multe ori nu sunt aruncate de la destructori în mod explicit. Se poate întâmpla ca o simplă comandă de a înregistra o distrugere a unui obiect să provoace aruncarea unei excepții. Să luăm în considerare următorul cod:

 class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }

În codul de mai sus, dacă excepția apare de două ori, cum ar fi în timpul distrugerii ambelor obiecte, instrucțiunea catch nu este niciodată executată. Deoarece există două excepții în paralel, indiferent dacă sunt de același tip sau de tip diferit, mediul de rulare C++ nu știe cum să le gestioneze și apelează o funcție de terminare care duce la terminarea execuției unui program.

Deci regula generală este: nu permiteți niciodată excepțiilor să părăsească destructorii. Chiar dacă este urâtă, potențiala excepție trebuie protejată astfel:

 try { writeToLog(); // could cause an exception to be thrown } catch (...) {}

Greșeala comună #7: Utilizarea „auto_ptr” (incorect)

Șablonul auto_ptr este depreciat din C++11 din mai multe motive. Este încă utilizat pe scară largă, deoarece majoritatea proiectelor sunt încă dezvoltate în C++98. Are o anumită caracteristică care probabil nu este familiară tuturor dezvoltatorilor C++ și ar putea cauza probleme serioase pentru cineva care nu este atent. Copierea obiectului auto_ptr va transfera o proprietate de la un obiect la altul. De exemplu, următorul cod:

 auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error

… va avea ca rezultat o eroare de încălcare a accesului. Numai obiectul „b” va conține un pointer către obiectul clasei A, în timp ce „a” va fi gol. Încercarea de a accesa un membru al clasei al obiectului „a” va duce la o eroare de încălcare a accesului. Există multe moduri de a folosi auto_ptr incorect. Patru lucruri foarte importante de reținut despre ele sunt:

  1. Nu utilizați niciodată auto_ptr în interiorul containerelor STL. Copierea containerelor va lăsa containerele sursă cu date nevalide. Unii algoritmi STL pot duce, de asemenea, la invalidarea „auto_ptr”.

  2. Nu utilizați niciodată auto_ptr ca argument al funcției, deoarece aceasta va duce la copiere și va lăsa valoarea transmisă argumentului nevalidă după apelul funcției.

  3. Dacă auto_ptr este folosit pentru membrii de date ai unei clase, asigurați-vă că faceți o copie adecvată în interiorul unui constructor de copiere și al unui operator de atribuire sau nu permiteți aceste operațiuni făcându-le private.

  4. Ori de câte ori este posibil, utilizați un alt pointer inteligent modern în loc de auto_ptr.

Greșeala comună #8: Utilizarea iteratoarelor și referințelor invalidate

Ar fi posibil să scrii o carte întreagă pe acest subiect. Fiecare container STL are anumite condiții specifice în care invalidează iteratorii și referințele. Este important să fii conștient de aceste detalii în timpul utilizării oricărei operațiuni. La fel ca problema anterioară C++, și aceasta poate apărea foarte frecvent în medii multithreaded, deci este necesar să folosiți mecanisme de sincronizare pentru a o evita. Să vedem următorul cod secvenţial ca exemplu:

 vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element

Din punct de vedere logic, codul pare complet ok. Cu toate acestea, adăugarea celui de-al doilea element la vector poate duce la realocarea memoriei vectorului, ceea ce va face atât iteratorul, cât și referința invalide și va duce la o eroare de încălcare a accesului atunci când încercați să le accesați în ultimele 2 linii.

Greșeala comună #9: trecerea unui obiect după valoare

Greșeala comună #9

Probabil știți că este o idee proastă să treceți obiecte după valoare datorită impactului asupra performanței. Mulți o lasă așa pentru a evita introducerea de caractere suplimentare sau probabil se gândesc să revină mai târziu pentru a face optimizarea. De obicei, nu se realizează niciodată și, ca rezultat, duce la un cod și un cod mai puțin performant, care este predispus la un comportament neașteptat:

 class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);

Acest cod se va compila. Apelarea funcției „func1” va crea o copie parțială a obiectului „b”, adică va copia doar partea clasei „A” a obiectului „b” în obiectul „a” („problemă de tăiere”). Deci, în interiorul funcției, va apela și o metodă din clasa „A” în loc de o metodă din clasa „B”, care cel mai probabil nu este ceea ce se așteaptă de cineva care apelează funcția.

Probleme similare apar atunci când încercați să prindeți excepții. De exemplu:

 class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }

Când o excepție de tip ExceptionB este aruncată de la funcția „func2”, aceasta va fi prinsă de blocul catch, dar din cauza problemei de tăiere va fi copiată doar o parte din clasa ExceptionA, va fi apelată metoda incorectă și, de asemenea, re-arunca. va arunca o excepție incorectă la un bloc exterior try-catch.

Pentru a rezuma, treceți întotdeauna obiectele prin referință, nu după valoare.

Greșeala comună #10: Utilizarea conversiilor definite de utilizator de către constructor și operatori de conversie

Chiar și conversiile definite de utilizator sunt foarte utile uneori, dar pot duce la conversii neprevăzute care sunt foarte greu de localizat. Să presupunem că cineva a creat o bibliotecă care are o clasă de șir:

 class String { public: String(int n); String(const char *s); …. }

Prima metodă este menită să creeze un șir de lungime n, iar a doua este menită să creeze un șir care să conțină caracterele date. Dar problema începe imediat ce ai ceva de genul acesta:

 String s1 = 123; String s2 = 'abc';

În exemplul de mai sus, s1 va deveni un șir de dimensiunea 123, nu un șir care conține caracterele „123”. Cel de-al doilea exemplu conține ghilimele simple în loc de ghilimele duble (ceea ce se poate întâmpla accidental), ceea ce va duce, de asemenea, la apelarea primului constructor și la crearea unui șir cu o dimensiune foarte mare. Acestea sunt exemple cu adevărat simple și există multe cazuri mai complicate care duc la confuzie și conversii neprevăzute care sunt foarte greu de găsit. Există 2 reguli generale pentru a evita astfel de probleme:

  1. Definiți un constructor cu cuvânt cheie explicit pentru a interzice conversiile implicite.

  2. În loc să utilizați operatori de conversie, utilizați metode explicite de conversație. Necesită puțin mai multă tastare, dar este mult mai curat de citit și poate ajuta la evitarea rezultatelor imprevizibile.

Concluzie

C++ este un limbaj puternic. De fapt, multe dintre aplicațiile pe care le folosești în fiecare zi pe computer și pe care le-ai îndrăgit sunt probabil construite folosind C++. Ca limbaj, C++ oferă dezvoltatorului o mare flexibilitate, prin unele dintre cele mai sofisticate caracteristici văzute în limbajele de programare orientate pe obiecte. Cu toate acestea, aceste caracteristici sau flexibilități sofisticate pot deveni adesea cauza de confuzie și frustrare pentru mulți dezvoltatori dacă nu sunt utilizate în mod responsabil. Sperăm că această listă vă va ajuta să înțelegeți cum unele dintre aceste greșeli comune influențează ceea ce puteți obține cu C++.


Citiți suplimentare pe blogul Toptal Engineering:

  • Cum să înveți limbajele C și C++: Lista finală
  • C# vs. C++: Ce este la bază?