Подтвердить что ты не робот

Почему я = v [i ++] undefined?

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

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

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

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

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

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

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

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

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

4b9b3361

Ответ 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 в младший байт. Результирующее значение в я равно 0xBA000001, что мало кто ожидал бы, но был бы верным результатом для вашей операции undefined.

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

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

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

Ответ 2

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

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

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

a = v[i++];

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

a = v[i];
++i;

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

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

будет больше двух.

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

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

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

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

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

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

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

Ответ 3

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

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

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

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

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

Ответ 4

Вы специально ссылаетесь на стандарт С++ 11, поэтому я собираюсь ответить на ответ С++ 11. Это, однако, очень похоже на ответ С++ 03, но определение последовательности отличается.

С++ 11 определяет секвенированную связь между оценками в одном потоке. Он асимметричен, транзитивен и по-разному. Если какая-либо оценка A не секвенирована до того, как некоторая оценка B и B также не секвенированы до A, тогда две оценки не имеют последствий.

Оценка выражения включает в себя как вычисления значений (выработка значения некоторого выражения), так и побочные эффекты. Одним из примеров побочного эффекта является модификация объекта, который является наиболее важным для ответа на вопрос. Другие вещи также считаются побочными эффектами. Если побочный эффект не влияет на другой побочный эффект или вычисление значения на одном и том же объекте, то ваша программа имеет поведение undefined.

Итак, настройте. Первое важное правило:

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

Итак, любое полное выражение полностью оценивается перед следующим полным выражением. В вашем вопросе мы имеем дело только с одним полным выражением, а именно i = v[i++], поэтому нам не нужно беспокоиться об этом. Следующее важное правило:

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

Это означает, что в a + b, например, оценка a и b не подвержена влиянию (они могут быть оценены в любом порядке). Теперь для нашего окончательного важного правила:

Вычисления значений операндов оператора секвенируются перед вычислением значения результата оператора.

Итак, для a + b секвенированные перед отношениями могут быть представлены деревом, где направленная стрелка представляет собой упорядоченное до отношения:

a + b (value computation)
^   ^
|   |
a   b (value computation)

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

Теперь сделаем то же самое с вашим примером i = v[i++]. Мы используем тот факт, что v[i++] определяется как эквивалентный *(v + (i++)). Мы также используем некоторые дополнительные знания о последовательности приращения постфикса:

Вычисление значения выражения ++ секвенируется до модификации объекта операнда.

Итак, идем (а node дерева - вычисление значения, если не указано как побочный эффект):

i = v[i++]
^     ^
|     |
i★  v[i++] = *(v + (i++))
                  ^
                  |
               v + (i++)
               ^     ^
               |     |
               v     ++ (side effect on i)★
                     ^
                     |
                     i

Здесь вы можете видеть, что побочный эффект на i, i++ находится в отдельной ветки до использования i перед оператором присваивания (я обозначил каждую из этих оценок с помощью ★). Таким образом, мы определенно имеем поведение undefined! Я настоятельно рекомендую рисовать эти диаграммы, если вы когда-нибудь задумывались, приведет ли ваша последовательность оценок к вам.

Итак, теперь мы задаем вопрос о том, что значение i перед оператором присваивания не имеет значения, потому что мы все равно пишем. Но на самом деле, в общем случае, это не так. Мы можем переопределить оператор присваивания и использовать значение объекта перед назначением. Стандарту все равно, что мы не используем это значение - правила определены так, что любое вычисление значения, не подверженное побочным эффектам, будет undefined. Никаких "но. Это поведение undefined позволяет компилятору испускать более оптимизированный код. Если мы добавим последовательность для оператора присваивания, эта оптимизация не может быть использована.

Ответ 5

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

Инкремент в 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]

Ответ 6

Есть два правила.

Первое правило - это множественные записи, которые порождают "опасность записи-записи": один и тот же объект не может быть изменен более одного раза между двумя точками последовательности.

Второе правило касается "опасностей чтения и записи". Это так: если объект изменен в выражении, а также доступен, то все обращения к его значению должны быть предназначены для вычисления нового значения.

Выражения типа i++ + i++ и ваше выражение i = v[i++] нарушают первое правило. Они дважды изменяют объект.

Выражение типа i + i++ нарушает второе правило. Подвыражение i слева отмечает значение измененного объекта, не участвуя в вычислении его нового значения.

Итак, i = v[i++] нарушает другое правило (плохая запись-запись) из i + i++ (плохое чтение-запись).


Правила слишком упрощены, что порождает классы озадачивающих выражений. Рассмотрим это:

p = p->next = q

У этого, похоже, есть нормальная зависимость потока данных, которая не содержит опасностей: присвоение p = не может иметь место до тех пор, пока новое значение не будет известно. Новое значение является результатом p->next = q. Значение q не должно "забегать вперед" и попасть внутрь p, так что на него влияет p->next.

Тем не менее, это выражение нарушает второе правило: p модифицируется и также используется для цели, не связанной с вычислением его нового значения, а именно для определения места хранения, в котором размещено значение q!

Итак, извращенно компиляторам разрешено частично оценивать p->next = q, чтобы определить, что результат q, и сохранить его в p, а затем вернуться и завершить назначение p->next =. Или так казалось.

Ключевой вопрос здесь: каково значение выражения присваивания? В стандарте C указано, что значение выражения присваивания равно значению lvalue после назначения. Но это неоднозначно: его можно интерпретировать как значение "значение, которое будет иметь значение lvalue, когда произойдет присвоение", или как "значение, которое можно наблюдать в lvalue после того, как произошло присвоение". В С++ это ясно из формулировки "[i] n во всех случаях, назначение выполняется после вычисления значения правого и левого операндов и перед вычислением значения выражения присваивания.", Поэтому p = p->next = q появляется быть допустимым С++, но сомнительным C.

Ответ 7

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