Загружаем лабораторные…
// C++ · Лекция 07

Указатели и память

Где живёт переменная, что на самом деле делает & и *, как перемещаться по массиву и почему старый адрес не означает живой объект.

Адрес и указатель

У переменной можно обсуждать как минимум две разные вещи: её значение и место, по которому к объекту можно обратиться. Для int x = 7; значение — это семь, а выражение &x даёт адрес объекта. Указатель связывает эти идеи: он хранит адрес, а операция разыменования позволяет обратиться к расположенному по нему объекту. Это связь с исходным объектом, а не отдельная копия его числа.

Главная мысль

Указатель (pointer) — объект, значение которого представляет адрес другого объекта или особое нулевое значение. int* — «указатель на int».

Для начала удобно представлять оперативную память как ряд ячеек, каждая из которых имеет адрес. Переменная x занимает место, а &x позволяет получить её адрес. Указатель p хранит этот адрес, но сам тоже является отдельной переменной.

Модель памяти

Адрес указывает где находится значение; разыменование позволяет получить что там хранится.

Адрес объекта (Address) — Значение, позволяющее ссылаться на место размещения объекта. Для объяснений ниже используются условные адреса.

При каждом запуске реальные адреса могут отличаться. Виртуальная памятьУказатели в обычных пользовательских программах относятся к адресному пространству процесса. Не следует воспринимать напечатанный адрес как физический номер ячейки RAM.

address.cpp
#include <iostream>
int main() {
    int x = 7;
    int* p = &x;    // адрес x
    std::cout << p << '\n';  // адрес (зависит от запуска)
    std::cout << *p << '\n'; // 7
}
На первом шаге p хранит адрес x, а *p читает x.

После int* p = &x; изменение x не требует заново записывать его адрес в p. Объект остаётся тем же, меняется только его содержимое. В обратную сторону связь тоже работает: запись *p = 25; меняет x. При этом у самого p есть собственный адрес &p, потому что указатель тоже хранится как самостоятельный объект.

Указатель хранит адрес, а не копию значения. Измените объект в визуализации и проследите, почему адрес остаётся прежним. В примерах можно проверить запись через указатель и обращение к одному объекту через два разных имени.

Что хранится в p и что означает *p?
ОБЪЕКТ x / intx
7
условный адрес 0x1000
ОБЪЕКТ p / int*p
0x1000
условный адрес самого p: 0x2000
p ───────────→ x
p = 0x1000*p = 7&x = 0x1000
Адрес ≠ разрешение на доступ

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

Взятие адреса и разыменование

Полезно проговаривать эти две операции словами: &x — «получить адрес x», а *p — «обратиться к объекту по адресу из p». Так легче не перепутать указатель и данные, к которым он ведёт. В частности, получение адреса не читает копию всего объекта, а разыменование предоставляет возможность работать с самим объектом при выполнении необходимых условий.

ЗаписьЧто означаетТип результатаПример
&x
Взять адрес объекта
T*, если x имеет тип T
int* p = &x;
*p
Разыменовать указатель
Выражение типа T, обычно lvalue
*p = 42;
int* p
Объявить указатель
Тип переменной int*
int* p = nullptr;

Пусть p и q содержат адрес одного int. Тогда присваивание через *q будет видно и при чтении через *p: оба выражения обращаются к одному объекту. Но присваивание q = &y; изменит уже сам указатель q и перенаправит его на другой объект. Сравнивайте левую часть записи: q и *q обозначают разные объекты.

Одинаковый знак — разные роли

Звёздочка в int* p участвует в объявлении типа, а в *p — это оператор, извлекающий доступ к объекту по адресу.

В выражении *p = 25 меняется значение объекта, но сам указатель p остаётся прежним.

Разыменование (Dereference) — Применение оператора * к указателю для получения выражения, обозначающего объект по адресу.

dereference.cpp
int x = 5;
int* p = &x;
*p = 25;        // меняет x
// теперь x == 25

int* q = p;     // копия адреса
*q = 30;       // тоже меняет x
Копия указателя не является копией объекта.
&xТип: int*
Категория: prvalue
Адрес объекта
*pТип: int
Категория: lvalue
Сам объект
*(&x)В обычном случае это тот же объект x; ему можно присваивать.

Для корректного указателя p на живой объект выражение &(*p) получает исходный адрес. Но разыменовывать случайный или нулевой указатель ради такой операции нельзя.

Почему нельзя написать &(x + 1)? Унарный & для обычных объектов требует подходящее lvalue, тогда как x + 1 даёт временный prvalue. Техническая оговоркаЭто учебное правило для встроенного оператора взятия адреса. В C++ есть перегруженный operator& у пользовательских типов и особые варианты выражений; подробности будут позднее.

Разобранный пример и самостоятельная проверка

У нас есть объект x со значением семь и другой объект p, который хранит указатель на x. Выражение x обозначает исходное целое число, а &x получает его адрес. Переменная p содержит этот адрес. Разыменование *p позволяет обратиться к самому x: это не копия числа. Поэтому присваивание через *p меняет x, а значение указателя p остаётся прежним. У самого p тоже есть адрес, который записывается как &p. Он относится к другому объекту и имеет другой тип: p является указателем на int, а &p — указателем на такой указатель. Пока нам достаточно различать эти роли, не разбирая несколько разыменований. Реальные адреса при запуске могут меняться, поэтому в проверках сравниваем отношения, а не конкретные шестнадцатеричные числа. Получение адреса не передаёт владение объектом. X существует как локальная переменная; вызывать delete для &x нельзя.

code-guided-pointer.cpp
#include <iostream>

int main() {
    int x = 7;
    int* p = &x;
    *p = 25;
    std::cout << x << ' ' << *p << '\n';
    std::cout << std::boolalpha << (p == &x) << '\n';
    std::cout << "p: " << p << " address of p: " << &p << '\n';
}
Сначала 25 25 и true; конкретные адреса зависят от запуска.

Объект, адрес и доступ к значению

Для int x=25; int* p=&x; различайте пять выражений:

ВыражениеЧто означает
x
Сам объект int; при чтении — значение 25.
&x
Адрес x, тип int*.
p
Отдельный объект-указатель, хранящий адрес x.
*p
Доступ к тому же живому x; при чтении — 25.
&p
Адрес самого указателя p, тип int**.

Наблюдающий указатель не владеет целью и не продлевает её время жизни. Перед разыменованием цель должна существовать, а адрес должен быть допустим. Адреса на рисунках условны.

Типы указателей и объявления

Тип указателя сообщает компилятору, с каким типом объекта предполагается работать после разыменования. Это не декоративная подпись к напечатанному адресу: от типа зависят допустимые операции и смысл шага по массиву. Поэтому указатели разных типов нельзя считать взаимозаменяемыми только потому, что на выбранной платформе они занимают одинаковое количество байтов.

Главная мысль

Тип указателя — зависит от типа объекта, на который он указывает. int*, double* и char* — разные типы.

Объявления лучше читать по одной переменной. В int* first, second; звёздочка относится к декларатору first, а second имеет тип int. Если нужны два указателя, яснее написать два отдельных объявления. Также разделяйте размер объекта-указателя и размер указываемого объекта: адрес книги и сама книга — разные данные (аналогия помогает различать роли, но не описывает физическое устройство указателя).

Опасное объявление

int* p, q; объявляет p как указатель, а q как обычный int. Это следствие правил декларатора C++.

Поэтому читаемый стиль для курса: одна переменная — одно объявление.

Ловушка синтаксиса

Перенос звёздочки ближе к типу улучшает читаемость, но не меняет грамматику: int* p, q; всё ещё смешивает разные типы.

declarations.cpp
int x = 9;
int* p = &x;
int* q = &x;    // два указателя

