Раздел 9 — Указатели и адресная арифметика

Переменная, которая хранит адрес другой переменной, — и что с этим адресом можно делать

Прогресс курса Раздел 9 из 20

Что вы освоите в этом разделе

2 академических часа теории и 3 часа практики: практическая работа №13 «Адресная арифметика и сравнение указателей» — на портале. Все программы страницы — полные; собирайте их командой g++ -std=c++17 -Wall -Wextra файл.cpp -o файл.exe.
01

Зачем указатели

В разделе 7 функция меняла переменную вызывающего через параметр-ссылку int&. Зачем ещё один способ? Потому что ссылки есть только в C++, а Windows API, с которым мы будем работать с раздела 14, написан на C. Там результат возвращают по-другому: функции передают адрес переменной, и она записывает результат по этому адресу. Так выглядит чтение из файла или канала (раздел 17):

DWORD bytesRead;
ReadFile(file, buffer, sizeof(buffer), &bytesRead, nullptr);   // &bytesRead — «запиши сюда, сколько прочитано»

Кроме того, адреса нужны, чтобы передать в функцию массив (раздел 10), выделить память во время работы программы (раздел 18) и передать данные в поток (раздел 16). Всё это держится на одной идее из этого раздела.

Модель памяти. Оперативная память — длинный ряд байтов, пронумерованных подряд. Номер байта — его адрес. Каждая переменная занимает несколько байтов подряд: int — 4, double — 8. Адрес переменной — адрес её первого байта. Элементы массива лежат вплотную друг за другом: если a[0] начинается с адреса 1000, то a[1] — с 1004, a[2] — с 1008.

02

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

Указатель — переменная, в которой хранится адрес. Тип указателя говорит, что лежит по адресу: int* — указатель на int, double* — на double.

// Адрес и указатель: & берёт адрес переменной, * обращается к значению по адресу
#include <iostream>

int main()
{
    int x = 5;
    int y = 8;
    int* p = &x;                        // p хранит адрес x
    std::cout << "x = " << x << ", &x = " << &x << '\n';
    std::cout << "p = " << p << ", *p = " << *p << '\n';
    *p = 7;                             // запись по адресу — меняется x
    std::cout << "x = " << x << '\n';
    p = &y;                             // тот же указатель — теперь на y
    *p += 1;
    std::cout << "y = " << y << ", *p = " << *p << '\n';
    std::cout << "sizeof(int) = " << sizeof(int) << ", sizeof(p) = " << sizeof(p) << '\n';
    return 0;
}
x = 5, &x = 0x5ffe9c
p = 0x5ffe9c, *p = 5
x = 7
y = 9, *p = 9
sizeof(int) = 4, sizeof(p) = 8

Адрес у вас будет другим и может меняться от запуска к запуску: Windows размещает программу в памяти по-разному. Совпадать должны &x и p. Адрес выводится в шестнадцатеричной системе (раздел 3).

ЗаписьЧто означает
int* p;объявление: p — указатель на int
&xадрес переменной x; тип — int*, если x — int
*pразыменование: переменная, на которую указывает p. Её можно читать и изменять: *p = 7, *p += 1
p = &yуказатель теперь хранит другой адрес; x не меняется
sizeof(p)размер самого указателя: 8 байт в 64-разрядной программе, какой бы тип ни был по адресу
Звёздочка и амперсанд значат разное в объявлении и в выражении. В объявлении int* p звёздочка — часть типа, в выражении *p — «значение по адресу». В объявлении int& r амперсанд — ссылка (раздел 7), в выражении &x — «адрес x». И ещё: int* p, q; объявляет указатель p и обычный int q — звёздочка относится только к ближайшему имени. Правило курса: одно объявление указателя на строку.
03

nullptr и недействительные указатели

Указатель полезен, только если в нём адрес существующей переменной. Три случая, когда это не так:

СлучайПримерЧто будет
не инициализированint* p; *p = 5;в p случайное значение — запись по случайному адресу. g++ предупреждает: 'p' is used uninitialized
nullptrint* p = nullptr; *p = 5;программа аварийно завершается (Windows: Access violation)
«висячий» — переменной уже нетфункция вернула адрес своей локальной переменнойпосле выхода из функции её переменные исчезают (раздел 7, пункт 05); по адресу — что угодно

