Добавить в избранное

Указатели и управление памятью в C++: стек, куча и типичные ловушки

Чтение ≈ 4 мин

Как работает память в C++: стек и куча, сырые указатели, ссылки, new/delete, массивы и утечки, разыменование, арифметика указателей и почему современный C++ старается их избегать.

Стек и куча: два вида памяти

Переменная, объявленная в функции, живёт на стеке — память выделяется мгновенно при входе и освобождается при выходе из скоупа. Размер ограничен (обычно мегабайты), жизнь — время жизни скоупа.

Куча — память, которую вы выделяете сами через new/malloc и сами же обязаны освобождать. Больше размером, живёт сколько угодно, но управление — ваша ответственность.

void f() {
    int stackVar = 5;              // стек — умрёт при выходе
    int* heapVar = new int(5);     // куча — живёт пока не delete
    delete heapVar;                // без этого — утечка
}

Сырые указатели: синтаксис и семантика

Указатель — переменная, хранящая адрес другого объекта:

int x = 42;
int* p = &x;      // p хранит адрес x
*p = 10;          // разыменование — чтение/запись по адресу: теперь x = 10
int** pp = &p;    // указатель на указатель
nullptr;          // нулевой указатель (вместо NULL/0)

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

Указатель vs ссылка

int x = 5;
int* ptr = &x;    // может быть nullptr, можно переназначить, нужно разыменование
int& ref = x;     // псевдоним, всегда «настоящий» объект, обязан быть привязан при создании

Ссылки используют в параметрах функций и возвратах. Указатели — когда нужен «отсутствующий объект» (nullptr) или пересадка на другой объект.

new/delete и почему их почти не пишут

int* p = new int(42);
delete p;

int* arr = new int[100];
delete[] arr;    // для массивов — delete[], иначе UB

Каждый new обязан встретить свой delete ровно один раз. Забыл — утечка. Удалил дважды — double free и UB. Удалил через delete вместо delete[] — UB. Исключение между new и delete — утечка.

Поэтому современный C++ заменяет new/delete на RAII:

// вместо
Widget* w = new Widget;
use(w);
delete w;

// пишут
auto w = std::make_unique<Widget>();
use(*w);           // удалится сам

// или вообще без кучи
Widget w;
use(w);

Арифметика указателей и массивы

Указатель на элемент массива можно сдвигать: p+1 — следующий элемент, не байт! Компилятор умножает на sizeof(T):

int a[5] = {10, 20, 30, 40, 50};
int* p = a;          // a само распадается в указатель на первый элемент
*(p + 2) == a[2];    // то же самое
p[3] == a[3];        // индексный синтаксис на указателе

Из этого следует знаменитая ошибка: размер массива через sizeof(a)/sizeof(a[0]) работает только в скоупе объявления. После распада в указатель sizeof(p) вернёт размер указателя (8), а не массива.

Висячие указатели — главная боль

int* dangling() {
    int local = 5;
    return &local;       // local умрёт — указатель висячий
}

int* p = new int(7);
delete p;
*p = 10;                 // use-after-free

Правила безопасности:

  • не возвращать указатели/ссылки на локальные переменные;
  • после delete присваивать nullptr — хотя бы детектируемо;
  • std::vector и std::string вместо ручных массивов;
  • умные указатели для владения; сырые — только как «наблюдатели» внутри scope.

Двойные указатели и передача в функции

Чтобы функция могла изменить сам указатель (выделить память внутри), передают указатель на указатель или ссылку на указатель:

// C-стиль
bool allocate(int** out) { *out = new int(5); return *out != nullptr; }

// C++ стиль — проще
bool allocate(int*& out) { out = new int(5); return out != nullptr; }

// лучше — вернуть объект владения
std::unique_ptr<int> allocate() { return std::make_unique<int>(5); }

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

unique_ptr — единоличное владение, размер сырого указателя, ноль оверхеда:

std::unique_ptr<Widget> w = std::make_unique<Widget>();
w->draw();
// delete не нужен

shared_ptr — разделяемое владение со счётчиком ссылок; weak_ptr — наблюдатель против циклов. Полный разбор — в статье про умные указатели.

Диагностика: ASan и Valgrind

На этапе разработки включайте санитайзеры — они ловят use-after-free, утечки и выходы за границы:

g++ -fsanitize=address,undefined -g main.cpp -o app
./app
# AddressSanitizer покажет: где выделено, где освобождено, где обратились после смерти

Итог

Сырые указатели нужны для понимания языка и работы со старым кодом, но в новом коде: объекты на стеке по умолчанию, unique_ptr для владения в куче, T&/T* только как невладеющие параметры. Помните про висячие ссылки, парность new/delete и -fsanitize при отладке.

Полный пример жизненного цикла

struct Node {
    int value;
    Node* next;
    Node(int v) : value(v), next(nullptr) {}
};

Node* head = new Node(1);          // ручное управление — опасно
head->next = new Node(2);
head->next->next = new Node(3);

// обход и печать
for (Node* p = head; p; p = p->next)
    std::cout << p->value << ' ';

// освобождение — не забудьте каждый!
while (head) {
    Node* next = head->next;
    delete head;
    head = next;
}

Каждая ошибка в этом коде — потенциальная утечка или краш. Именно поэтому современный C++ предпочитает std::unique_ptr и контейнеры.

Проверка на утечки

g++ -g -fsanitize=address app.cpp -o app
./app
# AddressSanitizer сообщит о каждой утечке с местом выделения

valgrind --leak-check=full ./app    # альтернатива без пересборки

new[] и delete[]: массивы в куче

int* arr = new int[100];    // массив из 100 int
arr[5] = 42;
delete[] arr;               // именно delete[], не delete!

new[]/delete[] — парная операция для массивов. delete без [] на массиве — UB. В реальном коде вместо сырого массива — std::vector<int>(100).

Память стека и кучи: числа

  • Стек на поток: обычно 1–8 МБ, выделяется мгновенно (декремент указателя).
  • Аллокация в куче: десятки-сотни наносекунд, метаданные аллокатора, фрагментация.
  • Рекурсия глубиной миллион — гарантированный stack overflow; структура на мегабайт в стеке — то же самое.

Правила выживания с указателями

  • Инициализируйте указатели (nullptr) — висячий указатель неотличим от валидного.
  • После delete — p = nullptr, если указатель ещё живёт в области видимости.
  • Не delete дважды — только nullptr можно удалять многократно.
  • Возвращайте из функций unique_ptr/shared_ptr, а не сырые указатели.
  • Проверяйте анализаторами: ASan, UBSan, Valgrind — они находят ошибки памяти за вас.

Итог

Ручная память в C++ — это инструмент низкого уровня: понимать обязательно, использовать через обёртки по возможности. Стек vs куча, указатели и их опасности, RAII-обёртки вместо new/delete, санитайзеры для проверки — и ошибки памяти перестают быть самым частым багом в вашем коде.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий