Указатели: полное руководство с визуализацией

Указатели — это не «магия» и не «страшный синтаксис». Это просто адреса ячеек в памяти. Как только вы увидите, что происходит «под капотом», тема станет простой. Ниже — пять интерактивных демонстраций, где память показана как реальная сетка ячеек.

Адреса и память & и * Массивы и функции new / delete

Прежде чем начать: зачем вообще нужны указатели

Почти каждый студент задаёт этот вопрос. Ответ — в трёх сценариях, где без указателей не обойтись.

📦

Динамическая память

Мы не знаем заранее, сколько памяти нужно. Например, читаем числа из файла — их может быть 10 или 10000. Указатель позволяет запросить память во время работы программы.

🔄

Изменение аргументов

Функция swap(a, b) должна поменять значения в вызывающем коде. Без указателей функция получит только копии и не сможет изменить оригиналы.

⚡

Эффективность

Передать функции большую структуру целиком — дорого. Передать указатель — дёшево: копируется всего 8 байт адреса, а не, скажем, 10 КБ данных.

Главная мысль. Указатель — это не значение. Это адрес, по которому лежит значение. Представьте бумажную записку: вы можете передать другу записку с адресом дома — друг сам сходит по адресу и сделает что нужно. Вместо того чтобы копировать весь дом и передавать копию.

Что такое память: сетка пронумерованных ячеек

Память компьютера — это огромный массив байтов. У каждого байта есть свой уникальный номер — адрес. Именно по адресам процессор и находит нужные данные.

🏢
Аналогия с многоквартирным домом. Память — это дом с огромным количеством квартир. У каждой квартиры есть номер (адрес). В каждой квартире живут данные. Когда мы говорим «переменная x», мы на самом деле имеем в виду «жилец в квартире номер 0x1000».
Схематичное представление оперативной памяти (упрощённо):
Что важно понять. Адреса в реальной памяти — это большие числа (например, 0x7ffd3c2a4b10). Для наглядности здесь используются «красивые» адреса вроде 0x1000. Логика от этого не меняется.

Переменная — это три вещи одновременно

Когда вы пишете int x = 42;, происходят три вещи:

  1. 1. Компилятор выбирает свободный участок памяти — например, 4 байта с адресом 0x1000.
  2. 2. В эту область записывается значение 42.
  3. 3. Имя x связывается с адресом 0x1000 — компилятор впредь будет обращаться по этому адресу.
x имя адрес 0x1000 42 значение Имя x — это просто метка для адреса 0x1000. Когда вы пишете x в коде, компилятор подставляет адрес 0x1000.

Демонстрация 1: операторы & и *

Самая важная и простая демонстрация. Здесь два оператора, но они путаются у всех — потому что знак * играет разные роли в зависимости от контекста.

& — «взять адрес»

Применяется к переменной, возвращает её адрес в памяти.

пример
int x = 42;
cout << &x;   // напечатает адрес,
              // например 0x7ffd...b10

* — «пойти по адресу»

Применяется к указателю, даёт доступ к значению по этому адресу.

пример
int *p = &x;   // p хранит адрес x
cout << *p;    // 42 — значение по адресу
*p = 100;      // изменили x через p
Внимание на путаницу. Знак * в объявлении int *p; — это часть типа («p — указатель на int»), а не разыменование. И только в выражении *p = 100; это уже разыменование («пойти по адресу и записать 100»). Это разные вещи, хоть и выглядят одинаково.

Пошаговая трассировка

Нажимайте «Шаг», чтобы увидеть, как меняется память на каждом этапе.

int x = 42; // создать переменную x
int *p; // создать указатель p
p = &x; // p хранит адрес x
*p = 100; // изменить x через p
int y = *p + 5; // прочитать значение через p
Готово к запуску
Состояние памяти:
$ // здесь появятся комментарии к шагам

Демонстрация 2: зачем указатели в функциях

Классический пример — функция обмена значений. Здесь особенно наглядно, почему без указателей не обойтись.

Не работает

swap по значению
void swap(int a, int b) {
    int t = a;
    a = b;
    b = t;
}

int main() {
    int x = 1, y = 2;
    swap(x, y);
    // x = 1, y = 2  — НЕ поменялись!
}

Функция получает копии x и y. Меняет копии, а оригиналы остаются нетронутыми.

Работает

swap по указателям
void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

int main() {
    int x = 1, y = 2;
    swap(&x, &y);   // передаём адреса
    // x = 2, y = 1 — поменялись!
}

Функция получает адреса. По адресу меняет оригинальные x и y.

Пошаговая трассировка вызова swap(&x, &y)

int x = 1, y = 2; // две переменные в main
вызов swap(&x, &y) // передаём адреса
внутри swap: a и b — копии указателей (те же адреса)
int t = *a; // t = 1 (значение x)
*a = *b; // x = 2
*b = t; // y = 1
возврат из swap // в main x=2, y=1
Готово к запуску
Состояние памяти (main + кадр вызова swap):

Демонстрация 3: указатели и массивы

Массив и указатель очень тесно связаны — имя массива фактически является указателем на первый элемент. Это объясняет, почему a[i] и *(a + i) — одно и то же.

