10 najczęstszych błędów C++ popełnianych przez programistów

Opublikowany: 2022-03-11

Istnieje wiele pułapek, na które może napotkać programista C++. To może sprawić, że wysokiej jakości programowanie będzie bardzo trudne, a konserwacja bardzo kosztowna. Poznanie składni języka i posiadanie dobrych umiejętności programowania w podobnych językach, takich jak C# i Java, nie wystarczy, aby w pełni wykorzystać potencjał C++. Unikanie błędów w C++ wymaga wieloletniego doświadczenia i dużej dyscypliny. W tym artykule przyjrzymy się niektórym typowym błędom, które popełniają programiści na wszystkich poziomach, jeśli nie są wystarczająco ostrożni przy tworzeniu C++.

Częsty błąd nr 1: Nieprawidłowe używanie „nowych” i „usuwanych” par

Bez względu na to, jak bardzo się staramy, bardzo trudno jest zwolnić całą dynamicznie przydzielaną pamięć. Nawet jeśli możemy to zrobić, często nie jest to bezpieczne od wyjątków. Spójrzmy na prosty przykład:

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

Jeśli zostanie zgłoszony wyjątek, obiekt „a” nigdy nie zostanie usunięty. Poniższy przykład pokazuje bezpieczniejszy i krótszy sposób na zrobienie tego. Używa auto_ptr, który jest przestarzały w C++11, ale stary standard jest nadal powszechnie używany. Można go zastąpić C++11 unique_ptr lub scoped_ptr z Boost, jeśli to możliwe.

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

Bez względu na to, co się stanie, po utworzeniu obiektu „a” zostanie on usunięty, gdy tylko wykonanie programu wyjdzie z zakresu.

Jednak był to tylko najprostszy przykład tego problemu C++. Istnieje wiele przykładów, kiedy usuwanie powinno odbywać się w innym miejscu, na przykład w funkcji zewnętrznej lub innym wątku. Dlatego należy całkowicie unikać używania nowych/usuwanych w parach, a zamiast nich używać odpowiednich inteligentnych wskaźników.

Powszechny błąd nr 2: zapomniany wirtualny destruktor

Jest to jeden z najczęstszych błędów, który prowadzi do wycieków pamięci wewnątrz klas pochodnych, jeśli jest w nich przydzielona pamięć dynamiczna. W niektórych przypadkach wirtualny destruktor nie jest pożądany, np. gdy klasa nie jest przeznaczona do dziedziczenia, a jej rozmiar i wydajność mają kluczowe znaczenie. Wirtualny destruktor lub jakakolwiek inna wirtualna funkcja wprowadza do struktury klasy dodatkowe dane, tj. wskaźnik do wirtualnej tabeli, która zwiększa rozmiar dowolnej instancji klasy.

Jednak w większości przypadków klasy mogą być dziedziczone, nawet jeśli nie jest to pierwotnie zamierzone. Dlatego bardzo dobrą praktyką jest dodanie wirtualnego destruktora, gdy klasa jest deklarowana. W przeciwnym razie, jeśli klasa nie może zawierać funkcji wirtualnych ze względu na wydajność, dobrą praktyką jest umieszczenie komentarza wewnątrz pliku deklaracji klasy wskazującego, że klasa nie powinna być dziedziczona. Jedną z najlepszych opcji uniknięcia tego problemu jest użycie IDE, które obsługuje tworzenie wirtualnego destruktora podczas tworzenia klasy.

Dodatkowym punktem do przedmiotu są zajęcia/szablony z biblioteki standardowej. Nie są przeznaczone do dziedziczenia i nie mają wirtualnego destruktora. Jeśli na przykład utworzymy nową ulepszoną klasę stringów, która publicznie dziedziczy po std::string, istnieje możliwość, że ktoś użyje jej niepoprawnie ze wskaźnikiem lub referencją do std::string i spowoduje wyciek pamięci.

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

Aby uniknąć takich problemów C++, bezpieczniejszym sposobem ponownego użycia klasy/szablonu z biblioteki standardowej jest użycie prywatnego dziedziczenia lub kompozycji.

