c++ - точки - что такое undefined behavior




Почему f(i=-1, i=-1) неопределенное поведение? (8)

Во-первых, «скалярный объект» означает тип типа int , float или указатель (см. Что такое скалярный объект в C ++? ).

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

f(++i, ++i);

будет иметь неопределенное поведение. Но

f(i = -1, i = -1);

менее очевидна.

Несколько иной пример:

int i;
f(i = 1, i = -1);
std::cout << i << "\n";

Какое назначение произошло «последним», i = 1 или i = -1 ? Он не определен в стандарте. Действительно, это означает, что i может быть 5 (см. Ответ harmic для совершенно правдоподобного объяснения того, как это может быть так). Или вы можете выполнить segfault. Или переформатируйте жесткий диск.

Но теперь вы спрашиваете: «Как насчет моего примера? Я использовал одно и то же значение ( -1 ) для обоих назначений. Что может быть непонятно в этом?»

Вы правы ... кроме того, как это описывал комитет по стандартам C ++.

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

Они могли бы сделать специальное исключение для вашего особого случая, но они этого не сделали. (И почему они должны? Какое использование это когда-либо возможно?) Итак, i все равно может быть 5 . Или ваш жесткий диск может быть пустым. Таким образом, ответ на ваш вопрос:

Это неопределенное поведение, потому что не определено, что такое поведение.

(Это заслуживает внимания, потому что многие программисты считают «неопределенным» означает «случайный» или «непредсказуемый». Это не означает, что это не определено стандартом. Поведение может быть на 100% последовательным и по-прежнему не определено.)

Могло ли быть определено поведение? Да. Было ли это определено? Нет. Следовательно, он «неопределен».

Тем не менее, «undefined» не означает, что компилятор будет форматировать ваш жесткий диск ... это означает, что он может и все равно будет совместимым с стандартами компилятором. Реально, я уверен, что g ++, Clang и MSVC будут делать то, что вы ожидали. Они просто не «должны».

Может возникнуть другой вопрос. Почему комитет по стандартам C ++ решил сделать этот побочный эффект нелогичным? , Этот ответ будет включать историю и мнения комитета. Или Что хорошего в том, что этот побочный эффект не имеет последствий в C ++? , что допускает любое обоснование, независимо от того, было ли это фактическим аргументом комитета по стандартам. Вы можете задать эти вопросы здесь или на programers.stackexchange.com.

Я читал о порядке нарушений оценки , и они приводят пример, который меня озадачивает.

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

// snip
f(i = -1, i = -1); // undefined behavior

В этом контексте i является скалярным объектом , что, по-видимому, означает

Арифметические типы (3.9.1), типы перечислений, типы указателей, указатель на типы членов (3.9.2), std :: nullptr_t и cv-квалификационные версии этих типов (3.9.3), все вместе называются скалярными типами.

Я не вижу, как в этом случае утверждение неоднозначно. Мне кажется, что независимо от того, сначала оценивается первый или второй аргумент, i заканчивается как -1 , и оба аргумента также равны -1 .

Может кто-то прояснить?

ОБНОВИТЬ

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

РЕЗЮМЕ

Поскольку этот вопрос получил тонну внимания, я подведу основные вопросы / ответы. Во-первых, позвольте мне дать небольшое отступление, чтобы указать, что «почему» может иметь тесно связанные, но тонко разные значения, а именно «по какой причине », «по какой причине » и «с какой целью ». Я сгруппирую ответы, по какому из этих значений «почему» они обратились.

по какой причине

Главный ответ здесь - Пол Дрейпер , а Мартин J - аналогичный, но не столь обширный ответ. Ответ Пола Дрейпера сводится к

Это неопределенное поведение, потому что не определено, что такое поведение.

Ответ в целом очень хорош с точки зрения объяснения того, что говорит стандарт C ++. Он также рассматривает некоторые связанные случаи UB, такие как f(++i, ++i); и f(i=1, i=-1); , В первом из связанных случаев неясно, должен ли первый аргумент быть i+1 и вторым i+2 или наоборот; во втором, не ясно, должен ли i быть 1 или -1 после вызова функции. Оба эти случая - UB, потому что они подпадают под следующее правило:

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

Следовательно, f(i=-1, i=-1) также является UB, поскольку оно подпадает под одно и то же правило, несмотря на то, что намерение программиста (IMHO) очевидно и однозначно.

Пол Дрейпер также делает это в своем заключении, что

Могло ли быть определено поведение? Да. Было ли это определено? Нет.

что подводит нас к вопросу «по какой причине / цели был f(i=-1, i=-1) оставлен как неопределенное поведение?»

по какой причине / цели

Хотя в стандарте C ++ есть некоторые недочеты (возможно, неосторожные), многие упущения хорошо аргументированы и служат определенной цели. Хотя я знаю, что цель часто либо «облегчает работу компилятора-писателя», либо «более быстрый код», мне в основном было интересно узнать, есть ли хорошая причина оставить f(i=-1, i=-1) как UB.

harmic и supercat предоставляют основные ответы, которые дают основание для UB. Harmic указывает, что оптимизирующий компилятор, который может разбить якобы атомарные операции назначения на несколько машинных инструкций, и что он может дополнительно чередовать эти инструкции для оптимальной скорости. Это может привести к некоторым очень неожиданным результатам: i заканчиваю как -2 в его сценарии! Таким образом, harmic демонстрирует, как присвоение одного и того же значения переменной более одного раза может иметь негативные последствия, если операции не подвержены влиянию.

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

davidf также дает пример инструкций чередования, очень похожих на harmic's.

Хотя каждый из примеров harmic, supercat и davidf несколько придуман, взятые вместе, они все еще служат для того, чтобы обеспечить ощутимую причину, по которой f(i=-1, i=-1) должно быть неопределенным поведением.