int* a, b;     // a: int*, b: int
// Лучше: одно объявление на строку
В одном объявлении * относится к конкретному декларатору.
Размер указателя — не размер объекта
sizeof(int)
Размер целого

Обычно 4 байта на современных платформах.

sizeof(int*)
Размер адреса

Часто 8 байт в 64-битных ABI; стандарт не обещает фиксированный размер.

Даже если два типа указателей имеют одинаковый размер, это не делает их взаимозаменяемыми.

Зачем это нужно: функции и изменение переменных

Обычная передача аргумента по значению создаёт отдельный параметр функции. Если передать число, функция получает своё число; если передать указатель, она получает свою копию адреса. Во втором случае обе копии адреса могут вести к одному исходному объекту. Поэтому функция способна изменить этот объект через разыменование, хотя сам параметр всё равно передан по значению.

Главная мысль

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

Различайте *p = 10; и p = &other; внутри функции. Первая запись изменяет объект вызывающего кода, если передан его корректный адрес. Вторая перенаправляет только локальную копию указателя и не меняет переменную-указатель у вызывающей стороны. Когда требуется изменить именно её, пригодится дополнительный уровень косвенности из предыдущего раздела либо другой подходящий интерфейс функции.

Параметр-указатель тоже передаётся по значению, но копия адреса позволяет обратиться к исходному объекту. Сравните обмен копий чисел с обменом через адреса. Отдельный пример добавляет явную обработку отсутствующего аргумента.

Почему копии не меняют исходные переменные?
ИСХОДНАЯ ПЕРЕМЕННАЯx
1
ИСХОДНАЯ ПЕРЕМЕННАЯy
2
До вызова: x = 1, y = 2.

Передаём не копии чисел, а адреса

swap_by_value(x, y) работает с копиями. swap_by_ptr(&x, &y) передаёт адреса тех же объектов, и функция делает изменения через *a и *b.

В C++ также есть std::swap и ссылки, которые мы будем изучать позднее; вручную писать такую функцию обычно не нужно.

swap_pointer.cpp
#include <iostream>
void swap_by_value(int a, int b) {
    int t = a; a = b; b = t;
}
void swap_by_ptr(int* a, int* b) {
    int t = *a;
    *a = *b;
    *b = t;
}
int main() {
    int x = 1, y = 2;
    swap_by_value(x, y);    // x = 1, y = 2
    swap_by_ptr(&x, &y);   // x = 2, y = 1
    std::cout << x << ' ' << y;
}
Для swap_by_ptr нужны указатели на действительные int.
scanf.cpp
#include <cstdio>
int main() {
    int x = 0;
    if (std::scanf("%d", &x) != 1) {
        std::puts("Некорректный ввод");
        return 1;
    }
    std::printf("%d\n", x);
}
scanf изменяет переменную через адрес, а количество успешно считанных значений возвращает отдельно.

Связь с scanf

В C функция scanf принимает адрес переменной, чтобы записать в неё прочитанное значение. Для printf обычно достаточно самого значения.

Проверять результат scanf необходимо: для одного ожидаемого значения успешный результат — 1.

В C++ используй <cstdio>, std::scanf, std::printf. Одного <iostream> недостаточно для гарантированного объявления этих функций.

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

Арифметика указателей нужна прежде всего для движения по последовательным элементам одного массива. Если p указывает на первый элемент, p + 1 обозначает следующий элемент независимо от его размера в байтах. Такой шаг удобно сравнивать с переходом к следующему месту в ряду: важно число элементов, а пересчёт в размещение выполняется с учётом их типа.

Прибавление целого k к T* сдвигает указатель на k элементов типа T, а не на k байт. Это имеет смысл при обходе массива, где элементы гарантированно размещены подряд.

Главная мысль

Арифметика указателей — смещение по элементам одного массива. При sizeof(int) == 4 выражение p + 2 соответствует шагу на 8 байт от исходного адреса, если такое смещение допустимо.