nullptr — специальное значение «ни на что не указывает». В отличие от мусора, его можно проверить: if (p != nullptr). Отсюда правило: указатель инициализируется при объявлении — адресом переменной или nullptr. Если функция ищет элемент и может не найти, nullptr в результате означает «не найдено» (пункт 07).

int* makeValue()
{
    int value = 42;
    return &value;                      // ошибка: value исчезнет при выходе из функции
}
warning: address of local variable 'value' returned [-Wreturn-local-addr]

Такая программа может напечатать 42 — и ошибка останется незамеченной, а может мусор или завершиться аварийно: это неопределённое поведение, как выход за границы массива в разделе 8. Современный g++ (проверено на версии 14) вместо адреса мёртвой переменной возвращает nullptr — программа падает на *p при любых ключах. Функция возвращает значение (int), а не адрес своей переменной.

04

Параметр-указатель

Функция из раздела 7, которая меняет местами две переменные, — теперь через адреса:

// Обмен через параметры-указатели и через параметры-ссылки: результат один, запись вызова разная
#include <iostream>

void swapPtr(int* a, int* b);
void swapRef(int& a, int& b);

int main()
{
    int x = 1;
    int y = 2;
    swapPtr(&x, &y);                    // адреса передаются явно — видно, что x и y изменятся
    std::cout << "after swapPtr: x = " << x << ", y = " << y << '\n';
    swapRef(x, y);
    std::cout << "after swapRef: x = " << x << ", y = " << y << '\n';
    return 0;
}

// Меняет местами значения по двум адресам
void swapPtr(int* a, int* b)
{
    const int temp = *a;
    *a = *b;
    *b = temp;
}

// То же через ссылки — раздел 7
void swapRef(int& a, int& b)
{
    const int temp = a;
    a = b;
    b = temp;
}
after swapPtr: x = 2, y = 1
after swapRef: x = 1, y = 2

Параметр-указатель — тоже параметр по значению: в функцию копируется адрес. Но по копии адреса функция добирается до той же самой переменной x — и меняет её. Внутри функции везде *a: без звёздочки a = b поменяло бы местами только локальные копии адресов, и x с y остались бы прежними.

Указатель умеет то, чего не умеет ссылка: он может быть nullptr. Поэтому через указатель удобно возвращать необязательный результат — так устроены многие функции Windows API:

// Необязательный результат: nullptr вместо адреса — «этот результат не нужен»
#include <iostream>

bool divide(int a, int b, int* quotient, int* remainder);

int main()
{
    int q = 0;
    int r = 0;
    if (divide(17, 5, &q, &r)) {
        std::cout << "17 / 5 = " << q << ", remainder " << r << '\n';
    }
    if (divide(-17, 5, &q, nullptr)) {
        std::cout << "-17 / 5 = " << q << '\n';
    }
    if (!divide(1, 0, &q, &r)) {
        std::cout << "1 / 0: division by zero\n";
    }
    return 0;
}

// Целое деление a на b. Частное и остаток пишутся по адресам, если адрес не nullptr.
// b == 0 — false, по адресам ничего не пишется
bool divide(int a, int b, int* quotient, int* remainder)
{
    if (b == 0) {
        return false;
    }
    if (quotient != nullptr) {
        *quotient = a / b;
    }
    if (remainder != nullptr) {
        *remainder = a % b;
    }
    return true;
}
17 / 5 = 3, remainder 2
-17 / 5 = -3
1 / 0: division by zero
Ссылка int&Указатель int*
Вызовf(x) — по вызову не видно, что x изменитсяf(&x) — видно
«Нет значения»невозможно: ссылка всегда связана с переменнойnullptr; функция обязана проверить
Внутри функцииa*a
Перенаправитьнельзяможно: p = &y

Правило курса: в своих функциях — ссылки, как в разделе 7; указатель — когда нужен nullptr («результат не нужен», «не найдено») и в вызовах Windows API, где выбора нет. В практической работе №13 обмен элементов делает функция с параметрами-указателями — на ней отрабатывается запись *a.

