Арифметика указателей определена только внутри массива, включая указатель на one-past-end. В данном контексте, объекты, не являющиеся массивами, рассматриваются как массив длины один.
Детальное описание Ссылка на заголовок
В коде ниже есть неопределённое поведение:
// UB
int f() {
int x = 0;
return *(&x - 1 + 1);
}
Хотя Clang, GCC и MSVC успешно компилируют этот код и он, скорее всего, будет выглядеть как корректно работающий, в нём всё равно присутствует UB. В общем случае компиляторам разрешено предполагать, что неопределённое поведение никогда не происходит, поэтому им не нужно генерировать код, который корректно обрабатывает такие ситуации. При UB возможно что угодно: код может не работать, код может “работать”, или может “работать” до тех пор, пока другой уровень оптимизации, версия компилятора, целевая архитектура или небольшое изменение кода не приведут к “поломке”.
Но почему здесь возникает неопределённое поведение, если мы, казалось бы, просто возвращаем значение x?
Арифметика указателей Ссылка на заголовок
Сначала отметим, что + и - лево-ассоциативны, поэтому &x - 1 + 1 группируется как (&x - 1) + 1.
Теперь посмотрим в Стандарт на правило про арифметику указателей, которое требует, чтобы сложение/вычитание указателя оставалось в пределах одного и того же массива или указателя one-past-end:
Когда выражение
Jцелочисленного типа прибавляется к выражениюPуказательного типа или вычитается из него, результат имеет типP.
- Если
Pвычисляется в значение нулевого указателя, аJвычисляется в0, то результат — значение нулевого указателя.- В противном случае, если
Pуказывает на (возможно, гипотетический) элемент массива с индексомiобъекта-массиваx, содержащегоnэлементов, то выраженияP + JиJ + P(гдеJимеет значениеj) указывают на (возможно, гипотетический) элемент массиваxс индексомi + j, если0 ≤ i + j ≤ n, а выражениеP - Jуказывает на (возможно, гипотетический) элемент массиваxс индексомi − j, если0 ≤ i − j ≤ n.- Иначе поведение не определено.
Таким образом, UB появляется из-за того, что &x - 1 выходит за допустимый диапазон во время вычисления — даже несмотря на то, что этот промежуточный указатель не разыменовывается.
В нашем примере нет массивов, однако Стандарт говорит, что для арифметики указателей объект, не являющийся массивом, рассматривается как массив длины один:
Объект типа
T, который не является элементом массива, считается принадлежащим массиву типаTдлины один.
То есть x трактуется как int[1]: &x — это элемент 0, &x + 1 — допустимый указатель one-past-end, а &x - 1 выходит за пределы допустимого диапазона.
Проверка через constexpr Ссылка на заголовок
Constant evaluation не может выполнять операции, которые приводят к UB, поэтому наличие UB можно продемонстрировать, произведя вычисление на этапе компиляции. Для этого добавим constexpr и вычислим функцию во время компиляции с помощью static_assert:
constexpr int f() {
int x = 0;
return *(&x - 1 + 1);
}
static_assert(f() == 0);
Clang отказывается компилировать этот код:
error: static assertion expression is not an integral constant expression
8 | static_assert(f() == 0);
| ^~~~~~~~
note: cannot refer to element -1 of non-array object in a constant expression
error C2131: expression did not evaluate to a constant
note: failure was caused by out of range index -1; allowed range is 0 <= index < 1
note: the call stack of the evaluation (the oldest call first) is
note: while evaluating function 'int f(void)'
Compiler returned: 2
Однако, текущий GCC 15.2 успешно компилирует этот код, что является багом.
Перестановка операций Ссылка на заголовок
Если изменить выражение с &x - 1 + 1 на &x + 1 - 1, то UB больше нет:
&xлогически рассматривается как массивint[1], поэтому&x + 1— допустимый указатель one-past-end;- вычитание 1 возвращает указатель обратно к
&x.
Все три компилятора успешно компилируют этот вариант.
Зачем существует это правило? Ссылка на заголовок
Одна из причин в том, что требование оставаться в пределах массива при арифметике указателей позволяет компилятору выполнять оптимизации на основе анализа алиасинга (alias analysis). Неопределённое поведение (UB) даёт компилятору право считать, что некоторые “невозможные” ситуации не происходят, поэтому ему не нужно генерировать код, который корректно обрабатывает такие случаи. Например, компилятор может предполагать, что после серии арифметических операций указатель всё ещё указывает на элемент того же самого объекта-массива (или на one-past-end). То же самое относится и к одиночному объекту, не являющемуся массивом.
Если бы это было не так, компилятору пришлось бы быть “параноиком”: указатель, полученный из &x, мог бы после серии арифметических операций указывать на другой объект — то есть alias-ить несвязанные объекты. Это заставило бы делать куда более консервативные предположения и могло бы отключить многие оптимизации.
Отмечу, что указатель one-past-end может иметь тот же адрес, что и другой объект. Однако его можно использовать только для арифметики и сравнений в пределах того же массива; попытка использовать его для доступа к несвязанному объекту или модификации такого объекта — это UB.
Другая причина — переносимость: правило поддерживает реализации на архитектурах с “неплоской” адресацией, где указатель — это не просто целочисленный адрес и может содержать дополнительные метаданные (сегменты, права и т. п.).
Ссылки / Дополнительное чтение Ссылка на заголовок
- C99 rationale v5.10 (см. обсуждение арифметики указателей и сегментированных архитектур)
- WG14 provenance/alias-analysis notes
- Pointers Are More Abstract Than You Might Expect in C