Die 10 häufigsten C++-Fehler, die Entwickler machen

Veröffentlicht: 2022-03-11

Es gibt viele Fallstricke, auf die ein C++-Entwickler stoßen kann. Dies kann eine qualitativ hochwertige Programmierung sehr schwierig und die Wartung sehr teuer machen. Das Erlernen der Sprachsyntax und gute Programmierkenntnisse in ähnlichen Sprachen wie C# und Java reichen einfach nicht aus, um das volle Potenzial von C++ auszuschöpfen. Es erfordert jahrelange Erfahrung und große Disziplin, Fehler in C++ zu vermeiden. In diesem Artikel werfen wir einen Blick auf einige der häufigsten Fehler, die von Entwicklern aller Ebenen gemacht werden, wenn sie bei der C++-Entwicklung nicht vorsichtig genug sind.

Häufiger Fehler Nr. 1: Falsche Verwendung von „Neu“- und „Löschen“-Paaren

Egal, wie sehr wir uns bemühen, es ist sehr schwierig, den gesamten dynamisch zugewiesenen Speicher freizugeben. Auch wenn wir das können, ist es oft nicht sicher vor Ausnahmen. Betrachten wir ein einfaches Beispiel:

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

Wenn eine Ausnahme ausgelöst wird, wird das „a“-Objekt niemals gelöscht. Das folgende Beispiel zeigt einen sichereren und kürzeren Weg, dies zu tun. Es verwendet auto_ptr, das in C++11 veraltet ist, aber der alte Standard ist immer noch weit verbreitet. Es kann nach Möglichkeit durch C++11 unique_ptr oder scoped_ptr von Boost ersetzt werden.

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

Egal was passiert, nach dem Erstellen des „a“-Objekts wird es gelöscht, sobald die Programmausführung den Gültigkeitsbereich verlässt.

Dies war jedoch nur das einfachste Beispiel für dieses C++-Problem. Es gibt viele Beispiele, wo das Löschen an anderer Stelle erfolgen sollte, vielleicht in einer äußeren Funktion oder einem anderen Thread. Aus diesem Grund sollte die Verwendung von new/delete in Paaren vollständig vermieden und stattdessen geeignete intelligente Zeiger verwendet werden.

Häufiger Fehler Nr. 2: Vergessener virtueller Destruktor

Dies ist einer der häufigsten Fehler, der zu Speicherlecks in abgeleiteten Klassen führt, wenn ihnen dynamischer Speicher zugewiesen wird. Es gibt einige Fälle, in denen ein virtueller Destruktor nicht wünschenswert ist, dh wenn eine Klasse nicht für die Vererbung vorgesehen ist und ihre Größe und Leistung entscheidend sind. Ein virtueller Destruktor oder eine andere virtuelle Funktion fügt zusätzliche Daten in eine Klassenstruktur ein, dh einen Zeiger auf eine virtuelle Tabelle, wodurch die Größe jeder Instanz der Klasse größer wird.

Klassen können jedoch in den meisten Fällen auch dann vererbt werden, wenn dies ursprünglich nicht beabsichtigt war. Daher ist es eine sehr gute Praxis, einen virtuellen Destruktor hinzuzufügen, wenn eine Klasse deklariert wird. Andernfalls, wenn eine Klasse aus Leistungsgründen keine virtuellen Funktionen enthalten darf, empfiehlt es sich, einen Kommentar in eine Klassendeklarationsdatei einzufügen, der angibt, dass die Klasse nicht geerbt werden soll. Eine der besten Möglichkeiten, dieses Problem zu vermeiden, ist die Verwendung einer IDE, die die Erstellung virtueller Destruktoren während einer Klassenerstellung unterstützt.

Ein weiterer Punkt zum Thema sind Klassen/Templates aus der Standardbibliothek. Sie sind nicht für die Vererbung vorgesehen und haben keinen virtuellen Destruktor. Wenn wir zum Beispiel eine neue erweiterte String-Klasse erstellen, die öffentlich von std::string erbt, besteht die Möglichkeit, dass jemand sie falsch mit einem Zeiger oder einer Referenz auf std::string verwendet und ein Speicherleck verursacht.

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

Um solche C++-Probleme zu vermeiden, besteht eine sicherere Methode zur Wiederverwendung einer Klasse/Vorlage aus der Standardbibliothek darin, private Vererbung oder Zusammensetzung zu verwenden.

Häufiger Fehler Nr. 3: Löschen eines Arrays mit „Löschen“ oder Verwenden eines Smart Pointers

