скачать - страуструп c++




Почему i=v[i++] undefined? (6)

В этом примере я бы подумал, что подвыражение i ++ будет полностью оценено до того, как оценивается подвыражение v [...] и что результатом оценки подвыражения является i (до приращения), но значение i равно добавочное значение после того, как это подвыражение было полностью оценено.

Инкремент в i++ должен быть оценен перед индексированием v и, таким образом, перед назначением i , но сохранение значения этого приращения обратно в память не должно происходить раньше. В заявлении i = v[i++] есть две подоперации, которые изменяют i (т. Е. В конечном итоге вызывают сохранение из регистра в переменную i ). Выражение i++ эквивалентно x=i+1 , i=x , и нет необходимости, чтобы обе операции выполнялись последовательно:

x = i+1;
y = v[i];
i = y;
i = x;

При этом расширении результат i не связан со значением в v[i] . При другом расширении назначение i = x может выполняться до назначения i = y , и результат будет i = v[i]

Из стандарта C ++ (C ++ 11), §1.9.15, который обсуждает порядок оценки, приведен следующий пример кода:

void g(int i, int* v) {
    i = v[i++]; // the behavior is undefined
}

Как отмечено в образце кода, поведение не определено.

(Примечание: ответ на другой вопрос с немного другой конструкцией i + i++ , почему это неопределенное и неопределенное поведение a = i + i ++ , может применяться здесь: ответ по существу состоит в том, что поведение не определено по историческим причинам, а не из по-видимому, подразумевает некоторое оправдание того, что это не определено - см. цитату сразу же. Кроме того, этот связанный вопрос указывает на то, что поведение должно быть неуказанным , тогда как в этом вопросе я спрашиваю, почему поведение не очень хорошо, указанный .)

Обоснование, данное стандартом для неопределенного поведения, выглядит следующим образом:

Если побочный эффект скалярного объекта не влияет на какой-либо другой побочный эффект на один и тот же скалярный объект или вычисление значения, используя значение одного и того же скалярного объекта, поведение не определено.

В этом примере я бы подумал, что подвыражение i++ будет полностью оценено до того, как оценивается подвыражение v[...] и что результатом оценки подвыражения является i (до приращения), но значение i равно добавочное значение после того, как это подвыражение было полностью оценено. Я бы подумал, что в этот момент (после того, как подвыражение i++ было полностью оценено), выполняется оценка v[...] , за которой следует присваивание i = ...

Поэтому, хотя приращение i бессмысленно, я все же думаю, что это должно быть определено .

Почему это неопределенное поведение?


Я бы подумал, что подвыражение i ++ будет полностью оценено до того, как оценивается подвыражение v [...]

Но почему вы так думаете?

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

a = v[i++];

Целью стандарта является то, что испускаемый код может быть:

a = v[i];
++i;

которые могут быть двумя инструкциями, где:

tmp = i;
++i;
a = v[tmp];

было бы более двух.

«Оптимизированный код» ломается, когда a является i , но стандарт разрешает оптимизацию в любом случае , говоря, что поведение исходного кода не определено, когда a является i .

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

Также будьте осторожны, что многие примеры, поднятые в этих дискуссиях, облегчают рассказ о том, что UB вокруг, чем в целом. Это приводит к тому, что люди говорят, что «очевидно» поведение должно быть определено, а оптимизация запрещена. Но учтите:

void g(int *i, int* v, int *dst) {
    *dst = v[(*i)++];
}

Поведение этой функции определяется при i != dst , и в этом случае вам нужна вся оптимизация, которую вы можете получить (поэтому C99 вводит restrict , чтобы позволить больше оптимизаций, чем C89 или C ++). Чтобы дать вам оптимизацию, поведение не определено, когда i == dst . Стандарты C и C ++ проделывают тонкую линию, когда дело доходит до сглаживания, между неопределенным поведением, которое не ожидается программистом, и запрещающими желаемые оптимизации, которые не выполняются в определенных случаях. Количество вопросов об этом на SO предполагает, что респонденты предпочли бы немного меньшую оптимизацию и немного более определенное поведение, но рисовать линию все равно не так просто.

Помимо того, полностью ли определено поведение, возникает вопрос, должен ли он быть UB или просто неуказанный порядок выполнения определенных четко определенных операций, соответствующих подвыражениям. Причина C для UB связана с идеей о точках последовательности и тем фактом, что компилятор не должен иметь понятия значения модифицированного объекта до следующей точки последовательности. Поэтому вместо того, чтобы ограничивать оптимизатор, говоря, что значение «значение» изменяется в некоторой неопределенной точке, стандарт просто говорит (перефразировать): (1) любой код, который полагается на значение измененного объекта до следующей точки последовательности, имеет UB; (2) любой код, который модифицирует измененный объект, имеет UB. Если «измененным объектом» является любой объект, который был бы изменен с момента последней точки последовательности в одном или нескольких из правовых порядков оценки подвыражений.

Другие языки (например, Java) проходят весь путь и полностью определяют порядок побочных эффектов выражения, поэтому определенно существует подход против подхода C. C ++ просто не принимает этот случай.


Подумайте о последовательностях машинных операций, необходимых для каждого из следующих операторов присваивания, при условии, что данные объявления действуют:

extern int *foo(void);
extern int *p;

*p = *foo();
*foo() = *p;

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

[For *p = *foo()]
call foo (which yields result in r0 and trashes r1)
load r0 from address held in r0
load r1 from address held in p
store r0 to address held in r1

[For *foo() = *p]
call foo (which yields result in r0 and trashes r1)
load r1 from address held in p
load r1 from address held in r1
store r1 to address held in r0

В любом случае, если p или * p были прочитаны в регистр перед вызовом foo, то, если «foo» не обещает нарушить этот регистр, компилятору необходимо будет добавить дополнительный шаг, чтобы сохранить его значение, прежде чем вызывать «foo», , и еще один дополнительный шаг для восстановления значения после этого. Этого дополнительного шага можно избежать, используя регистр, который «foo» не будет беспокоить, но это поможет только в том случае, если существует такой регистр, который не содержит значения, необходимого для окружающего кода.

Предоставление компилятору информации о значении «p» до или после вызова функции в свободное время позволит эффективно обрабатывать обе модели выше. Требование, чтобы адрес левого операнда «=» всегда оценивался до того, как правая сторона, скорее всего, сделает первое присваивание выше менее эффективным, чем это могло бы быть иначе, и потребовало бы, чтобы был оценен адрес левого операнда после того как правая сторона сделает второе назначение менее эффективным.


Причина не только историческая. Пример:

int f(int& i0, int& i1) {
    return i0 + i1++;
}

Теперь, что происходит с этим вызовом:

int i = 3;
int j = f(i, i);

Конечно, можно поставить требования к коду в f так, чтобы результат этого вызова был корректно определен (Java делает это), но C и C ++ не налагают ограничений; это дает больше свободы оптимизаторам.


Я бы поделился вашими аргументами, если в примере был v[++i] , но поскольку i++ модифицирует i как побочный эффект, он не определен, когда значение изменяется. Стандарт, вероятно, может давать результат так или иначе, но нет истинного способа узнать, какое значение i должно быть: (i + 1) или (v[i + 1]) .


Я собираюсь разработать патологический компьютер 1 . Это многоядерная система с высокой задержкой и однопотоком с подключением в потоке, которая работает с инструкциями на уровне байтов. Таким образом, вы делаете запрос на то, чтобы что-то произошло, затем компьютер запускает (в своем собственном «потоке» или «задаче») набор инструкций на уровне байтов и определенное количество циклов позже, когда операция завершена.

Между тем основной поток исполнения продолжается:

void foo(int v[], int i){
  i = v[i++];
}

становится в псевдокоде:

input variable i // = 0x00000000
input variable v // = &[0xBAADF00D, 0xABABABABAB, 0x10101010]
task get_i_value: GET_VAR_VALUE<int>(i)
reg indx = WAIT(get_i_value)
task write_i++_back: WRITE(i, INC(indx))
task get_v_value: GET_VAR_VALUE<int*>(v)
reg arr = WAIT(get_v_value)
task get_v[i]_value = CALC(arr + sizeof(int)*indx)
reg pval = WAIT(get_v[i]_value)
task read_v[i]_value = LOAD_VALUE<int>(pval)
reg got_value = WAIT(read_v[i]_value)
task write_i_value_again = WRITE(i, got_value)
(discard, discard) = WAIT(write_i++_back, write_i_value_again)

Таким образом, вы заметите, что я не дождался write_i++_back до самого конца, в то же время, когда я ждал write_i_value_again (какое значение я загрузил из v[] ). И, на самом деле, эти записи являются единственной записью в память.

Представьте, что если запись в память является очень медленной частью этого компьютерного дизайна, и они попадают в очередь вещей, которые обрабатываются блоком изменения параллельной памяти, который делает вещи на основе каждого байта.

Таким образом, write(i, 0x00000001) и write(i, 0xBAADF00D) выполняются неупорядоченными и параллельными. Каждый из них превращается в байтовую запись, и они упорядочены произвольно.

В итоге мы записываем 0x00 затем 0xBA в старший байт, затем 0xAD и 0x00 в следующий байт, затем 0xF0 0x00 в следующий байт и, наконец, 0x0D 0x01 в 0x01 байт. Результирующее значение в i равно 0xBA000001 , чего мало кто ожидал бы, но будет действительным результатом для вашей неопределенной операции.

Теперь все, что я сделал, было результатом неопределенного значения. Мы не разбивали систему. Но компилятор был бы свободен, чтобы сделать его полностью неопределенным - возможно, отправка двух таких запросов в контроллер памяти для одного и того же адреса в той же партии инструкций фактически приводит к сбою системы. Это все равно будет «правильным» способом скомпилировать C ++ и «действительную» среду исполнения.

Помните, что это язык, где ограничение размера указателей на 8 бит по-прежнему является допустимой средой выполнения. C ++ позволяет компилировать довольно целевые объекты.

1 : Как отмечено в комментарии @ SteveJessop ниже, шутка состоит в том, что этот патологический компьютер ведет себя так же, как современный настольный компьютер, пока вы не перейдете к операции на уровне байтов. int запись внутри процессора не так уж редко встречается на некоторых аппаратных средствах (например, когда int не выравнивается так, как CPU хочет, чтобы он был выровнен).





language-lawyer