У массива из пяти элементов допустимы обращения к индексам от нуля до четырёх. Указатель begin + 5 можно сформировать как границу обхода, но по нему нельзя читать элемент: шестого объекта в массиве нет. Поэтому цикл обычно сначала сравнивает текущую позицию с концом и только затем разыменовывает её. Разность подходящих указателей показывает число шагов между ними, а не количество байтов.

Шаг указателя измеряется элементами массива. Позиция сразу за концом нужна для остановки обхода, но читать значение по ней нельзя. Сначала дойдите до END в визуализации, затем сравните это с условием завершения цикла.

Пройди по массиву указателем p + k
[0]
10
0x1000
[1]
20
0x1004
[2]
30
0x1008
[3]
40
0x100C
[4]
50
0x1010
one-past
END
0x1014
k = 0
p + 0 = 0x1000; *(p + 0) = 10; можно разыменовать.

Разность двух указателей

Если p и q ссылаются на элементы одного массива (или позицию за последним), то q - p — расстояние в элементах, а не в байтах.

Тип результата — std::ptrdiff_t. Например, &a[4] - &a[0] равно 4.

p + 1, p += 2, ++p — способы перемещаться по допустимому диапазону.

arithmetic.cpp
#include <iostream>
#include <vector>
int main() {
    std::vector<int> v{10, 20, 30, 40, 50};
    int* p = v.data();
    std::cout << *(p + 2) << '\n'; // 30
    std::cout << (&v[4] - &v[0]) << '\n'; // 4
    int* end = p + v.size();            // допустимо
    std::cout << (end - p) << '\n'; // 5
    // std::cout << *end;               // UB
}
Данные вектора размещены непрерывно, пока их не инвалидировала операция изменения контейнера.
ОперацияМожно?Условие
p + k, p - k
Да
Результат остаётся в допустимом диапазоне своего массива, включая one-past
q - p
Да
Указатели относятся к одному массиву; результат представим в ptrdiff_t
p + q
Нет
Сложение двух указателей не определено как операция языка
*end
Нет
Нельзя разыменовывать указатель на позицию за концом
(&x) - (&y)
Нельзя безопасно
Для двух самостоятельных локальных объектов такая разность имеет UB
Граница допустимого

Две локальные переменные могут случайно оказаться рядом по адресам. Это не делает их массивом. Шаг от &x к y через арифметику указателей не становится допустимым.

Время жизни объекта и висячие указатели

Адрес не является обещанием, что объект будет существовать сколько угодно долго. Локальная переменная может закончить своё время жизни при выходе из блока, а сохранённое ранее значение указателя останется в другой переменной. Такое рассогласование и создаёт висячий указатель (у вас сохранился способ обратиться к объекту, но сам объект уже перестал существовать).

Главная мысль

Время жизни (lifetime) — период существования объекта C++. По окончании времени жизни нельзя продолжать пользоваться объектом через старый указатель, даже если прежние байты памяти физически сохранились.

Адрес сохранился, объекта больше нет

Указатель может пережить локальную переменную, адрес которой он хранит. Такой указатель называют висячим (dangling pointer).

Компилятор может использовать прежнее место памяти под другие объекты. Поведение программы с разыменованием старого адреса не определено. Почему иногда печатается «правильное» значение?Физические байты могли не измениться, либо оптимизации не затронули пример. Это случайное наблюдение: оно не делает обращение допустимым.

lifetime.cpp
int* p = nullptr;
{
    int value = 42;
    p = &value;
}                      // value уничтожен
// *p = 5;                // UB: объект не жив
p = nullptr;             // больше не используем адрес
Присваивание nullptr не восстанавливает старый объект, но помогает не использовать устаревшее значение.