Häufiger Fehler Nr. 3

Das Erstellen temporärer Arrays mit dynamischer Größe ist häufig erforderlich. Nachdem sie nicht mehr benötigt werden, ist es wichtig, den zugewiesenen Speicher freizugeben. Das große Problem dabei ist, dass C++ einen speziellen Löschoperator mit []-Klammern erfordert, der sehr leicht vergessen wird. Der delete[]-Operator löscht nicht nur den für ein Array zugewiesenen Speicher, sondern ruft zuerst Destruktoren aller Objekte aus einem Array auf. Es ist auch falsch, den delete-Operator ohne []-Klammern für primitive Typen zu verwenden, obwohl es für diese Typen keinen Destruktor gibt. Es gibt keine Garantie für jeden Compiler, dass ein Zeiger auf ein Array auf das erste Element des Arrays zeigt, daher kann die Verwendung von delete ohne []-Klammern ebenfalls zu undefiniertem Verhalten führen.

Die Verwendung von intelligenten Zeigern wie auto_ptr, unique_ptr<T>, shared_ptr mit Arrays ist ebenfalls falsch. Wenn ein solcher intelligenter Zeiger einen Bereich verlässt, ruft er einen Löschoperator ohne []-Klammern auf, was zu denselben oben beschriebenen Problemen führt. Wenn für ein Array ein intelligenter Zeiger verwendet werden muss, ist es möglich, scoped_array oder shared_array von Boost oder eine unique_ptr<T[]>-Spezialisierung zu verwenden.

Wenn die Funktionalität der Referenzzählung nicht benötigt wird, was meistens bei Arrays der Fall ist, ist es am elegantesten, stattdessen STL-Vektoren zu verwenden. Sie kümmern sich nicht nur um die Freigabe von Speicher, sondern bieten auch zusätzliche Funktionalitäten.

Häufiger Fehler Nr. 4: Zurückgeben eines lokalen Objekts nach Referenz

Dies ist meistens ein Anfängerfehler, aber es ist erwähnenswert, da es eine Menge Legacy-Code gibt, der unter diesem Problem leidet. Schauen wir uns den folgenden Code an, bei dem ein Programmierer eine Art Optimierung vornehmen wollte, indem er unnötiges Kopieren vermeidet:

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

Das Objekt „sum“ zeigt nun auf das lokale Objekt „result“. Aber wo befindet sich das Objekt „Ergebnis“, nachdem die SumComplex-Funktion ausgeführt wurde? Nirgends. Es befand sich auf dem Stapel, aber nachdem die Funktion zurückgegeben wurde, wurde der Stapel entpackt und alle lokalen Objekte der Funktion wurden zerstört. Dies führt letztendlich zu einem undefinierten Verhalten, sogar für primitive Typen. Um Leistungsprobleme zu vermeiden, ist es manchmal möglich, die Rückgabewertoptimierung zu verwenden:

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

Wenn eine Rückgabezeile einen Konstruktor eines Objekts enthält, wird der Code für die meisten heutigen Compiler optimiert, um unnötiges Kopieren zu vermeiden – der Konstruktor wird direkt auf dem „Summe“-Objekt ausgeführt.

Häufiger Fehler Nr. 5: Verwenden eines Verweises auf eine gelöschte Ressource

Diese C++-Probleme treten häufiger auf, als Sie vielleicht denken, und treten normalerweise in Multithread-Anwendungen auf. Betrachten wir den folgenden Code:

Thema 1:

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

Thema 2:

 connections.DeleteConnection(connectionId); // …

Thema 1:

 connection.send(data);

Wenn in diesem Beispiel beide Threads dieselbe Verbindungs-ID verwenden, führt dies zu einem undefinierten Verhalten. Zugriffsverletzungsfehler sind oft sehr schwer zu finden.

Wenn in diesen Fällen mehr als ein Thread auf dieselbe Ressource zugreift, ist es sehr riskant, Zeiger oder Verweise auf die Ressourcen beizubehalten, da ein anderer Thread sie löschen kann. Es ist viel sicherer, intelligente Zeiger mit Referenzzählung zu verwenden, z. B. shared_ptr von Boost. Es verwendet atomare Operationen zum Erhöhen/Verringern eines Referenzzählers, sodass es Thread-sicher ist.

Häufiger Fehler Nr. 6: Ausnahmen erlauben, Destruktoren zu hinterlassen

