I 10 errori C++ più comuni che gli sviluppatori fanno
Pubblicato: 2022-03-11Ci sono molte insidie che uno sviluppatore C++ può incontrare. Ciò può rendere la programmazione di qualità molto difficile e la manutenzione molto costosa. Imparare la sintassi del linguaggio e avere buone capacità di programmazione in linguaggi simili, come C# e Java, non è sufficiente per sfruttare tutto il potenziale di C++. Richiede anni di esperienza e grande disciplina per evitare errori in C++. In questo articolo, daremo un'occhiata ad alcuni degli errori comuni che vengono commessi dagli sviluppatori di tutti i livelli se non sono abbastanza attenti con lo sviluppo C++.
Errore comune n. 1: utilizzare le coppie "nuovo" ed "elimina" in modo errato
Non importa quanto ci proviamo, è molto difficile liberare tutta la memoria allocata dinamicamente. Anche se possiamo farlo, spesso non è al sicuro dalle eccezioni. Vediamo un semplice esempio:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }
Se viene generata un'eccezione, l'oggetto "a" non viene mai eliminato. L'esempio seguente mostra un modo più sicuro e più breve per farlo. Utilizza auto_ptr che è deprecato in C++11, ma il vecchio standard è ancora ampiamente utilizzato. Può essere sostituito con C++11 unique_ptr o scoped_ptr da Boost, se possibile.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }
Qualunque cosa accada, dopo aver creato l'oggetto "a", verrà eliminato non appena l'esecuzione del programma esce dall'ambito.
Tuttavia, questo era solo l'esempio più semplice di questo problema C++. Ci sono molti esempi in cui l'eliminazione dovrebbe essere eseguita in un altro posto, magari in una funzione esterna o in un altro thread. Questo è il motivo per cui l'uso di nuovo/cancella in coppia dovrebbe essere completamente evitato e dovrebbero invece essere utilizzati puntatori intelligenti appropriati.
Errore comune n. 2: distruttore virtuale dimenticato
Questo è uno degli errori più comuni che porta a perdite di memoria all'interno delle classi derivate se al loro interno è allocata memoria dinamica. Ci sono alcuni casi in cui il distruttore virtuale non è desiderabile, cioè quando una classe non è destinata all'ereditarietà e le sue dimensioni e prestazioni sono cruciali. Il distruttore virtuale o qualsiasi altra funzione virtuale introduce dati aggiuntivi all'interno di una struttura di classe, ovvero un puntatore a una tabella virtuale che aumenta la dimensione di qualsiasi istanza della classe.
Tuttavia, nella maggior parte dei casi le classi possono essere ereditate anche se non sono originariamente previste. Quindi è un'ottima pratica aggiungere un distruttore virtuale quando viene dichiarata una classe. In caso contrario, se una classe non deve contenere funzioni virtuali per motivi di prestazioni, è buona norma inserire un commento all'interno di un file di dichiarazione di classe indicando che la classe non deve essere ereditata. Una delle migliori opzioni per evitare questo problema consiste nell'usare un IDE che supporti la creazione di distruttori virtuali durante la creazione di una classe.
Un ulteriore punto sull'argomento sono le classi/modelli dalla libreria standard. Non sono destinati all'ereditarietà e non hanno un distruttore virtuale. Se, ad esempio, creiamo una nuova classe stringa avanzata che eredita pubblicamente da std::string c'è la possibilità che qualcuno la usi in modo errato con un puntatore o un riferimento a std::string e causi una perdita di memoria.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }
Per evitare tali problemi di C++, un modo più sicuro per riutilizzare una classe/modello dalla libreria standard consiste nell'usare l'ereditarietà o la composizione privata.
Errore comune n. 3: eliminare un array con "cancella" o utilizzare un puntatore intelligente
La creazione di array temporanei di dimensioni dinamiche è spesso necessaria. Dopo che non sono più necessari, è importante liberare la memoria allocata. Il grosso problema qui è che C++ richiede uno speciale operatore di eliminazione con [] parentesi, che viene dimenticato molto facilmente. L'operatore delete[] non eliminerà solo la memoria allocata per un array, ma chiamerà prima i distruttori di tutti gli oggetti da un array. Non è inoltre corretto utilizzare l'operatore di eliminazione senza [] parentesi per i tipi primitivi, anche se non esiste un distruttore per questi tipi. Non vi è alcuna garanzia per ogni compilatore che un puntatore a un array punterà al primo elemento dell'array, quindi l'utilizzo di delete senza [] parentesi può comportare anche un comportamento indefinito.
Anche l'uso di puntatori intelligenti, come auto_ptr, unique_ptr<T>, shared_ptr, con le matrici non è corretto. Quando un tale puntatore intelligente esce da un ambito, chiamerà un operatore di eliminazione senza [] parentesi, il che provoca gli stessi problemi descritti sopra. Se è richiesto l'utilizzo di un puntatore intelligente per un array, è possibile utilizzare scoped_array o shared_array da Boost o una specializzazione unique_ptr<T[]>.
Se la funzionalità di conteggio dei riferimenti non è richiesta, come avviene principalmente per gli array, il modo più elegante è utilizzare invece i vettori STL. Non si occupano solo di rilasciare memoria, ma offrono anche funzionalità aggiuntive.
Errore comune n. 4: restituire un oggetto locale per riferimento
Questo è principalmente un errore da principiante, ma vale la pena menzionarlo poiché c'è molto codice legacy che soffre di questo problema. Diamo un'occhiata al codice seguente in cui un programmatore voleva eseguire una sorta di ottimizzazione evitando la copia non necessaria:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);
L'oggetto "somma" punterà ora all'oggetto locale "risultato". Ma dove si trova l'oggetto "risultato" dopo l'esecuzione della funzione SumComplex? Luogo inesistente. Si trovava nello stack, ma dopo che la funzione è stata restituita, lo stack è stato annullato e tutti gli oggetti locali della funzione sono stati distrutti. Ciò alla fine si tradurrà in un comportamento indefinito, anche per i tipi primitivi. Per evitare problemi di prestazioni, a volte è possibile utilizzare l'ottimizzazione del valore di ritorno:
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);
Per la maggior parte dei compilatori odierni, se una riga di ritorno contiene un costruttore di un oggetto, il codice verrà ottimizzato per evitare qualsiasi copia non necessaria - il costruttore verrà eseguito direttamente sull'oggetto "sum".
Errore comune n. 5: utilizzare un riferimento a una risorsa eliminata
Questi problemi C++ si verificano più spesso di quanto si possa pensare e di solito si verificano nelle applicazioni multithread. Consideriamo il seguente codice:
Discussione 1:
Connection& connection= connections.GetConnection(connectionId); // ...
Discussione 2:
connections.DeleteConnection(connectionId); // …
Discussione 1:
connection.send(data);
In questo esempio, se entrambi i thread utilizzano lo stesso ID di connessione, si verificherà un comportamento non definito. Gli errori di violazione di accesso sono spesso molto difficili da trovare.
In questi casi, quando più thread accedono alla stessa risorsa è molto rischioso mantenere puntatori o riferimenti alle risorse, perché qualche altro thread può eliminarlo. È molto più sicuro utilizzare i puntatori intelligenti con il conteggio dei riferimenti, ad esempio shared_ptr da Boost. Utilizza operazioni atomiche per aumentare/diminuire un contatore di riferimento, quindi è thread-safe.
Errore comune n. 6: consentire alle eccezioni di lasciare i distruttori
Non è spesso necessario generare un'eccezione da un distruttore. Anche allora, c'è un modo migliore per farlo. Tuttavia, per lo più, le eccezioni non vengono generate esplicitamente dai distruttori. Può succedere che un semplice comando per registrare la distruzione di un oggetto provochi un'eccezione. Consideriamo il seguente codice:

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"; }
Nel codice precedente, se l'eccezione si verifica due volte, ad esempio durante la distruzione di entrambi gli oggetti, l'istruzione catch non viene mai eseguita. Poiché esistono due eccezioni in parallelo, indipendentemente dal fatto che siano dello stesso tipo o di un tipo diverso, l'ambiente di runtime C++ non sa come gestirlo e chiama una funzione di terminazione che provoca l'interruzione dell'esecuzione di un programma.
Quindi la regola generale è: non permettere mai alle eccezioni di lasciare distruttori. Anche se è brutto, la potenziale eccezione deve essere protetta in questo modo:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}
Errore comune n. 7: utilizzo di "auto_ptr" (erroneamente)
Il modello auto_ptr è deprecato da C++11 per diversi motivi. È ancora ampiamente utilizzato, poiché la maggior parte dei progetti è ancora in fase di sviluppo in C++98. Ha una certa caratteristica che probabilmente non è familiare a tutti gli sviluppatori C++ e potrebbe causare seri problemi a qualcuno che non è attento. La copia dell'oggetto auto_ptr trasferirà una proprietà da un oggetto all'altro. Ad esempio, il seguente codice:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error
… comporterà un errore di violazione di accesso. Solo l'oggetto “b” conterrà un puntatore all'oggetto di Classe A, mentre “a” sarà vuoto. Il tentativo di accedere a un membro della classe dell'oggetto "a" risulterà in un errore di violazione di accesso. Esistono molti modi per utilizzare auto_ptr in modo errato. Quattro cose molto critiche da ricordare su di loro sono:
Non utilizzare mai auto_ptr all'interno di contenitori STL. La copia dei contenitori lascerà i contenitori di origine con dati non validi. Alcuni algoritmi STL possono anche portare all'invalidazione di "auto_ptr".
Non utilizzare mai auto_ptr come argomento di funzione poiché ciò porterà alla copia e lasciare il valore passato all'argomento non valido dopo la chiamata della funzione.
Se auto_ptr viene utilizzato per i membri dati di una classe, assicurati di fare una copia corretta all'interno di un costruttore di copie e di un operatore di assegnazione, oppure disabilita queste operazioni rendendole private.
Quando possibile, usa qualche altro puntatore intelligente moderno invece di auto_ptr.
Errore comune n. 8: utilizzo di iteratori e riferimenti invalidati
Sarebbe possibile scrivere un intero libro su questo argomento. Ogni contenitore STL ha alcune condizioni specifiche in cui invalida iteratori e riferimenti. È importante essere a conoscenza di questi dettagli durante l'utilizzo di qualsiasi operazione. Proprio come il precedente problema C++, anche questo può verificarsi molto frequentemente in ambienti multithread, quindi è necessario utilizzare meccanismi di sincronizzazione per evitarlo. Vediamo come esempio il seguente codice sequenziale:
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
Da un punto di vista logico il codice sembra completamente a posto. Tuttavia, l'aggiunta del secondo elemento al vettore può comportare la riallocazione della memoria del vettore che renderà non validi sia l'iteratore che il riferimento e si verificherà un errore di violazione di accesso quando si tenta di accedervi nelle ultime 2 righe.
Errore comune n. 9: passare un oggetto per valore
Probabilmente sai che è una cattiva idea passare gli oggetti per valore a causa del suo impatto sulle prestazioni. Molti lo lasciano così per evitare di digitare caratteri extra, o probabilmente pensano di tornare in seguito per fare l'ottimizzazione. Di solito non viene mai eseguito e, di conseguenza, porta a codice con prestazioni inferiori e codice soggetto a comportamenti imprevisti:
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);
Questo codice verrà compilato. Il richiamo della funzione “func1” creerà una copia parziale dell'oggetto “b”, ovvero copierà solo la parte di classe “A” dell'oggetto “b” nell'oggetto “a” (“slicing problem”). Quindi all'interno della funzione chiamerà anche un metodo della classe "A" invece di un metodo della classe "B" che molto probabilmente non è quello che si aspetta da qualcuno che chiama la funzione.
Problemi simili si verificano quando si tenta di catturare le eccezioni. Per esempio:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }
Quando un'eccezione di tipo ExceptionB viene generata dalla funzione "func2", verrà catturata dal blocco catch, ma a causa del problema di slicing verrà copiata solo una parte della classe ExceptionA, verrà chiamato il metodo errato e anche rilanciato genererà un'eccezione errata a un blocco try-catch esterno.
Per riassumere, passa sempre gli oggetti per riferimento, non per valore.
Errore comune n. 10: utilizzo di conversioni definite dall'utente da parte del costruttore e degli operatori di conversione
Anche le conversioni definite dall'utente sono molto utili a volte, ma possono portare a conversioni impreviste che sono molto difficili da individuare. Diciamo che qualcuno ha creato una libreria che ha una classe stringa:
class String { public: String(int n); String(const char *s); …. }
Il primo metodo ha lo scopo di creare una stringa di lunghezza n e il secondo ha lo scopo di creare una stringa contenente i caratteri specificati. Ma il problema inizia non appena hai qualcosa del genere:
String s1 = 123; String s2 = 'abc';
Nell'esempio sopra, s1 diventerà una stringa di dimensione 123, non una stringa che contiene i caratteri "123". Il secondo esempio contiene virgolette singole invece di virgolette doppie (che possono verificarsi per caso) che risulteranno anche nella chiamata del primo costruttore e nella creazione di una stringa di dimensioni molto grandi. Questi sono esempi davvero semplici e ci sono molti casi più complicati che portano a confusione e conversioni imprevedibili che sono molto difficili da trovare. Ci sono 2 regole generali su come evitare tali problemi:
Definisci un costruttore con una parola chiave esplicita per impedire le conversioni implicite.
Invece di usare gli operatori di conversione, usa metodi di conversazione espliciti. Richiede un po' più di digitazione, ma è molto più pulito da leggere e può aiutare a evitare risultati imprevedibili.
Conclusione
C++ è un linguaggio potente. In effetti, molte delle applicazioni che usi ogni giorno sul tuo computer e che hai imparato ad amare sono probabilmente costruite utilizzando C++. Come linguaggio, C++ offre un'enorme flessibilità allo sviluppatore, attraverso alcune delle funzionalità più sofisticate viste nei linguaggi di programmazione orientati agli oggetti. Tuttavia, queste sofisticate funzionalità o flessibilità possono spesso diventare causa di confusione e frustrazione per molti sviluppatori se non utilizzate in modo responsabile. Si spera che questo elenco ti aiuti a capire come alcuni di questi errori comuni influenzano ciò che puoi ottenere con C++.
Ulteriori letture sul blog di Toptal Engineering:
- Come imparare i linguaggi C e C++: l'elenco definitivo
- C# vs. C++: cosa c'è al centro?