Следить нужно не только за локальными блоками. Контейнер тоже может переместить свои элементы при изменении внутреннего буфера, после чего старые адреса использовать нельзя. Например, после операции, вызвавшей перераспределение памяти vector, подходящий адрес следует получить заново. Присваивание nullptr может помочь явно обозначить отсутствие используемого объекта, но не восстанавливает его жизнь и не исправляет другие сохранённые копии старого указателя.

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

Когда адрес перестаёт вести к живому объекту
p = 0x3000
→
int value = 42;
Объект существует: *p допустимо.

p = &value;

Проверка не спасает

p != nullptr проверяет только «нулевой указатель или нет». Это не гарантирует, что объект ещё жив. Нельзя проверять корректность висячего указателя простым сравнением с nullptr.

Пустой указатель nullptr; дополнение о void*

void* и nullptr решают разные задачи, хотя оба встречаются рядом с указателями. Первое позволяет хранить адрес объекта без указания его конкретного типа в этом указателе. Второе обозначает отсутствие объекта, на который можно было бы указывать. «Тип скрыт» не означает «объекта нет», а «указатель ненулевой» не означает «объект гарантированно пригоден для доступа».

nullptr — специальное пустое значение

С C++11 существует ключевое слово nullptr типа std::nullptr_t. Его можно преобразовать в любой тип указателя как нулевой указатель.

По сравнению с макросом NULL, nullptr лучше ведёт себя при перегрузке функций и ясно выражает намерение.

nullptr.cpp
#include <iostream>
int main() {
    int* p = nullptr;
    if (p == nullptr) {
        std::cout << "нет объекта\n";
    }
    int x = 3;
    p = &x;
    if (p) std::cout << *p << '\n';
}
Перед разыменованием проверяем состояние указателя; отдельно важен срок жизни объекта.

void* — адрес без информации о типе

Указатель на объект, например int*, неявно преобразуется к void*. Но у void* нельзя применить * и стандартную арифметику: размер неизвестного типа не определён.

Чтобы работать с объектом, нужно получить указатель на его настоящий тип, например static_cast<int*>(raw).

void_pointer.cpp
int x = 20;
void* raw = &x;              // int* -> void*
// *raw;                     // ошибка компиляции
// raw + 1;                  // не стандартный C++
int* p = static_cast<int*>(raw);
*p = 21;                    // x == 21
Обратный каст безопасен здесь лишь потому, что под адресом действительно живёт int.

Преобразование из void* обратно в int* не проводит проверку содержимого памяти. Оно уместно, когда вы уже знаете, что исходный адрес получен от подходящего живого int. С nullptr логика другая: функция может договориться считать его сигналом «аргумент отсутствует» и обработать этот случай до разыменования. Такой договор нужно соблюдать на стороне вызывающего кода.

nullptr обозначает отсутствие адреса. Проверка на nullptr помогает только тогда, когда ненулевое значение действительно указывает на живой объект. void* решает другую задачу: сохраняет адрес объекта, скрывая конкретный тип.

Два состояния указателя
p = nullptr→p == nullptr
*p — недопустимое разыменование

Указатель на указатель

Указатель на указатель нужен, когда объектом, с которым требуется работать через адрес, сам является указатель. Это не особый способ хранения «более точного» адреса, а ещё один уровень связи. Если p хранит адрес числа, то pp = &p хранит адрес переменной p. По цепочке можно либо перенаправить указатель, либо изменить конечное число.

Указатель — это самостоятельный объект, а значит у него тоже есть адрес. Если p имеет тип int*, то &p имеет тип int**.

Разбирайте **pp двумя последовательными шагами. Первое * приводит к переменной-указателю p, второе — к объекту, на который она сейчас указывает. Поэтому *pp = &y; перенаправляет p на y, а последующее **pp = 12; записывает двенадцать уже в y. Во всех звеньях цепочки должны находиться корректные указатели на живые подходящие объекты.

Один уровень разыменования получает сам указатель p, второй — объект, на который он сейчас указывает. Смена *pp перенаправляет p. Это отличается от изменения **pp, которое записывает значение в выбранный объект.