Es ist nicht oft erforderlich, eine Ausnahme von einem Destruktor auszulösen. Selbst dann gibt es einen besseren Weg, dies zu tun. Ausnahmen werden jedoch meistens nicht explizit von Destruktoren geworfen. Es kann vorkommen, dass ein einfacher Befehl zum Protokollieren einer Zerstörung eines Objekts eine Ausnahme auslöst. Betrachten wir folgenden Code:

 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"; }

Wenn im obigen Code eine Ausnahme zweimal auftritt, z. B. während der Zerstörung beider Objekte, wird die catch-Anweisung nie ausgeführt. Da es zwei parallele Ausnahmen gibt, egal ob sie vom gleichen Typ oder unterschiedlich sind, weiß die C++-Laufzeitumgebung nicht, wie sie damit umgehen soll, und ruft eine Beendigungsfunktion auf, die zur Beendigung der Ausführung eines Programms führt.

Die allgemeine Regel lautet also: Lassen Sie niemals zu, dass Ausnahmen Destruktoren hinterlassen. Auch wenn es hässlich ist, muss eine potenzielle Ausnahme wie folgt geschützt werden:

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

Häufiger Fehler Nr. 7: Verwendung von „auto_ptr“ (falsch)

Die auto_ptr-Vorlage ist aus mehreren Gründen in C++11 veraltet. Es ist immer noch weit verbreitet, da die meisten Projekte immer noch in C++98 entwickelt werden. Es hat eine bestimmte Eigenschaft, die wahrscheinlich nicht allen C++-Entwicklern bekannt ist und jemandem, der nicht aufpasst, ernsthafte Probleme bereiten könnte. Durch das Kopieren des auto_ptr-Objekts wird ein Eigentumsrecht von einem Objekt auf ein anderes übertragen. Zum Beispiel der folgende Code:

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

… führt zu einem Zugriffsverletzungsfehler. Nur das Objekt „b“ enthält einen Zeiger auf das Objekt der Klasse A, während „a“ leer ist. Der Versuch, auf ein Klassenmitglied des Objekts „a“ zuzugreifen, führt zu einem Zugriffsverletzungsfehler. Es gibt viele Möglichkeiten, auto_ptr falsch zu verwenden. Vier sehr wichtige Dinge, an die man sich erinnern sollte, sind:

  1. Verwenden Sie niemals auto_ptr innerhalb von STL-Containern. Das Kopieren von Containern hinterlässt Quellcontainer mit ungültigen Daten. Einige STL-Algorithmen können auch zu einer Invalidierung von „auto_ptr“s führen.

  2. Verwenden Sie niemals auto_ptr als Funktionsargument, da dies zum Kopieren führt, und lassen Sie den an das Argument übergebenen Wert nach dem Funktionsaufruf ungültig.

  3. Wenn auto_ptr für Datenmember einer Klasse verwendet wird, stellen Sie sicher, dass Sie innerhalb eines Kopierkonstruktors und eines Zuweisungsoperators eine ordnungsgemäße Kopie erstellen, oder verbieten Sie diese Operationen, indem Sie sie privat machen.

  4. Verwenden Sie nach Möglichkeit einen anderen modernen intelligenten Zeiger anstelle von auto_ptr.

Häufiger Fehler Nr. 8: Verwenden ungültiger Iteratoren und Referenzen

Über dieses Thema könnte man ein ganzes Buch schreiben. Jeder STL-Container hat einige spezifische Bedingungen, unter denen er Iteratoren und Referenzen ungültig macht. Es ist wichtig, sich dieser Details bewusst zu sein, wenn Sie eine Operation verwenden. Genau wie das vorherige C++-Problem kann auch dieses Problem in Multithread-Umgebungen sehr häufig auftreten, sodass es erforderlich ist, Synchronisationsmechanismen zu verwenden, um es zu vermeiden. Sehen wir uns den folgenden sequenziellen Code als Beispiel an:

 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

Aus logischer Sicht scheint der Code völlig in Ordnung zu sein. Das Hinzufügen des zweiten Elements zum Vektor kann jedoch zu einer Neuzuweisung des Vektorspeichers führen, was sowohl den Iterator als auch die Referenz ungültig macht und zu einem Zugriffsverletzungsfehler führt, wenn versucht wird, auf sie in den letzten beiden Zeilen zuzugreifen.

Häufiger Fehler Nr. 9: Übergabe eines Objekts nach Wert

Häufiger Fehler Nr. 9