Częsty błąd nr 3: usuwanie tablicy za pomocą „usuń” lub za pomocą inteligentnego wskaźnika

Powszechny błąd nr 3

Często konieczne jest tworzenie tymczasowych tablic o dynamicznym rozmiarze. Gdy nie są już potrzebne, ważne jest, aby zwolnić przydzieloną pamięć. Dużym problemem jest tutaj to, że C++ wymaga specjalnego operatora usuwania z nawiasami [], o czym bardzo łatwo się zapomina. Operator delete[] nie tylko usunie pamięć przydzieloną dla tablicy, ale najpierw wywoła destruktory wszystkich obiektów z tablicy. Niepoprawne jest również użycie operatora usuwania bez nawiasów [] dla typów pierwotnych, mimo że nie ma destruktora dla tych typów. Nie ma gwarancji dla każdego kompilatora, że ​​wskaźnik do tablicy będzie wskazywał na pierwszy element tablicy, więc użycie delete bez nawiasów [] może również spowodować niezdefiniowane zachowanie.

Używanie inteligentnych wskaźników, takich jak auto_ptr, unique_ptr<T>, shared_ptr, z tablicami również jest niepoprawne. Gdy taki inteligentny wskaźnik wychodzi z zakresu, wywoła operator usuwania bez nawiasów [], co spowoduje te same problemy, które opisano powyżej. Jeśli użycie inteligentnego wskaźnika jest wymagane dla tablicy, możliwe jest użycie scoped_array lub shared_array ze specjalizacji Boost lub unique_ptr<T[]>.

Jeśli funkcjonalność zliczania referencji nie jest wymagana, co ma miejsce głównie w przypadku tablic, najbardziej eleganckim sposobem jest użycie zamiast tego wektorów STL. Nie tylko zajmują się zwalnianiem pamięci, ale oferują również dodatkowe funkcjonalności.

Powszechny błąd nr 4: Zwracanie lokalnego obiektu przez odniesienie

Jest to głównie błąd początkującego, ale warto o tym wspomnieć, ponieważ istnieje wiele przestarzałego kodu, który cierpi z powodu tego problemu. Spójrzmy na poniższy kod, w którym programista chciał dokonać pewnego rodzaju optymalizacji, unikając niepotrzebnego kopiowania:

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

Obiekt „suma” będzie teraz wskazywał na lokalny obiekt „wynik”. Ale gdzie znajduje się obiekt „wynik” po wykonaniu funkcji SumComplex? Nigdzie. Znajdował się na stosie, ale po zwróceniu funkcji stos został rozpakowany i wszystkie lokalne obiekty z funkcji zostały zniszczone. W końcu doprowadzi to do niezdefiniowanego zachowania, nawet w przypadku typów prymitywnych. Aby uniknąć problemów z wydajnością, czasami można zastosować optymalizację wartości zwrotu:

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

W przypadku większości dzisiejszych kompilatorów, jeśli wiersz powrotu zawiera konstruktor obiektu, kod zostanie zoptymalizowany, aby uniknąć wszelkiego niepotrzebnego kopiowania - konstruktor zostanie wykonany bezpośrednio na obiekcie „sum”.

Powszechny błąd nr 5: Używanie odniesienia do usuniętego zasobu

Te problemy C++ zdarzają się częściej, niż myślisz, i są zwykle obserwowane w aplikacjach wielowątkowych. Rozważmy następujący kod:

Wątek 1:

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

Wątek 2:

 connections.DeleteConnection(connectionId); // …

Wątek 1:

 connection.send(data);

W tym przykładzie, jeśli oba wątki używają tego samego identyfikatora połączenia, spowoduje to niezdefiniowane zachowanie. Błędy związane z naruszeniem dostępu są często bardzo trudne do znalezienia.

W takich przypadkach, gdy więcej niż jeden wątek uzyskuje dostęp do tego samego zasobu, bardzo ryzykowne jest przechowywanie wskaźników lub odwołań do zasobów, ponieważ inny wątek może go usunąć. O wiele bezpieczniej jest używać inteligentnych wskaźników z liczeniem referencji, na przykład shared_ptr z Boost. Używa operacji atomowych do zwiększania/zmniejszania licznika odwołań, więc jest bezpieczny wątkowo.