Два уровня косвенности: pp → p → значение
int** pp
&p
адрес указателя p
int* p
&x
адрес x
int x
5
int y
9
*pp = &x; **pp = 5. Оператор ** — два последовательных применения *.

Два разыменования

*pp — сам указатель p; **pp — объект, на который указывает p.

pp→p→x

Через *pp = &y можно изменить, куда направлен p; через **pp = 12 — изменить значение выбранного объекта.

pointer_to_pointer.cpp
int x = 5;
int y = 9;
int* p = &x;
int** pp = &p;

**pp = 7;     // x становится 7
*pp = &y;     // p теперь указывает на y
**pp = 12;    // y становится 12
У pp два уровня косвенности: сначала p, затем x или y.
+Дополнительно⌄

Указатель не обязан быть обычным числом

Во вводном объяснении мы считаем его числовым адресом. Стандарт C++ не требует, чтобы любой указатель хранился как простое целое число или чтобы все указатели занимали 8 байт. Отображение std::cout << p — форматирование указателя, а не арифметика над его числовым представлением.

Сравнение указателей

Равенство указателей осмысленно для подходящих совместимых типов, но < и > не стоит использовать для определения порядка независимых локальных объектов. Порядок для элементов одного массива соответствует порядку элементов. Для полного порядка указателей в ассоциативных структурах есть std::less.

Выравнивание (alignment)

Размещение объектов часто требует адресов, кратных определённым степеням двойки, но нельзя универсально утверждать, что int всегда стоит на адресе, кратном 4, или что double всегда на кратном 8: это зависит от платформы, типа и специальных способов размещения.

Приведение указателей к целым числам

reinterpret_cast разрешает низкоуровневые преобразования в некоторых случаях, но не делает случайный адрес допустимым. Для промежуточного целочисленного представления, когда оно поддерживается реализацией, используют std::uintptr_t из <cstdint>. Даже обратное преобразование адреса не означает, что по нему живёт нужный объект.

casts.cpp
#include <cstdint>
int x = 3;
std::uintptr_t address = reinterpret_cast<std::uintptr_t>(&x);
// Нельзя просто придумать число и считать,
// что после преобразования оно даёт рабочий int*.
uintptr_t опционален: доступен, когда реализация предоставляет соответствующий тип.

Как находить ошибки памяти

Во время разработки полезно включать предупреждения и AddressSanitizer / UndefinedBehaviorSanitizer. Это помогает находить выходы за границы и use-after-scope, хотя инструменты не доказывают отсутствие всех видов UB.

build.sh
# g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
#   -g -O1 -fsanitize=address,undefined \
#   -fno-omit-frame-pointer demo.cpp -o demo
# ./demo
Команды для локальной отладки; это не встроенный компилятор HTML-страницы.

Термины

Указатель / Pointer

Объект с адресом другого объекта или особым нулевым значением.

int* p = &x;

Примеры кода

Пустой указатель и доступ к живому объекту

pointer-cases.cpp
#include <iostream>
int main() {
    int enabled = 0;
    if (!(std::cin >> enabled) || (enabled != 0 && enabled != 1)) {
        std::cerr << "Expected 0 or 1\n";
        return 1;
    }
    int value = 7;
    int* pointer = nullptr;
    if (enabled == 1) pointer = &value;
    if (pointer == nullptr) {
        std::cout << "No object\n";
        return 0;
    }
    *pointer = 25;
    std::cout << value << ' ' << *pointer << '\n';
}

Сначала 25 25 и true; конкретные адреса зависят от запуска.

code-guided-pointer.cpp
#include <iostream>

int main() {
    int x = 7;
    int* p = &x;
    *p = 25;
    std::cout << x << ' ' << *p << '\n';
    std::cout << std::boolalpha << (p == &x) << '\n';
    std::cout << "p: " << p << " address of p: " << &p << '\n';
}

Обсудить лекцию с ИИ

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

Посмотреть промпт