«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » Delphi и C++ » Указатели и управление памятью в C++

Указатели и управление памятью в C++

Указатели в C++ пугают новичков, особенно тех, кто пришёл из Delphi или языков со сборщиком мусора. На самом деле идея проста: указатель — это переменная, которая хранит адрес другой переменной в памяти. Сложности начинаются, когда за этими адресами нужно следить вручную: вовремя освобождать память, не обращаться к уже удалённым объектам, не выходить за границы массивов.

В этой статье разберём основы работы с указателями, динамическую память, типичные ошибки и современные способы сделать код безопасным.

Что такое указатель

Любая переменная живёт где-то в памяти и имеет адрес. Оператор взятия адреса & позволяет его получить, а оператор разыменования * — обратиться к значению по адресу.

int x = 10;
int* p = &x;      // p хранит адрес x
*p = 25;          // меняем x через указатель
std::cout << x;   // выведет 25

Тип указателя важен: int* знает, что по адресу лежит целое число, поэтому при арифметике сдвигается сразу на размер int. Указатель, который ни на что не указывает, нужно инициализировать значением nullptr. Это защищает от случайного обращения к мусору.

Указатели и массивы

В C++ имя массива в большинстве выражений превращается в указатель на его первый элемент. Поэтому обход можно записать двумя способами:

int a[5] = {1, 2, 3, 4, 5};
int* p = a;
for (int i = 0; i < 5; ++i) {
    std::cout << *(p + i) << " ";   // то же, что a[i]
}

Запомните: выражение a[i] — это просто сокращённая запись *(a + i). Отсюда же главная опасность: компилятор не проверяет, что индекс не выходит за границы. Обращение за пределы массива приводит к неопределённому поведению — программа может упасть сразу, а может годами работать с испорченными данными.

Стек и куча

Память программы делится на несколько областей. Нам важны две:

  • Стек — быстрая память для локальных переменных. Освобождается автоматически при выходе из области видимости. Размер ограничен.
  • Куча — большая область для динамических объектов. Живут они столько, сколько вы решите, но освобождать их нужно явно.

Если объём данных заранее неизвестен или объект должен пережить функцию, которая его создала, придётся использовать кучу. Для этого служат операторы new и delete.

int* p = new int(42);     // выделили память в куче
std::cout << *p;
delete p;                 // освободили
p = nullptr;              // защита от повторного использования

int* arr = new int[100];  // массив
delete[] arr;             // для массивов только delete[]

Правило парности: каждому new соответствует delete, каждому new[] — delete[]. Перепутать их — классическая ошибка, ведущая к повреждению памяти.

Типичные ошибки при работе с указателями

Утечка памяти

Вы выделили память и потеряли к ней доступ, не освободив. Для маленькой утилиты это терпимо, для сервера, который работает неделями, — катастрофа: память постепенно заканчивается.

void leak() {
    int* data = new int[1000];
    if (somethingWrong()) return;   // забыли delete[] при раннем выходе
    delete[] data;
}

Висячий указатель

Указатель ссылается на память, которая уже освобождена. Любое обращение к нему — неопределённое поведение.

int* p = new int(5);
delete p;
*p = 7;      // ошибка: память уже освобождена

Двойное освобождение

Повторный вызов delete для одного и того же адреса портит внутренние структуры менеджера памяти. Обнуление указателя после удаления делает такой вызов безвредным, ведь удаление nullptr ничего не делает.

Возврат адреса локальной переменной

Локальная переменная умирает вместе с функцией. Указатель на неё после выхода становится висячим.

int* bad() {
    int local = 3;
    return &local;   // опасно: local исчезнет
}

Ссылки и когда их использовать

Ссылка — это псевдоним существующего объекта. В отличие от указателя, она не может быть пустой и не может быть перенаправлена на другой объект после инициализации. Поэтому ссылки безопаснее для передачи параметров.

  • Используйте const T&, чтобы передать большой объект без копирования и без права изменения.
  • Используйте T&, когда функция должна изменить аргумент.
  • Используйте указатель, когда значение может отсутствовать (nullptr) или нужно менять цель.

RAII — главный принцип безопасной памяти

RAII означает «получение ресурса есть инициализация». Идея в том, что ресурс захватывается в конструкторе объекта и освобождается в деструкторе. Так как деструктор вызывается автоматически при выходе из области видимости, включая выход по исключению, утечки становятся практически невозможными.

class Buffer {
    int* data_;
public:
    explicit Buffer(size_t n) : data_(new int[n]) {}
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;
};

Обратите внимание на запрет копирования. Если объект владеет памятью, наивное копирование приведёт к тому, что два объекта будут пытаться удалить одну и ту же область. Это известное правило трёх (или пяти): если вы пишете деструктор, подумайте о копирующем конструкторе и операторе присваивания.

Умные указатели

Современный C++ предлагает готовые обёртки, которые реализуют RAII за вас. Они лежат в заголовке <memory>.

ТипВладениеКогда применять
std::unique_ptrЕдиноличноеПо умолчанию для динамических объектов
std::shared_ptrРазделяемое, со счётчиком ссылокКогда объект нужен нескольким владельцам
std::weak_ptrНаблюдатель без владенияЧтобы разорвать циклические ссылки
#include <memory>

auto p = std::make_unique<int>(42);
std::cout << *p;
// delete не нужен: память освободится сама

auto s = std::make_shared<std::string>("привет");
auto s2 = s;   // счётчик ссылок равен двум

Практическое правило: если нет веских причин, используйте unique_ptr. Он не добавляет накладных расходов по сравнению с обычным указателем и чётко выражает идею единоличного владения. К shared_ptr переходите, когда владельцев действительно несколько.

Отдельно стоит помнить про циклические ссылки: если два объекта держат друг друга через shared_ptr, счётчик никогда не дойдёт до нуля, и память не освободится. В таких случаях одну из связей делают через weak_ptr.

Контейнеры вместо ручных массивов

Во многих случаях динамические массивы вообще не нужны. Класс std::vector сам управляет памятью, умеет расти и проверяет границы при использовании метода at. Строки лучше хранить в std::string, а не в массивах char.

std::vector<int> v;
v.push_back(1);
v.push_back(2);
std::cout << v.at(1);   // безопасный доступ

Как искать ошибки с памятью

  • Компилируйте с максимальным уровнем предупреждений и не игнорируйте их.
  • Используйте санитайзеры: адресный санитайзер находит выход за границы и использование после освобождения.
  • Применяйте Valgrind на Linux для поиска утечек.
  • Включайте проверки итераторов в отладочной сборке.
  • Пишите тесты, которые гоняют код с разными входными данными.

Памятка для тех, кто пришёл из Delphi

В Delphi объекты классов всегда живут в куче, а переменная — это по сути неявный указатель. В C++ объект может лежать прямо на стеке, и это нормальная, предпочтительная практика. Не создавайте через new то, что можно объявить локально. Кроме того, деструктор в C++ вызывается детерминированно, поэтому нет необходимости в конструкциях вроде try-finally для освобождения: достаточно RAII-объекта.

Заключение

Указатели — мощный инструмент, но держать его голым в руках стоит только там, где это необходимо. Современный подход прост: по умолчанию используйте объекты на стеке и контейнеры стандартной библиотеки, для динамических объектов берите unique_ptr, для общего владения — shared_ptr, а сырые указатели оставьте для неоперабельных наблюдателей и взаимодействия со старым кодом. Придерживаясь этих правил, вы забудете о большинстве мучительных отладок с утечками и падениями.

ИБ
Ирина Белова
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».