Powszechny błąd nr 6: dopuszczenie wyjątków do pozostawienia destruktorów

Często nie jest konieczne zgłaszanie wyjątku z destruktora. Nawet wtedy jest na to lepszy sposób. Jednak wyjątki w większości nie są jawnie zgłaszane przez destruktory. Może się zdarzyć, że proste polecenie rejestrujące zniszczenie obiektu spowoduje wyrzucenie wyjątku. Rozważmy następujący kod:

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

W powyższym kodzie, jeśli wyjątek wystąpi dwukrotnie, na przykład podczas niszczenia obu obiektów, instrukcja catch nigdy nie zostanie wykonana. Ponieważ równolegle istnieją dwa wyjątki, bez względu na to, czy są tego samego czy innego typu, środowisko wykonawcze C++ nie wie, jak sobie z tym poradzić i wywołuje funkcję zakończenia, która powoduje zakończenie wykonywania programu.

Więc ogólna zasada brzmi: nigdy nie pozwalaj wyjątkom na pozostawienie destruktorów. Nawet jeśli jest brzydki, potencjalny wyjątek należy chronić w następujący sposób:

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

Powszechny błąd nr 7: Używanie „auto_ptr” (niepoprawnie)

Szablon auto_ptr jest przestarzały z C++11 z wielu powodów. Jest nadal szeroko stosowany, ponieważ większość projektów jest wciąż rozwijana w C++98. Ma pewną cechę, która prawdopodobnie nie jest znana wszystkim programistom C++ i może spowodować poważne problemy dla kogoś, kto nie jest ostrożny. Kopiowanie obiektu auto_ptr spowoduje przeniesienie własności z jednego obiektu na inny. Na przykład następujący kod:

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

… spowoduje błąd naruszenia dostępu. Tylko obiekt „b” będzie zawierał wskaźnik do obiektu klasy A, podczas gdy „a” będzie puste. Próba uzyskania dostępu do członka klasy obiektu „a” spowoduje błąd naruszenia dostępu. Istnieje wiele sposobów na nieprawidłowe użycie auto_ptr. Cztery bardzo ważne rzeczy, o których należy pamiętać, to:

  1. Nigdy nie używaj auto_ptr w kontenerach STL. Kopiowanie kontenerów spowoduje pozostawienie kontenerów źródłowych z nieprawidłowymi danymi. Niektóre algorytmy STL mogą również prowadzić do unieważnienia „auto_ptr”.

  2. Nigdy nie używaj auto_ptr jako argumentu funkcji, ponieważ prowadzi to do kopiowania i pozostawia niepoprawną wartość przekazaną do argumentu po wywołaniu funkcji.

  3. Jeśli auto_ptr jest używane dla elementów członkowskich danych klasy, należy wykonać odpowiednią kopię w konstruktorze kopiującym i operatorze przypisania lub zabronić tych operacji, czyniąc je prywatnymi.

  4. Jeśli to możliwe, użyj innego nowoczesnego inteligentnego wskaźnika zamiast auto_ptr.

Powszechny błąd nr 8: używanie nieważnych iteratorów i odwołań

Na ten temat można by napisać całą książkę. Każdy kontener STL ma określone warunki, w których unieważnia iteratory i referencje. Ważne jest, aby być świadomym tych szczegółów podczas korzystania z jakiejkolwiek operacji. Podobnie jak poprzedni problem C++, ten również może wystąpić bardzo często w środowiskach wielowątkowych, dlatego wymagane jest użycie mechanizmów synchronizacji, aby go uniknąć. Spójrzmy na następujący kod sekwencyjny jako przykład:

 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

Z logicznego punktu widzenia kod wydaje się całkowicie w porządku. Jednak dodanie drugiego elementu do wektora może skutkować ponownym przydzieleniem pamięci wektora, co spowoduje, że zarówno iterator, jak i odwołanie będą nieważne i spowoduje błąd naruszenia dostępu podczas próby dostępu do nich w ostatnich 2 wierszach.