05

const и указатели

У указателя две вещи, которые можно запретить менять: значение по адресу и сам адрес. Объявление читается справа налево:

int x = 1;
int y = 2;
const int* p = &x;         // p — указатель на const int: *p = 5 нельзя, p = &y можно
int* const q = &x;         // q — константный указатель на int: *q = 5 можно, q = &y нельзя
const int* const r = &x;   // нельзя ни то, ни другое
06

Имя массива — адрес первого элемента

Почти в любом выражении имя массива превращается в адрес его первого элемента: a — то же, что &a[0], и тип у него int*. Поэтому указатель можно направить на массив без амперсанда:

// Имя массива — адрес его первого элемента; a[i] — то же, что *(a + i)
#include <iostream>

int main()
{
    int a[5] = {10, 20, 30, 40, 50};
    int* p = a;                                 // то же, что p = &a[0]
    std::cout << "a = " << a << ", &a[0] = " << &a[0] << '\n';
    std::cout << "a + 1 = " << a + 1 << ", &a[1] = " << &a[1] << '\n';
    std::cout << "*(a + 3) = " << *(a + 3) << ", a[3] = " << a[3] << ", p[3] = " << p[3] << '\n';
    std::cout << "sizeof(a) = " << sizeof(a) << ", sizeof(p) = " << sizeof(p) << '\n';
    return 0;
}
a = 0x5ffe80, &a[0] = 0x5ffe80
a + 1 = 0x5ffe84, &a[1] = 0x5ffe84
*(a + 3) = 40, a[3] = 40, p[3] = 40
sizeof(a) = 20, sizeof(p) = 8
07

Адресная арифметика

К указателю на элемент массива можно прибавить целое число: p + i — адрес элемента, стоящего на i позиций правее. Компилятор сам умножает i на размер элемента: для int* адрес растёт на 4 · i, для double* — на 8 · i. Поэтому тип указателя важен не только при разыменовании.

ОперацияРезультатУсловие
p + i, p - iуказатель на i элементов правее / левеерезультат — внутри массива или сразу за последним элементом
++p, --p, p += iто же с изменением pто же
q - pсколько элементов (не байтов) между ними; тип std::ptrdiff_t — целое со знакомp и q — в одном массиве
p - aиндекс элемента, на который указывает pp указывает в массив a
p + q, p * 2не собирается: у суммы двух адресов нет смысла—

Обход массива указателем: p идёт от a до a + n, на каждом шаге ++p переводит его на следующий элемент. Индекс не нужен, а когда он всё-таки нужен для вывода, это p - a:

// Обход массива указателем: сумма, наибольший, поиск. Индексов нет — только адреса
#include <iostream>

constexpr int N = 8;

int main()
{
    int a[N] = {4, -2, 9, 0, 9, 5, -7, 3};
    int* const end = a + N;                     // адрес за последним элементом

    int sum = 0;
    for (int* p = a; p < end; ++p) {
        sum += *p;
    }

    int* maxPtr = a;                            // указатель на первый наибольший
    for (int* p = a + 1; p < end; ++p) {
        if (*p > *maxPtr) {
            maxPtr = p;
        }
    }
    std::cout << "sum " << sum << ", max " << *maxPtr << " at " << maxPtr - a << '\n';

    int x;
    std::cout << "x: ";
    std::cin >> x;
    if (std::cin.fail()) {
        std::cout << "Error: not a number\n";
        return 1;
    }
    int* found = nullptr;                       // nullptr — «не найдено»
    for (int* p = a; p < end && found == nullptr; ++p) {
        if (*p == x) {
            found = p;
        }
    }
    if (found != nullptr) {
        std::cout << x << " at " << found - a << '\n';
    } else {
        std::cout << x << " not found\n";
    }
    return 0;
}
sum 21, max 9 at 2
x: 5
5 at 5

sum 21, max 9 at 2
x: 1
1 not found

Сравните с разделом 8: наибольший хранился как индекс maxIndex, «не найдено» было −1. Здесь то же самое — указатель maxPtr и nullptr. Результат поиска-указателя сразу годится для работы с элементом: *found = 0 обнулит найденный элемент.

