Введение: когда 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