Powszechny błąd nr 9: przekazywanie obiektu według wartości

Powszechny błąd nr 9

Prawdopodobnie wiesz, że przekazywanie obiektów według wartości jest złym pomysłem ze względu na jego wpływ na wydajność. Wielu zostawia to w ten sposób, aby uniknąć wpisywania dodatkowych znaków lub prawdopodobnie myśli o powrocie później w celu optymalizacji. Zwykle nigdy się to nie kończy, co w rezultacie prowadzi do mniej wydajnego kodu i kodu, który jest podatny na nieoczekiwane zachowanie:

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

Ten kod się skompiluje. Wywołanie funkcji „func1” utworzy częściową kopię obiektu „b”, czyli skopiuje tylko część obiektu „b” klasy „A” do obiektu „a” („problem krojenia”). Więc wewnątrz funkcji wywoła również metodę z klasy „A” zamiast metody z klasy „B”, co najprawdopodobniej nie jest tym, czego oczekuje ktoś, kto wywołuje funkcję.

Podobne problemy występują podczas próby wyłapania wyjątków. Na przykład:

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

Gdy wyjątek typu ExceptionB zostanie zgłoszony z funkcji „func2” zostanie przechwycony przez blok catch, ale z powodu problemu z krojeniem zostanie skopiowana tylko część z klasy ExceptionA, zostanie wywołana nieprawidłowa metoda i ponownie wyrzuci zgłosi niepoprawny wyjątek do zewnętrznego bloku try-catch.

Podsumowując, zawsze przekaż obiekty według referencji, a nie według wartości.

Częsty błąd nr 10: używanie konwersji zdefiniowanych przez użytkownika przez konstruktorów i operatorów konwersji

Nawet konwersje zdefiniowane przez użytkownika są czasami bardzo przydatne, ale mogą prowadzić do nieprzewidzianych konwersji, które są bardzo trudne do zlokalizowania. Załóżmy, że ktoś utworzył bibliotekę, która ma klasę ciągu:

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

Pierwsza metoda ma na celu stworzenie łańcucha o długości n, a druga ma na celu stworzenie łańcucha zawierającego podane znaki. Ale problem zaczyna się, gdy tylko masz coś takiego:

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

W powyższym przykładzie s1 stanie się ciągiem o rozmiarze 123, a nie ciągiem zawierającym znaki „123”. Drugi przykład zawiera pojedyncze cudzysłowy zamiast podwójnych cudzysłowów (co może się zdarzyć przez przypadek), co spowoduje również wywołanie pierwszego konstruktora i utworzenie łańcucha o bardzo dużym rozmiarze. To są naprawdę proste przykłady i istnieje wiele bardziej skomplikowanych przypadków, które prowadzą do zamieszania i nieprzewidzianych konwersji, które bardzo trudno znaleźć. Istnieją 2 ogólne zasady, jak uniknąć takich problemów:

  1. Zdefiniuj konstruktora z jawnym słowem kluczowym, aby uniemożliwić niejawne konwersje.

  2. Zamiast używać operatorów konwersji, użyj jawnych metod konwersacji. Wymaga nieco więcej pisania, ale jest znacznie czystszy w czytaniu i może pomóc uniknąć nieprzewidywalnych wyników.

Wniosek

C++ to potężny język. W rzeczywistości wiele aplikacji, których używasz na co dzień na swoim komputerze i które pokochałeś, jest prawdopodobnie zbudowanych w C++. Jako język, C++ daje programiście ogromną elastyczność dzięki niektórym z najbardziej wyrafinowanych funkcji, jakie można znaleźć w obiektowych językach programowania. Jednak te wyrafinowane funkcje lub elastyczność mogą często stać się przyczyną zamieszania i frustracji dla wielu programistów, jeśli nie są używane w sposób odpowiedzialny. Mam nadzieję, że ta lista pomoże ci zrozumieć, jak niektóre z tych typowych błędów wpływają na to, co możesz osiągnąć za pomocą C++.


Dalsza lektura na blogu Toptal Engineering:

  • Jak nauczyć się języków C i C++: ostateczna lista
  • C# vs. C++: co jest rdzeniem?