ЗаписьЧто делаетПри p на 50 в массиве 40 50 60
*p++значение по p, затем p сдвигается: постфиксный ++ сильнее звёздочки и относится к p50; p на 60, массив не изменился
(*p)++увеличивает значение по адресу50; массив 40 51 60, p на месте
++*pто же, результат — новое значение51; массив 40 51 60

В своих программах пишите *p и ++p отдельными инструкциями — запись *p++ нужно уметь читать в чужом коде, но легко перепутать с (*p)++.

08

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

СравнениеКогда имеет смысл
p == q, p != qвсегда: указывают ли на один и тот же элемент; p == nullptr — пуст ли указатель
p < q, p <= q, p > q, p >= qтолько внутри одного массива (включая адрес за последним элементом): p < q — элемент *p левее *q. Для указателей в разные массивы результат ничего не значит

Адрес за последним элементом a + n — законная граница: его можно вычислить, хранить и сравнивать, но нельзя разыменовывать — там уже не массив. Поэтому цикл обхода — p < a + n, так же как i < n. А вот адрес левее начала a - 1 нельзя даже вычислять: стандарт C++ разрешает арифметику только в пределах от a до a + n. Из-за этого обход с конца пишется так, чтобы указатель уменьшался только после проверки:

for (int* p = a + n - 1; p >= a; --p)   // НЕВЕРНО: последний --p вычисляет a - 1

for (int* p = a + n; p > a; ) {        // верно: сравнение с a, потом --p, потом *p
    --p;
    ...
}

Первый вариант на практике обычно работает — поэтому ошибка и живёт в чужом коде годами. Но компилятор вправе считать, что p >= a всегда истинно (ведь p по правилам не может уйти левее a), и с оптимизацией цикл может не закончиться. То же с шагом больше единицы: p += 2 от предпоследнего элемента уводит за a + n. Проверяйте, есть ли куда шагать, до шага: end - p >= 2.

Два указателя навстречу — приём, ради которого сравнение указателей и нужно. Левый идёт от начала, правый от конца, пока left < right:

// Разворот массива двумя указателями навстречу друг другу; обход с конца без выхода за начало
#include <iostream>

constexpr int N = 7;

void swapPtr(int* a, int* b);

int main()
{
    int a[N] = {1, 2, 3, 4, 5, 6, 7};
    int* left = a;
    int* right = a + N - 1;
    while (left < right) {                      // встретились — все пары обменяны
        swapPtr(left, right);
        ++left;
        --right;
    }
    for (int* p = a; p < a + N; ++p) {
        std::cout << *p << ' ';
    }
    std::cout << '\n';

    for (int* p = a + N; p > a; ) {             // сначала сравнение, потом --p: адрес a - 1 не вычисляется
        --p;
        std::cout << *p << ' ';
    }
    std::cout << '\n';
    return 0;
}

// swapPtr — из пункта 04
7 6 5 4 3 2 1
1 2 3 4 5 6 7

Почему --right здесь безопасен: цикл идёт, пока left < right, а left не левее a — значит, right правее a, и после уменьшения не уйдёт за начало. При нечётном n указатели встречаются на среднем элементе, при чётном — проходят друг мимо друга; условие left < right ложно в обоих случаях. Тот же приём решает «разделить массив на две группы»: левый ищет элемент, которому место справа, правый — которому место слева, и они меняются.

09

Указатели и матрица

Двумерный массив int m[3][4] — массив из трёх строк, каждая из которых — массив из четырёх int (раздел 8, пункт 06). Значит, m[i] — имя одномерного массива-строки, и по правилу пункта 06 оно превращается в адрес первого элемента строки &m[i][0]. Строку можно обходить указателем, как обычный массив:

// Строка матрицы — одномерный массив: m[i] — адрес её первого элемента
#include <iostream>

constexpr int ROWS = 3;
constexpr int COLS = 4;

