Указатели в C и C++: адрес, разыменование, арифметика и типичные ошибки

Указатели в C и C++: адрес, разыменование, арифметика и типичные ошибки Полезное

Указатель — это переменная, значение которой — адрес объекта в памяти (или особое значение «нулевой указатель», которое ни на какой объект не указывает). У указателя есть тип: int * хранит адрес int, и от этого типа зависит, сколько байт прочитает разыменование и на сколько сдвинется p + 1.

Три понятия, которые легко смешать, разведем сразу на одной переменной 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 * на распространенных платформах, но стандарт этого не гарантирует для всех типов, например для указателей на функции.

OTUS Журнал