Топ ошибок начинающих C++ разработчиков: продвинутый уровень

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: когда C++ перестаёт быть "просто языком"

Вы уже написали десяток консольных программ, научились использовать std::vector и даже разобрались с шаблонами. Но C++ — это язык, который никогда не прощает поверхностного подхода. Начинающий разработчик переходит на новый уровень ровно в тот момент, когда сталкивается с неопределённым поведением (UB) и начинает понимать, что компилятор — не добрый друг, а строгий контрагент.

В этой статье мы разберём не банальные ошибки вроде забытого ; или путаницы между = и ==. Мы поговорим о ловушках, которые поджидают тех, кто уже знает синтаксис, но ещё не освоил философию языка. Эти ошибки — причина 90% трудноуловимых багов в промышленном коде.

1. Игнорирование Rule of Five и Rule of Zero

Проблема: неявно сгенерированные специальные функции-члены

Многие разработчики, перешедшие с Java или Python, считают, что компилятор сам "сделает всё красиво" с копированием и перемещением объектов. Это опасное заблуждение.

Рассмотрим класс с динамическим ресурсом:

class Buffer {
public:
    Buffer(size_t size) : data(new char[size]), size(size) {}
    ~Buffer() { delete[] data; }
    
    // Всё отлично? Нет! Нет конструктора копирования и оператора присваивания!
private:
    char* data;
    size_t size;
};

void process() {
    Buffer a(100);
    Buffer b = a;  // Поверхностное копирование! Два указателя на одну память.
} // Двойное освобождение — UB!

Правило Rule of Five гласит: если вы определили хотя бы один из пяти специальных методов (деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением), вы должны определить их все.

Решение: Используйте std::unique_ptr или std::vector для управления ресурсами. Это подход Rule of Zero: если ваш класс не управляет ресурсами напрямую, компилятор сам сгенерирует корректные специальные функции.

class Buffer {
public:
    Buffer(size_t size) : data(std::make_unique<char[]>(size)), size(size) {}
    // Rule of Zero — деструктор, копирование и перемещение работают корректно!
private:
    std::unique_ptr<char[]> data;
    size_t size;
};

2. Неправильное использование move-семантики

Проблема: move не делает объект "пустым" автоматически

Одна из самых коварных ошибок — вера в то, что после std::move исходный объект становится "безопасным" для использования. На самом деле std::move — это просто приведение к rvalue-ссылке. Состояние перемещённого объекта зависит от реализации.

std::string a = "Hello, World!";
std::string b = std::move(a);
std::cout << a;  // Технически — UB? Нет, но содержимое a неопределено!
// Может быть пустая строка, а может — "Hello, World!"

Стандарт гарантирует только то, что перемещённый объект находится в корректном, но неопределённом состоянии. Вы можете вызвать a.clear() или a = "new value", но не должны полагаться на его содержимое.

Совет: После перемещения либо сбросьте объект в известное состояние, либо прекратите его использовать. Для пользовательских типов всегда документируйте постусловия move-конструктора.

3. Утечки памяти через сырые указатели и исключения

Проблема: исключения "разрывают" цепочку освобождения ресурсов

Даже опытные разработчики иногда пишут код с ручным управлением памятью:

void process() {
    int* ptr1 = new int(42);
    int* ptr2 = new int(100);
    
    // Если здесь вылетит исключение — утечка ptr1!
    dangerous_function();
    
    delete ptr2;
    delete ptr1;
}

В C++ нет finally (до C++23), и исключения делают ручное управление ресурсами крайне опасным. Даже если вы аккуратно расставите try/catch, код станет нечитаемым и хрупким.

Решение: Используйте RAII-обёртки. Умные указатели — ваш главный инструмент:

void process() {
    auto ptr1 = std::make_unique<int>(42);
    auto ptr2 = std::make_unique<int>(100);
    
    // Даже если dangerous_function() бросит исключение — 
    // деструкторы ptr1 и ptr2 будут вызваны при раскрутке стека!
    dangerous_function();
} // Автоматическое освобождение

Для массивов используйте std::vector, для shared-ресурсов — std::shared_ptr. Забудьте про new и delete в прикладном коде.

4. Неопределённое поведение при работе с контейнерами

Проблема: инвалидация итераторов и ссылок

Это классика, но многие продолжают наступать на эти грабли. Добавление элемента в std::vector может инвалидировать все итераторы и ссылки на его элементы, если произойдёт реаллокация.

std::vector<int> vec = {1, 2, 3};
int& ref = vec[1];  // Ссылка на второй элемент

vec.push_back(4);    // Если произошла реаллокация — ref висячая ссылка!
std::cout << ref;   // UB!

Аналогичная проблема с std::unordered_map: вставка может вызвать рехэширование и инвалидировать итераторы.

Как избежать:

  • Используйте reserve() для std::vector, если знаете примерный размер.
  • Не храните долгоживущие ссылки/итераторы на элементы контейнеров, которые могут изменяться.
  • Для списков (std::list) вставка не инвалидирует итераторы — используйте их, если нужна стабильность.
  • При работе с std::deque

Блоги

Рекомендаций по книгам