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

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

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

// 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.

Кто-нибудь может прояснить?


UPDATE

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


СУЩНОСТЬ

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

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

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

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

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

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

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

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

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

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

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

Несмотря на некоторые недочеты (возможно, неосторожные) в стандарте С++, многие упущения хорошо аргументированы и служат определенной цели. Хотя я знаю, что цель часто либо "облегчает работу с компилятором-писателем", либо "более быстрый код", Мне было интересно узнать, есть ли повод по уважительной причине 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) должно быть undefined.

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

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

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

Ответ 1

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

Если 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.

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

Ответ 2

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


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

f(++i, ++i);

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

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

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

Несколько другой пример:

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

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

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

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

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

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

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

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

Может ли быть определено поведение? Да. Было ли это определено? Нет. Следовательно, это "undefined" .

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


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

Ответ 3

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

// config.h
#define VALUEA  1

// defaults.h
#define VALUEB  1

// prog.cpp
f(i = VALUEA, i = VALUEB);

Рассмотрим случай, когда это было разрешено.

Теперь, несколько месяцев спустя, возникает необходимость изменить

 #define VALUEB 2

Кажется, безвредный, не так ли? И все же внезапно prog.cpp больше не компилируется. Тем не менее, мы считаем, что компиляция не должна зависеть от значения литерала.

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

ИЗМЕНИТЬ

@HeartWare отметил, что постоянные выражения формы A DIV B не допускаются на некоторых языках, когда B равно 0, а причина компиляции не выполняется. Следовательно, изменение константы может привести к ошибкам компиляции в другом месте. Который, ИМХО, несчастлив. Но, безусловно, полезно ограничивать такие вещи неизбежным.

Ответ 4

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

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

Конечно, тот факт, что кто-то может реализовать компилятор 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", не должно быть никакого способа, чтобы двум вызовам передавались разные значения, но это все равно может случиться. С другой стороны, в тех случаях, когда оба магазина будут писать одно и то же значение, такая странность не может произойти и на большинстве платформ не было бы разумной причины для реализации сделать что-нибудь странное. К сожалению, некоторым авторам компилятора не нужны никакие извините за глупое поведение за пределами "потому что Стандарт позволяет это", поэтому даже эти случаи небезопасны.

Ответ 5

Тот факт, что результат будет таким же в большинстве реализаций в этом случае, является случайным; порядок оценки по-прежнему undefined. Рассмотрим f(i = -1, i = -2): здесь, порядок имеет значение. Единственная причина, по которой в вашем примере это не имеет значения, - это то, что оба значения -1.

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

Ответ 6

Путаница заключается в том, что сохранение постоянного значения в локальной переменной не является одной атомной инструкцией для каждой архитектуры, для которой C запускается. Процессор в этом случае работает на вопросах, отличных от компилятора. Например, на ARM, где каждая команда не может переносить полную 32-битную константу, для хранения int в переменной требуется больше одной инструкции. Пример с этим псевдокодом, где вы можете хранить только 8 бит за раз и должны работать в 32-битном регистре, я является 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 Но мой пример все еще действителен, так как важно учитывать, что даже константа может быть не такой очевидной, как кажется.

Ответ 7

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

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

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

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

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

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

Ответ 8

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

struct A {
    bool first;
    A () : first (false) {
    }
    const A & operator = (int i) {
        first = !first;
        return * this;
    }
};

void f (A a1, A a2) {
    // ...
}


// ...
A i;
f (i = -1, i = -1);   // the argument evaluated first has ax.first == true

Ответ 9

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

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

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

Ответ 10

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

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

Это статья MSDN Скалярные типы.

Ответ 11

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

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