Указатели хранят адреса, а адреса — это числа, поэтому разумно ожидать, что с ними будет работать арифметика. Она работает, но не совсем так, как обычная целочисленная. 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)++ оставляет указатель на месте и увеличивает значение, на которое он указывает. Постфиксный ++ связывается сильнее, чем *, поэтому смысл меняют именно скобки.