p 0x2000 10 a[0] 0x2004 20 a[1] 0x2008 30 a[2] 0x200C 40 a[3] 0x2010 50 a[4] p указывает на a[0] p + 4 = &a[4] p[i]  ≡  *(p + i)
Почему p + 1 не сдвигает адрес на 1 байт. int занимает 4 байта. p + 1 означает «следующий элемент массива», то есть адрес увеличивается на sizeof(int) = 4 байта. Это называется арифметикой указателей.

Интерактивно: p[i] vs *(p + i)

0
$ p[0] = 10 ≡ *(p + 0) = 10

Демонстрация 4: динамическая память — new и delete

Иногда размер данных заранее неизвестен. Тогда память запрашивается у системы во время работы программы. Именно так рождается динамический массив.

динамический массив
int n;
cin >> n;              // пользователь вводит размер

int *arr = new int[n];  // выделить память под n целых

for (int i = 0; i < n; i++)
    cin >> arr[i];       // заполнить массив

// ... работаем с массивом ...

delete[] arr;           // освободить память
arr = nullptr;          // обнулить указатель
Правило. Если выделили через new — освободите через delete. Если new[] (массив) — delete[]. Забытый delete — это утечка памяти: программа будет занимать память, пока не завершится.

Пошаговая трассировка

int n = 3;
int *arr = new int[n]; // выделение памяти
arr[0] = 10; arr[1] = 20; arr[2] = 30;
cout << arr[1]; // читаем значение
delete[] arr; // освобождение памяти
arr = nullptr; // обнуляем
Состояние памяти:
$ // нажмите «Выполнить по шагам»

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

Каждая из них рано или поздно случится и у вас. Главное — узнать её сразу.

Ошибка 1: разыменование нулевого указателя

плохой код
int *p = nullptr;
*p = 100;   // ← segfault! Программа упадёт

Как избежать. Всегда проверяйте указатель перед разыменованием: if (p != nullptr) *p = 100;

Ошибка 2: «висячий» указатель (dangling pointer)

плохой код
int *p = new int(42);
delete p;           // память освобождена
cout << *p;         // ← читаем освобождённую память!

// или:
int *q;
{
    int x = 10;
    q = &x;
}                    // x уничтожена, но q всё ещё хранит её адрес
cout << *q;         // ← тоже мусор

Как избежать. После delete сразу ставьте p = nullptr;. Не сохраняйте адреса локальных переменных за пределами их блока.

Ошибка 3: утечка памяти

плохой код
void process() {
    int *buffer = new int[1000];
    // ... работаем ...
    // забыли delete[] buffer;
}

// при повторных вызовах память будет заканчиваться

Как избежать. Правило: сколько раз new — столько раз delete. В современном C++ предпочитайте умные указатели unique_ptr и shared_ptr — они освобождают память автоматически.

Ошибка 4: путаница * в объявлении и в выражении

плохой код
int a = 10, b = 20;

int *p = &a;      // здесь * — часть типа «указатель на int»
*p = b;           // здесь * — разыменование: «по адресу p записать b»
                  // в итоге a стало равно 20, а b не изменилось

int x = *p;       // читаем значение по адресу p → x = 20

Как читать. Смотрите на контекст: если * стоит рядом с типом в объявлении — это «указатель на». Если в выражении перед именем указателя — это «значение по адресу».

Шпаргалка: всё, что нужно запомнить

Синтаксис Что означает Пример
int x; Обычная переменная типа int x = 42;
int *p; Указатель на int. Хранит адрес, где лежит int p = &x;
&x Взять адрес переменной x cout << &x;
*p Разыменовать: получить значение по адресу p cout << *p;
*p = 100; Записать 100 по адресу p изменит переменную, на которую указывает p
int **pp; Указатель на указатель pp = &p;
int *arr = new int[n]; Выделить память под n целых вернёт адрес первого элемента
delete[] arr; Освободить память, выделенную через new[] обязателен, если выделяли через new[]
p = nullptr; Обнулить указатель — «никуда не указывает» безопасное состояние
p[i] или *(p + i) i-й элемент от адреса p эквивалентные записи
Итог. Указатель — это не «опасная магия». Это переменная, в которой хранится число-адрес. Оператор & берёт адрес, оператор * идёт по адресу. Всё остальное — детали, которые приходят с практикой. Когда сомневаетесь, снова откройте эту страницу и посмотрите на картинку памяти: она подскажет.

Практика

  1. Объявите int x = 5; и int *p = &x;. Выведите x, &x, p, *p. Объясните каждое значение.
  2. Что выведет этот код? Проверьте на бумаге, потом в компиляторе:
    int a = 10, b = 20;
    int *p = &a;
    *p = 15;
    p = &b;
    *p = 25;
    cout << a << " " << b;
  3. Напишите функцию void increment(int *p), которая увеличивает значение по адресу на 1. Проверьте её работу в main.
  4. Зачем в swap нужны указатели? Объясните, почему передача по значению не работает.
  5. Напишите функцию, которая принимает массив и его размер и возвращает сумму элементов через арифметику указателей (*(arr + i) вместо arr[i]).
  6. Найдите ошибку в этом коде:
    int *p;
    *p = 42;