Sie wissen wahrscheinlich, dass es aufgrund der Auswirkungen auf die Leistung keine gute Idee ist, Objekte nach Wert zu übergeben. Viele lassen es so, um das Eintippen zusätzlicher Zeichen zu vermeiden, oder denken wahrscheinlich daran, später zurückzukehren, um die Optimierung durchzuführen. Es wird normalerweise nie erledigt und führt als Ergebnis zu weniger performantem Code und Code, der anfällig für unerwartetes Verhalten ist:

 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);

Dieser Code wird kompiliert. Der Aufruf der Funktion „func1“ erzeugt eine Teilkopie des Objekts „b“, dh es wird nur der Teil der Klasse „A“ des Objekts „b“ auf das Objekt „a“ kopiert („Slicing-Problem“). Innerhalb der Funktion wird also auch eine Methode der Klasse „A“ anstelle einer Methode der Klasse „B“ aufgerufen, was höchstwahrscheinlich nicht das ist, was von jemandem erwartet wird, der die Funktion aufruft.

Ähnliche Probleme treten auf, wenn versucht wird, Ausnahmen abzufangen. Zum Beispiel:

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

Wenn eine Ausnahme vom Typ ExceptionB von der Funktion „func2“ ausgelöst wird, wird sie vom catch-Block abgefangen, aber aufgrund des Slicing-Problems wird nur ein Teil der Klasse ExceptionA kopiert, eine falsche Methode wird aufgerufen und auch erneut ausgelöst wird eine falsche Ausnahme für einen externen Try-Catch-Block auslösen.

Zusammenfassend: Übergeben Sie Objekte immer als Referenz, nicht als Wert.

Häufiger Fehler Nr. 10: Verwenden von benutzerdefinierten Konvertierungen durch Konstruktoren und Konvertierungsoperatoren

Sogar die benutzerdefinierten Konvertierungen sind manchmal sehr nützlich, können jedoch zu unvorhergesehenen Konvertierungen führen, die sehr schwer zu lokalisieren sind. Nehmen wir an, jemand hat eine Bibliothek mit einer String-Klasse erstellt:

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

Die erste Methode soll eine Zeichenfolge der Länge n erstellen, und die zweite soll eine Zeichenfolge erstellen, die die angegebenen Zeichen enthält. Aber das Problem beginnt, sobald Sie so etwas haben:

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

Im obigen Beispiel wird s1 zu einer Zeichenfolge der Größe 123, nicht zu einer Zeichenfolge, die die Zeichen „123“ enthält. Das zweite Beispiel enthält einfache Anführungszeichen anstelle von doppelten Anführungszeichen (was versehentlich passieren kann), was auch dazu führt, dass der erste Konstruktor aufgerufen und eine Zeichenfolge mit einer sehr großen Größe erstellt wird. Dies sind wirklich einfache Beispiele, und es gibt viele kompliziertere Fälle, die zu Verwirrung und unvorhergesehenen Konvertierungen führen, die sehr schwer zu finden sind. Es gibt 2 allgemeine Regeln, wie Sie solche Probleme vermeiden können:

  1. Definieren Sie einen Konstruktor mit dem expliziten Schlüsselwort, um implizite Konvertierungen zu verbieten.

  2. Verwenden Sie anstelle von Konvertierungsoperatoren explizite Konversationsmethoden. Es erfordert etwas mehr Tipparbeit, ist aber viel sauberer zu lesen und kann dazu beitragen, unvorhersehbare Ergebnisse zu vermeiden.

Fazit

C++ ist eine mächtige Sprache. Tatsächlich werden viele der Anwendungen, die Sie jeden Tag auf Ihrem Computer verwenden und die Sie lieben gelernt haben, wahrscheinlich mit C++ erstellt. Als Sprache bietet C++ dem Entwickler ein enormes Maß an Flexibilität durch einige der raffiniertesten Features, die in objektorientierten Programmiersprachen zu finden sind. Diese ausgefeilten Funktionen oder Flexibilitäten können jedoch bei vielen Entwicklern oft zu Verwirrung und Frustration führen, wenn sie nicht verantwortungsbewusst eingesetzt werden. Hoffentlich hilft Ihnen diese Liste zu verstehen, wie einige dieser häufigen Fehler das beeinflussen, was Sie mit C++ erreichen können.


Weiterführende Literatur im Toptal Engineering Blog:

  • So lernen Sie die Sprachen C und C++: Die ultimative Liste
  • C# vs. C++: Was ist der Kern?