Top 10 des erreurs C++ les plus courantes commises par les développeurs
Publié: 2022-03-11Un développeur C++ peut rencontrer de nombreux pièges. Cela peut rendre la programmation de qualité très difficile et la maintenance très coûteuse. Apprendre la syntaxe du langage et avoir de bonnes compétences en programmation dans des langages similaires, comme C# et Java, ne suffit pas pour utiliser tout le potentiel de C++. Il faut des années d'expérience et une grande discipline pour éviter les erreurs en C++. Dans cet article, nous allons examiner certaines des erreurs courantes commises par les développeurs de tous niveaux s'ils ne sont pas suffisamment prudents avec le développement C++.
Erreur courante n° 1 : utiliser les paires « nouveau » et « supprimer » de manière incorrecte
Peu importe nos efforts, il est très difficile de libérer toute la mémoire allouée dynamiquement. Même si nous pouvons le faire, ce n'est souvent pas à l'abri d'exceptions. Prenons un exemple simple :
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }Si une exception est levée, l'objet "a" n'est jamais supprimé. L'exemple suivant montre une manière plus sûre et plus courte de le faire. Il utilise auto_ptr qui est obsolète en C++11, mais l'ancien standard est encore largement utilisé. Il peut être remplacé par C++11 unique_ptr ou scoped_ptr de Boost si possible.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }Quoi qu'il arrive, après avoir créé l'objet "a", il sera supprimé dès que l'exécution du programme sortira de la portée.
Cependant, ce n'était que l'exemple le plus simple de ce problème C++. Il existe de nombreux exemples où la suppression doit être effectuée à un autre endroit, peut-être dans une fonction externe ou un autre thread. C'est pourquoi l'utilisation de new/delete par paires doit être complètement évitée et des pointeurs intelligents appropriés doivent être utilisés à la place.
Erreur courante #2 : Destructeur virtuel oublié
C'est l'une des erreurs les plus courantes qui entraîne des fuites de mémoire à l'intérieur des classes dérivées s'il y a de la mémoire dynamique allouée à l'intérieur de celles-ci. Il existe certains cas où le destructeur virtuel n'est pas souhaitable, c'est-à-dire lorsqu'une classe n'est pas destinée à l'héritage et que sa taille et ses performances sont cruciales. Le destructeur virtuel ou toute autre fonction virtuelle introduit des données supplémentaires à l'intérieur d'une structure de classe, c'est-à-dire un pointeur vers une table virtuelle qui agrandit la taille de toute instance de la classe.
Cependant, dans la plupart des cas, les classes peuvent être héritées même si ce n'est pas prévu à l'origine. C'est donc une très bonne pratique d'ajouter un destructeur virtuel lorsqu'une classe est déclarée. Sinon, si une classe ne doit pas contenir de fonctions virtuelles pour des raisons de performances, il est recommandé de mettre un commentaire dans un fichier de déclaration de classe indiquant que la classe ne doit pas être héritée. L'une des meilleures options pour éviter ce problème consiste à utiliser un IDE prenant en charge la création de destructeurs virtuels lors de la création d'une classe.
Un point supplémentaire au sujet sont les classes/modèles de la bibliothèque standard. Ils ne sont pas destinés à l'héritage et n'ont pas de destructeur virtuel. Si, par exemple, nous créons une nouvelle classe de chaîne améliorée qui hérite publiquement de std::string, il est possible que quelqu'un l'utilise de manière incorrecte avec un pointeur ou une référence à std::string et provoque une fuite de mémoire.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }Pour éviter de tels problèmes C++, un moyen plus sûr de réutiliser une classe/un modèle de la bibliothèque standard consiste à utiliser l'héritage ou la composition privée.
Erreur courante n° 3 : suppression d'un tableau avec "delete" ou à l'aide d'un pointeur intelligent
La création de tableaux temporaires de taille dynamique est souvent nécessaire. Une fois qu'ils ne sont plus nécessaires, il est important de libérer la mémoire allouée. Le gros problème ici est que C++ nécessite un opérateur de suppression spécial avec des crochets [], qui est très facilement oublié. L'opérateur delete[] ne supprimera pas seulement la mémoire allouée pour un tableau, mais il appellera d'abord les destructeurs de tous les objets d'un tableau. Il est également incorrect d'utiliser l'opérateur de suppression sans crochets [] pour les types primitifs, même s'il n'y a pas de destructeur pour ces types. Il n'y a aucune garantie pour chaque compilateur qu'un pointeur vers un tableau pointe vers le premier élément du tableau, donc l'utilisation de delete sans crochets [] peut également entraîner un comportement indéfini.
L'utilisation de pointeurs intelligents, tels que auto_ptr, unique_ptr<T>, shared_ptr, avec des tableaux est également incorrecte. Lorsqu'un tel pointeur intelligent sort d'une portée, il appelle un opérateur de suppression sans crochets [], ce qui entraîne les mêmes problèmes que ceux décrits ci-dessus. Si l'utilisation d'un pointeur intelligent est requise pour un tableau, il est possible d'utiliser scoped_array ou shared_array de Boost ou une spécialisation unique_ptr<T[]>.
Si la fonctionnalité de comptage de références n'est pas requise, ce qui est principalement le cas pour les tableaux, la manière la plus élégante consiste à utiliser des vecteurs STL à la place. Ils ne se contentent pas de libérer de la mémoire, mais offrent également des fonctionnalités supplémentaires.
Erreur courante n° 4 : renvoyer un objet local par référence
Il s'agit principalement d'une erreur de débutant, mais il convient de le mentionner car de nombreux codes hérités souffrent de ce problème. Regardons le code suivant où un programmeur voulait faire une sorte d'optimisation en évitant les copies inutiles :
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);L'objet "somme" pointera maintenant vers l'objet local "résultat". Mais où se trouve l'objet "résultat" après l'exécution de la fonction SumComplex ? Nulle part. Il était situé sur la pile, mais après le retour de la fonction, la pile a été déballée et tous les objets locaux de la fonction ont été détruits. Cela entraînera éventuellement un comportement indéfini, même pour les types primitifs. Pour éviter les problèmes de performances, il est parfois possible d'utiliser l'optimisation de la valeur de retour :
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);Pour la plupart des compilateurs actuels, si une ligne de retour contient un constructeur d'un objet, le code sera optimisé pour éviter toute copie inutile - le constructeur sera exécuté directement sur l'objet « somme ».
Erreur courante #5 : Utiliser une référence à une ressource supprimée
Ces problèmes C++ se produisent plus souvent que vous ne le pensez et sont généralement rencontrés dans les applications multithread. Considérons le code suivant :
Sujet 1 :
Connection& connection= connections.GetConnection(connectionId); // ...Sujet 2 :
connections.DeleteConnection(connectionId); // …Sujet 1 :
connection.send(data);Dans cet exemple, si les deux threads utilisaient le même ID de connexion, cela entraînera un comportement indéfini. Les erreurs de violation d'accès sont souvent très difficiles à trouver.
Dans ces cas, lorsque plusieurs threads accèdent à la même ressource, il est très risqué de conserver des pointeurs ou des références aux ressources, car un autre thread peut le supprimer. Il est beaucoup plus sûr d'utiliser des pointeurs intelligents avec comptage de références, par exemple shared_ptr de Boost. Il utilise des opérations atomiques pour augmenter/diminuer un compteur de référence, il est donc thread-safe.
Erreur courante n° 6 : Autoriser les exceptions à quitter les destructeurs
Il n'est pas souvent nécessaire de lever une exception à partir d'un destructeur. Même dans ce cas, il existe une meilleure façon de le faire. Cependant, les exceptions ne sont généralement pas levées explicitement par les destructeurs. Il peut arriver qu'une simple commande de journalisation d'une destruction d'un objet provoque une levée d'exception. Considérons le code suivant :

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"; }Dans le code ci-dessus, si l'exception se produit deux fois, comme lors de la destruction des deux objets, l'instruction catch n'est jamais exécutée. Parce qu'il y a deux exceptions en parallèle, qu'elles soient du même type ou de type différent, l'environnement d'exécution C++ ne sait pas comment le gérer et appelle une fonction terminate qui entraîne l'arrêt de l'exécution d'un programme.
La règle générale est donc la suivante : ne laissez jamais les exceptions laisser des destructeurs. Même si c'est moche, l'exception potentielle doit être protégée comme ceci :
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}Erreur courante #7 : Utiliser "auto_ptr" (incorrectement)
Le modèle auto_ptr est obsolète depuis C++11 pour plusieurs raisons. Il est encore largement utilisé, puisque la plupart des projets sont encore développés en C++98. Il a une certaine caractéristique qui n'est probablement pas familière à tous les développeurs C++, et pourrait causer de sérieux problèmes à quelqu'un qui ne fait pas attention. La copie de l'objet auto_ptr transférera une propriété d'un objet à un autre. Par exemple, le code suivant :
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error… entraînera une erreur de violation d'accès. Seul l'objet "b" contiendra un pointeur vers l'objet de classe A, tandis que "a" sera vide. Essayer d'accéder à un membre de classe de l'objet "a" entraînera une erreur de violation d'accès. Il existe de nombreuses façons d'utiliser auto_ptr de manière incorrecte. Quatre choses très importantes à retenir à leur sujet sont :
N'utilisez jamais auto_ptr dans les conteneurs STL. La copie des conteneurs laissera les conteneurs source avec des données non valides. Certains algorithmes STL peuvent également conduire à l'invalidation des "auto_ptr".
N'utilisez jamais auto_ptr comme argument de fonction car cela entraînerait une copie et laisserait la valeur transmise à l'argument invalide après l'appel de la fonction.
Si auto_ptr est utilisé pour les membres de données d'une classe, assurez-vous de faire une copie appropriée à l'intérieur d'un constructeur de copie et d'un opérateur d'affectation, ou interdisez ces opérations en les rendant privées.
Dans la mesure du possible, utilisez un autre pointeur intelligent moderne au lieu de auto_ptr.
Erreur courante n° 8 : utiliser des itérateurs et des références invalidés
Il serait possible d'écrire un livre entier sur ce sujet. Chaque conteneur STL a des conditions spécifiques dans lesquelles il invalide les itérateurs et les références. Il est important d'être conscient de ces détails lors de l'utilisation de toute opération. Tout comme le problème C++ précédent, celui-ci peut également se produire très fréquemment dans des environnements multithreads, il est donc nécessaire d'utiliser des mécanismes de synchronisation pour l'éviter. Voyons le code séquentiel suivant comme exemple :
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 elementD'un point de vue logique, le code semble tout à fait correct. Cependant, l'ajout du deuxième élément au vecteur peut entraîner une réallocation de la mémoire du vecteur, ce qui rendra l'itérateur et la référence invalides et entraînera une erreur de violation d'accès lors de la tentative d'accès dans les 2 dernières lignes.
Erreur courante n° 9 : Passer un objet par valeur
Vous savez probablement que c'est une mauvaise idée de passer des objets par valeur en raison de son impact sur les performances. Beaucoup le laissent ainsi pour éviter de taper des caractères supplémentaires, ou pensent probablement à revenir plus tard pour faire l'optimisation. Cela n'est généralement jamais fait et, par conséquent, conduit à un code moins performant et à un code sujet à un comportement inattendu :
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);Ce code compilera. L'appel de la fonction « func1 » créera une copie partielle de l'objet « b », c'est-à-dire qu'elle ne copiera que la partie de l'objet « b » de la classe « A » vers l'objet « a » (« problème de découpage »). Ainsi, à l'intérieur de la fonction, il appellera également une méthode de la classe "A" au lieu d'une méthode de la classe "B", ce qui n'est probablement pas ce qui est attendu par quelqu'un qui appelle la fonction.
Des problèmes similaires se produisent lors de la tentative d'interception d'exceptions. Par exemple:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }Lorsqu'une exception de type ExceptionB est levée à partir de la fonction "func2", elle sera capturée par le bloc catch, mais à cause du problème de découpage, seule une partie de la classe ExceptionA sera copiée, une méthode incorrecte sera appelée et également relancée lèvera une exception incorrecte à un bloc try-catch extérieur.
Pour résumer, passez toujours les objets par référence, et non par valeur.
Erreur courante n° 10 : utilisation de conversions définies par l'utilisateur par constructeur et opérateurs de conversion
Même les conversions définies par l'utilisateur sont parfois très utiles, mais elles peuvent entraîner des conversions imprévues très difficiles à localiser. Disons que quelqu'un a créé une bibliothèque qui a une classe de chaîne :
class String { public: String(int n); String(const char *s); …. }La première méthode est destinée à créer une chaîne de longueur n, et la seconde est destinée à créer une chaîne contenant les caractères donnés. Mais le problème commence dès que vous avez quelque chose comme ça :
String s1 = 123; String s2 = 'abc';Dans l'exemple ci-dessus, s1 deviendra une chaîne de taille 123, et non une chaîne contenant les caractères "123". Le deuxième exemple contient des guillemets simples au lieu de guillemets doubles (ce qui peut arriver par accident), ce qui entraînera également l'appel du premier constructeur et la création d'une chaîne de très grande taille. Ce sont des exemples très simples, et il existe de nombreux cas plus compliqués qui conduisent à la confusion et à des conversions imprévues qui sont très difficiles à trouver. Il existe 2 règles générales pour éviter de tels problèmes :
Définissez un constructeur avec un mot-clé explicite pour interdire les conversions implicites.
Au lieu d'utiliser des opérateurs de conversion, utilisez des méthodes de conversation explicites. Il nécessite un peu plus de frappe, mais il est beaucoup plus propre à lire et peut aider à éviter des résultats imprévisibles.
Conclusion
C++ est un langage puissant. En fait, de nombreuses applications que vous utilisez quotidiennement sur votre ordinateur et que vous aimez sont probablement construites en C++. En tant que langage, C++ offre une énorme flexibilité au développeur, grâce à certaines des fonctionnalités les plus sophistiquées des langages de programmation orientés objet. Cependant, ces fonctionnalités ou flexibilités sophistiquées peuvent souvent devenir une source de confusion et de frustration pour de nombreux développeurs si elles ne sont pas utilisées de manière responsable. J'espère que cette liste vous aidera à comprendre comment certaines de ces erreurs courantes influencent ce que vous pouvez réaliser avec C++.
Lectures complémentaires sur le blog Toptal Engineering :
- Comment apprendre les langages C et C++ : la liste ultime
- C# contre C++ : qu'y a-t-il au cœur ?
