Указатель — это переменная, значение которой — адрес объекта в памяти (или особое значение «нулевой указатель», которое ни на какой объект не указывает). У указателя есть тип: int * хранит адрес int, и от этого типа зависит, сколько байт прочитает разыменование и на сколько сдвинется p + 1.
Содержание
- Минимальный пример: адрес, разыменование, изменение через указатель
- Зачем указатели: передача в функцию
- Арифметика указателей и массивы
- const и указатели: что именно нельзя менять
- Нулевой указатель: NULL, nullptr
- Динамическая память: malloc и free
- Типичные ошибки и как их ловить
- Выводы
- Где применяется / связь с практикой
- FAQ
Три понятия, которые легко смешать, разведем сразу на одной переменной x:
| Запись | Что это | Пример значения |
|---|---|---|
x |
сам объект, его значение | 10 |
&x |
адрес объекта (операция взятия адреса) | 0x16b12aa78 |
p |
переменная-указатель, хранит адрес | 0x16b12aa78 |
*p |
разыменование: объект по адресу из p |
10 |
Примеры проверены на Apple clang 21 (arm64, 64-битная платформа), стандарты C17/C23 и C++20. Конкретные адреса у вас будут другими — это нормально.
Минимальный пример: адрес, разыменование, изменение через указатель
#include <stdio.h>
int main(void) {
int x = 10;
int *p = &x; /* p хранит адрес x */
printf("x = %d\n", x);
printf("&x = %p\n", (void *)&x);
printf("p = %p\n", (void *)p);
printf("*p = %d\n", *p); /* разыменование: значение по адресу */
*p = 20; /* меняем x через указатель */
printf("x = %d\n", x);
printf("sizeof(p) = %zu\n", sizeof p);
return 0;
}
x = 10
&x = 0x16b12aa78
p = 0x16b12aa78
*p = 10
x = 20
sizeof(p) = 8
Что здесь происходит:
int *pобъявляет указатель наint;= &xкладет в него адресx. Строки&xиpпечатают одно и то же число.*pв выражении — это сам объектx, поэтому*p = 20меняетx.%pожидает именноvoid *, поэтому указатель приводим явно:(void *)p. Без приведения часть компиляторов выдает предупреждение, а формально это неопределенное поведение.- Размер указателя зависит от платформы, а не от типа данных: на типичных 64-битных системах 8 байт, на 32-битных — 4.
Звездочка в объявлении относится к имени, а не к типу. В строке int *p, q; переменная p — указатель, а q — обычный int (у нас sizeof p = 8, sizeof q = 4). Надежнее объявлять по одной переменной в строке.
Зачем указатели: передача в функцию
В C аргументы передаются по значению: функция получает копию. Чтобы функция изменила переменную вызывающего кода, ей передают адрес.
#include <stdio.h>
void swap_bad(int a, int b) { int t = a; a = b; b = t; }
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int main(void) {
int x = 1, y = 2;
swap_bad(x, y);
printf("swap_bad: x=%d y=%d\n", x, y);
swap(&x, &y);
printf("swap: x=%d y=%d\n", x, y);
return 0;
}
swap_bad: x=1 y=2
swap: x=2 y=1
swap_bad переставила свои копии, swap — сами x и y. Тот же прием используют, чтобы не копировать большую структуру (передают const struct S *) и чтобы вернуть из функции несколько результатов. Другие задачи указателей — динамическая память, строки как char *, связные структуры данных (списки, деревья) и указатели на функции для обратных вызовов.
Арифметика указателей и массивы
p + 1 — это адрес следующего элемента типа T, то есть сдвиг на sizeof(T) байт, а не на один байт. Имя массива в большинстве выражений превращается в указатель на первый элемент, поэтому a[i] по определению равно *(a + i).
#include <stdio.h>
#include <stddef.h>
int main(void) {
int a[4] = {10, 20, 30, 40};
int *p = a; /* то же, что &a[0] */
printf("*(p + 2) = %d, a[2] = %d\n", *(p + 2), a[2]);
printf("p + 1 - p = %td элемент\n", (p + 1) - p);
printf("байт между p и p + 1: %td\n",
(char *)(p + 1) - (char *)p);
for (int *it = a; it != a + 4; ++it) /* a + 4 - адрес за концом */
printf("%d ", *it);
printf("\n");
return 0;
}
*(p + 2) = 30, a[2] = 30
p + 1 - p = 1 элемент
байт между p и p + 1: 4
10 20 30 40
Разность двух указателей считается в элементах и имеет тип ptrdiff_t. Граница правил: арифметика определена только внутри одного массива плюс позиция «сразу за последним элементом». Адрес a + 4 можно вычислить и сравнить, но разыменовать нельзя; a + 5 и вычитание указателей из разных массивов — неопределенное поведение.
Массив и указатель при этом не одно и то же. sizeof a внутри main дает 16 (весь массив из 4 int, прогнано), а если передать a в функцию с параметром int *, там sizeof вернет размер указателя. Поэтому длину массива передают отдельным аргументом.
const и указатели: что именно нельзя менять
| Объявление | Менять *p |
Менять p |
Когда нужно |
|---|---|---|---|
const int *p |
нельзя | можно | функция только читает данные |
int *const p |
можно | нельзя | указатель привязан к одному объекту |
const int *const p |
нельзя | нельзя | и то, и другое |
Правило чтения: const слева от * защищает данные, справа — сам указатель. Нарушение ловит компилятор, clang 21 для двух запрещенных присваиваний пишет:
constp.c:7:8: error: read-only variable is not assignable
constp.c:8:7: error: cannot assign to variable 'b' with const-qualified type 'int *const'
Нулевой указатель: NULL, nullptr
Нулевой указатель означает «не указывает ни на что». Его можно хранить и сравнивать, но разыменовывать нельзя. В C традиционно пишут NULL, начиная с C23 есть и ключевое слово nullptr. В C++ с C++11 используют nullptr: NULL там — макрос нулевой константы, который в распространенных реализациях раскрывается в целое (0 или встроенное __null у GCC), и это мешает перегрузке.
#include <iostream>
#include <memory>
void f(int) { std::cout << "f(int)\n"; }
void f(const char *) { std::cout << "f(const char*)\n"; }
int main() {
f(0); // литерал 0 - это int
f(nullptr); // nullptr - только указатель
auto p = std::make_unique<int>(42); // владеющий указатель
int &r = *p; // ссылка на тот же объект
r = 7;
std::cout << *p << '\n';
} // память освобождается автоматически, delete не нужен
f(int)
f(const char*)
7
Вызов f(NULL) с этими перегрузками clang отвергает: error: call to 'f' is ambiguous, GCC 14 — error: call of overloaded 'f(NULL)' is ambiguous. В другой реализации реакция может отличаться, nullptr таких сюрпризов не дает.
В примере заодно видна разница указателя и ссылки C++. Ссылка int &r — другое имя уже существующего объекта: ее нельзя оставить пустой и перенаправить. Указатель может быть нулевым и может указывать на разные объекты по очереди. Для владения динамической памятью в современном C++ берут std::unique_ptr или std::shared_ptr, а не «голые» new/delete.
Динамическая память: malloc и free
int *arr = malloc(n * sizeof *arr);
if (arr == NULL) { /* памяти не хватило - обработать */ }
/* ... работа с arr[0] .. arr[n - 1] ... */
free(arr);
arr = NULL; /* чтобы случайное повторное использование было заметным */
Правила: проверять результат malloc, освобождать каждый блок ровно один раз и только после того, как он больше не нужен, не обращаться к памяти после free. Обнуление после free не спасает от других копий этого же адреса, это лишь страховка для одной переменной.
Типичные ошибки и как их ловить
Главная сложность в том, что многие ошибки с указателями не падают сразу. Пример чтения после освобождения:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof *p);
if (p == NULL) return 1;
*p = 42;
free(p);
printf("%d\n", *p); /* ошибка: чтение после free */
return 0;
}
Обычная сборка (clang uaf.c) у нас напечатала 2 и завершилась с кодом 0: программа «работает», но читает мусор. GCC 14 на Linux в том же прогоне напечатал 1644393447, тоже с кодом 0; на другой машине может быть 42, другое число или падение. GCC здесь хотя бы предупреждает при сборке: warning: pointer 'p' used after 'free' [-Wuse-after-free], но такое предупреждение ловит только очевидные случаи. Сборка с AddressSanitizer показывает ошибку прямо:
clang -g -fsanitize=address uaf.c -o uaf && ./uaf
==902==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0 ...
READ of size 4 at 0x6020000000d0 thread T0
#0 0x0001007948d4 in main uaf.c:9
...
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:9 in main
Исправление — прочитать значение до free (или освободить память позже) и после освобождения к указателю не обращаться.
| Ошибка | Симптом | Что делать |
|---|---|---|
| Неинициализированный указатель | случайные значения, падение в непредсказуемом месте | инициализировать при объявлении: адресом или NULL |
Разыменование NULL |
падение (segmentation fault) | проверять результат malloc и функций поиска |
| Висячий указатель на локальную переменную | «иногда работает» | не возвращать &local; clang предупреждает -Wreturn-stack-address, GCC — -Wreturn-local-addr |
Чтение/запись после free |
мусор, порча данных | не трогать память после free, ловить -fsanitize=address |
Двойной free |
обычно аварийное завершение, но возможна и тихая порча кучи | один владелец блока, обнулять после free |
| Выход за границы массива | порча соседних данных | передавать длину, проверять индексы |
| Утечка памяти | растет потребление памяти | парный free на каждый malloc, в C++ — умные указатели |
Инструменты: предупреждения -Wall -Wextra, AddressSanitizer (-fsanitize=address) в clang и GCC, на Linux также Valgrind. Санитайзер находит ошибку только на реально выполненном пути программы, поэтому нужен вместе с тестами.
Выводы
- Указатель хранит адрес и имеет тип;
&берет адрес,*в выражении дает объект по адресу. p + 1сдвигает на размер элемента,a[i]равно*(a + i); арифметика определена только в пределах одного массива и позиции за его концом.const int *pзащищает данные,int *const p— сам указатель.- Для нулевого указателя в C++ используйте
nullptr, в C —NULLилиnullptrс C23. - Ошибки памяти часто не падают сразу; собирайте отладочные версии с
-Wall -Wextra -fsanitize=address.
Где применяется / связь с практикой
Освойте тему на практике
Указатели нужны везде, где код работает с памятью напрямую: системное и встраиваемое программирование, драйверы, игровые движки, высоконагруженные сервисы на C++. Современный C++ прячет большую часть ручной работы за ссылками, контейнерами и умными указателями, но понимать адреса и время жизни объектов все равно приходится. Эту базу вместе с RAII и умными указателями разбирают на курсе «C++-разработчик. Базовый уровень». Посмотреть формат занятий бесплатно можно на открытых уроках Otus.
FAQ
Что такое указатель на указатель (int **)?
Это переменная, хранящая адрес другого указателя. Так функция может изменить сам указатель вызывающего кода, а в char **argv так передается массив строк.
Можно ли разыменовать void *?
Нет: у void * нет типа объекта, поэтому сначала указатель приводят к конкретному типу. Арифметика над void * в стандартном C тоже не определена, GCC и clang разрешают ее как расширение.
Сколько байт занимает указатель?
Зависит от платформы: обычно 8 байт на 64-битных системах и 4 на 32-битных. Размер одинаков для int * и double * на распространенных платформах, но стандарт этого не гарантирует для всех типов, например для указателей на функции.



