Menu

Арифметика указателей в C: сложение, вычитание и обход памяти

Прибавление 1 к указателю не добавляет 1 байт — оно перемещает его к следующему объекту этого типа. Здесь разобраны ptr+1, инкремент и декремент, разность указателей, их сравнение, обход массива указателем и правило «на один за концом».

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Указатели хранят адреса, а адреса — это числа, поэтому разумно ожидать, что с ними будет работать арифметика. Она работает, но не совсем так, как обычная целочисленная. C масштабирует каждую операцию над указателем на размер того, на что он указывает, и это превращает «прибавить единицу» в гораздо более полезное «перейти к следующему элементу».

Именно это одно проектное решение делает обход массива указателем в C таким же естественным, как обход по индексу.

Прибавление 1 сдвигает на один объект, а не на один байт

Возьмём указатель, прибавим 1 и выведем оба адреса:

int * прыгает на 4 байта, double * — на 8, char * — на 1; каждый попадает ровно туда, где начался бы следующий объект его типа. Правило простое:

ptr + n  ==  (адрес в ptr) + n * sizeof(*ptr)

Именно поэтому у указателя вообще есть тип. Голый адрес без типа не имел бы ни малейшего представления, насколько далеко находится «следующий».

Обход массива указателем

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

Оба цикла выводят одни и те же пять чисел. Во втором scores превращается в указатель на свой первый элемент, p++ сдвигается на один int, а *p читает лежащий там элемент. Вот как это выглядит в памяти:

 scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
|   88    |   92    |   75    |   60    |   100   |
+---------+---------+---------+---------+---------+
 ^         ^                                       ^
 p         p+1                                     scores+5
 (начало)  (на 4 байта дальше)                     (на один за концом)

Связь между двумя формами точна и достойна запоминания: scores[i] определено как *(scores + i). Подробнее об этом — в указателях и массивах.

Инкремент, декремент и ловушка *p++

++ и -- работают с указателями с тем же масштабированием.

Выражение, которое сбивает с толку всех, — это *p++. Постфиксный ++ связывается сильнее, чем *, поэтому оно означает «взять текущий p, сдвинуть p, затем разыменовать старое значение»:

Три выражения, три разных смысла:

ВыражениеУказатель послеПолученное значение
*p++сдвинутэлемент до сдвига
*++pсдвинутэлемент после сдвига
(*p)++не изменилсястарое значение, а элемент увеличен

*p++ — идиоматический C: именно так пишут копирование строк и буферов, — но ставьте скобки, когда имеете в виду что-то другое.

Вычитание двух указателей

Вычитание одного указателя из другого даёт количество элементов между ними, а не количество байт:

Тип результата — ptrdiff_t, знаковый целый тип из <stddef.h>, выводимый через %td. Вычитание указателей на два разных массива даёт неопределённый результат: стандарт определяет эту операцию только в пределах одного объекта.

Из указателя также можно вычесть целое число (p - 3 шагает на три элемента назад), но складывать два указателя нельзя. «Сумма двух адресов» не обозначает ничего.

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

Операторы отношения работают с указателями внутрь одного массива и означают ровно то, что подсказывает расположение в памяти: p < q истинно, когда p адресует более ранний элемент.

front < back — вся история завершения этого цикла, и она корректна, потому что оба указателя остаются внутри одного массива. == и != — тоже естественный способ проверить цикл на достижение конечной метки, что подводит нас к правилу, которое делает всё это законным.

Правило «на один за концом»

C явно разрешает формировать указатель на позицию сразу за последним элементом массива. Его можно вычислять, хранить и сравнивать с ним. Его нельзя разыменовывать.

int arr[4];

 arr[0]   arr[1]   arr[2]   arr[3]   (нет элемента)
+------+ +------+ +------+ +------+  +- - - -+
|      | |      | |      | |      |  |       |
+------+ +------+ +------+ +------+  +- - - -+
 ^                                    ^
 arr                                  arr + 4
 можно использовать                   можно СОЗДАТЬ и СРАВНИВАТЬ
                                      никогда нельзя разыменовывать

Именно это правило делает стандартную форму цикла корректной:

for (int *p = arr; p != arr + n; p++) {
    /* ... */
}

На последней итерации p становится arr + n, сравнение оказывается ложным, и цикл завершается — так и не прочитав эту позицию.

Две вещи всё же остаются неопределёнными. Формирование указателя на две позиции за концом или на одну перед началом — неопределённое поведение, даже если вы никогда его не разыменовываете:

int *bad1 = arr + n + 1;   // неопределено - слишком далеко за концом
int *bad2 = arr - 1;       // неопределено - перед началом

Второй случай важен на практике: цикл в обратную сторону, написанный как for (int *p = arr + n - 1; p >= arr; p--), вычисляет arr - 1 на последнем декременте. Он работает на любом распространённом компиляторе и всё равно формально не определён. Чистый обратный цикл этого избегает:

Указатели void и арифметика, которую нельзя выполнить

void * хранит адрес без прикреплённого типа — именно так malloc и qsort остаются универсальными. Поскольку размера элемента нет, арифметика указателей над void * стандартом не разрешена: сначала приведите его к конкретному типу (или к char * для работы с байтами).

void *v = buffer;
// v + 1;              // не стандартный C - не на что масштабировать
char *b = v;
b + 1;                 // нормально - на один байт вперёд

GCC и Clang принимают арифметику над void * как расширение, трактуя его как char *, поэтому код, который на это полагается, компилируется — до того дня, когда его соберут где-нибудь построже. Компилируйте с -std=c17 -pedantic, если хотите об этом узнать.

Что это вам даёт

Арифметика указателей — не оптимизационный трюк, к которому изредка прибегают: это механизм, лежащий под индексацией массивов, работой со строками и любым обходом буфера в стандартной библиотеке. strlen — это указатель, доведённый до завершающего '\0' и вычтенный из начала. memcpy — это два указателя, шагающих в ногу.

Дальше посмотрим, как на самом деле работает эквивалентность массивов и указателей, включая ловушку sizeof, на которую попадаются все при первой передаче массива в функцию: указатели и массивы.

Часто задаваемые вопросы

Что делает ptr + 1 в C?

Он сдвигает указатель вперёд на один объект, а не на один байт. Для int * на типичной машине это 4 байта, для double * — 8. Компилятор сам умножает на sizeof(*ptr), и именно поэтому тип указателя имеет значение.

Можно ли вычитать указатели в C?

Да, если оба указывают внутрь одного массива. end - start даёт количество элементов между ними, тип результата — ptrdiff_t (выводится через %td). Вычитание указателей на несвязанные объекты — неопределённое поведение.

Законно ли указывать на позицию за последним элементом массива?

Да — формирование указателя на позицию сразу за последним элементом разрешено явно, чтобы работали циклы вида for (int *p = arr; p != arr + n; p++). Такой указатель можно вычислять и сравнивать, но его разыменование — неопределённое поведение.

Почему *p++ — это не то же самое, что (*p)++?

*p++ увеличивает указатель и разыменовывает его старое значение — читает текущий элемент, а затем сдвигается. (*p)++ оставляет указатель на месте и увеличивает значение, на которое он указывает. Постфиксный ++ связывается сильнее, чем *, поэтому смысл меняют именно скобки.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