10 самых распространенных ошибок C++, которые допускают разработчики
Опубликовано: 2022-03-11Есть много ловушек, с которыми может столкнуться разработчик C++. Это может сделать качественное программирование очень сложным, а обслуживание очень дорогим. Изучение синтаксиса языка и наличие хороших навыков программирования на подобных языках, таких как C# и Java, недостаточно, чтобы использовать весь потенциал C++. Требуется многолетний опыт и большая дисциплина, чтобы избежать ошибок в C++. В этой статье мы рассмотрим некоторые распространенные ошибки, которые допускают разработчики всех уровней, если они недостаточно осторожны при разработке на C++.
Распространенная ошибка № 1: неправильное использование пар «новый» и «удалить»
Как бы мы ни старались, освободить всю динамически выделенную память очень сложно. Даже если мы можем это сделать, это часто не застраховано от исключений. Давайте рассмотрим простой пример:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }
Если возникает исключение, объект «а» никогда не удаляется. В следующем примере показан более безопасный и короткий способ сделать это. Он использует auto_ptr, который устарел в C++11, но старый стандарт все еще широко используется. Его можно заменить на C++11 unique_ptr или scoped_ptr из Boost, если это возможно.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }
Что бы ни случилось, после создания объекта «а» он будет удален, как только выполнение программы выйдет из области видимости.
Однако это был лишь простейший пример этой проблемы C++. Есть много примеров, когда удаление должно выполняться в каком-то другом месте, возможно, во внешней функции или другом потоке. Вот почему следует полностью избегать использования пар new/delete и вместо этого следует использовать соответствующие интеллектуальные указатели.
Распространенная ошибка № 2: Забытый виртуальный деструктор
Это одна из самых распространенных ошибок, которая приводит к утечкам памяти внутри производных классов, если внутри них выделена динамическая память. Бывают случаи, когда виртуальный деструктор нежелателен, т.е. когда класс не предназначен для наследования, а его размер и производительность имеют решающее значение. Виртуальный деструктор или любая другая виртуальная функция вводит в структуру класса дополнительные данные, т.е. указатель на виртуальную таблицу, которая увеличивает размер любого экземпляра класса.
Однако в большинстве случаев классы могут быть унаследованы, даже если это изначально не предполагалось. Поэтому рекомендуется добавлять виртуальный деструктор при объявлении класса. В противном случае, если класс не должен содержать виртуальные функции из соображений производительности, рекомендуется поместить комментарий в файл объявления класса, указывающий, что класс не должен наследоваться. Один из лучших способов избежать этой проблемы — использовать IDE, которая поддерживает создание виртуального деструктора во время создания класса.
Еще одним пунктом в теме являются классы/шаблоны из стандартной библиотеки. Они не предназначены для наследования и не имеют виртуального деструктора. Если, например, мы создадим новый расширенный строковый класс, который публично наследуется от std::string, существует вероятность того, что кто-то неправильно использует его с указателем или ссылкой на std::string и вызовет утечку памяти.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }
Чтобы избежать таких проблем C++, более безопасным способом повторного использования класса/шаблона из стандартной библиотеки является использование закрытого наследования или композиции.
Распространенная ошибка № 3: удаление массива с помощью «удалить» или с помощью интеллектуального указателя
Часто необходимо создавать временные массивы динамического размера. После того, как они больше не требуются, важно освободить выделенную память. Большая проблема здесь в том, что C++ требует специального оператора удаления со скобками [], о котором очень легко забывают. Оператор delete[] не просто удалит память, выделенную под массив, но сначала вызовет деструкторы всех объектов из массива. Также некорректно использовать оператор удаления без скобок [] для примитивных типов, даже если для этих типов нет деструктора. Для каждого компилятора нет гарантии, что указатель на массив будет указывать на первый элемент массива, поэтому использование удаления без квадратных скобок [] также может привести к неопределенному поведению.
Использование интеллектуальных указателей, таких как auto_ptr, unique_ptr<T>, shared_ptr, с массивами также некорректно. Когда такой интеллектуальный указатель выходит из области действия, он вызывает оператор удаления без скобок [], что приводит к тем же проблемам, что и описанные выше. Если для массива требуется использование интеллектуального указателя, можно использовать scoped_array или shared_array от Boost или специализацию unique_ptr<T[]>.
Если функциональность подсчета ссылок не требуется, что в основном относится к массивам, наиболее элегантным способом является использование вместо этого векторов STL. Они не только заботятся об освобождении памяти, но и предлагают дополнительные функции.
Распространенная ошибка № 4: Возврат локального объекта по ссылке
В основном это ошибка новичка, но о ней стоит упомянуть, так как существует много устаревшего кода, который страдает от этой проблемы. Давайте посмотрим на следующий код, в котором программист хотел провести некоторую оптимизацию, избегая ненужного копирования:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);
Объект «сумма» теперь будет указывать на локальный объект «результат». Но где находится объект «результат» после выполнения функции SumComplex? Нигде. Он находился в стеке, но после возврата функции стек был развёрнут, и все локальные объекты из функции были уничтожены. В конечном итоге это приведет к неопределенному поведению даже для примитивных типов. Чтобы избежать проблем с производительностью, иногда можно использовать оптимизацию возвращаемого значения:
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);
Для большинства современных компиляторов, если строка возврата содержит конструктор объекта, код будет оптимизирован, чтобы избежать ненужного копирования — конструктор будет выполняться непосредственно над объектом «сумма».
Распространенная ошибка № 5: Использование ссылки на удаленный ресурс
Эти проблемы C++ случаются чаще, чем вы думаете, и обычно наблюдаются в многопоточных приложениях. Рассмотрим следующий код:
Тема 1:
Connection& connection= connections.GetConnection(connectionId); // ...
Тема 2:
connections.DeleteConnection(connectionId); // …
Тема 1:
connection.send(data);
В этом примере, если оба потока используют один и тот же идентификатор соединения, это приведет к неопределенному поведению. Ошибки нарушения прав доступа часто очень трудно найти.
В таких случаях, когда несколько потоков обращаются к одному и тому же ресурсу, очень рискованно сохранять указатели или ссылки на ресурсы, потому что какой-то другой поток может их удалить. Гораздо безопаснее использовать умные указатели с подсчетом ссылок, например, shared_ptr от Boost. Он использует атомарные операции для увеличения/уменьшения счетчика ссылок, поэтому он является потокобезопасным.
Распространенная ошибка № 6: разрешение исключений оставлять деструкторы
Не часто возникает необходимость генерировать исключение из деструктора. Даже в этом случае есть лучший способ сделать это. Однако исключения в большинстве случаев не выбрасываются из деструкторов явным образом. Может случиться так, что простая команда для регистрации уничтожения объекта вызовет генерацию исключения. Рассмотрим следующий код:

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"; }
В приведенном выше коде, если исключение возникает дважды, например, во время уничтожения обоих объектов, оператор catch никогда не выполняется. Поскольку существует два параллельных исключения, независимо от того, относятся ли они к одному типу или к разным, среда выполнения C++ не знает, как с этим справиться, и вызывает функцию завершения, которая приводит к завершению выполнения программы.
Итак, общее правило таково: никогда не позволяйте исключениям покидать деструкторы. Даже если это уродливо, потенциальное исключение должно быть защищено следующим образом:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}
Распространенная ошибка № 7: Использование «auto_ptr» (неверно)
Шаблон auto_ptr устарел в C++11 по ряду причин. Он по-прежнему широко используется, так как большинство проектов все еще разрабатываются на C++98. У него есть определенная характеристика, которая, вероятно, знакома не всем разработчикам C++ и может вызвать серьезные проблемы у неосторожного человека. Копирование объекта auto_ptr приведет к передаче права собственности с одного объекта на другой. Например, следующий код:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error
… приведет к ошибке нарушения прав доступа. Только объект «b» будет содержать указатель на объект класса A, а «a» будет пустым. Попытка доступа к члену класса объекта «a» приведет к ошибке нарушения прав доступа. Есть много способов неправильного использования auto_ptr. Четыре очень важные вещи, которые нужно помнить о них:
Никогда не используйте auto_ptr внутри контейнеров STL. Копирование контейнеров оставит исходные контейнеры с неверными данными. Некоторые алгоритмы STL также могут привести к аннулированию «auto_ptr».
Никогда не используйте auto_ptr в качестве аргумента функции, так как это приведет к копированию и оставит значение, переданное в аргумент, недействительным после вызова функции.
Если auto_ptr используется для элементов данных класса, обязательно сделайте правильную копию внутри конструктора копирования и оператора присваивания или запретите эти операции, сделав их закрытыми.
По возможности используйте какой-нибудь другой современный интеллектуальный указатель вместо auto_ptr.
Распространенная ошибка № 8: использование недействительных итераторов и ссылок
На эту тему можно было бы написать целую книгу. У каждого контейнера STL есть определенные условия, при которых он делает недействительными итераторы и ссылки. Важно помнить об этих деталях при использовании любой операции. Как и предыдущая проблема C++, эта также может очень часто возникать в многопоточных средах, поэтому для ее предотвращения необходимо использовать механизмы синхронизации. В качестве примера рассмотрим следующий последовательный код:
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
С логической точки зрения код выглядит вполне нормально. Однако добавление второго элемента в вектор может привести к перераспределению памяти вектора, что сделает итератор и ссылку недействительными и приведет к ошибке нарушения прав доступа при попытке доступа к ним в последних двух строках.
Распространенная ошибка № 9: передача объекта по значению
Вы, наверное, знаете, что передавать объекты по значению — плохая идея из-за влияния на производительность. Многие оставляют это так, чтобы не вводить лишние символы, или, возможно, думают вернуться позже, чтобы выполнить оптимизацию. Обычно это никогда не делается и в результате приводит к менее производительному коду и коду, склонному к неожиданному поведению:
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);
Этот код будет скомпилирован. Вызов функции «func1» создаст частичную копию объекта «b», т.е. скопирует только часть класса «A» объекта «b» в объект «a» («задача нарезки»). Таким образом, внутри функции он также будет вызывать метод из класса «А» вместо метода из класса «В», что, скорее всего, не соответствует ожиданиям тех, кто вызывает функцию.
Аналогичные проблемы возникают при попытке перехватить исключения. Например:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }
Когда из функции func2 выбрасывается исключение типа ExceptionB, оно будет перехвачено блоком catch, но из-за проблемы с нарезкой будет скопирована только часть из класса ExceptionA, будет вызван некорректный метод, а также повторный бросок вызовет неправильное исключение для внешнего блока try-catch.
Подводя итог, всегда передавайте объекты по ссылке, а не по значению.
Распространенная ошибка № 10: использование пользовательских преобразований с помощью конструктора и операторов преобразования
Даже определяемые пользователем конверсии иногда очень полезны, но они могут привести к непредсказуемым конверсиям, которые очень трудно обнаружить. Допустим, кто-то создал библиотеку со строковым классом:
class String { public: String(int n); String(const char *s); …. }
Первый метод предназначен для создания строки длины n, а второй предназначен для создания строки, содержащей заданные символы. Но проблема начинается, как только у вас есть что-то вроде этого:
String s1 = 123; String s2 = 'abc';
В приведенном выше примере s1 станет строкой размером 123, а не строкой, содержащей символы «123». Второй пример содержит одинарные кавычки вместо двойных кавычек (что может произойти случайно), что также приведет к вызову первого конструктора и созданию строки очень большого размера. Это действительно простые примеры, и есть много более сложных случаев, которые приводят к путанице и непредсказуемым преобразованиям, которые очень трудно найти. Есть 2 общих правила, как избежать таких проблем:
Определите конструктор с явным ключевым словом, чтобы запретить неявные преобразования.
Вместо использования операторов преобразования используйте явные методы диалога. Это требует немного больше набора текста, но его гораздо проще читать и это может помочь избежать непредсказуемых результатов.
Заключение
С++ — мощный язык. На самом деле, многие из приложений, которые вы используете каждый день на своем компьютере и полюбили, вероятно, созданы с использованием C++. Как язык, C++ предоставляет разработчику огромную гибкость благодаря некоторым наиболее сложным функциям, наблюдаемым в объектно-ориентированных языках программирования. Однако эти сложные функции или гибкие возможности часто могут стать причиной путаницы и разочарования для многих разработчиков, если они не используются ответственно. Надеюсь, этот список поможет вам понять, как некоторые из этих распространенных ошибок влияют на то, чего вы можете достичь с помощью C++.
Дальнейшее чтение в блоге Toptal Engineering:
- Как выучить языки C и C++: полный список
- C# против C++: что лежит в основе?