Арифметика указателей определена только внутри массива, включая указатель на 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

Аналогично MSVC:

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.

Другая причина — переносимость: правило поддерживает реализации на архитектурах с “неплоской” адресацией, где указатель — это не просто целочисленный адрес и может содержать дополнительные метаданные (сегменты, права и т. п.).

Ссылки / Дополнительное чтение Ссылка на заголовок

  1. C99 rationale v5.10 (см. обсуждение арифметики указателей и сегментированных архитектур)
  2. WG14 provenance/alias-analysis notes
  3. Pointers Are More Abstract Than You Might Expect in C