Я согласился с ответом на причину, потому что он наилучшим образом справлялся со всеми значениями того, почему, хотя ответ Пола Дрейпера более подробно ответил на вопрос «по какой причине».

другие ответы

JohnB указывает, что если мы рассмотрим перегруженные операторы присваивания (вместо простых скаляров), то мы также JohnB с проблемами.


Мне кажется, что единственное правило, связанное с секвенированием выражения аргумента функции, здесь:

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

Это не определяет последовательность между выражениями аргументов, поэтому в этом случае мы заканчиваем:

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

На практике, на большинстве компиляторов, приведенный вами пример будет работать нормально (в отличие от «стирания жесткого диска» и других теоретических неопределенных последствий поведения).
Это, однако, ответственность, поскольку это зависит от конкретного поведения компилятора, даже если оба назначенных значения одинаковы. Кроме того, очевидно, что если вы попытались присвоить разные значения, результаты будут «истинно» неопределенными:

void f(int l, int r) {
    return l < -1;
}
auto b = f(i = -1, i = -2);
if (b) {
    formatDisk();
}

Поведение обычно определяется как неопределенное, если есть какая-то мыслимая причина, по которой компилятор, который пытался быть «полезным», мог бы сделать что-то, что могло бы вызвать совершенно неожиданное поведение.

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

Конечно, тот факт, что кто-то может реализовать компилятор C на такой платформе, не предполагает, что такое поведение не должно определяться на аппаратных платформах при использовании хранилищ типов, достаточно малых для обработки атомарно. Пытаться сохранить два разных значения неестественно, может вызвать странность, если компилятор не знает об этом; например, учитывая:

uint8_t v;  // Global

void hey(uint8_t *p)
{
  moo(v=5, (*p)=6);
  zoo(v);
  zoo(v);
}

если компилятор подключает вызов к «moo» и может сказать, что он не изменяет «v», он может хранить от 5 до v, а затем хранить от 6 до * p, а затем передать 5 в «зоопарк», а затем передайте содержимое v в «зоопарк». Если «зоопарк» не изменяет «v», не должно быть никакого способа передать два вызова другим значениям, но это все равно может случиться. С другой стороны, в тех случаях, когда оба магазина будут писать одно и то же значение, такая странность не может произойти, и на большинстве платформ не будет разумной причины для реализации, чтобы сделать что-нибудь странное. К сожалению, некоторые авторы компиляторов не нуждаются в каких-либо предлогах для глупого поведения за пределами «потому что Стандарт позволяет это», поэтому даже эти случаи небезопасны.


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

Если A не секвенирован до того, как B и B не секвенированы до A, тогда существуют две возможности:

  • оценки A и B не имеют последствий: они могут выполняться в любом порядке и могут перекрываться (в рамках одного потока выполнения компилятор может чередовать инструкции ЦП, содержащие A и B)

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

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

Например, представьте, что было более эффективно обнулить память, а затем уменьшить ее, по сравнению с загрузкой значения -1 in. Затем это:

f(i=-1, i=-1)

может стать:

clear i
clear i
decr i
decr i

Теперь я -2.

Это, вероятно, фиктивный пример, но это возможно.


Путаница заключается в том, что сохранение постоянного значения в локальной переменной не является одной атомной инструкцией для каждой архитектуры, для которой C запускается. Процессор в этом случае работает на вопросах, отличных от компилятора. Например, на ARM, где каждая команда не может переносить полную 32-битную константу, для хранения int в переменной требуется больше одной инструкции. Пример с этим псевдокодом, где вы можете хранить только 8 бит за раз и должны работать в 32-битном регистре, i является int32:

reg = 0xFF; // first instruction
reg |= 0xFF00; // second
reg |= 0xFF0000; // third
reg |= 0xFF000000; // fourth
i = reg; // last

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

reg = 0xFF;
reg |= 0xFF00;
reg |= 0xFF0000;
reg = 0xFF;
reg |= 0xFF000000;
i = reg; // writes 0xFF0000FF == -16776961
reg |= 0xFF00;
reg |= 0xFF0000;
reg |= 0xFF000000;
i = reg; // writes 0xFFFFFFFF == -1

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


Собственно, есть причина не зависеть от того, что компилятор проверяет, что i назначается с тем же значением дважды, так что можно заменить его на одно назначение. Что, если у нас есть некоторые выражения?

void g(int a, int b, int c, int n) {
    int i;
    // hey, compiler has to prove Fermat's theorem now!
    f(i = 1, i = (ipow(a, n) + ipow(b, n) == ipow(c, n)));
}

Это просто отвечает на вопрос «Я не уверен, что« скалярный объект »мог бы означать, помимо чего-то вроде int или float».

Я бы интерпретировал «скалярный объект» как аббревиатуру «объект скалярного типа» или просто «переменную скалярного типа». Затем pointer , enum (константа) имеют скалярный тип.

Это статья MSDN Scalar Types .


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

N5659 §4.6:15
Оценки A и B неопределенно секвенированы, когда либо A секвенирован до того, как B или B секвенированы до A , но неуточнено, что. [ Примечание . Неопределенно упорядоченные оценки не могут перекрываться, но они могут быть выполнены в первую очередь. - конечная нота ]

N5659 § 8.2.2:5
Инициализация параметра, включая все связанные вычисления значения и побочный эффект, неопределенно упорядочена по сравнению с любым другим параметром.

Это позволяет некоторым случаям, которые были бы UB до:

f(i = -1, i = -1); // value of i is -1
f(i = -1, i = -2); // value of i is either -1 or -2, but not specified which one




undefined-behavior