int main()
{
    int m[ROWS][COLS] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };
    for (int i = 0; i < ROWS; ++i) {
        int* const row = m[i];                  // то же, что &m[i][0]
        int sum = 0;
        for (int* p = row; p < row + COLS; ++p) {
            sum += *p;
        }
        std::cout << "row " << i << ": sum " << sum << '\n';
    }
    std::cout << "m[2][1] = " << m[2][1] << ", *(m[2] + 1) = " << *(m[2] + 1) << '\n';
    return 0;
}
row 0: sum 10
row 1: sum 26
row 2: sum 42
m[2][1] = 10, *(m[2] + 1) = 10

m[i][j] — это *(m[i] + j): сначала выбрана строка, затем в ней — элемент. Строки лежат в памяти подряд (раздел 8), и соблазнительно пройти всю матрицу одним указателем от m[0] до m[0] + ROWS * COLS. На практике это работает, но формально указатель, полученный из строки 0, не должен уходить за её конец. В курсе матрицу обходят построчно: внешний цикл по строкам, внутри — указатель по строке. Как передать матрицу в функцию — раздел 10.

10

Ловушки раздела

ОшибкаПризнакСообщение g++Как избежать
указатель не инициализированзапись по случайному адресу, аварийное завершение'p' is used uninitializedпри объявлении — адрес или nullptr
разыменование nullptrаварийное завершение (Access violation)нетif (p != nullptr) перед *p, если указатель может быть пустым
функция возвращает адрес своей локальной переменнойиногда верное значение, иногда мусорaddress of local variable 'value' returnedвозвращать значение, а не адрес
int* p, q;q — обычный intinvalid conversion from 'int*' to 'int'одно объявление указателя на строку
адрес переменной другого типане собираетсяcannot convert 'double*' to 'int*' in initializationтип указателя — тип переменной
запись через const int*не собираетсяassignment of read-only location '* p'для записи — int*; const — если функция только читает
присваивание имени массиване собираетсяincompatible types in assignment of 'int*' to 'int [3]'завести указатель: int* p = a;
a = b в функции вместо *a = *bфункция ничего не меняетнетв теле — работа со значениями через *
указатель левее a или правее a + n; разыменование a + nобычно «работает», иногда мусор или бесконечный циклнетобход с конца — p > a и --p до *p; шаг больше 1 — проверка до шага
< между указателями в разные массивыбессмысленный результатнетупорядоченно сравнивать только внутри одного массива
*p++ вместо (*p)++сдвинулся указатель, а значение не изменилосьиногда value computed is not used*p и ++p — отдельными инструкциями
Проверка цикла с указателем — та же, что с индексом в разделе 8: подставьте первое и последнее значение указателя и убедитесь, что каждый *p, *(p + 1), *(p - 1) в теле попадает в a … a + n - 1, а сам указатель никогда не выходит за a … a + n. Тесты: n = 1, n = 2, чётное и нечётное n, ответ в первом и в последнем элементе.

Ключевые выводы раздела

Запомните главное

  • Указатель хранит адрес; &x — адрес, *p — значение по адресу
  • Указатель инициализируется при объявлении — адресом или nullptr; nullptr проверяется перед разыменованием
  • Параметр-указатель позволяет функции менять переменную вызывающего и принимать nullptr — «не нужно»
  • const int* — нельзя менять значение, int* const — нельзя менять адрес
  • Имя массива — адрес первого элемента; a[i] — это *(a + i)
  • p + i — на i элементов дальше; q - p — число элементов между; p - a — индекс
  • Указатель живёт в пределах a … a + n; a + n не разыменовывается, a - 1 не вычисляется
  • < и > между указателями — только внутри одного массива; два указателя навстречу — пока left < right
Связи раздела. Опирается на раздел 3 — типы и их размеры, шестнадцатеричная запись, const; раздел 7 — параметры по значению и по ссылке, стек вызовов и время жизни локальных переменных; раздел 8 — массивы, матрицы, выход за границы. Нужен для: раздела 10 — массив и строка в функции передаются как адрес; раздела 11 — оператор -> для структур; раздела 12 — this и указатель на базовый класс; разделов 14–19 — все функции Windows API возвращают результаты через указатели, HANDLE и LPVOID — тоже адреса; раздела 18 — память, выделенная во время работы программы, доступна только по адресу.
Практическая работа №13 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 8: Массивы, матрицы, сортировка Практика на портале