it-swarm.com.ru

В какой момент цикла переполнение целых чисел становится неопределенным поведением?

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

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Эта программа содержит неопределенное поведение на моей платформе, потому что a переполнится в 3-м цикле.

Приводит ли это к тому, что вся программа имеет неопределенное поведение, или только после того, как фактически происходит переполнение ? Может ли компилятор решить, что a будет переполнен, чтобы он мог объявить весь цикл неопределенным и не беспокоиться о запуске printfs, даже если все они происходят до переполнения?

(С метками C и C++, хотя они разные, потому что мне будут интересны ответы на оба языка, если они будут разными.)

85
jcoder

Если вас интересует чисто теоретический ответ, стандарт C++ позволяет неопределенному поведению «путешествовать во времени»:

[intro.execution]/5: Соответствующая реализация, выполняющая правильно сформированную программу, должна производить то же наблюдаемое поведение как одно из возможных исполнений соответствующего экземпляра абстрактной машины с той же программой и тот же вклад. Однако, если любое такое выполнение содержит неопределенную операцию, это International Стандарт не предъявляет никаких требований к реализации, выполняющей эту программу с этим вводом (даже в отношении операций, предшествующих первой неопределенной операции)

Таким образом, если ваша программа содержит неопределенное поведение, то поведение вашей всей программы не определено.

106
TartanLlama

Во-первых, позвольте мне исправить заголовок этого вопроса:

Неопределенное поведение не относится (конкретно) к сфере исполнения.

Неопределенное поведение влияет на все этапы: компиляция, компоновка, загрузка и выполнение.

Некоторые примеры, подтверждающие это, помните, что ни один раздел не является исчерпывающим:

  • компилятор может предположить, что части кода, которые содержат неопределенное поведение, никогда не выполняются, и, таким образом, предположить, что пути выполнения, которые привели бы к ним, являются мертвым кодом. Смотрите Что должен знать каждый программист на Си о неопределенном поведении никто иной, как Крис Латтнер.
  • компоновщик может предположить, что при наличии нескольких определений слабого символа (распознаваемого по имени) все определения идентичны благодаря правилу единого определения
  • загрузчик (если вы используете динамические библиотеки) может предполагать то же самое, выбирая первый найденный символ; это обычно (ab) используется для перехвата вызовов с помощью трюков LD_PRELOAD в Unixes
  • выполнение может завершиться ошибкой (SIGSEV), если вы используете висячие указатели

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


Я рекомендую посмотреть это видео от Майкла Спенсера (LLVM Developer): CppCon 2016: Мой маленький оптимизатор: неопределенное поведение - это волшебство .

30
Matthieu M.

Агрессивно оптимизирующий компилятор C или C++, нацеленный на 16-битную int, будет знать, что поведение при добавлении 1000000000 к типу int равно undefined.

В соответствии с любым стандартом разрешено делать все, что пожелает, что может включать удаление всей программы, оставляя int main(){}.

Но как насчет больших ints? Я пока не знаю компилятора, который делает это (и я ни в коем случае не являюсь экспертом в разработке компиляторов C и C++), но я представляю, что sometime компилятор, предназначенный для 32-битной int или выше, будет выясните, что цикл бесконечен (i не изменяется) и, поэтому a в конечном итоге переполнится. Итак, еще раз, он может оптимизировать вывод до int main(){}. Суть, которую я пытаюсь здесь подчеркнуть, заключается в том, что по мере того, как оптимизация компилятора становится все более агрессивной, все больше и больше неопределенных конструкций поведения проявляются неожиданным образом.

Тот факт, что ваш цикл бесконечен, сам по себе не определен, так как вы пишете в стандартный вывод в теле цикла.

28
Bathsheba

Технически, в соответствии со стандартом C++, если программа содержит неопределенное поведение, поведение всей программы, даже во время компиляции (до того, как программа будет выполнена), не определено.

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

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

11
bwDraco

Чтобы понять почему неопределенное поведение, можно «путешествовать во времени», как адекватно выразился @TartanLlama , давайте посмотрим на правило «как будто»:

1.9 Выполнение программы 

1 Семантические описания в этом международном стандарте определяют параметризованная недетерминированная абстрактная машина. Это Интернационал Стандарт не предъявляет никаких требований к структуре соответствия Реализации. В частности, им не нужно копировать или эмулировать структура абстрактной машины. Скорее, соответствующие реализации должны эмулировать (только) наблюдаемое поведение реферата машина как объяснено ниже.

При этом мы могли бы рассматривать программу как «черный ящик» с входом и выходом. Входными данными могут быть пользовательский ввод, файлы и многое другое. Результатом является «наблюдаемое поведение», упомянутое в стандарте.

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

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

Неопределенное поведение - это отсутствие соответствия между входом и выходом. Программа может иметь неопределенное поведение для одних входных данных, но определенное поведение для других. Тогда отображение между входом и выходом просто неполное; есть вход, для которого не существует никакого сопоставления с выходом.
Программа в вопросе имеет неопределенное поведение для любого ввода, поэтому отображение пустое.

9
alain

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

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

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

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Это выглядит достаточно вменяемым. Однако что, если numberOfNewChars действительно велико и переполняется? Затем он оборачивается и становится указателем, меньшим, чем endOfBufferPtr, поэтому логика переполнения никогда не будет вызвана. Поэтому они добавили вторую проверку перед этой:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

