В чем смысл делать вычитание двух указателей, не связанных с одним и тем же массивом, неопределенным поведением?

Согласно проекту C++ expr.add, когда вы вычитаете указатели одного типа, но не принадлежащие одному и тому же массиву, поведение не определено (выделение мое):

Когда два выражения указателя P и Q вычитаются, тип результата является определяемым реализацией знаковым целочисленным типом; этот тип должен быть того же типа, который определен как std :: ptrdiff_t в заголовке ([support.types]).

  • Если P и Q оба имеют нулевые значения указателя, результат равен 0. (5.2)
  • В противном случае, если P и Q указывают соответственно на элементы x [i] и x [j] одного и того же объекта массива x, выражение P - Q имеет значение i-j.

  • В противном случае поведение не определено. [Примечание: если значение i-j не находится в диапазоне представимых значений типа std :: ptrdiff_t, поведение не определено. - конец примечания]

Каково обоснование для того, чтобы сделать такое поведение неопределенным, например, не определяемым реализацией?

Ответы

Ответ 1

Говоря более академично: указатели - это не числа. Они указатели.

Это правда, что указатель в вашей системе реализован в виде числового представления адресоподобного представления местоположения в некотором абстрактном виде памяти (возможно, в виртуальном пространстве памяти для каждого процесса).

Но C++ не заботится об этом. C++ хочет, чтобы вы рассматривали указатели как пост-закладки, как определенные объекты. Числовые значения адреса являются лишь побочным эффектом. Единственная арифметика, которая имеет смысл для указателя, - это прямое и обратное прохождение через массив объектов; ничто иное не имеет философского значения.

Это может показаться довольно загадочным и бесполезным, но на самом деле это преднамеренно и полезно. C++ не хочет ограничивать реализации внедрением дополнительного значения в практические низкоуровневые свойства компьютера, которыми он не может управлять. И, поскольку нет никаких причин для этого (зачем вам это нужно?), Он просто говорит, что результат не определен.

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

Ответ 2

Как отмечают некоторые в комментариях, если результирующее значение не имеет некоторого значения или не может быть использовано каким-либо образом, нет смысла определять поведение.

Было проведено исследование для языка C, чтобы ответить на вопросы, связанные с Pointer Provenance (и с намерением предложить изменения в формулировке спецификации C). Один из вопросов был:

Можно ли сделать полезное смещение между двумя отдельно выделенными объектами путем вычитания между объектами (используя либо указатель, либо целочисленную арифметику), чтобы сделать пригодный для использования указатель на вторую, добавив смещение к первому? (источник)

Заключение авторов исследования было опубликовано в статье под названием " Изучение семантики С и происхождение указателей", и в отношении этого конкретного вопроса ответ был:

Арифметика межобъектного указателя Первый пример в этом разделе основывался на угадывании (а затем проверке) смещения между двумя распределениями. Что если вместо этого вычислить смещение с вычитанием указателя; это должно позволить одному перемещаться между объектами, как показано ниже?

// pointer_offset_from_ptr_subtraction_global_xy.c
#include <stdio.h>
#include <string.h>
#include <stddef.h>

int x=1, y=2;
int main() {
    int *p = &x;
    int *q = &y;
    ptrdiff_t offset = q - p;
    int *r = p + offset;
    if (memcmp(&r, &q, sizeof(r)) == 0) {
        *r = 11; // is this free of UB?
        printf("y=%d *q=%d *r=%d\n",y,*q,*r);
    }
}

В ISO C11 qp - это UB (как вычитание указателя между указателями на разные объекты, которые в некоторых исполнениях абстрактных машин не связаны с прошлым). В вариантной семантике, которая позволяет создавать указатели "больше, чем один", нужно было бы выбрать, является ли доступ *r=11 UB или нет. Базовая семантика провенанса запретит это, потому что r сохранит происхождение распределения x, но его адрес не ограничен для этого. Вероятно, это наиболее желательная семантика: мы нашли очень мало примеров идиом, которые намеренно используют арифметику межобъектных указателей, и свобода, которая запрещает это, дает анализ и оптимизацию псевдонимов, кажется значительным.

Это исследование было собрано сообществом C++, обобщено и отправлено в WG21 (Комитет по стандартам C++) для обратной связи.

Актуальный пункт Резюме:

Разница в указателях определяется только для указателей с одинаковым происхождением и в пределах одного массива.

Итак, они решили оставить его неопределенным на данный момент.

Обратите внимание, что в Комитете по стандартизации C++ есть исследовательская группа ИК12 для изучения неопределенного поведения и уязвимостей. Эта группа проводит систематический обзор для каталогизации случаев уязвимостей и неопределенного/неуказанного поведения в стандарте и рекомендует согласованный набор изменений для определения и/или определения поведения. Вы можете отслеживать ход работы этой группы, чтобы увидеть, произойдут ли в будущем какие-либо изменения в поведении, которое в настоящее время не определено или не определено.

Ответ 3

Сначала посмотрите на этот вопрос, упомянутый в комментариях, почему он не очень хорошо определен. Краткий ответ заключается в том, что произвольная арифметика указателей невозможна в моделях сегментированной памяти, используемых некоторыми (теперь архаичными?) Системами.

Каково обоснование, чтобы сделать такое поведение неопределенным вместо, например, определенной реализации?

Всякий раз, когда стандарт определяет что-то как неопределенное поведение, обычно его можно указать просто для определения реализации. Итак, зачем указывать что-либо как неопределенное?

Ну, неопределенное поведение более снисходительно. В частности, если допустить, что не существует неопределенного поведения, компилятор может выполнить оптимизацию, которая сломает программу, если предположения не верны. Таким образом, причиной для определения неопределенного поведения является оптимизация.

Рассмотрим функцию fun(int* arr1, int* arr2) которая принимает два указателя в качестве аргументов. Эти указатели могут указывать на один и тот же массив или нет. Допустим, функция выполняет arr1 + n одного из указанных массивов (arr1 + n) и должна сравнивать каждую позицию с другим указателем на равенство ((arr1 + n) != arr2) в каждой итерации. Например, чтобы убедиться, что указанный объект не переопределен.

Допустим, мы вызываем функцию следующим образом: fun(array1, array2). Компилятор знает, что (array1 + n) != array2, потому что иначе поведение не определено. Следовательно, если вызов функции (arr1 + n) != arr2, компилятор может удалить избыточную проверку (arr1 + n) != arr2 которая всегда верна. Если бы арифметика указателя на границах массива была хорошо (или даже реализована) определена, то (array1 + n) == array2 мог бы быть верным с некоторым n, и эта оптимизация была бы невозможна - если компилятор не может доказать это (array1 + n) != array2 выполняется для всех возможных значений n которые иногда бывает труднее доказать.


Арифметика указателей для членов класса может быть реализована даже в сегментированных моделях памяти. То же самое касается итерации по границам подмассива. Есть варианты использования, где они могут быть весьма полезны, но технически это UB.

Аргументом для UB в этих случаях является больше возможностей для оптимизации UB. Вам не обязательно соглашаться с тем, что это достаточный аргумент.