Похоже, вы позаботились об ошибке переполнения буфера, верно? Однако была отправлена ​​ошибка о том, что этот буфер переполнен в определенной версии Debian! Тщательное расследование показало, что эта версия Debian была первой, которая использовала версию gcc с особой способностью к истощению. В этой версии gcc компилятор распознал, что currentPtr + numberOfNewChars может никогда не быть указателем меньшего размера, чем currentPtr, потому что переполнение для указателей - неопределенное поведение! Этого было достаточно, чтобы gcc оптимизировал всю проверку, и вдруг вы не были защищены от переполнения буфера даже если вы написали код для проверки!

Это было специальное поведение. Все было законно (хотя, как я слышал, gcc откатил это изменение в следующей версии). Это не то, что я бы назвал интуитивным поведением, но если вы немного напрягаете воображение, легко увидеть, как небольшой вариант этой ситуации может стать проблемой остановки для компилятора. Из-за этого авторы спецификаций сделали это «неопределенным поведением» и заявили, что компилятор может делать абсолютно все, что ему нравится.

6
Cort Ammon

Предполагая, что int 32-битный, неопределенное поведение происходит на третьей итерации. Так, если, например, цикл был только условно достижимым или мог быть условно завершен до третьей итерации, не было бы неопределенного поведения, если третья итерация фактически не достигнута. Тем не менее, в случае неопределенного поведения, весь вывод программы не определен, включая вывод, который «в прошлом» относительно вызова неопределенного поведения. Например, в вашем случае это означает, что нет гарантии, что вы увидите 3 сообщения «Hello» в выходных данных.

6
R..

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

for (int i=0; i<n; i++)
  foo[i] = i*scale;

компилятор может преобразовать это в:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

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

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

Даже на машинах с бесшумным циклическим переполнением при переполнении это может привести к сбоям в работе, если Будет некоторое число меньше n, что при умножении на масштаб даст 0. Он также может превратиться в бесконечный цикл, если масштабирование считывалось из памяти больше , Чем один раз, и что-то неожиданно меняло его значение (в любом случае, когда "Scale" мог изменить середину цикла без вызова UB, компилятор не быть разрешено выполнять оптимизацию).

В то время как большинство таких оптимизаций не будет иметь никаких проблем в случаях, когда два коротких типа без знака .__ умножаются, чтобы получить значение, которое находится между INT_MAX + 1 И UINT_MAX, в некоторых случаях в gcc такое умножение происходит внутри цикла может привести к преждевременному выходу из цикла. Я не заметил такого поведения, вытекающего из инструкций сравнения в сгенерированном коде .__, но это наблюдается в случаях , Где компилятор использует переполнение, чтобы сделать вывод, что цикл может выполняться не более 4 или менее раз; по умолчанию он не генерирует предупреждения в тех случаях, когда некоторые входные данные вызывают UB, а другие нет, даже если его выводы приводят к игнорированию верхней границы цикла

4
supercat

Неопределенное поведение по определению является серой областью. Вы просто не можете предсказать, что он будет или не будет делать - вот что означает «неопределенное поведение» означает .

С незапамятных времен программисты всегда пытались спасти остатки определенности из неопределенной ситуации. У них есть некоторый код, который они действительно хотят использовать, но который оказывается неопределенным, поэтому они пытаются возразить: «Я знаю, что он не определен, но, конечно, в худшем случае, он сделает это или это; он никогда не будет делать тот ." И иногда эти аргументы более или менее правильны - но часто они ошибочны. И по мере того, как компиляторы становятся умнее и умнее (или, как некоторые люди могут сказать, хитрее и хитрее), границы вопроса постоянно меняются.

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

4
Steve Summit

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

Однако, конечно, это само по себе не определено, потому что оптимизация не определена. :)

1
Graham

Поскольку этот вопрос имеет двойные теги C и C++, я постараюсь ответить на оба вопроса. C и C++ используют разные подходы. 

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

Мы можем видеть это из Отчета о дефекте 109 , который в своей сути спрашивает:

Однако, если Стандарт C признает отдельное существование «неопределенных значений» (чье простое создание не включает полностью «неопределенное поведение»), тогда человек, выполняющий тестирование компилятора, может написать тестовый пример, такой как следующий, и он/она также может ожидать (или, возможно, требуют), чтобы соответствующая реализация, по крайней мере, скомпилировала этот код (и, возможно, также позволила бы ему выполнить) без «сбоя».

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

Итак, главный вопрос заключается в следующем: должен ли вышеуказанный код быть «успешно переведен» (что бы это ни значило)? (См. Сноску, прилагаемую к подпункту 5.1.1.3.) 

и ответ был:

Стандарт C использует термин «неопределенно оцениваемый», а не «неопределенное значение». Использование неопределенного объекта приводит к неопределенному поведению . В сноске к подпункту 5.1.1.3 указывается, что реализация может производить любое количество диагностических сообщений, если действительная программа по-прежнему правильно переведена Если выражение, выделение которого привело бы к неопределенному поведению, появляется в контексте, где требуется постоянное выражение, содержащая его программа не является строго соответствующей. Кроме того, если каждое возможное выполнение данной программы приведет к неопределенному поведению, данная программа не будет строго соответствовать . Соответствующая реализация не должна не переводить строго соответствующую программу просто потому, что некоторое возможное выполнение этой программы может привести к неопределенному поведению. Поскольку foo никогда не может быть вызван, приведенный пример должен быть успешно переведен соответствующей реализацией.

В C++ подход кажется более мягким и предполагает, что программа имеет неопределенное поведение независимо от того, может ли реализация доказать это статически или нет.

У нас есть [intro.abstrac] p5 , который говорит:

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

0
Shafik Yaghmour